From 9af047999ec4199e4cdb36be4a0433eca874825a Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 11:10:43 +0100 Subject: [PATCH 01/19] feat(backends): accept composed Adapter directly in add_adapter/resolve_adapter LocalHFBackend.add_adapter and OpenAIBackend.add_adapter previously rejected a composed Adapter (identity + io_contract + weights), accepting only the deprecated IntrinsicAdapter/EmbeddedIntrinsicAdapter shims or a bare LocalFileBinding. AdapterMixin.resolve_adapter's lazy-registration path also constructed the shims directly. Widen both backends' add_adapter to dispatch on a composed Adapter's weights binding (LocalFileBinding for LocalHFBackend's PEFT reality, EmbeddedBinding for both backends' Embedded/Granite Switch reality), and switch resolve_adapter to build composed Adapter instances instead of the shims. Add a non-shim _discover_embedded_adapters() factory that reuses the shim's adapter_index.json/io.yaml parsing internally. _generate_from_intrinsic in both backends now derives the adapter-function name and io.yaml config generically (from the shim directly, or re-derived/cached for a composed Adapter) instead of assuming a shim instance. The shim classes and their own callers are unchanged; this is additive groundwork for Epic #929, issue #1144. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/adapter.py | 113 +++++++++- mellea/backends/huggingface.py | 261 ++++++++++++++++++---- mellea/backends/openai.py | 126 ++++++++--- test/backends/test_adapters/test_shims.py | 76 +++---- test/backends/test_huggingface_unit.py | 30 ++- test/backends/test_openai_intrinsics.py | 2 +- 6 files changed, 481 insertions(+), 127 deletions(-) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 990f999970..29d54d6678 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -298,6 +298,77 @@ def download_and_get_path(self, base_model_name: str) -> str: T = TypeVar("T") +def _composed_adapter_key(adapter: "_AdapterCore") -> str: + """Return the registry key for a composed `Adapter`, mirroring `qualified_name`. + + A composed `Adapter` (unlike the deprecated shims) has no `qualified_name` + of its own; backends key their registries on this instead. + + Args: + adapter: The composed adapter to key. + + Returns: + str: `"_"`. + """ + return f"{adapter.identity.name}_{adapter.identity.adapter_type}" + + +def _discover_embedded_adapters( + source: str, + *, + revision: str = "main", + cache_dir: str | None = None, + intrinsic_name: str | None = None, +) -> list[tuple["_AdapterCore", dict]]: + """Discover embedded adapter functions from a Granite Switch source. + + Non-shim equivalent of `EmbeddedIntrinsicAdapter.from_source()`: returns + composed `Adapter` instances instead of the deprecated shim (Epic #929, + issue #1144). Reuses the shim's discovery, `adapter_index.json`/`io.yaml` + parsing, and hub-snapshot materialization internally — including its + path-escape guard — rather than duplicating that logic, and lifts out the + already-correct `identity`/`io_contract`/`weights` triple each shim + instance built. + + The raw parsed `io.yaml` config is returned alongside each adapter because + a composed `Adapter` has no field for it (that's shim-only state) and it + cannot be cheaply re-derived later — callers that need it at generation + time (see `LocalHFBackend`/`OpenAIBackend`'s `_generate_from_intrinsic`) + must cache it themselves, keyed by `_composed_adapter_key`. + + Args: + source (str): Local path to a model directory, or a Hugging Face Hub + repo ID (e.g. `"ibm-granite/granite-switch-micro"`). + revision (str): Git revision (only used for Hub downloads). + cache_dir (str | None): Cache directory (only used for Hub downloads). + intrinsic_name (str | None): If provided, only load the adapter + matching this adapter function name. `None` loads all adapters. + + Returns: + list[tuple[_AdapterCore, dict]]: One `(adapter, io_yaml_config)` pair + per entry in the index. + """ + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + shims = EmbeddedIntrinsicAdapter.from_source( + source, + revision=revision, + cache_dir=cache_dir, + intrinsic_name=intrinsic_name, + ) + return [ + ( + _AdapterCore( + identity=shim.identity, + io_contract=shim.io_contract, + weights=shim.weights, + ), + shim.config, + ) + for shim in shims + ] + + def get_adapter_for_intrinsic( intrinsic_name: str, intrinsic_adapter_types: list[AdapterType] | tuple[AdapterType, ...], @@ -672,21 +743,39 @@ def resolve_adapter(self, name: str) -> _AdapterCore: or getattr(self, "_model_id", None) or base ) - for a in EmbeddedIntrinsicAdapter.from_source( + # Composed Adapter, not the deprecated EmbeddedIntrinsicAdapter + # shim (Epic #929, issue #1144). Valid only for backends whose + # add_adapter supports the Embedded/Granite Switch reality + # (currently OpenAIBackend and LocalHFBackend when configured + # with load_embedded_adapters=True). + configs = getattr(self, "_composed_adapter_configs", None) + for a, config in _discover_embedded_adapters( repo_id, intrinsic_name=name ): - # EmbeddedIntrinsicAdapter is valid only for backends whose - # add_adapter supports the Embedded/Granite Switch reality - # (currently OpenAIBackend and LocalHFBackend when configured - # with load_embedded_adapters=True). + if configs is not None: + configs[_composed_adapter_key(a)] = config self.add_adapter(a) else: # AdapterType.LORA is the pre-Phase-1 default (mirrors old _util.py). # Every current catalog entry supports LORA. Phase 2 (see epic #929) # will select the type from catalog availability instead of hardcoding. + # Composed Adapter, not the deprecated IntrinsicAdapter shim + # (Epic #929, issue #1144). + metadata = fetch_intrinsic_metadata(name) self.add_adapter( - IntrinsicAdapter( - name, adapter_type=AdapterType.LORA, base_model_name=base + _AdapterCore( + identity=Identity( + name=name, + adapter_type="lora", + capability=metadata.effective_capability, + ), + io_contract=get_io_contract(name), + weights=LocalFileBinding( + name=name, + adapter_type=AdapterType.LORA, + repo_id=metadata.repo_id, + revision=metadata.revision, + ), ) ) @@ -930,6 +1019,16 @@ def _find_adapter( # or binding — can be popped: re-check that invariant when #1465 moves # generation inside `adapter_scope`. adapters = list(getattr(self, "_added_adapters", {}).values()) + # LocalFile/PEFT composed Adapters (Epic #929, issue #1144) don't live + # in _added_adapters — that dict holds their LocalFileBinding, keyed + # for the PEFT lifecycle (see LocalHFBackend.add_adapter); only + # _composed_adapters carries the _AdapterCore object this method + # matches on. Embedded composed adapters may live in either, per + # backend (LocalHFBackend uses _composed_adapters; OpenAIBackend + # stores them directly in _added_adapters), so checking both here + # keeps this method backend-agnostic without either backend needing + # to override it. + adapters += list(getattr(self, "_composed_adapters", {}).values()) if adapter_types is None: for a in adapters: if isinstance(a, _AdapterCore) and ( diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 9f30cb963e..02cb4eae97 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -25,6 +25,7 @@ import jinja2 import jinja2.meta +import yaml from packaging.requirements import Requirement as PackagingRequirement from packaging.version import InvalidVersion, Version @@ -73,6 +74,7 @@ ) from ..core.base import AbstractMelleaTool from ..formatters import ChatFormatter, TemplateFormatter, granite as granite_formatters +from ..formatters.granite import intrinsics as intrinsics from ..formatters.granite.base.util import ( _LLGUIDANCE_GRAMMAR_DEFAULTS, _GuidanceLogitsProcessor, @@ -103,7 +105,13 @@ _await_embedded_generation, _fire_embedded_invocation_complete, ) -from .adapters.adapter import AdapterInput, EmbeddedIntrinsicAdapter +from .adapters.adapter import ( + AdapterInput, + EmbeddedIntrinsicAdapter, + _composed_adapter_key, + _discover_embedded_adapters, +) +from .adapters.catalog import AdapterType from .backend import FormatterBackend from .cache import Cache, SimpleLRUCache from .model_ids import ModelIdentifier @@ -544,6 +552,19 @@ def __init__( str, LocalHFAdapter | LocalFileBinding | EmbeddedIntrinsicAdapter ] = {} self._loaded_adapters: dict[str, LocalHFAdapter | LocalFileBinding] = {} + # Composed Adapter instances (Epic #929, issue #1144), keyed by + # _composed_adapter_key(). LocalFile/PEFT composed adapters also + # register their LocalFileBinding into _added_adapters under the + # same key (see add_adapter) so the existing PEFT lifecycle keeps + # working unchanged; this dict is what makes _find_adapter() see the + # composed Adapter object itself (identity/io_contract), which the + # bare binding doesn't carry. + self._composed_adapters: dict[str, _AdapterCore] = {} + # Raw io.yaml config for composed Embedded adapters, keyed the same + # way. Embedded composed adapters have no `.config` field (that's + # shim-only state) and it can't be cheaply re-derived at generation + # time, unlike the LocalFile case (see _intrinsic_adapter_name_and_config). + self._composed_adapter_configs: dict[str, dict] = {} self._adapter_source = adapter_source self._uses_embedded_adapters = load_embedded_adapters @@ -792,6 +813,103 @@ def _generate_intrinsic_with_adapter_scope( _assert_correct_adapters(adapter.qualified_name, self._model) return out + def _generate_composed_local_file_with_adapter_scope( + self, + adapter: _AdapterCore, + generate_func: Callable[..., _T], + *args: Any, + **kwargs: Any, + ) -> _T: + """Runs `generate_func` with a composed `Adapter`'s `LocalFileBinding` active. + + Composed-Adapter counterpart of `_generate_intrinsic_with_adapter_scope` + (Epic #929, issue #1144). Unlike the deprecated `IntrinsicAdapter` shim, + `adapter.weights` here is already a real, registered `LocalFileBinding` + (see `add_adapter`) — there is no throwaway `_IntrinsicPeftBinding` to + build, so this drives `adapter_scope` directly on `adapter` itself. + + Args: + adapter: The composed `Adapter` to activate for this call; its + `weights` must be a `LocalFileBinding` already registered with + this backend. + generate_func: The synchronous generation callable to invoke while + `adapter` is active. + *args: Positional arguments forwarded to `generate_func`. + **kwargs: Keyword arguments forwarded to `generate_func`. + + Returns: + Whatever `generate_func` returns. + """ + binding = adapter.weights + assert isinstance(binding, LocalFileBinding) + with self._generation_lock: + binding.prepare() + with self.adapter_scope(adapter): + _assert_correct_adapters(binding.qualified_name, self._model) + out = generate_func(*args, **kwargs) + _assert_correct_adapters(binding.qualified_name, self._model) + return out + + def _intrinsic_adapter_name_and_config( + self, adapter: IntrinsicAdapter | EmbeddedIntrinsicAdapter | _AdapterCore + ) -> tuple[str, dict]: + """Return the adapter-function name and raw io.yaml config for an adapter. + + The shims (`IntrinsicAdapter`/`EmbeddedIntrinsicAdapter`) carry both + directly (`.name`/`.config`). A composed `Adapter` carries neither — + `io_contract` does not yet drive `IntrinsicsRewriter`/ + `IntrinsicsResultProcessor` (Epic #929, issue #1144) — so this derives + the same io.yaml config a shim would have loaded: + + - LocalFile/PEFT reality: re-downloaded via `obtain_io_yaml` from the + binding's own `repo_id`/`revision`, mirroring + `IntrinsicAdapter.__init__`. + - Embedded/Granite Switch reality: looked up from + `_composed_adapter_configs`, cached at registration time (see + `add_adapter`/`register_embedded_adapter_model`) since it comes from + `adapter_index.json`/`io.yaml` and cannot be cheaply re-derived. + + Args: + adapter: The adapter to resolve a name and config for. + + Returns: + tuple[str, dict]: The adapter-function name and its parsed + io.yaml config. + + Raises: + ValueError: A composed Embedded adapter has no cached config + (never registered via `add_adapter`/`register_embedded_adapter_model`). + TypeError: A composed Adapter's `weights` is neither a + `LocalFileBinding` nor an `EmbeddedBinding`. + """ + if isinstance(adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter)): + return adapter.name, adapter.config + if isinstance(adapter.weights, LocalFileBinding): + binding = adapter.weights + io_yaml_path = intrinsics.obtain_io_yaml( + binding.name, + self.base_model_name, + binding.repo_id, + revision=binding.resolved_revision(), + alora=binding.adapter_type is AdapterType.ALORA, + ) + with open(io_yaml_path, encoding="utf-8") as f: + config = yaml.safe_load(f) + return adapter.identity.name, config + if isinstance(adapter.weights, EmbeddedBinding): + key = _composed_adapter_key(adapter) + config = self._composed_adapter_configs.get(key) + if config is None: + raise ValueError( + f"No io.yaml config cached for composed embedded adapter {key!r}; " + "register it via register_embedded_adapter_model() or resolve_adapter()." + ) + return adapter.identity.name, config + raise TypeError( + "Unsupported weights binding for composed Adapter: " + f"{type(adapter.weights).__name__}" + ) + async def _generate_from_intrinsic( self, action: Intrinsic, @@ -891,17 +1009,20 @@ async def _generate_from_intrinsic( # TODO: Code below this point is mostly specific to RagIntrinsics # It should be refactored into a specific adapter.transform() function. - if not isinstance(adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter)): + if not isinstance( + adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter, _AdapterCore) + ): raise TypeError( - "LocalHFBackend only supports IntrinsicAdapter or " - f"EmbeddedIntrinsicAdapter, got: {type(adapter).__name__}" + "LocalHFBackend only supports IntrinsicAdapter, EmbeddedIntrinsicAdapter, " + f"or a composed Adapter, got: {type(adapter).__name__}" ) - intrinsic_config = adapter.config - assert intrinsic_config is not None + adapter_name, intrinsic_config = self._intrinsic_adapter_name_and_config( + adapter + ) rewriter = granite_formatters.IntrinsicsRewriter( - config_dict=intrinsic_config, model_name=adapter.name + config_dict=intrinsic_config, model_name=adapter_name ) result_processor = granite_formatters.IntrinsicsResultProcessor( config_dict=intrinsic_config @@ -932,12 +1053,19 @@ async def _generate_from_intrinsic( # us having specific caching for each Component/Message. rewritten_request = rewritten.model_dump() - if isinstance(adapter, EmbeddedIntrinsicAdapter): - if not isinstance(adapter.weights, EmbeddedBinding): - raise TypeError( - "EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " - f"got {type(adapter.weights).__name__}. Activation cannot proceed." - ) + # A caller can reassign `.weights` on the mutable EmbeddedIntrinsicAdapter + # shim after construction; a composed Adapter's `.weights` is frozen and + # therefore always agrees with its own type, so this guard only ever + # fires for the shim. + if isinstance(adapter, EmbeddedIntrinsicAdapter) and not isinstance( + adapter.weights, EmbeddedBinding + ): + raise TypeError( + "EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " + f"got {type(adapter.weights).__name__}. Activation cannot proceed." + ) + adapter_is_embedded = isinstance(adapter.weights, EmbeddedBinding) + if isinstance(adapter.weights, EmbeddedBinding): extra_body = rewritten_request.setdefault("extra_body", {}) if not isinstance(extra_body, dict): raise TypeError( @@ -1012,18 +1140,7 @@ def __getattr__(self_proxy, name: str) -> Any: model_arg = _CapturingModelProxy() # type: ignore[assignment] - if isinstance(adapter, IntrinsicAdapter): - chat_response = asyncio.to_thread( - self._generate_intrinsic_with_adapter_scope, - adapter, - granite_formatters.base.util.generate_with_transformers, # type: ignore - # Passed as args/kwargs to generate. - self._tokenizer, - model_arg, - generate_input, - other_input, - ) - else: + if adapter_is_embedded: chat_response = _await_embedded_generation( asyncio.to_thread( self._generate_embedded_with_generation_lock, @@ -1036,6 +1153,28 @@ def __getattr__(self_proxy, name: str) -> Any: ), adapter.identity, ) + elif isinstance(adapter, IntrinsicAdapter): + chat_response = asyncio.to_thread( + self._generate_intrinsic_with_adapter_scope, + adapter, + granite_formatters.base.util.generate_with_transformers, # type: ignore + # Passed as args/kwargs to generate. + self._tokenizer, + model_arg, + generate_input, + other_input, + ) + else: + chat_response = asyncio.to_thread( + self._generate_composed_local_file_with_adapter_scope, + adapter, + granite_formatters.base.util.generate_with_transformers, # type: ignore + # Passed as args/kwargs to generate. + self._tokenizer, + model_arg, + generate_input, + other_input, + ) output = ModelOutputThunk(None) output._gen.start = datetime.datetime.now() @@ -1105,11 +1244,7 @@ async def granite_formatters_processing( rewritten=rewritten, result_processor=result_processor, input_ids=generate_input["input_tokens"], - embedded_identity=( - adapter.identity - if isinstance(adapter, EmbeddedIntrinsicAdapter) - else None - ), + embedded_identity=(adapter.identity if adapter_is_embedded else None), ) # TODO: Post-processing should release the lock for this generation. @@ -2420,21 +2555,56 @@ def add_adapter(self, adapter: AdapterInput) -> None: Args: adapter (AdapterInput): The adapter to register. Must be a - `LocalHFAdapter`, `LocalFileBinding`, or - `EmbeddedIntrinsicAdapter`; other adapter realities are - rejected. + `LocalHFAdapter`, `LocalFileBinding`, `EmbeddedIntrinsicAdapter`, + or a composed `Adapter`; other adapter realities are rejected. Raises: TypeError: If `adapter` is not a supported local or embedded adapter. Exception: If `adapter` has already been added to a different backend. """ if not isinstance( - adapter, (LocalHFAdapter, LocalFileBinding, EmbeddedIntrinsicAdapter) + adapter, + (LocalHFAdapter, LocalFileBinding, EmbeddedIntrinsicAdapter, _AdapterCore), ): raise TypeError( - "LocalHFBackend requires a LocalHFAdapter, LocalFileBinding, or " - f"EmbeddedIntrinsicAdapter; got {type(adapter).__name__}." + "LocalHFBackend requires a LocalHFAdapter, LocalFileBinding, " + "EmbeddedIntrinsicAdapter, or a composed Adapter; got " + f"{type(adapter).__name__}." ) + + # A composed Adapter that isn't one of the shim subclasses (both of + # which are also _AdapterCore instances): dispatch on its weights + # binding instead of the shim/legacy branches below, which mutate + # attributes a composed Adapter's frozen dataclass doesn't have. + if isinstance(adapter, _AdapterCore) and not isinstance( + adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter) + ): + key = _composed_adapter_key(adapter) + if key in self._added_adapters or key in self._composed_adapters: + MelleaLogger.get_logger().warning( + f"Client code attempted to add {adapter.identity.name} with type " + f"{adapter.identity.adapter_type} but {key!r} is already " + f"registered on {self.__class__.__name__}. The backend is " + "refusing to do this, because adapter loading is not idempotent." + ) + return + if isinstance(adapter.weights, EmbeddedBinding): + adapter.weights.source = self.base_model_name + self._composed_adapters[key] = adapter + return + if isinstance(adapter.weights, LocalFileBinding): + binding = adapter.weights + if binding.backend is None: + binding.bind_backend(self) + binding.prepare() + self._composed_adapters[key] = adapter + return + raise TypeError( + "LocalHFBackend does not support the " + f"{type(adapter.weights).__name__} weights reality for a " + "composed Adapter." + ) + if adapter.backend is not None: if adapter.backend is self: MelleaLogger.get_logger().warning( @@ -2496,12 +2666,15 @@ def register_embedded_adapter_model( # No lock here: both call sites (here, and this class's __init__) run # single-threaded during construction, before the backend is exposed to # any other thread — see the matching note on OpenAIBackend's twin. - adapters = EmbeddedIntrinsicAdapter.from_source( + discovered = _discover_embedded_adapters( source, revision=revision, cache_dir=cache_dir ) - for adapter in adapters: + names = [] + for adapter, config in discovered: self.add_adapter(adapter) - return [adapter.intrinsic_name for adapter in adapters] + self._composed_adapter_configs[_composed_adapter_key(adapter)] = config + names.append(adapter.identity.name) + return names def load_peft_adapter(self, adapter_qualified_name: str) -> None: """Load a previously registered adapter into the underlying Hugging Face model. @@ -2629,6 +2802,11 @@ def remove_adapter(self, adapter_qualified_name: str) -> None: adapter.backend = None adapter.path = None + # A composed Adapter registered here (see add_adapter) shares this + # qualified name; drop its entry too so a stale one doesn't outlive + # the binding it wraps. + self._composed_adapters.pop(adapter_qualified_name, None) + self._composed_adapter_configs.pop(adapter_qualified_name, None) def activate_peft_adapter(self, adapter_qualified_name: str) -> None: """Switch a previously loaded PEFT adapter on for subsequent generation. @@ -2713,7 +2891,10 @@ def list_adapters(self) -> list[str]: all adapters that have been registered via `add_adapter`, whether or not they have also been loaded via `load_peft_adapter`. """ - return list(self._added_adapters.keys()) + # A composed Embedded adapter lives only in _composed_adapters (see + # add_adapter); a composed LocalFile adapter's binding is also in + # _added_adapters under the same key, so dict.fromkeys dedupes it. + return list(dict.fromkeys([*self._added_adapters, *self._composed_adapters])) def _assert_correct_adapters(expected_state: str, model: PreTrainedModel): diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index b018543cec..1d11c4cd3a 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -57,10 +57,17 @@ from ._options import resolve_model_options from .adapters import EmbeddedActivationRequest, EmbeddedBinding, Identity from .adapters._core import ( + Adapter as _AdapterCore, _await_embedded_generation, _fire_embedded_invocation_complete, ) -from .adapters.adapter import AdapterInput, AdapterMixin, EmbeddedIntrinsicAdapter +from .adapters.adapter import ( + AdapterInput, + AdapterMixin, + EmbeddedIntrinsicAdapter, + _composed_adapter_key, + _discover_embedded_adapters, +) from .backend import FormatterBackend from .model_options import ModelOption from .tools import ( @@ -279,7 +286,12 @@ def __init__( self._client_cache = ClientCache(2) - self._added_adapters: dict[str, EmbeddedIntrinsicAdapter] = {} + self._added_adapters: dict[str, EmbeddedIntrinsicAdapter | _AdapterCore] = {} + # Raw io.yaml config for composed Adapter instances (Epic #929, issue + # #1144), keyed by _composed_adapter_key(). A composed Adapter has no + # `.config` field (that's shim-only state); see + # _intrinsic_adapter_name_and_config. + self._composed_adapter_configs: dict[str, dict] = {} self._adapter_lock = threading.RLock() # Call once to create an async_client and populate the cache. @@ -323,26 +335,46 @@ def add_adapter(self, adapter: AdapterInput) -> None: Args: adapter (AdapterInput): The adapter to register. Must be an - `EmbeddedIntrinsicAdapter`. + `EmbeddedIntrinsicAdapter` or a composed `Adapter` whose + `weights` is an `EmbeddedBinding`. Raises: - TypeError: If `adapter` is not an `EmbeddedIntrinsicAdapter`. + TypeError: If `adapter` is not a supported Embedded adapter. """ - if not isinstance(adapter, EmbeddedIntrinsicAdapter): - raise TypeError( - f"OpenAIBackend currently only supports EmbeddedIntrinsicAdapter. " - f"Got: {type(adapter).__name__}" - ) - if adapter.qualified_name in self._added_adapters: - MelleaLogger.get_logger().warning( - f"attempted to add adapter {adapter.qualified_name!r} but it is " - "already registered; refusing to overwrite it." - ) + if isinstance(adapter, EmbeddedIntrinsicAdapter): + if adapter.qualified_name in self._added_adapters: + MelleaLogger.get_logger().warning( + f"attempted to add adapter {adapter.qualified_name!r} but it is " + "already registered; refusing to overwrite it." + ) + return + adapter.backend = self + if isinstance(adapter.weights, EmbeddedBinding): + adapter.weights.source = self.base_model_name + self._added_adapters[adapter.qualified_name] = adapter return - adapter.backend = self - if isinstance(adapter.weights, EmbeddedBinding): + + if isinstance(adapter, _AdapterCore): + if not isinstance(adapter.weights, EmbeddedBinding): + raise TypeError( + "OpenAIBackend only supports the Embedded/Granite Switch " + f"reality for a composed Adapter; got {type(adapter.weights).__name__}." + ) + key = _composed_adapter_key(adapter) + if key in self._added_adapters: + MelleaLogger.get_logger().warning( + f"attempted to add adapter {key!r} but it is already " + "registered; refusing to overwrite it." + ) + return adapter.weights.source = self.base_model_name - self._added_adapters[adapter.qualified_name] = adapter + self._added_adapters[key] = adapter + return + + raise TypeError( + "OpenAIBackend currently only supports EmbeddedIntrinsicAdapter or a " + f"composed Adapter. Got: {type(adapter).__name__}" + ) def list_adapters(self) -> list[str]: """Return qualified names of all registered adapters. @@ -362,6 +394,41 @@ def _adapter_activation_lock( # Convenience registration helpers # ------------------------------------------------------------------ + def _intrinsic_adapter_name_and_config( + self, adapter: "EmbeddedIntrinsicAdapter | _AdapterCore" + ) -> tuple[str, dict]: + """Return the adapter-function name and raw io.yaml config for an adapter. + + `EmbeddedIntrinsicAdapter` carries both directly (`.name`/`.config`). + A composed `Adapter` carries neither — `io_contract` does not yet + drive `IntrinsicsRewriter`/`IntrinsicsResultProcessor` (Epic #929, + issue #1144) — so its config is looked up from + `_composed_adapter_configs`, cached at registration time (see + `add_adapter`/`register_embedded_adapter_model`) since it comes from + `adapter_index.json`/`io.yaml` and cannot be cheaply re-derived. + + Args: + adapter: The adapter to resolve a name and config for. + + Returns: + tuple[str, dict]: The adapter-function name and its parsed + io.yaml config. + + Raises: + ValueError: A composed adapter has no cached config (never + registered via `add_adapter`/`register_embedded_adapter_model`). + """ + if isinstance(adapter, EmbeddedIntrinsicAdapter): + return adapter.name, adapter.config + key = _composed_adapter_key(adapter) + config = self._composed_adapter_configs.get(key) + if config is None: + raise ValueError( + f"No io.yaml config cached for composed adapter {key!r}; register " + "it via register_embedded_adapter_model() or resolve_adapter()." + ) + return adapter.identity.name, config + def register_embedded_adapter_model( self, source: str, *, revision: str = "main", cache_dir: str | None = None ) -> list[str]: @@ -375,19 +442,18 @@ def register_embedded_adapter_model( Returns: list[str]: Names of the registered intrinsics. """ - import os - # No lock here: both call sites (here, and this class's __init__) run # single-threaded during construction, before the backend is exposed to # any other thread — see the matching note on LocalHFBackend's twin. - adapters = EmbeddedIntrinsicAdapter.from_source( + discovered = _discover_embedded_adapters( source, revision=revision, cache_dir=cache_dir ) - - for adapter in adapters: + names = [] + for adapter, config in discovered: self.add_adapter(adapter) - - return [a.intrinsic_name for a in adapters] + self._composed_adapter_configs[_composed_adapter_key(adapter)] = config + names.append(adapter.identity.name) + return names @property def _async_client(self) -> openai.AsyncOpenAI: @@ -785,16 +851,18 @@ async def _generate_from_intrinsic( # TODO: OpenAIBackend only supports EmbeddedAdapters. # It should be refactored into a specific adapter.transform() function. - if not isinstance(adapter, EmbeddedIntrinsicAdapter): + if not isinstance(adapter, (EmbeddedIntrinsicAdapter, _AdapterCore)): raise TypeError( - f"OpenAIBackend only supports EmbeddedIntrinsicAdapter, got: {type(adapter).__name__}" + "OpenAIBackend only supports EmbeddedIntrinsicAdapter or a composed " + f"Adapter, got: {type(adapter).__name__}" ) - intrinsic_config = adapter.config - assert intrinsic_config is not None + adapter_name, intrinsic_config = self._intrinsic_adapter_name_and_config( + adapter + ) rewriter = granite_formatters.IntrinsicsRewriter( - config_dict=intrinsic_config, model_name=adapter.name + config_dict=intrinsic_config, model_name=adapter_name ) result_processor = granite_formatters.IntrinsicsResultProcessor( config_dict=intrinsic_config diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index ff7bd67d41..e5705590f0 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -24,7 +24,7 @@ get_io_contract, ) from mellea.backends.adapters._core import Identity, LocalFileBinding -from mellea.backends.adapters.adapter import AdapterMixin +from mellea.backends.adapters.adapter import AdapterMixin, _composed_adapter_key from mellea.backends.adapters.catalog import AdapterType, IntrinsicsCatalogEntry # --------------------------------------------------------------------------- @@ -584,7 +584,12 @@ def test_resolve_adapter_raises_without_base_model(): def test_resolve_adapter_lazy_creates_and_returns(): - """resolve_adapter must create an IntrinsicAdapter when none is registered.""" + """resolve_adapter must create a composed Adapter when none is registered. + + Epic #929, issue #1144: resolve_adapter's default (LORA) construction site + builds a composed `Adapter(identity, io_contract, LocalFileBinding)`, not + the deprecated `IntrinsicAdapter` shim. + """ mock_catalog_entry = IntrinsicsCatalogEntry( name="answerability", repo_id="ibm-granite/granitelib-rag-r1.0", @@ -599,7 +604,7 @@ def test_resolve_adapter_lazy_creates_and_returns(): def fake_add_adapter(a): created_adapters.append(a) - mock_backend._added_adapters[a.qualified_name] = a + mock_backend._added_adapters[_composed_adapter_key(a)] = a mock_backend._added_adapters = {} mock_backend.add_adapter.side_effect = fake_add_adapter @@ -607,16 +612,9 @@ def fake_add_adapter(a): AdapterMixin._find_adapter(mock_backend, cap, types) ) - with ( - patch( - "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", - return_value=mock_catalog_entry, - ), - patch( - "mellea.backends.adapters.adapter.intrinsics.obtain_io_yaml", - return_value="/fake/adapter.yaml", - ), - patch("builtins.open", mock_open(read_data="key: value")), + with patch( + "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", + return_value=mock_catalog_entry, ): result = AdapterMixin.resolve_adapter(mock_backend, "answerability") @@ -624,8 +622,10 @@ def fake_add_adapter(a): "add_adapter must be called for a new capability" ) assert len(created_adapters) == 1 - assert isinstance(created_adapters[0], IntrinsicAdapter) - assert created_adapters[0].adapter_type == AdapterType.LORA + assert isinstance(created_adapters[0], Adapter) + assert not isinstance(created_adapters[0], IntrinsicAdapter) + assert isinstance(created_adapters[0].weights, LocalFileBinding) + assert created_adapters[0].weights.adapter_type == AdapterType.LORA assert result is created_adapters[0] @@ -648,22 +648,17 @@ def test_resolve_adapter_catalog_alias_returns_registered_adapter(): mock_backend._uses_embedded_adapters = False mock_backend._added_adapters = {} mock_backend.add_adapter.side_effect = lambda adapter: ( - mock_backend._added_adapters.__setitem__(adapter.qualified_name, adapter) + mock_backend._added_adapters.__setitem__( + _composed_adapter_key(adapter), adapter + ) ) mock_backend._find_adapter.side_effect = lambda cap, types=None: ( AdapterMixin._find_adapter(mock_backend, cap, types) ) - with ( - patch( - "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", - return_value=mock_catalog_entry, - ), - patch( - "mellea.backends.adapters.adapter.intrinsics.obtain_io_yaml", - return_value="/fake/adapter.yaml", - ), - patch("builtins.open", mock_open(read_data="key: value")), + with patch( + "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", + return_value=mock_catalog_entry, ): result = AdapterMixin.resolve_adapter(mock_backend, "guardian-core") @@ -743,7 +738,7 @@ def test_resolve_adapter_survives_reentrant_activation_lock(): mock_backend._added_adapters = {} mock_backend._adapter_activation_lock.return_value = real_lock mock_backend.add_adapter.side_effect = lambda a: ( - mock_backend._added_adapters.__setitem__(a.qualified_name, a) + mock_backend._added_adapters.__setitem__(_composed_adapter_key(a), a) ) mock_backend._find_adapter.side_effect = lambda cap, types=None: ( AdapterMixin._find_adapter(mock_backend, cap, types) @@ -754,11 +749,6 @@ def test_resolve_adapter_survives_reentrant_activation_lock(): "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", return_value=mock_catalog_entry, ), - patch( - "mellea.backends.adapters.adapter.intrinsics.obtain_io_yaml", - return_value="/fake/adapter.yaml", - ), - patch("builtins.open", mock_open(read_data="key: value")), real_lock, # simulate an already-in-progress caller holding the lock ): result = AdapterMixin.resolve_adapter(mock_backend, "answerability") @@ -792,23 +782,16 @@ def fake_add_adapter(a): assert tracking_lock.enter_count == 1 and tracking_lock.exit_count == 0, ( "add_adapter must run while the activation lock is held" ) - mock_backend._added_adapters[a.qualified_name] = a + mock_backend._added_adapters[_composed_adapter_key(a)] = a mock_backend.add_adapter.side_effect = fake_add_adapter mock_backend._find_adapter.side_effect = lambda cap, types=None: ( AdapterMixin._find_adapter(mock_backend, cap, types) ) - with ( - patch( - "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", - return_value=mock_catalog_entry, - ), - patch( - "mellea.backends.adapters.adapter.intrinsics.obtain_io_yaml", - return_value="/fake/adapter.yaml", - ), - patch("builtins.open", mock_open(read_data="key: value")), + with patch( + "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", + return_value=mock_catalog_entry, ): AdapterMixin.resolve_adapter(mock_backend, "answerability") @@ -847,7 +830,7 @@ def test_resolve_adapter_holds_activation_lock_once_across_embedded_loop(): mock_backend._adapter_activation_lock.return_value = tracking_lock def fake_add_adapter(a): - mock_backend._added_adapters[a.qualified_name] = a + mock_backend._added_adapters[_composed_adapter_key(a)] = a mock_backend.add_adapter.side_effect = fake_add_adapter mock_backend._find_adapter.side_effect = lambda cap, types=None: ( @@ -902,11 +885,12 @@ def test_resolve_adapter_concurrent_first_use_does_not_double_register(): ) def racy_add_adapter(adapter): - existing = registry.get(adapter.qualified_name) + key = _composed_adapter_key(adapter) + existing = registry.get(key) if existing is not None: return time.sleep(0.02) # widen the read-then-write window - registry[adapter.qualified_name] = adapter + registry[key] = adapter registrations.append(adapter) mock_backend.add_adapter.side_effect = racy_add_adapter diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index 7a79c22673..f2cee810dd 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -42,7 +42,10 @@ ServerMediatedBinding, ) from mellea.backends.adapters._core import Identity -from mellea.backends.adapters.adapter import EmbeddedIntrinsicAdapter +from mellea.backends.adapters.adapter import ( + EmbeddedIntrinsicAdapter, + _ShimWeightsBinding, +) from mellea.backends.adapters.catalog import IntrinsicsCatalogEntry from mellea.backends.huggingface import LocalHFBackend from mellea.core import ModelOutputThunk @@ -233,6 +236,7 @@ def _make_intrinsic_adapter_stub(): name="answerability", adapter_type="alora", capability="answerability" ), ) + object.__setattr__(adapter, "weights", _ShimWeightsBinding()) return adapter @@ -280,6 +284,15 @@ def _make_intrinsic_backend_stub(stub_backend): stub_backend._find_adapter = lambda cap, types=None: AdapterMixin._find_adapter( stub_backend, cap, types ) + stub_backend._intrinsic_adapter_name_and_config = lambda adapter: ( + LocalHFBackend._intrinsic_adapter_name_and_config(stub_backend, adapter) + ) + # Composed-Adapter counterpart of _generate_intrinsic_with_adapter_scope + # above — same bypass, for the same reason (these tests exercise + # option-merging/logits capture, not activation semantics). + stub_backend._generate_composed_local_file_with_adapter_scope = ( + lambda adapter, generate_func, *args, **kwargs: generate_func(*args, **kwargs) + ) return stub_backend @@ -323,12 +336,21 @@ def test_load_embedded_adapters_registers_checkpoint_adapters(): ) mock_from_source.assert_called_once_with( - "/tmp/switch-checkpoint", revision="main", cache_dir=None + "/tmp/switch-checkpoint", revision="main", cache_dir=None, intrinsic_name=None ) assert backend.list_adapters() == ["answerability_alora"] - assert backend._added_adapters["answerability_alora"] is adapter - assert adapter.backend is backend + # register_embedded_adapter_model discovers via the non-shim + # _discover_embedded_adapters factory (Epic #929, issue #1144), which + # lifts the shim's identity/io_contract/weights into a composed Adapter + # stored in _composed_adapters, not the shim instance itself. + registered = backend._composed_adapters["answerability_alora"] + assert registered.identity == adapter.identity + assert registered.weights is adapter.weights assert isinstance(adapter.weights, EmbeddedBinding) + # The composed Adapter is registered, not the shim instance, so the + # shim's own `.backend` is never touched; the binding it shares with the + # composed Adapter (asserted above) is what actually gets stamped. + assert adapter.weights.source == backend.base_model_name assert adapter.weights.source == backend.base_model_name backend._model.load_adapter.assert_not_called() diff --git a/test/backends/test_openai_intrinsics.py b/test/backends/test_openai_intrinsics.py index f6fa354591..df11c6e509 100644 --- a/test/backends/test_openai_intrinsics.py +++ b/test/backends/test_openai_intrinsics.py @@ -223,7 +223,7 @@ def _registered_intrinsic_names(backend: OpenAIBackend) -> set[str]: """Return the set of intrinsic names that have registered adapters.""" names = set() for adapter in backend._added_adapters.values(): - names.add(adapter.intrinsic_name) + names.add(adapter.identity.name) return names From cc331c5fa4c759b82f346c12ca9c5088e36b7bfc Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 11:22:40 +0100 Subject: [PATCH 02/19] test(backends): add composed-Adapter coverage alongside the deprecated shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds unit/integration tests exercising the composed Adapter registration and generation paths added for issue #1144: LocalHFBackend.add_adapter for a LocalFileBinding-backed composed Adapter (registration, duplicate-name refusal, capability lookup, adapter_scope activation), OpenAIBackend.add_adapter for an EmbeddedBinding-backed composed Adapter, and _generate_from_intrinsic end-to-end on both backends with a composed Adapter instead of a shim. Also confirmed the existing real-GPU e2e test (test_intrinsic_adapter_scope_e2e.py) already exercises the new composed path transparently, since resolve_adapter's default construction site changed — ran it locally against Metal/MPS and both cases pass. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- .../test_adapters/test_embedded_adapter.py | 69 ++++++++++++++ .../test_local_file_integration.py | 53 +++++++++++ test/backends/test_huggingface_unit.py | 92 ++++++++++++++++++- test/backends/test_openai_intrinsics_unit.py | 63 +++++++++++++ 4 files changed, 276 insertions(+), 1 deletion(-) diff --git a/test/backends/test_adapters/test_embedded_adapter.py b/test/backends/test_adapters/test_embedded_adapter.py index 6fc5367a12..0edd3afc78 100644 --- a/test/backends/test_adapters/test_embedded_adapter.py +++ b/test/backends/test_adapters/test_embedded_adapter.py @@ -6,6 +6,7 @@ import json import os import pathlib +from typing import Literal from unittest.mock import MagicMock, patch import pytest @@ -645,5 +646,73 @@ def test_adapter_source_defaults_to_model_id(self, model_dir, hub_cache_dir): assert len(backend._added_adapters) == 2 +class TestOpenAIBackendComposedAdapterRegistration: + """Composed-`Adapter` counterpart of `TestOpenAIBackendRegistration` (Epic + #929, issue #1144): `add_adapter` must accept a composed `Adapter` whose + `weights` is an `EmbeddedBinding` directly, alongside the deprecated shim. + """ + + @pytest.fixture + def backend(self): + os.environ.setdefault("OPENAI_API_KEY", "test-key") + from mellea.backends.openai import OpenAIBackend + + return OpenAIBackend( + model_id="granite-switch", base_url="http://localhost:8000/v1" + ) + + @staticmethod + def _make_composed_adapter( + name: str = "answerability", adapter_type: Literal["lora", "alora"] = "alora" + ): + from mellea.backends.adapters._core import Adapter, EmbeddedBinding, Identity + from mellea.backends.adapters.io_contracts import get_io_contract + + return Adapter( + identity=Identity(name=name, adapter_type=adapter_type, capability=name), + io_contract=get_io_contract(name), + weights=EmbeddedBinding(), + ) + + def test_add_composed_adapter(self, backend): + adapter = self._make_composed_adapter() + backend.add_adapter(adapter) + assert "answerability_alora" in backend._added_adapters + assert backend._added_adapters["answerability_alora"] is adapter + assert adapter.weights.source == backend.base_model_name # type: ignore[union-attr] + + def test_add_composed_adapter_rejects_non_embedded_weights(self, backend): + from mellea.backends.adapters._core import Adapter, Identity, LocalFileBinding + + adapter = Adapter( + identity=Identity(name="answerability", adapter_type="lora"), + io_contract=self._make_composed_adapter().io_contract, + weights=LocalFileBinding.from_catalog("answerability"), + ) + with pytest.raises(TypeError, match="Embedded/Granite Switch"): + backend.add_adapter(adapter) + + def test_add_composed_adapter_refuses_duplicate_name(self, backend): + first = self._make_composed_adapter() + second = self._make_composed_adapter() + backend.add_adapter(first) + backend.add_adapter(second) + assert backend._added_adapters["answerability_alora"] is first + + def test_composed_adapter_discoverable_by_capability(self, backend): + adapter = self._make_composed_adapter() + backend.add_adapter(adapter) + assert backend._find_adapter("answerability") is adapter + + def test_list_adapters_includes_composed_adapter(self, backend): + backend.add_adapter(self._make_composed_adapter()) + backend.add_adapter( + EmbeddedIntrinsicAdapter( + "citations", config=_CITATIONS_CONFIG, technology="lora" + ) + ) + assert set(backend.list_adapters()) == {"answerability_alora", "citations_lora"} + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) diff --git a/test/backends/test_adapters/test_local_file_integration.py b/test/backends/test_adapters/test_local_file_integration.py index a369ffad72..f224ec8002 100644 --- a/test/backends/test_adapters/test_local_file_integration.py +++ b/test/backends/test_adapters/test_local_file_integration.py @@ -161,5 +161,58 @@ def test_deactivate_runs_even_when_generation_body_raises(): assert isinstance(invocations[0].error, RuntimeError) +def test_add_adapter_registers_composed_adapter_via_backend(): + """`backend.add_adapter(composed_adapter)` drives the binding lifecycle + itself (Epic #929, issue #1144) — a caller no longer has to call + `binding.bind_backend()`/`binding.prepare()` separately before the + composed `Adapter` becomes resolvable by capability name. + """ + backend = _make_backend() + binding = _make_binding() + adapter = _make_adapter(binding) + + with patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ): + backend.add_adapter(adapter) + + assert binding.backend is backend + assert binding.qualified_name in backend.list_adapters() + # add_adapter also makes the composed Adapter itself discoverable by + # capability, unlike registering a bare LocalFileBinding directly. + found = backend._find_adapter("answerability") + assert found is adapter + + with capture_adapter_hooks() as mock_invoke: + with backend.adapter_scope(adapter): + backend._model.set_adapter.assert_called_with(binding.qualified_name) # type: ignore[union-attr] + backend._model.set_adapter.assert_called_with([]) # type: ignore[union-attr] + + invocations = [p for p in hook_payloads(mock_invoke) if hasattr(p, "outcome")] + assert len(invocations) == 1 + assert invocations[0].outcome == "success" + + +def test_add_adapter_rejects_second_composed_registration_for_same_capability(): + """A second `add_adapter` for the same capability is refused, not silently + overwritten — mirrors the shim's duplicate-registration guard.""" + backend = _make_backend() + binding = _make_binding() + adapter = _make_adapter(binding) + other_binding = _make_binding() + other_adapter = _make_adapter(other_binding) + + with patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ): + backend.add_adapter(adapter) + backend.add_adapter(other_adapter) + + assert backend._find_adapter("answerability") is adapter + assert other_binding.backend is None + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index f2cee810dd..a50033af0f 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -10,7 +10,7 @@ import warnings from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest @@ -829,6 +829,96 @@ def transform(self, chunk, rewritten): assert HookType.ADAPTER_FUNCTION_INVOCATION_COMPLETE not in fired_hook_types +@pytest.mark.asyncio +async def test_composed_adapter_drives_generate_from_intrinsic(stub_backend): + """A composed `Adapter` (not the `IntrinsicAdapter` shim) drives the full + `_generate_from_intrinsic` path — name/config resolution, the composed + `_generate_composed_local_file_with_adapter_scope` dispatch, and normal + post-processing — end to end (Epic #929, issue #1144). + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + Identity, + LocalFileBinding, + ) + from mellea.backends.adapters.catalog import AdapterType + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_intrinsic_backend_stub(stub_backend) + backend.processing = AsyncMock(return_value=None) + binding = LocalFileBinding( + name="answerability", + adapter_type=AdapterType.ALORA, + repo_id="ibm-granite/granitelib-rag-r1.0", + revision="abc123", + ) + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=binding, + ) + backend._added_adapters = {} + backend._composed_adapters = {"answerability_alora": composed} + backend.base_model_name = "granite-4.1-3b" + + def fake_transformers_inputs(request, tokenizer, model, ll_tokenizer=None): + return {"input_tokens": object()}, {} + + def fake_generate_with_transformers(tokenizer, model, generate_input, other_input): + return _FakeChatCompletionResponseWithContent('{"result": "ok"}') + + class _PassthroughResultProcessor: + def __init__(self, *args, **kwargs): + pass + + def transform(self, chunk, rewritten): + return chunk + + with ( + patch( + "mellea.backends.huggingface.granite_formatters.IntrinsicsRewriter", + _FakeRewriter, + ), + patch( + "mellea.backends.huggingface.granite_formatters.IntrinsicsResultProcessor", + _PassthroughResultProcessor, + ), + patch( + "mellea.formatters.granite.base.util.chat_completion_request_to_transformers_inputs", + side_effect=fake_transformers_inputs, + ), + patch( + "mellea.formatters.granite.base.util.generate_with_transformers", + side_effect=fake_generate_with_transformers, + ), + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), + ): + output = await LocalHFBackend._generate_from_intrinsic( + backend, + Intrinsic("answerability"), + ChatContext().add(Message("user", "Is the sky blue?")), + model_options={}, + ) + assert output._gen.generate is not None + await output._gen.generate + + assert output._gen.process is not None + processed = False + while not output._gen.queue.empty(): + item = output._gen.queue.get_nowait() + if item is not None: + await output._gen.process(output, item) + processed = True + assert processed, "the composed local-file path must produce a response" + + def test_generate_embedded_with_generation_lock_deactivates_peft_state(): """Embedded generation clears stale PEFT state before running the checkpoint.""" backend = _make_backend() diff --git a/test/backends/test_openai_intrinsics_unit.py b/test/backends/test_openai_intrinsics_unit.py index 323137ddc7..af84ba8459 100644 --- a/test/backends/test_openai_intrinsics_unit.py +++ b/test/backends/test_openai_intrinsics_unit.py @@ -175,6 +175,39 @@ def _make_context() -> ChatContext: return ChatContext().add(Message("user", "What is the square root of 4?")) +def _make_backend_with_composed_adapter(config: dict) -> OpenAIBackend: + """Return an OpenAIBackend with a composed `Adapter` registered directly. + + Composed-Adapter counterpart of `_make_backend_with_adapter` (Epic #929, + issue #1144): `add_adapter` and `_generate_from_intrinsic` must drive the + same generation path for a composed `Adapter` as for the deprecated + `EmbeddedIntrinsicAdapter` shim. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding, + Identity, + ) + from mellea.backends.adapters.adapter import _composed_adapter_key + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = OpenAIBackend( + model_id="granite-switch", + api_key="fake-key", + base_url="http://localhost:9999/v1", + ) + adapter = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=EmbeddedBinding(), + ) + backend.add_adapter(adapter) + backend._composed_adapter_configs[_composed_adapter_key(adapter)] = config + return backend + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -208,6 +241,36 @@ async def test_chat_template_kwargs_set(): assert extra_body["chat_template_kwargs"]["adapter_name"] == "answerability" +async def test_composed_adapter_drives_generate_from_intrinsic(): + """A composed `Adapter` (not the `EmbeddedIntrinsicAdapter` shim) drives + the same generation path — activation, name/config resolution — as the + shim (Epic #929, issue #1144).""" + backend = _make_backend_with_composed_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 + ) + await mot.avalue() + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + extra_body = call_kwargs.kwargs.get("extra_body", {}) + + assert "chat_template_kwargs" in extra_body + assert extra_body["chat_template_kwargs"]["adapter_name"] == "answerability" + + async def test_result_processor_applied(): """Full answerability config: likelihood + nest transforms produce the expected JSON.""" backend = _make_backend_with_adapter(_ANSWERABILITY_CONFIG) From 263ddc4de1898e22b6c4df45bed50723ff0d6d95 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 11:24:46 +0100 Subject: [PATCH 03/19] feat(cli): generate composed-Adapter scaffolding for trained aLoRA adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/alora/README_TEMPLATE.jinja generated a CustomIntrinsicAdapter subclass for every user-trained adapter's model-card usage example. Switch to a composed Adapter (Identity + get_io_contract's permissive fallback for non-catalog names + LocalFileBinding(repo_id=...)) instead, matching the backend-side change in the previous commits — get_io_contract already falls back to a permissive dict contract for names outside the catalog, so no catalog monkey-patching is needed. Also fixes a pre-existing bug in the generated async helper, which referenced `adapter` before it was assigned. Verified by rendering the template with sample values and compiling the extracted Python code block. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- cli/alora/README_TEMPLATE.jinja | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/cli/alora/README_TEMPLATE.jinja b/cli/alora/README_TEMPLATE.jinja index d2a01c5bd9..c77d08c458 100644 --- a/cli/alora/README_TEMPLATE.jinja +++ b/cli/alora/README_TEMPLATE.jinja @@ -41,21 +41,29 @@ from mellea.core import Context from mellea.backends import Backend from mellea.stdlib.components.simple import SimpleComponent from mellea.backends.adapters import AdapterMixin -from mellea.backends.adapters.adapter import CustomIntrinsicAdapter +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters.catalog import AdapterType from mellea.stdlib.components.intrinsic import Intrinsic _INTRINSIC_MODEL_ID = "{{ userid }}/{{ intrinsic_name }}" _INTRINSIC_ADAPTER_NAME = "{{ intrinsic_name }}" +_INTRINSIC_QUALIFIED_NAME = f"{_INTRINSIC_ADAPTER_NAME}_{AdapterType.ALORA.value}" -class {{ intrinsic_name_camelcase }}Adapter(CustomIntrinsicAdapter): - def __init__(self, base_model_name: str): - super().__init__( - model_id=_INTRINSIC_MODEL_ID, - intrinsic_name=_INTRINSIC_ADAPTER_NAME, - base_model_name=base_model_name, - ) +def _{{ intrinsic_name }}_adapter() -> Adapter: + """Compose this trained adapter (LoRA/aLoRA weights at `_INTRINSIC_MODEL_ID`) + from its Identity, output contract, and weights binding, rather than a + dedicated shim class.""" + return Adapter( + identity=Identity(name=_INTRINSIC_ADAPTER_NAME, adapter_type="alora"), + io_contract=get_io_contract(_INTRINSIC_ADAPTER_NAME), + weights=LocalFileBinding( + name=_INTRINSIC_ADAPTER_NAME, + adapter_type=AdapterType.ALORA, + repo_id=_INTRINSIC_MODEL_ID, + ), + ) class {{ intrinsic_name_camelcase }}Intrinsic(Intrinsic, SimpleComponent): @@ -68,8 +76,8 @@ class {{ intrinsic_name_camelcase }}Intrinsic(Intrinsic, SimpleComponent): async def async_{{ intrinsic_name }}({{ arglist }}, ctx: Context, backend: Backend | AdapterMixin): # Backend.add_adapter should be idempotent, but we'll go ahead and check just in case. - if adapter.qualified_name not in backend.list_adapters(): - backend.add_adapter({{ intrinsic_name_camelcase }}Adapter(backend.base_model_name)) + if _INTRINSIC_QUALIFIED_NAME not in backend.list_adapters(): + backend.add_adapter(_{{ intrinsic_name }}_adapter()) action = {{ intrinsic_name_camelcase }}Intrinsic("{{ intrinsic_name }}", {{ arglist_without_type_annotations }}) mot = await backend.generate_from_context(action, ctx) return mot @@ -77,9 +85,8 @@ async def async_{{ intrinsic_name }}({{ arglist }}, ctx: Context, backend: Backe def {{ intrinsic_name }}({{ arglist }}, ctx: Context, backend: Backend | AdapterMixin): # Backend.add_adapter should be idempotent, but we'll go ahead and check just in case. - adapter = {{ intrinsic_name_camelcase }}Adapter(backend.base_model_name) - if adapter.qualified_name not in backend.list_adapters(): - backend.add_adapter(adapter) + if _INTRINSIC_QUALIFIED_NAME not in backend.list_adapters(): + backend.add_adapter(_{{ intrinsic_name }}_adapter()) action = {{ intrinsic_name_camelcase }}Intrinsic({{ arglist_without_type_annotations }}) return mfuncs.act(action, ctx, backend) From 5f7b50c619fd7f0f139a0b666b37955686773bb3 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 11:49:43 +0100 Subject: [PATCH 04/19] feat(stdlib): allow Intrinsic/ALoraRequirement to target a non-catalog adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intrinsic.__init__ unconditionally called fetch_intrinsic_metadata(), raising ValueError for any name outside the built-in intrinsics catalog. The deprecated CustomIntrinsicAdapter shim worked around this by monkey-patching the global catalog before constructing an Intrinsic — the real reason that "temporary hack" existed, beyond adapter registration. Since get_io_contract() already falls back to a permissive contract for a non-catalog name, close the same gap here: when fetch_intrinsic_metadata() raises and the caller has supplied adapter_types explicitly, build a synthetic IntrinsicsCatalogEntry instead of failing — Intrinsic only ever reads .name/.adapter_types from it. Thread adapter_types through ALoraRequirement so it can target a custom adapter too. Also add an intrinsic_name filter to register_embedded_adapter_model on both backends, so a caller can register a single known embedded adapter through the composed-Adapter discovery path instead of every adapter in the source. Verified end-to-end against real adapters: the composed custom-adapter pattern this unblocks is exercised by docs/examples/aLora/stembolts_intrinsic.py against the real nfulton/stembolts Hugging Face repo (updated in a follow-up commit). Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/huggingface.py | 15 ++++++- mellea/backends/openai.py | 17 ++++++-- .../stdlib/components/intrinsic/intrinsic.py | 41 ++++++++++++++++--- mellea/stdlib/requirements/requirement.py | 14 ++++++- .../components/intrinsic/test_intrinsic.py | 29 +++++++++++++ test/stdlib/requirements/test_requirement.py | 23 ++++++++++- 6 files changed, 125 insertions(+), 14 deletions(-) diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 02cb4eae97..7add60a97e 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -2644,7 +2644,12 @@ def add_adapter(self, adapter: AdapterInput) -> None: self._added_adapters[adapter.qualified_name] = adapter def register_embedded_adapter_model( - self, source: str, *, revision: str = "main", cache_dir: str | None = None + self, + source: str, + *, + revision: str = "main", + cache_dir: str | None = None, + intrinsic_name: str | None = None, ) -> list[str]: """Register embedded adapter functions from a Granite Switch checkpoint. @@ -2652,6 +2657,9 @@ def register_embedded_adapter_model( source: Local checkpoint directory or Hugging Face Hub repository ID. revision: Git revision when loading from the Hub. cache_dir: Cache directory for Hub downloads. + intrinsic_name: If provided, register only the adapter matching + this adapter function name. `None` registers all adapters + found in `source`. Returns: Names of the registered adapter functions. @@ -2667,7 +2675,10 @@ def register_embedded_adapter_model( # single-threaded during construction, before the backend is exposed to # any other thread — see the matching note on OpenAIBackend's twin. discovered = _discover_embedded_adapters( - source, revision=revision, cache_dir=cache_dir + source, + revision=revision, + cache_dir=cache_dir, + intrinsic_name=intrinsic_name, ) names = [] for adapter, config in discovered: diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 1d11c4cd3a..7d15ee79b3 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -430,14 +430,22 @@ def _intrinsic_adapter_name_and_config( return adapter.identity.name, config def register_embedded_adapter_model( - self, source: str, *, revision: str = "main", cache_dir: str | None = None + self, + source: str, + *, + revision: str = "main", + cache_dir: str | None = None, + intrinsic_name: str | None = None, ) -> list[str]: - """Register all embedded adapters from an Embedded Adapter model. + """Register embedded adapters from an Embedded Adapter model. Args: source (str): A local model directory path or Hugging Face Hub repo ID. revision (str): Git revision when loading from Hugging Face Hub. cache_dir (str | None): Cache directory for HF downloads. + intrinsic_name (str | None): If provided, register only the adapter + matching this adapter function name. `None` registers all + adapters found in `source`. Returns: list[str]: Names of the registered intrinsics. @@ -446,7 +454,10 @@ def register_embedded_adapter_model( # single-threaded during construction, before the backend is exposed to # any other thread — see the matching note on LocalHFBackend's twin. discovered = _discover_embedded_adapters( - source, revision=revision, cache_dir=cache_dir + source, + revision=revision, + cache_dir=cache_dir, + intrinsic_name=intrinsic_name, ) names = [] for adapter, config in discovered: diff --git a/mellea/stdlib/components/intrinsic/intrinsic.py b/mellea/stdlib/components/intrinsic/intrinsic.py index c73b59d1cd..bac45d6c06 100644 --- a/mellea/stdlib/components/intrinsic/intrinsic.py +++ b/mellea/stdlib/components/intrinsic/intrinsic.py @@ -6,11 +6,12 @@ An `Intrinsic` component references a named adapter from Mellea's intrinsic catalog and transforms a chat completion request — typically by injecting new messages, modifying model parameters, or applying structured output constraints. It must be -paired with a backend that supports adapter loading (e.g. `LocalHFBackend` with an -attached `IntrinsicAdapter`). +paired with a backend that supports adapter loading (e.g. `LocalHFBackend` with a +composed `Adapter` — see `mellea.backends.adapters` — attached via `add_adapter`). """ from ....backends.adapters import AdapterType, fetch_intrinsic_metadata +from ....backends.adapters.catalog import IntrinsicsCatalogEntry from ....core import Component, ModelOutputThunk, Span, TemplateRepresentation @@ -22,17 +23,30 @@ class Intrinsic(Component[str]): Must be paired with a backend that supports adapter loading. Args: - intrinsic_name (str): The user-visible name of the intrinsic; must match - a known name in Mellea's intrinsics catalog. + intrinsic_name (str): The user-visible name of the intrinsic. For a + name outside Mellea's intrinsics catalog (a custom, non-catalog + adapter function), `adapter_types` must be provided explicitly — + this is what lets a custom `Intrinsic` construct without a + catalog entry, replacing the deprecated `CustomIntrinsicAdapter` + shim's catalog monkey-patch (Epic #929, issue #1144). intrinsic_kwargs (dict | None): Optional keyword arguments required by the intrinsic at invocation time. + adapter_types (tuple[AdapterType, ...] | None): Adapter types this + intrinsic supports. Required for a name outside the catalog; + optional (defaults to the catalog entry's own types) otherwise. Attributes: - metadata: The intrinsic metadata fetched from the catalog. + metadata: The intrinsic metadata fetched from the catalog, or a + synthetic entry for a name outside it (see `adapter_types` above). intrinsic_kwargs (dict): Keyword arguments passed to the intrinsic. intrinsic_name (str): User-visible name of this intrinsic (property). adapter_types (tuple[AdapterType, ...]): Available adapter types that implement this intrinsic (property). Defaults to values in self.metadata. + + Raises: + ValueError: `intrinsic_name` is not in the catalog and `adapter_types` + was not provided — there is no way to know which adapter + realities to look up without either. """ def __init__( @@ -42,7 +56,22 @@ def __init__( adapter_types: tuple[AdapterType, ...] | None = None, ) -> None: """Initialize Intrinsic by fetching metadata for the named intrinsic from the catalog.""" - self.metadata = fetch_intrinsic_metadata(intrinsic_name) + try: + self.metadata = fetch_intrinsic_metadata(intrinsic_name) + except ValueError: + if adapter_types is None: + raise + # A custom, non-catalog adapter function: the caller has already + # told us which adapter types to look up, so a catalog entry + # (whose repo_id/revision this class never reads — see + # `adapter_types` and `intrinsic_name` properties below) isn't + # needed to construct a usable Intrinsic. + self.metadata = IntrinsicsCatalogEntry( + name=intrinsic_name, + repo_id="", + revision="unpinned", + adapter_types=adapter_types, + ) if intrinsic_kwargs is None: intrinsic_kwargs = {} self.intrinsic_kwargs = intrinsic_kwargs diff --git a/mellea/stdlib/requirements/requirement.py b/mellea/stdlib/requirements/requirement.py index 3c992e0c28..7948535060 100644 --- a/mellea/stdlib/requirements/requirement.py +++ b/mellea/stdlib/requirements/requirement.py @@ -6,7 +6,7 @@ from collections.abc import Callable from typing import Any, cast, overload -from ...backends.adapters import get_io_contract +from ...backends.adapters import AdapterType, get_io_contract from ...core import ( CBlock, Context, @@ -82,13 +82,22 @@ class ALoraRequirement(Requirement, Intrinsic): description (str): Human-readable requirement description. intrinsic_name (str | None): Name of the ALoRA intrinsic to use. Defaults to `"requirement-check"`. + adapter_types (tuple[AdapterType, ...] | None): Adapter types to look + up for `intrinsic_name`. Required when `intrinsic_name` is a + custom, non-catalog adapter function (see `Intrinsic`); ignored + for a catalog name, which supplies its own. Attributes: use_aloras (bool): Always `True`; this class always attempts to use ALoRA adapters for validation. """ - def __init__(self, description: str, intrinsic_name: str | None = None): + def __init__( + self, + description: str, + intrinsic_name: str | None = None, + adapter_types: tuple[AdapterType, ...] | None = None, + ): """Initialize ALoraRequirement with a description and optional intrinsic adapter name.""" # TODO: We may want to actually do the validation_fn here so that we can set the score. super().__init__( @@ -104,6 +113,7 @@ def __init__(self, description: str, intrinsic_name: str | None = None): self, intrinsic_name=intrinsic_name, intrinsic_kwargs={"requirement": f"{self.description}"}, + adapter_types=adapter_types, ) diff --git a/test/stdlib/components/intrinsic/test_intrinsic.py b/test/stdlib/components/intrinsic/test_intrinsic.py index 6a4309cff1..3d9ef4cf3f 100644 --- a/test/stdlib/components/intrinsic/test_intrinsic.py +++ b/test/stdlib/components/intrinsic/test_intrinsic.py @@ -3,10 +3,39 @@ """Unit tests for the Intrinsic component.""" +import pytest + from mellea.backends.adapters import AdapterType from mellea.stdlib.components import Intrinsic +class TestCustomNonCatalogName: + """A name outside the intrinsics catalog (Epic #929, issue #1144). + + Replaces the deprecated `CustomIntrinsicAdapter` shim's catalog + monkey-patch: a custom adapter function can construct an `Intrinsic` + without any catalog mutation, as long as it supplies `adapter_types`. + """ + + def test_custom_name_without_adapter_types_raises(self): + with pytest.raises(ValueError, match="Unknown intrinsic name"): + Intrinsic("totally-made-up-name") + + def test_custom_name_with_adapter_types_constructs(self): + intrinsic = Intrinsic( + "totally-made-up-name", adapter_types=(AdapterType.ALORA,) + ) + assert intrinsic.intrinsic_name == "totally-made-up-name" + assert intrinsic.adapter_types == (AdapterType.ALORA,) + assert intrinsic.metadata.name == "totally-made-up-name" + + def test_known_name_ignores_synthetic_fallback(self): + """A real catalog name never hits the synthetic-entry path, even + without adapter_types.""" + intrinsic = Intrinsic("answerability") + assert intrinsic.metadata.repo_id != "" + + class TestAdapterTypesOverride: """Verify the adapter_types constructor parameter.""" diff --git a/test/stdlib/requirements/test_requirement.py b/test/stdlib/requirements/test_requirement.py index c51d14726b..3e9cca8753 100644 --- a/test/stdlib/requirements/test_requirement.py +++ b/test/stdlib/requirements/test_requirement.py @@ -6,7 +6,7 @@ import pytest -from mellea.backends.adapters import AdapterSchemaMismatchError +from mellea.backends.adapters import AdapterSchemaMismatchError, AdapterType from mellea.core import ModelOutputThunk, Requirement from mellea.stdlib.context import ChatContext from mellea.stdlib.requirements import LLMaJRequirement, simple_validate @@ -227,6 +227,7 @@ def test_alora_requirement_default_intrinsic(mock_intrinsic_init): r, intrinsic_name="requirement-check", intrinsic_kwargs={"requirement": "must be valid"}, + adapter_types=None, ) @@ -239,6 +240,26 @@ def test_alora_requirement_custom_intrinsic(mock_intrinsic_init): r, intrinsic_name="custom_check", intrinsic_kwargs={"requirement": "must be valid"}, + adapter_types=None, + ) + + +@patch("mellea.stdlib.requirements.requirement.Intrinsic.__init__") +def test_alora_requirement_forwards_adapter_types(mock_intrinsic_init): + """adapter_types is required to use a custom, non-catalog intrinsic_name + (Epic #929, issue #1144) — ALoraRequirement must forward it to Intrinsic. + """ + mock_intrinsic_init.return_value = None + r = ALoraRequirement( + "must be valid", + intrinsic_name="custom_check", + adapter_types=(AdapterType.ALORA,), + ) + mock_intrinsic_init.assert_called_once_with( + r, + intrinsic_name="custom_check", + intrinsic_kwargs={"requirement": "must be valid"}, + adapter_types=(AdapterType.ALORA,), ) From 746c92d1d98f3f0c519129e4a345719b34194f59 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 11:50:03 +0100 Subject: [PATCH 05/19] docs(intrinsics): replace deprecated shim classes with composed Adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites every doc page and example that constructed IntrinsicAdapter, EmbeddedIntrinsicAdapter, or CustomIntrinsicAdapter directly to compose an Adapter (Identity + IOContract + a weights binding) instead: - docs/docs/advanced/intrinsics.md: "Direct adapter function usage" and "Composable adapter construction" — the latter no longer says neither backend accepts a composed Adapter, since PR1 added that support. - docs/docs/advanced/lora-and-alora-adapters.md: "Use the adapter in Mellea" — corrects the stale claim that Adapter's weight-loading isn't implemented; LocalFileBinding already accepts an arbitrary repo_id. - docs/docs/integrations/openai.md, docs/examples/granite-switch/{README.md, manual_adapter_loading.py}: manual embedded-adapter loading now uses register_embedded_adapter_model(intrinsic_name=...) instead of EmbeddedIntrinsicAdapter.from_hub(). - docs/examples/aLora/101_example.py, docs/examples/intrinsics/intrinsics.py: catalog-backed adapters, composed via LocalFileBinding with an explicit ALORA adapter_type (requirement-check's catalog entry lists LoRA first, so LocalFileBinding.from_catalog would pick the wrong type). - docs/examples/aLora/stembolts_intrinsic.py: the hardest case — a fully custom, non-catalog adapter with its own output schema. Needs an explicit revision (LocalFileBinding.resolved_revision() only has a catalog fallback) and adapter_types passed to Intrinsic (see the previous commit). Every snippet claiming to run was verified against a real Granite model (and, for stembolts_intrinsic.py, the real nfulton/stembolts Hugging Face repo) rather than assumed from the pattern. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- docs/docs/advanced/intrinsics.md | 78 +++++++++---------- docs/docs/advanced/lora-and-alora-adapters.md | 47 +++++------ docs/docs/integrations/openai.md | 9 +-- docs/examples/aLora/101_example.py | 35 ++++++--- docs/examples/aLora/stembolts_intrinsic.py | 59 ++++++++++---- docs/examples/granite-switch/README.md | 2 +- .../granite-switch/manual_adapter_loading.py | 15 ++-- docs/examples/intrinsics/intrinsics.py | 28 ++++++- 8 files changed, 163 insertions(+), 110 deletions(-) diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index 298beb27dc..289b5a424d 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -214,24 +214,27 @@ print(result) ## Direct adapter function usage -> **Advanced:** For custom adapter tasks, use the `Intrinsic` component and -> `CustomIntrinsicAdapter` directly. +> **Advanced:** For custom adapter tasks, compose an `Adapter` directly from +> an `Identity`, an output contract, and a weights binding. ```python # Requires: mellea[hf] # Returns: dict import mellea.stdlib.functional as mfuncs -from mellea.backends.adapters.adapter import CustomIntrinsicAdapter +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract from mellea.backends.huggingface import LocalHFBackend from mellea.stdlib.components import Intrinsic, Message from mellea.stdlib.context import ChatContext backend = LocalHFBackend(model_id="ibm-granite/granite-4.1-3b") -# Register an adapter by task name -req_adapter = CustomIntrinsicAdapter( - "requirement-check", - base_model_name=backend.base_model_name, +# Compose an adapter by task name — get_io_contract returns the catalog's +# declared contract (or a permissive fallback for a name outside it), and +# LocalFileBinding.from_catalog resolves the repo/revision to download. +req_adapter = Adapter( + identity=Identity(name="requirement-check", adapter_type="alora"), + io_contract=get_io_contract("requirement-check"), + weights=LocalFileBinding.from_catalog("requirement-check"), ) backend.add_adapter(req_adapter) @@ -255,52 +258,39 @@ 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": }}`. +For a fully custom, non-catalog adapter — your own trained LoRA/aLoRA weights, +not one of the built-in adapter functions — see +[Adding a custom adapter function in 20 lines](../tutorials/custom-adapter-function.md). + ## 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, 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. +`Adapter` composes an `Identity`, an output contract (`IOContract`), and a +weights binding into a single, inspectable object. Both `LocalHFBackend` and +`OpenAIBackend` accept a composed `Adapter` directly via `add_adapter` — +dispatching on the weights binding's reality (`LocalFileBinding` for +LocalFile/PEFT, `EmbeddedBinding` for Embedded/Granite Switch) — alongside +the deprecated shim classes, which remain functional for now (Epic #929, +issue #1144). 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: +`prepare`/`activate`/`deactivate`/`release` lifecycle — driven automatically +by `add_adapter`, so a caller need not call `prepare()` itself: ```python # Requires: mellea[hf] -from mellea.backends.adapters import Adapter, EmbeddedBinding, Identity, IOContract, LocalFileBinding +from mellea.backends.adapters import Adapter, EmbeddedBinding, Identity, LocalFileBinding, get_io_contract 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_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="lora"), - io_contract=AnswerabilityContract(), - weights=hf_binding, + io_contract=get_io_contract("answerability"), + weights=LocalFileBinding.from_catalog("answerability"), ) +hf_backend.add_adapter(hf_adapter) # downloads and loads the weights ``` `EmbeddedBinding` has no weights to manage — the adapter is already part of @@ -315,20 +305,24 @@ switch_backend = OpenAIBackend( ) switch_adapter = Adapter( identity=Identity(name="answerability", adapter_type="alora"), - io_contract=AnswerabilityContract(), + io_contract=get_io_contract("answerability"), weights=EmbeddedBinding.from_base_model(switch_backend), ) +switch_backend.add_adapter(switch_adapter) ``` -Weights-binding support by backend today — this tracks the binding -implementations, not whether a composed `Adapter` can be registered directly: +Weights-binding support by backend today: | Backend | `LocalFileBinding` (LocalFile/PEFT) | `EmbeddedBinding` (Embedded/Granite Switch) | `ServerMediatedBinding` | | --- | --- | --- | --- | -| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a `LocalFileBinding` directly | ✅ shipping — `load_embedded_adapters=True`, via the deprecated `EmbeddedIntrinsicAdapter` shim | — | -| `OpenAIBackend` | — | ✅ shipping, via the deprecated `EmbeddedIntrinsicAdapter` shim above, which builds an `EmbeddedBinding` internally | — | +| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a composed `Adapter` or a bare `LocalFileBinding` directly | ✅ shipping — `load_embedded_adapters=True`, or `add_adapter`/`register_embedded_adapter_model` with a composed `Adapter` | — | +| `OpenAIBackend` | — | ✅ shipping — `load_embedded_adapters=True`, or `add_adapter`/`register_embedded_adapter_model` with a composed `Adapter` | — | `ServerMediatedBinding` has no backend implementation yet — see discussion #1486. +Discovering *multiple* embedded adapters from a Granite Switch checkpoint or +Hub repo (rather than one already-known name) still goes through +`register_embedded_adapter_model`, which builds the composed `Adapter` +instances for you. --- diff --git a/docs/docs/advanced/lora-and-alora-adapters.md b/docs/docs/advanced/lora-and-alora-adapters.md index b6198cdb23..f52b69fefd 100644 --- a/docs/docs/advanced/lora-and-alora-adapters.md +++ b/docs/docs/advanced/lora-and-alora-adapters.md @@ -117,34 +117,41 @@ m alora upload ./checkpoints/my_adapter \ ## Use the adapter in Mellea -Load the trained adapter into a `LocalHFBackend` using `CustomIntrinsicAdapter`. - -> **Note:** `CustomIntrinsicAdapter` is deprecated in favor of the `Adapter` / -> `WeightsBinding` model, but is still the only working way to load a -> locally-trained custom adapter — `Adapter`'s weight-loading is not yet -> implemented. This example will move to `Adapter` once that lands; see #1144. +Load the trained adapter into a `LocalHFBackend` by composing an `Adapter` +directly — `LocalFileBinding` accepts an arbitrary `repo_id`, so a locally +trained, non-catalog adapter does not need a dedicated shim class. ```python from mellea.backends.huggingface import LocalHFBackend -from mellea.backends.adapters.adapter import CustomIntrinsicAdapter +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters.catalog import AdapterType from mellea.stdlib.context import ChatContext from mellea import MelleaSession from mellea.stdlib.requirements import ALoraRequirement backend = LocalHFBackend(model_id="ibm-granite/granite-3.2-8b-instruct") -adapter = CustomIntrinsicAdapter( - model_id="your-org/my-adapter", # HF repo ID or local checkpoint path - base_model_name="granite-3.2-8b-instruct", - intrinsic_name="custom-failure-check", +adapter = Adapter( + identity=Identity(name="custom-failure-check", adapter_type="alora"), + # get_io_contract falls back to a permissive dict contract for names + # outside the built-in catalog, so this works for a custom adapter too. + io_contract=get_io_contract("custom-failure-check"), + weights=LocalFileBinding( + name="custom-failure-check", + adapter_type=AdapterType.ALORA, + repo_id="your-org/my-adapter", # HF repo ID or local checkpoint path + revision="main", # a custom adapter has no catalog entry to resolve this from + ), ) -backend.add_adapter(adapter) +backend.add_adapter(adapter) # downloads and loads the weights m = MelleaSession(backend, ctx=ChatContext()) failure_check = ALoraRequirement( "The failure mode must not be 'no_failure'.", intrinsic_name="custom-failure-check", + # Required for a name outside the intrinsics catalog — see Intrinsic. + adapter_types=(AdapterType.ALORA,), ) result = m.instruct( "Write a triage summary based on this technician note: {{note}}", @@ -155,17 +162,13 @@ print(str(result)) # Output will vary — LLM responses depend on model and temperature. ``` -> **Note:** `CustomIntrinsicAdapter` emits an advisory `UserWarning` because -> custom capability names are not part of Mellea's built-in capability registry. -> The adapter is still registered for routing. - `ALoraRequirement` routes validation through the adapter with the matching -`intrinsic_name`. Create `CustomIntrinsicAdapter` before the requirement: its -compatibility shim registers the custom name, which lets `ALoraRequirement` -resolve it. The adapter runs at the `check_requirement` prompt position. Its -`io.yaml` must transform the output into the -`{"requirement_check": {"score": }}` response schema; label-only -adapter output is not compatible with `ALoraRequirement`. +`intrinsic_name`. Call `backend.add_adapter(adapter)` before the requirement +runs — it registers the custom name so `ALoraRequirement` can resolve it. The +adapter runs at the `check_requirement` prompt position. Its `io.yaml` must +transform the output into the `{"requirement_check": {"score": }}` +response schema; label-only adapter output is not compatible with +`ALoraRequirement`. ## How automatic routing works diff --git a/docs/docs/integrations/openai.md b/docs/docs/integrations/openai.md index 861aa6a959..500e8185ed 100644 --- a/docs/docs/integrations/openai.md +++ b/docs/docs/integrations/openai.md @@ -348,7 +348,6 @@ The high-level adapter function wrappers (`rag.check_answerability`, For more control, load adapters manually with `load_embedded_adapters=False`: ```python -from mellea.backends.adapters.adapter import EmbeddedIntrinsicAdapter from mellea.backends.openai import OpenAIBackend from mellea.backends.model_ids import IBM_GRANITE_SWITCH_4_1_3B_PREVIEW from mellea.formatters import TemplateFormatter @@ -361,13 +360,13 @@ backend = OpenAIBackend( load_embedded_adapters=False, ) -# Load a single adapter from the model's Hugging Face repo -adapters = EmbeddedIntrinsicAdapter.from_hub( +# Discover and register a single adapter from the model's Hugging Face repo — +# this composes an Adapter (Identity + IOContract + EmbeddedBinding) rather +# than returning the deprecated EmbeddedIntrinsicAdapter shim. +backend.register_embedded_adapter_model( IBM_GRANITE_SWITCH_4_1_3B_PREVIEW.hf_model_name, intrinsic_name="answerability", ) -for adapter in adapters: - backend.add_adapter(adapter) ``` ## Troubleshooting diff --git a/docs/examples/aLora/101_example.py b/docs/examples/aLora/101_example.py index 822903cf20..1840f21f3b 100644 --- a/docs/examples/aLora/101_example.py +++ b/docs/examples/aLora/101_example.py @@ -1,10 +1,10 @@ # pytest: huggingface, e2e, slow """How `ALoraRequirement` routes validation through a fast adapter. -Uses the catalog-native `requirement-check` adapter (registered here via -`IntrinsicAdapter`, still the only working way to drive `_generate_from_intrinsic` — -see #1144) to compare aLoRA-backed validation against full LLM-as-judge generation -for the same requirement. `LLMaJRequirement` is used for the comparison because +Uses the catalog-native `requirement-check` adapter, registered here as a +composed `Adapter` (Identity + IOContract + LocalFileBinding), to compare +aLoRA-backed validation against full LLM-as-judge generation for the same +requirement. `LLMaJRequirement` is used for the comparison because `ALoraRequirement` always routes through the registered adapter regardless of `backend.default_to_constraint_checking_alora`. @@ -18,8 +18,13 @@ import time from mellea import MelleaSession, model_ids -from mellea.backends.adapters import AdapterType -from mellea.backends.adapters.adapter import IntrinsicAdapter +from mellea.backends.adapters import ( + Adapter, + Identity, + LocalFileBinding, + get_io_contract, +) +from mellea.backends.adapters.catalog import AdapterType, fetch_intrinsic_metadata from mellea.backends.huggingface import LocalHFBackend from mellea.core import GenerateLog, ValidationResult from mellea.stdlib.context import ChatContext @@ -40,11 +45,21 @@ # returning an ordinary failed check or propagating the exception. The ALORA # type is also load-bearing here: routing only looks up ("alora",), so # registering the LORA variant instead would hit the same failure. +_requirement_check_metadata = fetch_intrinsic_metadata("requirement-check") backend.add_adapter( - IntrinsicAdapter( # emits a DeprecationWarning — see module docstring, #1144 - "requirement-check", - adapter_type=AdapterType.ALORA, - base_model_name=backend.base_model_name, + Adapter( + identity=Identity( + name="requirement-check", + adapter_type="alora", + capability=_requirement_check_metadata.effective_capability, + ), + io_contract=get_io_contract("requirement-check"), + weights=LocalFileBinding( + name="requirement-check", + adapter_type=AdapterType.ALORA, + repo_id=_requirement_check_metadata.repo_id, + revision=_requirement_check_metadata.revision, + ), ) ) diff --git a/docs/examples/aLora/stembolts_intrinsic.py b/docs/examples/aLora/stembolts_intrinsic.py index da3a5d5f86..26f0bf86e4 100644 --- a/docs/examples/aLora/stembolts_intrinsic.py +++ b/docs/examples/aLora/stembolts_intrinsic.py @@ -12,37 +12,65 @@ import mellea.stdlib.functional as mfuncs from mellea.backends import Backend -from mellea.backends.adapters import AdapterMixin -from mellea.backends.adapters.adapter import CustomIntrinsicAdapter +from mellea.backends.adapters import ( + Adapter, + AdapterMixin, + Identity, + LocalFileBinding, + get_io_contract, +) +from mellea.backends.adapters.catalog import AdapterType from mellea.core import Context from mellea.stdlib.components import Message from mellea.stdlib.components.intrinsic import Intrinsic _INTRINSIC_MODEL_ID = "nfulton/stembolts" _INTRINSIC_ADAPTER_NAME = "stembolts" +_INTRINSIC_QUALIFIED_NAME = f"{_INTRINSIC_ADAPTER_NAME}_{AdapterType.ALORA.value}" -class StemboltAdapter(CustomIntrinsicAdapter): - def __init__(self, base_model_name: str): - super().__init__( - model_id=_INTRINSIC_MODEL_ID, - intrinsic_name=_INTRINSIC_ADAPTER_NAME, - base_model_name=base_model_name, - ) +def _stembolt_adapter() -> Adapter: + """Compose the stembolts adapter from its Identity, output contract, and + weights binding, rather than a dedicated shim class. + + `get_io_contract` falls back to a permissive dict contract for names + outside the built-in catalog — sufficient here, since the adapter's real + output shape (`{"defective_part": str, "diag_likelihood": float}`) is + enforced by its own `io.yaml` on the Hugging Face repo, not by `IOContract`. + """ + return Adapter( + identity=Identity(name=_INTRINSIC_ADAPTER_NAME, adapter_type="alora"), + io_contract=get_io_contract(_INTRINSIC_ADAPTER_NAME), + weights=LocalFileBinding( + name=_INTRINSIC_ADAPTER_NAME, + adapter_type=AdapterType.ALORA, + repo_id=_INTRINSIC_MODEL_ID, + # A LocalFileBinding resolves revision=None via the adapter + # function catalog, which has no entry for a custom, non-catalog + # name like this one — pin explicitly instead. + revision="main", + ), + ) class StemboltIntrinsic(Intrinsic): def __init__(self): - Intrinsic.__init__(self, intrinsic_name=_INTRINSIC_ADAPTER_NAME) + # adapter_types is required here: "stembolts" is outside Mellea's + # intrinsics catalog, so Intrinsic has no catalog entry to fall + # back to (Epic #929, issue #1144). + Intrinsic.__init__( + self, + intrinsic_name=_INTRINSIC_ADAPTER_NAME, + adapter_types=(AdapterType.ALORA,), + ) async def async_stembolt_failure_analysis( notes: str, ctx: Context, backend: Backend | AdapterMixin ): # add_adapter() refuses a duplicate qualified name with a warning, so guard first. - adapter = StemboltAdapter(backend.base_model_name) - if adapter.qualified_name not in backend.list_adapters(): - backend.add_adapter(adapter) + if _INTRINSIC_QUALIFIED_NAME not in backend.list_adapters(): + backend.add_adapter(_stembolt_adapter()) ctx = ctx.add(Message("user", content=notes)) @@ -55,9 +83,8 @@ def stembolt_failure_analysis( notes: str, ctx: Context, backend: Backend | AdapterMixin ): # add_adapter() refuses a duplicate qualified name with a warning, so guard first. - adapter = StemboltAdapter(backend.base_model_name) - if adapter.qualified_name not in backend.list_adapters(): - backend.add_adapter(adapter) + if _INTRINSIC_QUALIFIED_NAME not in backend.list_adapters(): + backend.add_adapter(_stembolt_adapter()) ctx = ctx.add(Message("user", content=notes)) diff --git a/docs/examples/granite-switch/README.md b/docs/examples/granite-switch/README.md index 1453fd1a9d..f35f46c5f2 100644 --- a/docs/examples/granite-switch/README.md +++ b/docs/examples/granite-switch/README.md @@ -58,7 +58,7 @@ Demonstrates `rag.flag_hallucinated_content()` using `OpenAIBackend` with ### manual_adapter_loading.py Shows how to manually load embedded adapters using -`EmbeddedIntrinsicAdapter.from_hub()` and `backend.add_adapter()`. Useful when +`backend.register_embedded_adapter_model(intrinsic_name=...)`. Useful when you only need a subset of adapters or want more control over adapter registration. diff --git a/docs/examples/granite-switch/manual_adapter_loading.py b/docs/examples/granite-switch/manual_adapter_loading.py index 8174c8b427..13c5a1bc0c 100644 --- a/docs/examples/granite-switch/manual_adapter_loading.py +++ b/docs/examples/granite-switch/manual_adapter_loading.py @@ -5,7 +5,9 @@ Instead of using `load_embedded_adapters=True` (which loads all adapters from the model repo at init), this example shows how to create an OpenAIBackend with `load_embedded_adapters=False` and then manually load individual adapters using -`EmbeddedIntrinsicAdapter.from_hub()` or `from_model_directory()`. +`register_embedded_adapter_model(intrinsic_name=...)`, which composes an +`Adapter` (Identity + IOContract + EmbeddedBinding) instead of the deprecated +`EmbeddedIntrinsicAdapter` shim. This is useful when: - You only need a subset of the model's embedded adapters. @@ -35,7 +37,6 @@ print(f"Skipped: vLLM server not reachable at {VLLM_BASE_URL}", file=sys.stderr) raise SystemExit(1) -from mellea.backends.adapters.adapter import EmbeddedIntrinsicAdapter from mellea.backends.model_ids import IBM_GRANITE_SWITCH_4_1_3B_PREVIEW from mellea.backends.openai import OpenAIBackend from mellea.formatters import TemplateFormatter @@ -56,19 +57,13 @@ ) # --- Option A: Load a single adapter from Hugging Face Hub --- -adapters = EmbeddedIntrinsicAdapter.from_hub( - SWITCH_MODEL_ID, intrinsic_name="answerability" -) -for adapter in adapters: - backend.add_adapter(adapter) +backend.register_embedded_adapter_model(SWITCH_MODEL_ID, intrinsic_name="answerability") # --- Option B (alternative): Load from a local model directory --- -# adapters = EmbeddedIntrinsicAdapter.from_model_directory( +# backend.register_embedded_adapter_model( # "/path/to/local/granite-switch-model", # intrinsic_name="answerability", # ) -# for adapter in adapters: -# backend.add_adapter(adapter) print(f"Registered adapters: {backend.list_adapters()}") diff --git a/docs/examples/intrinsics/intrinsics.py b/docs/examples/intrinsics/intrinsics.py index a2f6bc1b36..76c5ae6f50 100644 --- a/docs/examples/intrinsics/intrinsics.py +++ b/docs/examples/intrinsics/intrinsics.py @@ -2,7 +2,13 @@ import mellea.stdlib.functional as mfuncs from mellea.backends import model_ids -from mellea.backends.adapters.adapter import AdapterType, IntrinsicAdapter +from mellea.backends.adapters import ( + Adapter, + Identity, + LocalFileBinding, + get_io_contract, +) +from mellea.backends.adapters.catalog import AdapterType, fetch_intrinsic_metadata from mellea.backends.huggingface import LocalHFBackend from mellea.stdlib.components import Intrinsic, Message from mellea.stdlib.context import ChatContext @@ -23,9 +29,23 @@ # ) # --- End alternative --- -# Create the Adapter. IntrinsicAdapter's default to ALORAs. -req_adapter = IntrinsicAdapter( - "requirement-check", base_model_name=backend.base_model_name +# Compose the Adapter. requirement-check's catalog entry lists LoRA before +# aLoRA, so pin adapter_type explicitly rather than using +# LocalFileBinding.from_catalog (which would pick LoRA). +_requirement_check_metadata = fetch_intrinsic_metadata("requirement-check") +req_adapter = Adapter( + identity=Identity( + name="requirement-check", + adapter_type="alora", + capability=_requirement_check_metadata.effective_capability, + ), + io_contract=get_io_contract("requirement-check"), + weights=LocalFileBinding( + name="requirement-check", + adapter_type=AdapterType.ALORA, + repo_id=_requirement_check_metadata.repo_id, + revision=_requirement_check_metadata.revision, + ), ) # Add the adapter to the backend. From a043ed232d216cc9baa8a071611753e8fd1e3d8c Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 11:51:16 +0100 Subject: [PATCH 06/19] docs(alora): compose the stembolts adapter instead of subclassing the shim Matches the composed-Adapter rewrite already done for docs/examples/aLora/stembolts_intrinsic.py in the previous commit. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- docs/examples/aLora/README.md | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/examples/aLora/README.md b/docs/examples/aLora/README.md index 60588d6969..bcfb5ba59e 100644 --- a/docs/examples/aLora/README.md +++ b/docs/examples/aLora/README.md @@ -88,21 +88,31 @@ The generator will display the README and ask for confirmation before uploading ## Using Adapter Functions -You can now create a new adapter class for this model somewhere in your python project: +Compose the adapter directly from an `Identity`, an output contract, and a +weights binding — no dedicated adapter class needed: ```python -from mellea.backends.adapters.adapter import CustomIntrinsicAdapter - -class StemboltAdapter(CustomIntrinsicAdapter): - def __init__(self, base_model_name:str="granite-4.1-3b"): - super().__init__( - model_id="$USERNAME/stembolts", # REPLACE $USERNAME WITH YOUR HUGGING FACE USERNAME - intrinsic_name="stembolts", - base_model_name=base_model_name, - ) +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters.catalog import AdapterType + +def stembolt_adapter(base_model_name: str = "granite-4.1-3b") -> Adapter: + return Adapter( + identity=Identity(name="stembolts", adapter_type="alora"), + # get_io_contract falls back to a permissive dict contract for names + # outside the built-in catalog. + io_contract=get_io_contract("stembolts"), + weights=LocalFileBinding( + name="stembolts", + adapter_type=AdapterType.ALORA, + repo_id="$USERNAME/stembolts", # REPLACE $USERNAME WITH YOUR HUGGING FACE USERNAME + # A custom, non-catalog adapter has no catalog entry to resolve + # revision=None from — pin it explicitly. + revision="main", + ), + ) ``` -Using this adapter requires adding it to a backend: +Registering it with a backend downloads and loads the weights: ```python from mellea.backends.cache import SimpleLRUCache @@ -112,7 +122,7 @@ backend = LocalHFBackend( model_id="ibm-granite/granite-4.1-3b", cache=SimpleLRUCache(5) ) -backend.add_adapter(StemboltAdapter(base_model_name="granite-4.1-3b")) +backend.add_adapter(stembolt_adapter(base_model_name="granite-4.1-3b")) ``` ## Example Files From 08074762ee9fc545334b93b11788c0d109f3a3b3 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 12:00:04 +0100 Subject: [PATCH 07/19] docs(tutorials): add three adapter-function tutorials for issue #1144 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 07-custom-adapter-function.md: compose a custom, non-catalog Adapter in ~20 lines, replacing CustomIntrinsicAdapter — covers the required explicit revision and the adapter_types requirement on Intrinsic/ALoraRequirement added in an earlier commit. - 08-adapter-schema-migrations.md: revision pinning mechanics and handling AdapterSchemaMismatchError (directly, and via ValidationResult.error for ALoraRequirement) when an adapter's io.yaml changes underneath you. - 09-adapter-function-telemetry.md: the three adapter_function metrics (invocations, phase_duration, parse_failures) and their real attributes — verified against mellea/telemetry/metrics.py rather than assumed. States plainly that adapter-function spans don't exist yet (tracked in #1466), rather than describing a "span tree" that isn't there. Links from docs/docs/advanced/intrinsics.md. Every code snippet's mechanics were verified against the real APIs (import paths, exception signatures, catalog values) rather than assumed from the pattern in prior docs. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- docs/docs/advanced/intrinsics.md | 7 +- .../tutorials/07-custom-adapter-function.md | 145 ++++++++++++++++++ .../tutorials/08-adapter-schema-migrations.md | 142 +++++++++++++++++ .../09-adapter-function-telemetry.md | 104 +++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 docs/docs/tutorials/07-custom-adapter-function.md create mode 100644 docs/docs/tutorials/08-adapter-schema-migrations.md create mode 100644 docs/docs/tutorials/09-adapter-function-telemetry.md diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index 289b5a424d..3af2e2bd41 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -260,7 +260,7 @@ Output format is task-specific — `requirement-check` returns `{"requirement_ch For a fully custom, non-catalog adapter — your own trained LoRA/aLoRA weights, not one of the built-in adapter functions — see -[Adding a custom adapter function in 20 lines](../tutorials/custom-adapter-function.md). +[Adding a custom adapter function in 20 lines](../tutorials/07-custom-adapter-function.md). ## Composable adapter construction (advanced) @@ -332,3 +332,8 @@ Safety and factuality checks use a separate set of Guardian-specific adapter fun `guardian_check()`, `policy_guardrails()`, `factuality_detection()`, and `factuality_correction()`. These are documented in the [Safety Guardrails](../how-to/safety-guardrails) how-to guide. + +**See also:** +[Adding a custom adapter function in 20 lines](../tutorials/07-custom-adapter-function.md) | +[Handling a breaking adapter schema change](../tutorials/08-adapter-schema-migrations.md) | +[Reading adapter function telemetry](../tutorials/09-adapter-function-telemetry.md) diff --git a/docs/docs/tutorials/07-custom-adapter-function.md b/docs/docs/tutorials/07-custom-adapter-function.md new file mode 100644 index 0000000000..af38e31582 --- /dev/null +++ b/docs/docs/tutorials/07-custom-adapter-function.md @@ -0,0 +1,145 @@ +--- +title: "Tutorial: Adding a Custom Adapter Function" +description: "Compose your own trained LoRA/aLoRA adapter into Mellea without a shim class, in about 20 lines." +# diataxis: tutorial +--- + +This tutorial walks through registering a custom, non-catalog adapter +function by composing an `Adapter` directly — the replacement for the +deprecated `CustomIntrinsicAdapter` shim (Epic #929, issue #1144). + +By the end you will have covered: + +- Composing an `Adapter` from an `Identity`, an output contract, and a + `LocalFileBinding` +- Why a custom adapter needs an explicit `revision` +- Registering it with a backend and invoking it through `Intrinsic` +- Validating output with `ALoraRequirement` against a custom adapter name + +**Prerequisites:** `pip install "mellea[hf]"`, a trained LoRA/aLoRA adapter +uploaded to Hugging Face Hub (see +[LoRA and aLoRA adapters](../advanced/lora-and-alora-adapters.md) for +training and uploading), and a GPU or Apple Silicon Mac. + +--- + +## Step 1: Compose the adapter + +Mellea's built-in adapter functions (`answerability`, `requirement-check`, +etc.) are looked up from a catalog that supplies their `repo_id` and +`revision`. A custom adapter has no catalog entry, so you supply those +fields yourself: + +```python +# Requires: mellea[hf] +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters.catalog import AdapterType + +def custom_failure_check_adapter() -> Adapter: + return Adapter( + identity=Identity(name="custom-failure-check", adapter_type="alora"), + # get_io_contract falls back to a permissive dict contract for names + # outside the built-in catalog — sufficient here, since the real + # output shape is enforced by the adapter's own io.yaml, not by + # IOContract (which doesn't yet drive request/response handling). + io_contract=get_io_contract("custom-failure-check"), + weights=LocalFileBinding( + name="custom-failure-check", + adapter_type=AdapterType.ALORA, + repo_id="your-org/my-adapter", # your Hugging Face repo + # A LocalFileBinding with revision=None resolves it from the + # catalog — which has no entry for a custom name. Pin it + # explicitly (a branch, tag, or commit SHA). + revision="main", + ), + ) +``` + +> **Trap:** Omitting `revision` here raises `ValueError: Unknown intrinsic +> name '...'` from deep inside `LocalFileBinding.resolved_revision()` — easy +> to misread as a registration failure rather than what it actually is: no +> catalog entry to fall back to. + +## Step 2: Register it with a backend + +```python +from mellea.backends.huggingface import LocalHFBackend + +backend = LocalHFBackend(model_id="ibm-granite/granite-3.2-8b-instruct") +backend.add_adapter(custom_failure_check_adapter()) # downloads and loads the weights +``` + +`add_adapter` refuses a second registration under the same qualified name, so +guard repeated calls (e.g. inside a helper function) the same way the +built-in adapters do: + +```python +qualified_name = "custom-failure-check_alora" +if qualified_name not in backend.list_adapters(): + backend.add_adapter(custom_failure_check_adapter()) +``` + +## Step 3: Invoke it + +There are two ways to invoke a registered adapter, depending on whether you +need raw output or a pass/fail validation. + +### Directly, for raw output + +```python +from mellea.backends.adapters.catalog import AdapterType +from mellea.stdlib.components.intrinsic import Intrinsic +import mellea.stdlib.functional as mfuncs +from mellea.stdlib.components import Message +from mellea.stdlib.context import ChatContext + +# adapter_types is required for a name outside the catalog — Intrinsic has +# no catalog entry to look up adapter_types from otherwise. +action = Intrinsic("custom-failure-check", adapter_types=(AdapterType.ALORA,)) +ctx = ChatContext().add(Message("user", "Observed black soot on intake.")) +out, _ = mfuncs.act(action, ctx, backend) +print(out) +``` + +### As a validator, with `ALoraRequirement` + +If your adapter's `io.yaml` transforms its output into +`{"requirement_check": {"score": }}`, use it as a drop-in requirement +validator instead: + +```python +from mellea import MelleaSession +from mellea.stdlib.requirements import ALoraRequirement + +m = MelleaSession(backend, ctx=ChatContext()) + +failure_check = ALoraRequirement( + "The failure mode must not be 'no_failure'.", + intrinsic_name="custom-failure-check", + # Required for the same reason as Intrinsic's adapter_types above. + adapter_types=(AdapterType.ALORA,), +) +result = m.instruct( + "Summarize this technician note: {{note}}", + user_variables={"note": "High vibration at 3100 RPM."}, + requirements=[failure_check], +) +``` + +## What replaced the old approach + +The deprecated `CustomIntrinsicAdapter` shim did two things under the hood +that this tutorial's composed `Adapter` replaces without a class of its own: + +1. **Adapter registration** — `LocalFileBinding` already accepts an arbitrary + `repo_id`, so no shim was ever needed for this half. +2. **Making the name usable by `Intrinsic`/`ALoraRequirement`** — + `CustomIntrinsicAdapter` monkey-patched Mellea's global intrinsics catalog + to add the custom name before constructing anything. `Intrinsic` and + `ALoraRequirement` now accept `adapter_types` explicitly instead, so no + catalog mutation is needed — only the caller's own process is affected, + and there is no shared global state to leak between callers. + +**See also:** [Adapter functions](../advanced/intrinsics.md) | +[LoRA and aLoRA adapters](../advanced/lora-and-alora-adapters.md) | +[Reading adapter function telemetry](./adapter-function-telemetry) diff --git a/docs/docs/tutorials/08-adapter-schema-migrations.md b/docs/docs/tutorials/08-adapter-schema-migrations.md new file mode 100644 index 0000000000..cdb0447a74 --- /dev/null +++ b/docs/docs/tutorials/08-adapter-schema-migrations.md @@ -0,0 +1,142 @@ +--- +title: "Tutorial: Adapter Schema Migrations" +description: "Pin an adapter function's Hugging Face revision and handle AdapterSchemaMismatchError so a breaking schema change doesn't take your program down." +# diataxis: tutorial +--- + +An adapter function's output schema is defined by its `io.yaml`, hosted +alongside its weights on Hugging Face Hub. If the maintainer changes that +schema — adding, renaming, or removing a field the adapter's `IOContract` +requires — any program still pointed at `main` (or another mutable branch) +picks up the new schema on its next download, with no warning. This +tutorial covers the two mechanisms that keep that from breaking your +program: revision pinning, and handling the exception a mismatch raises. + +By the end you will have covered: + +- Why `LocalFileBinding`/`IntrinsicsCatalogEntry` pin `revision` to a commit + SHA rather than tracking `main` +- How to pin (or intentionally un-pin) a composed adapter's revision +- Catching `AdapterSchemaMismatchError` instead of letting it propagate +- Reading `ValidationResult.error` to distinguish "requirement not met" from + "output unparsable" when using `ALoraRequirement` + +**Prerequisites:** `pip install "mellea[hf]"`, a GPU or Apple Silicon Mac. + +--- + +## Step 1: Understand what `revision` actually pins + +`LocalFileBinding.revision` (and the built-in catalog's own +`IntrinsicsCatalogEntry.revision`) is a Hugging Face revision — a branch +name, tag, or commit SHA — passed straight through to the Hub download call. +Mellea's built-in catalog entries pin to commit SHAs by convention, precisely +so that a maintainer publishing a new `io.yaml` on `main` doesn't silently +change what your already-deployed program downloads: + +```python +# Requires: mellea[hf] +from mellea.backends.adapters.catalog import fetch_intrinsic_metadata + +metadata = fetch_intrinsic_metadata("requirement-check") +print(metadata.repo_id, metadata.revision) +# ibm-granite/granitelib-core-r1.0 d0a2a96a4cd07e96f0fe7ca29a42bfe088299d43 +``` + +`LocalFileBinding.resolved_revision()` uses this pinned value whenever you +construct a binding with `revision=None` (the default for +`LocalFileBinding.from_catalog(name)`) — you get the reproducibility for +free for a catalog adapter. A **custom** adapter has no catalog entry, so +`revision=None` there raises instead (see +[Adding a custom adapter function](./07-custom-adapter-function.md)) — +pinning is not optional for a custom adapter, only automatic for a catalog one. + +## Step 2: Pin explicitly when you need a specific version + +If a maintainer ships a breaking `io.yaml` change on a new branch or tag +before promoting it to the pinned revision Mellea's catalog tracks, or if +you're testing against an upcoming version ahead of a Mellea release that +updates the pin, construct the binding with an explicit `revision` instead +of `from_catalog`: + +```python +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters.catalog import AdapterType, fetch_intrinsic_metadata +from mellea.backends.huggingface import LocalHFBackend + +metadata = fetch_intrinsic_metadata("requirement-check") +backend = LocalHFBackend(model_id="ibm-granite/granite-3.2-8b-instruct") + +adapter = Adapter( + identity=Identity( + name="requirement-check", + adapter_type="alora", + capability=metadata.effective_capability, + ), + io_contract=get_io_contract("requirement-check"), + weights=LocalFileBinding( + name="requirement-check", + adapter_type=AdapterType.ALORA, + repo_id=metadata.repo_id, + # Explicit revision — the "v2" candidate you're validating against, + # not the catalog's currently-pinned "v1" SHA above. + revision="some-future-commit-sha-or-branch-name", + ), +) +backend.add_adapter(adapter) +``` + +Registering two revisions of the same adapter name on one backend at once +isn't supported — `add_adapter` keys the registry on +`f"{name}_{adapter_type}"`, so the second registration is refused. Use two +backend instances (or two processes) if you need to compare v1 and v2 output +side by side. + +## Step 3: Handle a schema mismatch gracefully + +Whether the mismatch comes from an unpinned adapter drifting underneath you, +or from deliberately testing a new revision, the failure mode is the same: +`IOContract.parse` raises `AdapterSchemaMismatchError` when the model's JSON +output doesn't satisfy the declared contract. + +Calling an adapter directly through `Intrinsic`/`mfuncs.act()` propagates +this exception — catch it where you call: + +```python +from mellea.backends.adapters import AdapterSchemaMismatchError +import mellea.stdlib.functional as mfuncs +from mellea.stdlib.components import Intrinsic + +try: + out, _ = mfuncs.act(Intrinsic("requirement-check", intrinsic_kwargs={ + "requirement": "The assistant is helpful." + }), ctx, backend) +except AdapterSchemaMismatchError as e: + print(f"Schema mismatch: expected {e.expected_keys}, observed {e.observed_keys}") + if e.reason: + print(f"Reason: {e.reason}") +``` + +`ALoraRequirement` handles this differently: it surfaces the error on +`ValidationResult.error` rather than raising, and fails the check closed +(`bool(result)` is `False`) so a schema mismatch is never silently treated +as "requirement not met": + +```python +val_res = m.validate([requirement]) +for result in val_res: + if result.error is not None: + print(f"Adapter output was unparsable: {result.error}") + elif not bool(result): + print("Requirement genuinely not met") +``` + +This distinction matters for monitoring: alert on `result.error is not None` +separately from your ordinary pass/fail rate — a spike in schema-mismatch +errors after an adapter update is a signal to re-pin or roll back, not +evidence your model got worse at the task. + +**See also:** +[Adapter functions](../advanced/intrinsics.md) | +[Adding a custom adapter function in 20 lines](./07-custom-adapter-function.md) | +[Reading adapter function telemetry](./09-adapter-function-telemetry.md) diff --git a/docs/docs/tutorials/09-adapter-function-telemetry.md b/docs/docs/tutorials/09-adapter-function-telemetry.md new file mode 100644 index 0000000000..ea68b3a336 --- /dev/null +++ b/docs/docs/tutorials/09-adapter-function-telemetry.md @@ -0,0 +1,104 @@ +--- +title: "Tutorial: Reading Adapter Function Telemetry" +description: "Build a dashboard from Mellea's adapter function metrics: invocation outcomes, phase durations, and parse failures." +# diataxis: tutorial +--- + +Adapter functions emit three OpenTelemetry metrics that cover invocation +outcomes, per-phase latency, and schema-mismatch failures. This tutorial +walks through enabling them and reading each one. + +By the end you will have covered: + +- Enabling metrics and picking an exporter +- The three adapter-function instruments and their attributes +- Building a dashboard query for each: success rate, phase latency, and + parse-failure rate +- What's *not* covered yet — span-level tracing for adapter functions + +**Prerequisites:** [Metrics](../observability/metrics.md) covers the +general metrics setup this tutorial builds on. `pip install "mellea[hf,telemetry]"`. + +--- + +## Step 1: Enable metrics + +Adapter function metrics follow the same on/off switch and exporters as +every other Mellea metric — no adapter-specific configuration: + +```bash +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true # or OTLP/Prometheus — see the Metrics guide +``` + +They are recorded by `AdapterFunctionMetricsPlugin` +(`mellea/telemetry/metrics_plugins.py`), which subscribes to the +`adapter_function_invocation_complete` and `adapter_function_phase_complete` +hooks — the same hooks `AdapterMixin.adapter_scope()` and +`EmbeddedBinding.apply_activation()` fire for every adapter call, composed +`Adapter` or deprecated shim alike. + +## Step 2: The three instruments + +| Metric | Type | Attributes | What it tells you | +| --- | --- | --- | --- | +| `mellea.adapter_function.invocations` | Counter | `name`, `revision`, `binding_type`, `adapter_type`, `outcome` | How many calls, split by outcome (`success`, `schema_error`, `error`) | +| `mellea.adapter_function.phase_duration` | Histogram | `name`, `phase` | How long each lifecycle phase took, in seconds | +| `mellea.adapter_function.parse_failures` | Counter | `name`, `revision` | How many calls failed *specifically* on `AdapterSchemaMismatchError` | + +`revision` is normalised to the string `"unpinned"` when the binding's +revision is `None` — filter on that value to find adapters running without a +pinned revision (see +[Adapter schema migrations](./08-adapter-schema-migrations.md) for why that +matters). `phase` is one of `"prepare"`, `"activate"`, `"generate"`, +`"parse"`, or `"deactivate"` — though as of this writing only three are +actually emitted: `"prepare"` once per `LocalFileBinding` (fired from +`add_adapter`/`binding.prepare()` — the download-and-load cost), then +`"activate"`/`"deactivate"` per call from `adapter_scope()` for the +LocalFile/PEFT reality, or `"activate"` alone per call for the Embedded +reality (`EmbeddedBinding.apply_activation()`, which has no lifecycle to +deactivate). `"generate"`/`"parse"` phase timing is tracked as future work +in issue #1466. + +## Step 3: Build the three dashboard panels + +**Success rate** — `mellea.adapter_function.invocations`, grouped by `name` +and `outcome`. A PromQL sketch: + +```promql +sum by (name, outcome) (rate(mellea_adapter_function_invocations_total[5m])) +``` + +Watch `outcome="error"` and `outcome="schema_error"` separately — an error +means the call itself failed (network, generation); a schema error means the +call succeeded but its output didn't parse against the declared contract +(see [Adapter schema migrations](./08-adapter-schema-migrations.md)). + +**Phase latency** — `mellea.adapter_function.phase_duration`, grouped by +`name` and `phase`. A p95 activate-phase latency that jumps for one adapter +but not others usually means its weights just got evicted from whatever +cache your deployment relies on (HF Hub's local cache, or your own). + +**Parse-failure rate** — `mellea.adapter_function.parse_failures` divided by +`mellea.adapter_function.invocations` for the same `name`. This is the +leading indicator for the schema-drift scenario in +[Adapter schema migrations](./08-adapter-schema-migrations.md): a +sudden rise, correlated with a `revision` change in your own deploy history, +is exactly the signal to re-pin or roll back. + +## What's not covered yet + +Mellea does not currently open OpenTelemetry **spans** for adapter function +calls — only these three metrics, driven by hooks, exist today. The +`ADAPTER_FUNCTION_*` hook family has no start hook for a tracing plugin to +open a span on yet (see the note in +`AdapterMixin.adapter_scope()`'s docstring); span-level tracing is tracked +separately in issue #1466. If your dashboard needs request-level tracing +today, correlate the metrics above with your own application-level spans +around the `mfuncs.act()`/`m.instruct()` call site instead. + +**See also:** +[Metrics](../observability/metrics.md) | +[Telemetry](../observability/telemetry.md) | +[Adapter functions](../advanced/intrinsics.md) | +[Adapter schema migrations](./08-adapter-schema-migrations.md) From e47f9f5fa23eb29086167c61c3af6b4e64695590 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 14:25:33 +0100 Subject: [PATCH 08/19] fix(backends): address code-review findings on the composed-Adapter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent-panel review (3 reviewers) on PR #1619 found several real bugs, verified against source before fixing: - LocalHFBackend.add_adapter's composed-LocalFileBinding branch skipped bind_backend() whenever the binding already had a backend set, so a binding already bound to a *different* backend silently registered on the new one instead of raising — prepare() then no-oped, leaving PEFT activation routed to the wrong backend's model with no error at any point. Fixed by always calling bind_backend(), which already has the right raise/no-op semantics for both cases. - _generate_composed_local_file_with_adapter_scope called binding.prepare() while holding _generation_lock, acquiring _lifecycle_lock in the opposite order LocalFileBinding.release() acquires them — a lock-order inversion that can deadlock a release() racing an in-flight generation. The prepare() call was also redundant (add_adapter/resolve_adapter already guarantee it); removed. - _intrinsic_adapter_name_and_config re-downloaded and re-parsed a composed LocalFile adapter's io.yaml on every _generate_from_intrinsic call — the default path via resolve_adapter — instead of once like the shim it replaces. Now cached in _composed_adapter_configs after first derivation. - remove_adapter couldn't deregister a composed Embedded adapter (it only lives in _composed_adapters, never _added_adapters, so the early-return on a plain _added_adapters miss never reached the cleanup below it). - Two documentation examples in intrinsics.md were wrong: "Direct adapter function usage" paired Identity(adapter_type="alora") with LocalFileBinding.from_catalog("requirement-check"), which resolves to LoRA (the catalog lists it first) — silently running the wrong adapter variant despite an apparently-successful earlier run. "Composable adapter construction"'s Embedded snippet registered an adapter via a bare add_adapter() that never populates the io.yaml config cache, so any real generation call would raise. Both rewritten and re-verified against real models/a real adapter registry. - A broken cross-doc link, a resolve_adapter cache-before-registration ordering hygiene issue, a synthetic-catalog-entry revision string colliding in meaning with the "unpinned" telemetry sentinel, stale Raises: docstrings on both backends' _generate_from_intrinsic, an assert in library code replaced with a matching TypeError, and a duplicate test assertion. Added regression tests for the two most serious fixes (cross-backend misrouting, config re-fetch) and confirmed both fail against the pre-fix code before confirming they pass against the fix. Re-verified against real models: docs/examples/intrinsics/intrinsics.py, docs/examples/aLora/stembolts_intrinsic.py, and the real-GPU e2e suite (test_intrinsic_adapter_scope_e2e.py, 2/2) all still pass after these changes. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- docs/docs/advanced/intrinsics.md | 44 ++++++--- .../tutorials/07-custom-adapter-function.md | 2 +- mellea/backends/adapters/adapter.py | 6 +- mellea/backends/huggingface.py | 89 ++++++++++++++----- mellea/backends/openai.py | 8 +- .../stdlib/components/intrinsic/intrinsic.py | 9 +- .../test_local_file_integration.py | 32 +++++++ test/backends/test_huggingface_unit.py | 55 +++++++++++- 8 files changed, 201 insertions(+), 44 deletions(-) diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index 3af2e2bd41..e87cc4a382 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -222,6 +222,7 @@ print(result) # Returns: dict import mellea.stdlib.functional as mfuncs from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters.catalog import AdapterType, fetch_intrinsic_metadata from mellea.backends.huggingface import LocalHFBackend from mellea.stdlib.components import Intrinsic, Message from mellea.stdlib.context import ChatContext @@ -229,12 +230,25 @@ from mellea.stdlib.context import ChatContext backend = LocalHFBackend(model_id="ibm-granite/granite-4.1-3b") # Compose an adapter by task name — get_io_contract returns the catalog's -# declared contract (or a permissive fallback for a name outside it), and -# LocalFileBinding.from_catalog resolves the repo/revision to download. +# declared contract (or a permissive fallback for a name outside it). +# requirement-check's catalog entry lists LoRA before aLoRA, so +# LocalFileBinding.from_catalog would pick LoRA — build the binding +# explicitly instead when you want the aLoRA variant specifically; identity +# and weights must agree, since nothing currently cross-checks them. +_metadata = fetch_intrinsic_metadata("requirement-check") req_adapter = Adapter( - identity=Identity(name="requirement-check", adapter_type="alora"), + identity=Identity( + name="requirement-check", + adapter_type="alora", + capability=_metadata.effective_capability, + ), io_contract=get_io_contract("requirement-check"), - weights=LocalFileBinding.from_catalog("requirement-check"), + weights=LocalFileBinding( + name="requirement-check", + adapter_type=AdapterType.ALORA, + repo_id=_metadata.repo_id, + revision=_metadata.revision, + ), ) backend.add_adapter(req_adapter) @@ -279,9 +293,8 @@ by `add_adapter`, so a caller need not call `prepare()` itself: ```python # Requires: mellea[hf] -from mellea.backends.adapters import Adapter, EmbeddedBinding, Identity, LocalFileBinding, get_io_contract +from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract from mellea.backends.huggingface import LocalHFBackend -from mellea.backends.openai import OpenAIBackend # LocalFile/PEFT reality — LocalHFBackend downloads and loads the weights. hf_backend = LocalHFBackend(model_id="ibm-granite/granite-4.1-3b") @@ -295,20 +308,27 @@ hf_backend.add_adapter(hf_adapter) # downloads and loads the weights `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: +that edits the outgoing request instead of a lifecycle. Its `io.yaml` config +comes from the served checkpoint's `adapter_index.json`, not from anything +you can construct by hand, so registration goes through +`register_embedded_adapter_model` rather than a bare `add_adapter` call: ```python +from mellea.backends.openai import OpenAIBackend + switch_backend = OpenAIBackend( model_id="granite-switch", api_key="EMPTY", base_url="http://localhost:8000/v1", + load_embedded_adapters=False, ) -switch_adapter = Adapter( - identity=Identity(name="answerability", adapter_type="alora"), - io_contract=get_io_contract("answerability"), - weights=EmbeddedBinding.from_base_model(switch_backend), +# Discovers "answerability" from the model's Hugging Face repo and composes +# an Adapter (Identity + IOContract + EmbeddedBinding) for it, including the +# io.yaml config a bare Adapter(weights=EmbeddedBinding.from_base_model(...)) +# construction has no way to supply. +switch_backend.register_embedded_adapter_model( + "granite-switch", intrinsic_name="answerability" ) -switch_backend.add_adapter(switch_adapter) ``` Weights-binding support by backend today: diff --git a/docs/docs/tutorials/07-custom-adapter-function.md b/docs/docs/tutorials/07-custom-adapter-function.md index af38e31582..e58a8e8095 100644 --- a/docs/docs/tutorials/07-custom-adapter-function.md +++ b/docs/docs/tutorials/07-custom-adapter-function.md @@ -142,4 +142,4 @@ that this tutorial's composed `Adapter` replaces without a class of its own: **See also:** [Adapter functions](../advanced/intrinsics.md) | [LoRA and aLoRA adapters](../advanced/lora-and-alora-adapters.md) | -[Reading adapter function telemetry](./adapter-function-telemetry) +[Reading adapter function telemetry](./09-adapter-function-telemetry.md) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 29d54d6678..b4c10afdc3 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -752,9 +752,13 @@ def resolve_adapter(self, name: str) -> _AdapterCore: for a, config in _discover_embedded_adapters( repo_id, intrinsic_name=name ): + # Register first, then cache the config: if add_adapter + # refuses the registration (e.g. a duplicate key), no + # orphaned config entry is left behind for a key that + # was never actually stored in the registry. + self.add_adapter(a) if configs is not None: configs[_composed_adapter_key(a)] = config - self.add_adapter(a) else: # AdapterType.LORA is the pre-Phase-1 default (mirrors old _util.py). # Every current catalog entry supports LORA. Phase 2 (see epic #929) diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 7add60a97e..612eda334e 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -828,6 +828,15 @@ def _generate_composed_local_file_with_adapter_scope( (see `add_adapter`) — there is no throwaway `_IntrinsicPeftBinding` to build, so this drives `adapter_scope` directly on `adapter` itself. + Does not call `binding.prepare()`: `add_adapter`/`resolve_adapter` + already guarantee it before this method's caller can reach a + registered adapter, and calling it again here would acquire + `binding._lifecycle_lock` while already holding `_generation_lock` — + the reverse of the order `LocalFileBinding.release()` acquires them + in (`_lifecycle_lock` then, via `_adapter_activation_lock()`, + `_generation_lock`), a lock-order inversion that can deadlock a + `release()` racing an in-flight generation on the same binding. + Args: adapter: The composed `Adapter` to activate for this call; its `weights` must be a `LocalFileBinding` already registered with @@ -839,11 +848,17 @@ def _generate_composed_local_file_with_adapter_scope( Returns: Whatever `generate_func` returns. + + Raises: + TypeError: `adapter.weights` is not a `LocalFileBinding`. """ binding = adapter.weights - assert isinstance(binding, LocalFileBinding) + if not isinstance(binding, LocalFileBinding): + raise TypeError( + "LocalHFBackend's composed local-file generation path requires " + f"a LocalFileBinding; got {type(binding).__name__}." + ) with self._generation_lock: - binding.prepare() with self.adapter_scope(adapter): _assert_correct_adapters(binding.qualified_name, self._model) out = generate_func(*args, **kwargs) @@ -861,9 +876,12 @@ def _intrinsic_adapter_name_and_config( `IntrinsicsResultProcessor` (Epic #929, issue #1144) — so this derives the same io.yaml config a shim would have loaded: - - LocalFile/PEFT reality: re-downloaded via `obtain_io_yaml` from the - binding's own `repo_id`/`revision`, mirroring - `IntrinsicAdapter.__init__`. + - LocalFile/PEFT reality: derived via `obtain_io_yaml` from the + binding's own `repo_id`/`revision` on first use, mirroring + `IntrinsicAdapter.__init__` — then cached in + `_composed_adapter_configs`, the same dict the Embedded reality + uses, so a Hugging Face Hub round trip and a YAML re-parse don't + happen on every generation call. - Embedded/Granite Switch reality: looked up from `_composed_adapter_configs`, cached at registration time (see `add_adapter`/`register_embedded_adapter_model`) since it comes from @@ -886,15 +904,19 @@ def _intrinsic_adapter_name_and_config( return adapter.name, adapter.config if isinstance(adapter.weights, LocalFileBinding): binding = adapter.weights - io_yaml_path = intrinsics.obtain_io_yaml( - binding.name, - self.base_model_name, - binding.repo_id, - revision=binding.resolved_revision(), - alora=binding.adapter_type is AdapterType.ALORA, - ) - with open(io_yaml_path, encoding="utf-8") as f: - config = yaml.safe_load(f) + key = _composed_adapter_key(adapter) + config = self._composed_adapter_configs.get(key) + if config is None: + io_yaml_path = intrinsics.obtain_io_yaml( + binding.name, + self.base_model_name, + binding.repo_id, + revision=binding.resolved_revision(), + alora=binding.adapter_type is AdapterType.ALORA, + ) + with open(io_yaml_path, encoding="utf-8") as f: + config = yaml.safe_load(f) + self._composed_adapter_configs[key] = config return adapter.identity.name, config if isinstance(adapter.weights, EmbeddedBinding): key = _composed_adapter_key(adapter) @@ -943,11 +965,15 @@ async def _generate_from_intrinsic( Raises: ValueError: If no adapter is registered for the requested intrinsic, - or if the context contains images or audio; `LocalHFBackend` - does not support multimodal inputs. - TypeError: If the adapter is neither an `IntrinsicAdapter` nor an - `EmbeddedIntrinsicAdapter`, or if an embedded adapter does not - have an `EmbeddedBinding`. + the context contains images or audio (`LocalHFBackend` does + not support multimodal inputs), or a composed Embedded + adapter has no io.yaml config cached (see + `_intrinsic_adapter_name_and_config`). + TypeError: If the adapter is neither an `IntrinsicAdapter`, an + `EmbeddedIntrinsicAdapter`, nor a composed `Adapter`; if an + `EmbeddedIntrinsicAdapter`'s `weights` is not an + `EmbeddedBinding`; or if a composed `Adapter`'s `weights` is + an unsupported binding type. """ if not ctx.is_chat_context: raise Exception("Does not yet support non-chat contexts.") @@ -2594,8 +2620,15 @@ def add_adapter(self, adapter: AdapterInput) -> None: return if isinstance(adapter.weights, LocalFileBinding): binding = adapter.weights - if binding.backend is None: - binding.bind_backend(self) + # bind_backend() itself raises if binding.backend is already + # a *different* backend, and no-ops if it's already self — + # calling it unconditionally (rather than guarding on + # `binding.backend is None`) is what makes that raise + # reachable; the guard used to skip straight to prepare() + # (a silent no-op once loaded), registering this adapter on + # `self` while the binding's weights stayed activated + # against whatever backend it was really bound to. + binding.bind_backend(self) binding.prepare() self._composed_adapters[key] = adapter return @@ -2805,6 +2838,14 @@ def remove_adapter(self, adapter_qualified_name: str) -> None: adapter = self._added_adapters.pop(adapter_qualified_name, None) if adapter is None: + # A composed Embedded adapter (see add_adapter) lives only in + # _composed_adapters — it never has a bare-binding entry in + # _added_adapters to mutate .backend/.path on — so it must be + # checked here rather than falling through to the "not + # registered" log below. + if self._composed_adapters.pop(adapter_qualified_name, None) is not None: + self._composed_adapter_configs.pop(adapter_qualified_name, None) + return MelleaLogger.get_logger().info( f"could not remove adapter {adapter_qualified_name} for backend {self}: " "adapter was not registered" @@ -2813,9 +2854,9 @@ def remove_adapter(self, adapter_qualified_name: str) -> None: adapter.backend = None adapter.path = None - # A composed Adapter registered here (see add_adapter) shares this - # qualified name; drop its entry too so a stale one doesn't outlive - # the binding it wraps. + # A composed LocalFile Adapter registered here (see add_adapter) + # shares this qualified name; drop its entry too so a stale one + # doesn't outlive the binding it wraps. self._composed_adapters.pop(adapter_qualified_name, None) self._composed_adapter_configs.pop(adapter_qualified_name, None) diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 7d15ee79b3..8bf0c021d5 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -837,9 +837,11 @@ async def _generate_from_intrinsic( 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 - `weights` isn't an EmbeddedBinding (only reachable if a caller + intrinsic, or a composed `Adapter` has no io.yaml config + cached (see `_intrinsic_adapter_name_and_config`). + TypeError: If the adapter is neither an `EmbeddedIntrinsicAdapter` + nor a composed `Adapter`, or its `weights` isn't an + `EmbeddedBinding` (for the shim, only reachable if a caller reassigns `.weights` after construction). """ if not ctx.is_chat_context: diff --git a/mellea/stdlib/components/intrinsic/intrinsic.py b/mellea/stdlib/components/intrinsic/intrinsic.py index bac45d6c06..8859f82e22 100644 --- a/mellea/stdlib/components/intrinsic/intrinsic.py +++ b/mellea/stdlib/components/intrinsic/intrinsic.py @@ -65,11 +65,16 @@ def __init__( # told us which adapter types to look up, so a catalog entry # (whose repo_id/revision this class never reads — see # `adapter_types` and `intrinsic_name` properties below) isn't - # needed to construct a usable Intrinsic. + # needed to construct a usable Intrinsic. "not-a-catalog-entry" + # (rather than "unpinned") avoids colliding with the + # `mellea.adapter_function.*` telemetry sentinel, which already + # uses the string "unpinned" to mean "revision is None" for a + # real, catalog-resolvable adapter — a different situation from + # this synthetic entry having no revision concept at all. self.metadata = IntrinsicsCatalogEntry( name=intrinsic_name, repo_id="", - revision="unpinned", + revision="not-a-catalog-entry", adapter_types=adapter_types, ) if intrinsic_kwargs is None: diff --git a/test/backends/test_adapters/test_local_file_integration.py b/test/backends/test_adapters/test_local_file_integration.py index f224ec8002..721190af50 100644 --- a/test/backends/test_adapters/test_local_file_integration.py +++ b/test/backends/test_adapters/test_local_file_integration.py @@ -214,5 +214,37 @@ def test_add_adapter_rejects_second_composed_registration_for_same_capability(): assert other_binding.backend is None +def test_add_adapter_rejects_binding_already_bound_to_a_different_backend(): + """Registering a composed Adapter whose LocalFileBinding is already bound + to a *different* backend must raise, not silently misroute. + + Regression: `add_adapter`'s composed-LocalFileBinding branch used to skip + `bind_backend()` — the only call that raises for this — whenever + `binding.backend` was already set, then called the now-no-op `prepare()` + and registered the adapter on the *new* backend anyway. A later + `adapter_scope()`/`activate()` on the new backend would then activate + PEFT state on the *original* backend's model instead, with no error at + any point. + """ + backend_a = _make_backend() + backend_b = _make_backend() + binding = _make_binding() + adapter = _make_adapter(binding) + + with patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ): + backend_a.add_adapter(adapter) + assert binding.backend is backend_a + + with pytest.raises(RuntimeError, match="cannot change the backend"): + backend_b.add_adapter(adapter) + + # The failed registration attempt must not have touched backend_a's claim. + assert binding.backend is backend_a + assert backend_b._find_adapter("answerability") is None + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index a50033af0f..dac97b0270 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -263,6 +263,8 @@ def _make_intrinsic_backend_stub(stub_backend): to_chat_messages=lambda linearized_ctx: [Message("user", "Is the sky blue?")] ) stub_backend._added_adapters = {} + stub_backend._composed_adapters = {} + stub_backend._composed_adapter_configs = {} stub_backend._tokenizer = object() stub_backend._model = object() stub_backend._llguidance_tokenizer = object() @@ -351,7 +353,6 @@ def test_load_embedded_adapters_registers_checkpoint_adapters(): # shim's own `.backend` is never touched; the binding it shares with the # composed Adapter (asserted above) is what actually gets stamped. assert adapter.weights.source == backend.base_model_name - assert adapter.weights.source == backend.base_model_name backend._model.load_adapter.assert_not_called() @@ -919,6 +920,58 @@ def transform(self, chunk, rewritten): assert processed, "the composed local-file path must produce a response" +def test_composed_local_file_config_is_cached_after_first_derivation(): + """`_intrinsic_adapter_name_and_config` must not re-derive (re-download, + re-parse) a composed LocalFile adapter's io.yaml on every call. + + Regression: the first version of this helper called + `intrinsics.obtain_io_yaml` (a Hugging Face Hub round trip) and re-parsed + the file on *every* invocation, unlike the shim it replaces (which loads + once in `IntrinsicAdapter.__init__`). Every composed-LocalFile intrinsic + call — the default path via `resolve_adapter` — paid that cost. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + Identity, + LocalFileBinding, + ) + from mellea.backends.adapters.catalog import AdapterType + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + backend._composed_adapter_configs = {} + binding = LocalFileBinding( + name="answerability", + adapter_type=AdapterType.ALORA, + repo_id="ibm-granite/granitelib-rag-r1.0", + revision="abc123", + ) + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=binding, + ) + + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ) as mock_obtain, + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), + ): + name1, config1 = backend._intrinsic_adapter_name_and_config(composed) + name2, config2 = backend._intrinsic_adapter_name_and_config(composed) + + assert mock_obtain.call_count == 1, ( + "obtain_io_yaml must only run once; the second call should hit the cache" + ) + assert name1 == name2 == "answerability" + assert config1 is config2 + + def test_generate_embedded_with_generation_lock_deactivates_peft_state(): """Embedded generation clears stale PEFT state before running the checkpoint.""" backend = _make_backend() From f7ef6e0ad96abd8682d2d57c3d06aef919e932cb Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 14:50:36 +0100 Subject: [PATCH 09/19] fix(backends): address Bob's code-review findings on PR #1619 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against source before applying: - Intrinsic.__init__ and ALoraRequirement.__init__ made adapter_types keyword-only. Positional misuse (e.g. Intrinsic(name, (AdapterType.ALORA,))) silently stored the tuple in intrinsic_kwargs instead of raising, failing confusingly at generation time instead of at the call site. No existing caller in this repo passes it positionally. - _generate_from_intrinsic's `adapter_is_embedded = isinstance(...)` was immediately followed by the identical isinstance check as an `if`, reading as a copy-paste mistake. Aliased adapter.weights to a local so the activation block narrows on that local directly, and moved the reusable bool below it (only needed by two later call sites that don't need narrowing) — one isinstance check per purpose, not two for the same one. - OpenAIBackend.register_embedded_adapter_model was missing the Raises: section its LocalHFBackend twin has; added the matching one. - remove_adapter's docstring/qualified_name-only Returns: on list_adapters didn't account for a composed Adapter's _composed_adapter_key() format. - Added the missing test for remove_adapter() on a composed Embedded adapter, and a comment on _composed_adapter_key documenting (and disclaiming the lack of runtime enforcement of) its equivalence with LocalFileBinding.qualified_name. One suggestion not applied: the isinstance(adapter, (EmbeddedIntrinsicAdapter, _AdapterCore)) arm in OpenAIBackend._generate_from_intrinsic is technically redundant (EmbeddedIntrinsicAdapter is an _AdapterCore subclass) but matches the identical pattern already used in LocalHFBackend's equivalent check — changing one without the other would be the inconsistency. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/adapter.py | 11 +++++- mellea/backends/huggingface.py | 19 ++++++---- mellea/backends/openai.py | 7 ++++ .../stdlib/components/intrinsic/intrinsic.py | 1 + mellea/stdlib/requirements/requirement.py | 1 + test/backends/test_huggingface_unit.py | 36 +++++++++++++++++++ 6 files changed, 68 insertions(+), 7 deletions(-) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index b4c10afdc3..44fcab489f 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -302,7 +302,16 @@ def _composed_adapter_key(adapter: "_AdapterCore") -> str: """Return the registry key for a composed `Adapter`, mirroring `qualified_name`. A composed `Adapter` (unlike the deprecated shims) has no `qualified_name` - of its own; backends key their registries on this instead. + of its own; backends key their registries on this instead. For a + LocalFile/PEFT composed adapter, this must produce the same string as + `adapter.weights.qualified_name` (`LocalFileBinding`'s own key) so a + backend's `_added_adapters` (keyed on the binding) and `_composed_adapters` + (keyed on this function) agree — which holds because `Identity.adapter_type` + is the `Literal["lora", "alora"]` string and `AdapterType.LORA.value`/ + `AdapterType.ALORA.value` are those same two strings. Not enforced by any + runtime check (see the `NOTE(#1516)` on the `Adapter` dataclass) — a + composed `Adapter` whose `identity.adapter_type` disagrees with its own + `weights.adapter_type` produces two different keys instead of one. Args: adapter: The composed adapter to key. diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 612eda334e..8b14787d90 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -1090,19 +1090,23 @@ async def _generate_from_intrinsic( "EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; " f"got {type(adapter.weights).__name__}. Activation cannot proceed." ) - adapter_is_embedded = isinstance(adapter.weights, EmbeddedBinding) - if isinstance(adapter.weights, EmbeddedBinding): + adapter_weights = adapter.weights + if isinstance(adapter_weights, EmbeddedBinding): extra_body = rewritten_request.setdefault("extra_body", {}) if not isinstance(extra_body, dict): raise TypeError( "Embedded adapter generation requires extra_body to be a dict." ) - await adapter.weights.apply_activation( + await adapter_weights.apply_activation( EmbeddedActivationRequest( extra_body=extra_body, api_params=rewritten_request ), adapter.identity, ) + # Reused below (generation-path dispatch, embedded_identity for + # post-processing) — computed once here rather than re-checked, since + # unlike the `if` above this isn't gating a narrowing-sensitive block. + adapter_is_embedded = isinstance(adapter_weights, EmbeddedBinding) generate_input, other_input = ( granite_formatters.base.util.chat_completion_request_to_transformers_inputs( # type: ignore @@ -2939,9 +2943,12 @@ def list_adapters(self) -> list[str]: """List the qualified names of all adapters registered with this backend. Returns: - list[str]: Qualified adapter names (i.e. `adapter.qualified_name`) for - all adapters that have been registered via `add_adapter`, whether - or not they have also been loaded via `load_peft_adapter`. + list[str]: Qualified adapter names for all adapters that have been + registered via `add_adapter`, whether or not they have also + been loaded via `load_peft_adapter`. For a shim or a bare + `LocalFileBinding`, this is `adapter.qualified_name`; for a + composed `Adapter`, it's the equivalent + `_composed_adapter_key()` format (the two formats agree). """ # A composed Embedded adapter lives only in _composed_adapters (see # add_adapter); a composed LocalFile adapter's binding is also in diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 8bf0c021d5..2416a42d65 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -449,6 +449,13 @@ def register_embedded_adapter_model( Returns: list[str]: Names of the registered intrinsics. + + Raises: + ImportError: If Hugging Face Hub support is not installed. + PermissionError: If the model repository is private or gated. + FileNotFoundError: If the source has no adapter index. + ValueError: If the source has no matching embedded adapter functions. + TypeError: If an adapter from the source has an unsupported binding. """ # No lock here: both call sites (here, and this class's __init__) run # single-threaded during construction, before the backend is exposed to diff --git a/mellea/stdlib/components/intrinsic/intrinsic.py b/mellea/stdlib/components/intrinsic/intrinsic.py index 8859f82e22..8a146b4a8e 100644 --- a/mellea/stdlib/components/intrinsic/intrinsic.py +++ b/mellea/stdlib/components/intrinsic/intrinsic.py @@ -53,6 +53,7 @@ def __init__( self, intrinsic_name: str, intrinsic_kwargs: dict | None = None, + *, adapter_types: tuple[AdapterType, ...] | None = None, ) -> None: """Initialize Intrinsic by fetching metadata for the named intrinsic from the catalog.""" diff --git a/mellea/stdlib/requirements/requirement.py b/mellea/stdlib/requirements/requirement.py index 7948535060..91711445e4 100644 --- a/mellea/stdlib/requirements/requirement.py +++ b/mellea/stdlib/requirements/requirement.py @@ -96,6 +96,7 @@ def __init__( self, description: str, intrinsic_name: str | None = None, + *, adapter_types: tuple[AdapterType, ...] | None = None, ): """Initialize ALoraRequirement with a description and optional intrinsic adapter name.""" diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index dac97b0270..9bff326d13 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -1450,6 +1450,42 @@ def test_remove_adapter_unregistered_name_is_noop(): backend.remove_adapter("never_registered_lora") # must not raise +def test_remove_adapter_deregisters_composed_embedded_adapter(): + """remove_adapter() must deregister a composed Embedded adapter too. + + Regression: a composed Embedded adapter lives only in _composed_adapters + (see add_adapter) — it never has a bare-binding entry in _added_adapters + to mutate .backend/.path on. remove_adapter's original early return on an + _added_adapters miss meant this case never reached the + _composed_adapters/_composed_adapter_configs cleanup below it. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding, + Identity, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=EmbeddedBinding(), + ) + backend.add_adapter(composed) + key = "answerability_alora" + assert key in backend.list_adapters() + backend._composed_adapter_configs[key] = {"parameters": {}} + + backend.remove_adapter(key) + + assert key not in backend.list_adapters() + assert key not in backend._composed_adapters + assert key not in backend._composed_adapter_configs + + def test_add_adapter_after_remove_adapter_allows_a_fresh_registration(): """#1528: removing an adapter frees its qualified_name for a different adapter object to register under — the name is no longer burned for the From 67990a2107b7169d5e4c30787a4953a86f374cde Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 17:03:24 +0100 Subject: [PATCH 10/19] fix(backends): fix Qwen-review findings on composed-Adapter path (#1144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes several real bugs found while verifying PR1 end-to-end: a wrong exception type/message from load_peft_adapter for composed Embedded adapters, a duplicate-name guard that missed _composed_adapters, register_embedded_adapter_model clobbering a live adapter's cached config on a refused duplicate, a yaml.safe_load() dict-type gap, and two ValueErrors in the CLI-generated README template (missing revision/adapter_types for a non-catalog adapter). Also corrects factual errors found by actually running the new tutorials and doc examples against real hardware rather than just reading them: a tutorial code sample referencing undefined variables, a metrics doc overstating what's tracked today (issue #1611), and a docs example passing a served-model alias where a real HF repo ID is required. Moves the "Reading Adapter Function Telemetry" tutorial into observability/metrics.md as a reference section — it never had a hands-on walkthrough, just a metrics table and PromQL sketches, so it was reference content misfiled as a tutorial. Files, unrelated to this PR's shim-vs-composed-Adapter change, a follow-up issue (#1620) for a CLI ergonomics gap surfaced during verification: m alora upload's default layout doesn't match what add-readme's generated usage code expects. Also files #1621 to track PR2 (deleting the deprecated shim classes) separately from #1144. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- cli/alora/README_TEMPLATE.jinja | 19 +- docs/docs/advanced/intrinsics.md | 7 +- docs/docs/observability/metrics.md | 84 +++++++++ .../tutorials/07-custom-adapter-function.md | 13 +- .../tutorials/08-adapter-schema-migrations.md | 31 ++- .../09-adapter-function-telemetry.md | 104 ----------- docs/sidebars.ts | 2 + mellea/backends/huggingface.py | 37 +++- mellea/stdlib/requirements/requirement.py | 9 +- test/backends/test_huggingface_unit.py | 176 ++++++++++++++++++ test/cli/test_alora_readme_template.py | 82 ++++++++ 11 files changed, 440 insertions(+), 124 deletions(-) delete mode 100644 docs/docs/tutorials/09-adapter-function-telemetry.md create mode 100644 test/cli/test_alora_readme_template.py diff --git a/cli/alora/README_TEMPLATE.jinja b/cli/alora/README_TEMPLATE.jinja index c77d08c458..872f1a30e1 100644 --- a/cli/alora/README_TEMPLATE.jinja +++ b/cli/alora/README_TEMPLATE.jinja @@ -32,6 +32,13 @@ and an intrinsic component. The adapter connects to the HuggingFace model reposi where the trained weights are stored, and the intrinsic wraps it as a composable Mellea component that can be used with any compatible backend. +> **Repo layout:** the code below only works if `{{ userid }}/{{ intrinsic_name }}` +> was uploaded with `m alora upload ... --intrinsic --io-yaml ./io.yaml` — that +> command places weights at `{{ intrinsic_name }}//alora/`, which is +> where `LocalFileBinding` looks for them. The plain `m alora upload` (without +> `--intrinsic`) pushes weights to the repo root instead, and this code will +> fail with `ValueError: Intrinsic '{{ intrinsic_name }}' ... not found`. + Make sure you have Mellea installed (`uv pip install mellea`), then use the code below to integrate this intrinsic into your application. @@ -62,13 +69,23 @@ def _{{ intrinsic_name }}_adapter() -> Adapter: name=_INTRINSIC_ADAPTER_NAME, adapter_type=AdapterType.ALORA, repo_id=_INTRINSIC_MODEL_ID, + # A custom, non-catalog adapter has no catalog entry to resolve + # revision=None from — pin it explicitly. + revision="main", ), ) class {{ intrinsic_name_camelcase }}Intrinsic(Intrinsic, SimpleComponent): def __init__(self, {{ arglist }}): - Intrinsic.__init__(self, intrinsic_name=_INTRINSIC_ADAPTER_NAME) + # adapter_types is required here: "{{ intrinsic_name }}" is outside + # Mellea's intrinsics catalog, so Intrinsic has no catalog entry to + # fall back to. + Intrinsic.__init__( + self, + intrinsic_name=_INTRINSIC_ADAPTER_NAME, + adapter_types=(AdapterType.ALORA,), + ) SimpleComponent.__init__(self, {{ arglist_as_kwargs }}) def format_for_llm(self): diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index e87cc4a382..5a36509481 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -315,9 +315,10 @@ you can construct by hand, so registration goes through ```python from mellea.backends.openai import OpenAIBackend +from mellea.backends.model_ids import IBM_GRANITE_SWITCH_4_1_3B_PREVIEW switch_backend = OpenAIBackend( - model_id="granite-switch", + model_id=IBM_GRANITE_SWITCH_4_1_3B_PREVIEW.hf_model_name, api_key="EMPTY", base_url="http://localhost:8000/v1", load_embedded_adapters=False, @@ -327,7 +328,7 @@ switch_backend = OpenAIBackend( # io.yaml config a bare Adapter(weights=EmbeddedBinding.from_base_model(...)) # construction has no way to supply. switch_backend.register_embedded_adapter_model( - "granite-switch", intrinsic_name="answerability" + IBM_GRANITE_SWITCH_4_1_3B_PREVIEW.hf_model_name, intrinsic_name="answerability" ) ``` @@ -356,4 +357,4 @@ Safety and factuality checks use a separate set of Guardian-specific adapter fun **See also:** [Adding a custom adapter function in 20 lines](../tutorials/07-custom-adapter-function.md) | [Handling a breaking adapter schema change](../tutorials/08-adapter-schema-migrations.md) | -[Reading adapter function telemetry](../tutorials/09-adapter-function-telemetry.md) +[Adapter function metrics](../observability/metrics.md#adapter-function-metrics) diff --git a/docs/docs/observability/metrics.md b/docs/docs/observability/metrics.md index cff637b63e..0a4067c97a 100644 --- a/docs/docs/observability/metrics.md +++ b/docs/docs/observability/metrics.md @@ -297,6 +297,90 @@ All sampling metrics include: | `gen_ai.tool.name` | Name of the invoked tool | `"search"`, `"calculator"` | | `status` | Execution outcome | `success`, `failure` | +## Adapter function metrics + +Mellea records metrics for adapter function calls (`core.requirement_check()`, +`rag.check_answerability()`, `Intrinsic`/`ALoraRequirement`, and the +deprecated shim classes alike) — no code changes are required beyond enabling +metrics as above. They are recorded by `AdapterFunctionMetricsPlugin` +(`mellea/telemetry/metrics_plugins.py`), which subscribes to the +`adapter_function_invocation_complete` and `adapter_function_phase_complete` +hooks fired by `AdapterMixin.adapter_scope()` and +`EmbeddedBinding.apply_activation()`. + +### Adapter function instruments + +| Metric Name | Type | Attributes | Description | +| ----------- | ---- | ---------- | ----------- | +| `mellea.adapter_function.invocations` | Counter | `name`, `revision`, `binding_type`, `adapter_type`, `outcome` | Calls, split by outcome (`success`, `schema_error`, `error`) | +| `mellea.adapter_function.phase_duration` | Histogram | `name`, `phase` | Lifecycle phase duration, in seconds | +| `mellea.adapter_function.parse_failures` | Counter | `name`, `revision` | Calls that raised `AdapterSchemaMismatchError` *while still inside* `adapter_scope()` — see the known gap below | + +`revision` reports the *resolved* revision actually used, not the raw value +passed in: for a `LocalFileBinding` constructed with `revision=None` (the +default for a catalog adapter), `adapter_scope()` resolves it to the +catalog's pinned SHA before recording the metric, so a catalog adapter used +without an explicit revision still reports that SHA rather than `"unpinned"`. +The `"unpinned"` label only appears when no revision could be resolved at +all — for a `LocalFileBinding`, that means resolution itself failed (a +non-catalog adapter with no explicit `revision` — see +[Adding a custom adapter function](../tutorials/07-custom-adapter-function.md)). +It is not a general filter for "running without a pinned revision." + +`phase` is one of `"prepare"`, `"activate"`, `"generate"`, `"parse"`, or +`"deactivate"` — though as of this writing only three are actually emitted: +`"prepare"` once per `LocalFileBinding` (fired from `add_adapter`/ +`binding.prepare()` — the download-and-load cost), then +`"activate"`/`"deactivate"` per call from `adapter_scope()` for the +LocalFile/PEFT reality, or `"activate"` alone per call for the Embedded +reality (`EmbeddedBinding.apply_activation()`, which has no lifecycle to +deactivate). `"generate"`/`"parse"` phase timing is tracked as future work in +issue #1466. + +> **Known gap:** every production caller (`core.requirement_check()`, +> `call_intrinsic()`, `ALoraRequirement`) parses the model's output *after* +> `adapter_scope()` has already exited and fired `outcome="success"` — so a +> real `AdapterSchemaMismatchError` from one of those call paths is correctly +> raised to your code, but is not yet reflected in `parse_failures` or in +> `invocations{outcome="schema_error"}`. Tracked in issue #1611. Until that's +> fixed, catch `AdapterSchemaMismatchError` in application code (see +> [Adapter schema migrations](../tutorials/08-adapter-schema-migrations.md)) +> rather than relying on these metrics to surface schema drift. + +### Adapter function dashboard queries + +**Success rate** — `mellea.adapter_function.invocations`, grouped by `name` +and `outcome`: + +```promql +sum by (name, outcome) (rate(mellea_adapter_function_invocations_total[5m])) +``` + +Watch `outcome="error"` and `outcome="schema_error"` separately — an error +means the call itself failed (network, generation); a schema error means the +call succeeded but its output didn't parse against the declared contract +(subject to the known gap above). + +**Phase latency** — `mellea.adapter_function.phase_duration`, grouped by +`name` and `phase`. A p95 activate-phase latency that jumps for one adapter +but not others usually means its weights just got evicted from whatever +cache your deployment relies on (HF Hub's local cache, or your own). + +**Parse-failure rate** — `mellea.adapter_function.parse_failures` divided by +`mellea.adapter_function.invocations` for the same `name`. In principle this +is the leading indicator for the schema-drift scenario in +[Adapter schema migrations](../tutorials/08-adapter-schema-migrations.md): a +sudden rise, correlated with a `revision` change in your own deploy history, +would be exactly the signal to re-pin or roll back — subject to the known gap +above. + +> **Adapter function tracing:** Mellea does not currently open OpenTelemetry +> **spans** for adapter function calls — only these metrics, driven by hooks, +> exist today. Span-level tracing is tracked separately in issue #1466. If +> your dashboard needs request-level tracing today, correlate the metrics +> above with your own application-level spans around the +> `mfuncs.act()`/`m.instruct()` call site instead. + ## Metrics export configuration Mellea supports multiple metrics exporters that can be used independently or diff --git a/docs/docs/tutorials/07-custom-adapter-function.md b/docs/docs/tutorials/07-custom-adapter-function.md index e58a8e8095..b96e4505ec 100644 --- a/docs/docs/tutorials/07-custom-adapter-function.md +++ b/docs/docs/tutorials/07-custom-adapter-function.md @@ -17,7 +17,8 @@ By the end you will have covered: - Validating output with `ALoraRequirement` against a custom adapter name **Prerequisites:** `pip install "mellea[hf]"`, a trained LoRA/aLoRA adapter -uploaded to Hugging Face Hub (see +uploaded to Hugging Face Hub with +`m alora upload ... --intrinsic --io-yaml ./io.yaml` (see [LoRA and aLoRA adapters](../advanced/lora-and-alora-adapters.md) for training and uploading), and a GPU or Apple Silicon Mac. @@ -30,6 +31,14 @@ etc.) are looked up from a catalog that supplies their `repo_id` and `revision`. A custom adapter has no catalog entry, so you supply those fields yourself: +> **Repo layout matters:** `LocalFileBinding` only finds weights at +> `///` inside `repo_id` — the same layout +> Mellea's own built-in adapter functions use, and the one `m alora upload +> --intrinsic --io-yaml ./io.yaml` produces. The plain `m alora upload` +> (no `--intrinsic`) pushes weights to the repo root instead, which +> `LocalFileBinding` cannot find — if you used that command, re-upload with +> `--intrinsic` before continuing. + ```python # Requires: mellea[hf] from mellea.backends.adapters import Adapter, Identity, LocalFileBinding, get_io_contract @@ -142,4 +151,4 @@ that this tutorial's composed `Adapter` replaces without a class of its own: **See also:** [Adapter functions](../advanced/intrinsics.md) | [LoRA and aLoRA adapters](../advanced/lora-and-alora-adapters.md) | -[Reading adapter function telemetry](./09-adapter-function-telemetry.md) +[Adapter function metrics](../observability/metrics.md#adapter-function-metrics) diff --git a/docs/docs/tutorials/08-adapter-schema-migrations.md b/docs/docs/tutorials/08-adapter-schema-migrations.md index cdb0447a74..94aaf10ade 100644 --- a/docs/docs/tutorials/08-adapter-schema-migrations.md +++ b/docs/docs/tutorials/08-adapter-schema-migrations.md @@ -99,18 +99,27 @@ or from deliberately testing a new revision, the failure mode is the same: `IOContract.parse` raises `AdapterSchemaMismatchError` when the model's JSON output doesn't satisfy the declared contract. -Calling an adapter directly through `Intrinsic`/`mfuncs.act()` propagates -this exception — catch it where you call: +`mfuncs.act(Intrinsic(...), ...)` on its own only returns the model's raw +output — it does not parse or validate it, so it never raises this error. +The high-level adapter function wrappers (`core.requirement_check`, +`core.check_certainty`, the `rag` module's functions) do both: they call +`mfuncs.act()` internally, then parse the result against the adapter's +contract. Catch the exception there: ```python from mellea.backends.adapters import AdapterSchemaMismatchError -import mellea.stdlib.functional as mfuncs -from mellea.stdlib.components import Intrinsic +from mellea.stdlib.components.intrinsic import core +from mellea.stdlib.components import Message +from mellea.stdlib.context import ChatContext + +context = ( + ChatContext() + .add(Message("user", "Can you help me plan a trip?")) + .add(Message("assistant", "Sure — where are you headed?")) +) try: - out, _ = mfuncs.act(Intrinsic("requirement-check", intrinsic_kwargs={ - "requirement": "The assistant is helpful." - }), ctx, backend) + score = core.requirement_check(context, backend, "The assistant is helpful.") except AdapterSchemaMismatchError as e: print(f"Schema mismatch: expected {e.expected_keys}, observed {e.observed_keys}") if e.reason: @@ -123,6 +132,12 @@ except AdapterSchemaMismatchError as e: as "requirement not met": ```python +from mellea import MelleaSession +from mellea.stdlib.requirements import ALoraRequirement + +m = MelleaSession(backend, ctx=context) +requirement = ALoraRequirement("The assistant is helpful.") + val_res = m.validate([requirement]) for result in val_res: if result.error is not None: @@ -139,4 +154,4 @@ evidence your model got worse at the task. **See also:** [Adapter functions](../advanced/intrinsics.md) | [Adding a custom adapter function in 20 lines](./07-custom-adapter-function.md) | -[Reading adapter function telemetry](./09-adapter-function-telemetry.md) +[Adapter function metrics](../observability/metrics.md#adapter-function-metrics) diff --git a/docs/docs/tutorials/09-adapter-function-telemetry.md b/docs/docs/tutorials/09-adapter-function-telemetry.md deleted file mode 100644 index ea68b3a336..0000000000 --- a/docs/docs/tutorials/09-adapter-function-telemetry.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: "Tutorial: Reading Adapter Function Telemetry" -description: "Build a dashboard from Mellea's adapter function metrics: invocation outcomes, phase durations, and parse failures." -# diataxis: tutorial ---- - -Adapter functions emit three OpenTelemetry metrics that cover invocation -outcomes, per-phase latency, and schema-mismatch failures. This tutorial -walks through enabling them and reading each one. - -By the end you will have covered: - -- Enabling metrics and picking an exporter -- The three adapter-function instruments and their attributes -- Building a dashboard query for each: success rate, phase latency, and - parse-failure rate -- What's *not* covered yet — span-level tracing for adapter functions - -**Prerequisites:** [Metrics](../observability/metrics.md) covers the -general metrics setup this tutorial builds on. `pip install "mellea[hf,telemetry]"`. - ---- - -## Step 1: Enable metrics - -Adapter function metrics follow the same on/off switch and exporters as -every other Mellea metric — no adapter-specific configuration: - -```bash -export MELLEA_METRICS_ENABLED=true -export MELLEA_METRICS_CONSOLE=true # or OTLP/Prometheus — see the Metrics guide -``` - -They are recorded by `AdapterFunctionMetricsPlugin` -(`mellea/telemetry/metrics_plugins.py`), which subscribes to the -`adapter_function_invocation_complete` and `adapter_function_phase_complete` -hooks — the same hooks `AdapterMixin.adapter_scope()` and -`EmbeddedBinding.apply_activation()` fire for every adapter call, composed -`Adapter` or deprecated shim alike. - -## Step 2: The three instruments - -| Metric | Type | Attributes | What it tells you | -| --- | --- | --- | --- | -| `mellea.adapter_function.invocations` | Counter | `name`, `revision`, `binding_type`, `adapter_type`, `outcome` | How many calls, split by outcome (`success`, `schema_error`, `error`) | -| `mellea.adapter_function.phase_duration` | Histogram | `name`, `phase` | How long each lifecycle phase took, in seconds | -| `mellea.adapter_function.parse_failures` | Counter | `name`, `revision` | How many calls failed *specifically* on `AdapterSchemaMismatchError` | - -`revision` is normalised to the string `"unpinned"` when the binding's -revision is `None` — filter on that value to find adapters running without a -pinned revision (see -[Adapter schema migrations](./08-adapter-schema-migrations.md) for why that -matters). `phase` is one of `"prepare"`, `"activate"`, `"generate"`, -`"parse"`, or `"deactivate"` — though as of this writing only three are -actually emitted: `"prepare"` once per `LocalFileBinding` (fired from -`add_adapter`/`binding.prepare()` — the download-and-load cost), then -`"activate"`/`"deactivate"` per call from `adapter_scope()` for the -LocalFile/PEFT reality, or `"activate"` alone per call for the Embedded -reality (`EmbeddedBinding.apply_activation()`, which has no lifecycle to -deactivate). `"generate"`/`"parse"` phase timing is tracked as future work -in issue #1466. - -## Step 3: Build the three dashboard panels - -**Success rate** — `mellea.adapter_function.invocations`, grouped by `name` -and `outcome`. A PromQL sketch: - -```promql -sum by (name, outcome) (rate(mellea_adapter_function_invocations_total[5m])) -``` - -Watch `outcome="error"` and `outcome="schema_error"` separately — an error -means the call itself failed (network, generation); a schema error means the -call succeeded but its output didn't parse against the declared contract -(see [Adapter schema migrations](./08-adapter-schema-migrations.md)). - -**Phase latency** — `mellea.adapter_function.phase_duration`, grouped by -`name` and `phase`. A p95 activate-phase latency that jumps for one adapter -but not others usually means its weights just got evicted from whatever -cache your deployment relies on (HF Hub's local cache, or your own). - -**Parse-failure rate** — `mellea.adapter_function.parse_failures` divided by -`mellea.adapter_function.invocations` for the same `name`. This is the -leading indicator for the schema-drift scenario in -[Adapter schema migrations](./08-adapter-schema-migrations.md): a -sudden rise, correlated with a `revision` change in your own deploy history, -is exactly the signal to re-pin or roll back. - -## What's not covered yet - -Mellea does not currently open OpenTelemetry **spans** for adapter function -calls — only these three metrics, driven by hooks, exist today. The -`ADAPTER_FUNCTION_*` hook family has no start hook for a tracing plugin to -open a span on yet (see the note in -`AdapterMixin.adapter_scope()`'s docstring); span-level tracing is tracked -separately in issue #1466. If your dashboard needs request-level tracing -today, correlate the metrics above with your own application-level spans -around the `mfuncs.act()`/`m.instruct()` call site instead. - -**See also:** -[Metrics](../observability/metrics.md) | -[Telemetry](../observability/telemetry.md) | -[Adapter functions](../advanced/intrinsics.md) | -[Adapter schema migrations](./08-adapter-schema-migrations.md) diff --git a/docs/sidebars.ts b/docs/sidebars.ts index fd02bfa1dc..3e71ba73aa 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -20,6 +20,8 @@ const sidebars: SidebarsConfig = { 'tutorials/making-agents-reliable', 'tutorials/mifying-legacy-code', 'tutorials/streaming-validation', + 'tutorials/custom-adapter-function', + 'tutorials/adapter-schema-migrations', ], }, { diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 8b14787d90..278a387654 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -896,7 +896,9 @@ def _intrinsic_adapter_name_and_config( Raises: ValueError: A composed Embedded adapter has no cached config - (never registered via `add_adapter`/`register_embedded_adapter_model`). + (never registered via `add_adapter`/`register_embedded_adapter_model`), + or a composed LocalFile adapter's `io.yaml` did not parse to a + mapping. TypeError: A composed Adapter's `weights` is neither a `LocalFileBinding` nor an `EmbeddedBinding`. """ @@ -915,7 +917,13 @@ def _intrinsic_adapter_name_and_config( alora=binding.adapter_type is AdapterType.ALORA, ) with open(io_yaml_path, encoding="utf-8") as f: - config = yaml.safe_load(f) + loaded = yaml.safe_load(f) + if not isinstance(loaded, dict): + raise ValueError( + f"io.yaml for adapter {binding.name!r} at {io_yaml_path} " + f"did not parse to a mapping (got {type(loaded).__name__})." + ) + config = loaded self._composed_adapter_configs[key] = config return adapter.identity.name, config if isinstance(adapter.weights, EmbeddedBinding): @@ -2653,7 +2661,9 @@ def add_adapter(self, adapter: AdapterInput) -> None: f"adapter {adapter.name} with type {adapter.adapter_type} has already been added to backend {adapter.backend}" ) - existing = self._added_adapters.get(adapter.qualified_name) + existing = self._added_adapters.get( + adapter.qualified_name + ) or self._composed_adapters.get(adapter.qualified_name) if existing is not None: MelleaLogger.get_logger().warning( f"Client code attempted to add {adapter.name} with type {adapter.adapter_type} " @@ -2719,8 +2729,18 @@ def register_embedded_adapter_model( ) names = [] for adapter, config in discovered: + key = _composed_adapter_key(adapter) self.add_adapter(adapter) - self._composed_adapter_configs[_composed_adapter_key(adapter)] = config + # add_adapter() silently refuses (logs a warning, returns) rather than + # raising when `key` is already registered — checking `key in + # self._composed_adapters` isn't enough, since a prior registration + # already left it there; compare identity to confirm *this* adapter + # is the one that's actually registered before caching its config, + # or a refused call would overwrite the live adapter's config with + # this one's and falsely report it as registered below. + if self._composed_adapters.get(key) is not adapter: + continue + self._composed_adapter_configs[key] = config names.append(adapter.identity.name) return names @@ -2742,6 +2762,15 @@ def load_peft_adapter(self, adapter_qualified_name: str) -> None: """ adapter = self._added_adapters.get(adapter_qualified_name, None) if adapter is None: + # A composed Embedded adapter never lands in `_added_adapters` (see + # `add_adapter`'s composed-Adapter branch) — check there before + # concluding the name was never added at all. + composed = self._composed_adapters.get(adapter_qualified_name, None) + if composed is not None and isinstance(composed.weights, EmbeddedBinding): + raise TypeError( + f"cannot load embedded adapter {adapter_qualified_name} through PEFT; " + "it is activated by the chat template" + ) raise ValueError( f"could not load adapter {adapter_qualified_name} for backend {self}: adapter was not previously added" ) diff --git a/mellea/stdlib/requirements/requirement.py b/mellea/stdlib/requirements/requirement.py index 91711445e4..2e33fae678 100644 --- a/mellea/stdlib/requirements/requirement.py +++ b/mellea/stdlib/requirements/requirement.py @@ -84,12 +84,17 @@ class ALoraRequirement(Requirement, Intrinsic): Defaults to `"requirement-check"`. adapter_types (tuple[AdapterType, ...] | None): Adapter types to look up for `intrinsic_name`. Required when `intrinsic_name` is a - custom, non-catalog adapter function (see `Intrinsic`); ignored - for a catalog name, which supplies its own. + custom, non-catalog adapter function (see `Intrinsic`). Optional + for a catalog name, which otherwise supplies its own — but an + explicit value here overrides the catalog's, it is not ignored. Attributes: use_aloras (bool): Always `True`; this class always attempts to use ALoRA adapters for validation. + + Raises: + ValueError: `intrinsic_name` is a custom, non-catalog adapter + function and `adapter_types` was not provided. """ def __init__( diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index 9bff326d13..e3e47d5c9d 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -1206,6 +1206,58 @@ def fake_set_adapter(name_or_names): backend._model.active_adapters.side_effect = lambda: list(active) # type: ignore[attr-defined] +def test_generate_composed_local_file_with_adapter_scope_activates_during_generation(): + """Composed-adapter counterpart of the test above, exercising the real + `_generate_composed_local_file_with_adapter_scope` method directly. + + Coverage gap noted in review: the only other test reaching this method + goes through `stub_backend`, whose fixture replaces it with a + pass-through bypassing the lock hold, `adapter_scope` drive, and both + `_assert_correct_adapters` calls entirely — so the method that owns that + logic had no direct coverage of its own, unlike its shim sibling above. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + LocalFileBinding as _LocalFileBinding, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + _wire_fake_peft_model(backend) + + binding = _LocalFileBinding( + name="answerability", + adapter_type=AdapterType.ALORA, + repo_id="fake/repo", + revision="fake0000000000000000000000000000000000000", + ) + binding.backend = backend + binding.path = "/fake/path" + binding._loaded = True + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=binding, + ) + + seen_during_generation = [] + + def fake_generate(): + seen_during_generation.append(backend._model.active_adapters()) + return "output" + + out = backend._generate_composed_local_file_with_adapter_scope( + composed, fake_generate + ) + + assert out == "output" + assert seen_during_generation == [[binding.qualified_name]] + assert backend._model.active_adapters() == [] + backend._model.set_adapter.assert_any_call([]) # type: ignore[attr-defined] + + def test_generate_intrinsic_with_adapter_scope_activates_during_generation(): """Generation demonstrably runs with the adapter active — asserted from inside the generate callback via the real (mocked) PEFT model state, not @@ -1486,6 +1538,130 @@ def test_remove_adapter_deregisters_composed_embedded_adapter(): assert key not in backend._composed_adapter_configs +def test_load_peft_adapter_on_composed_embedded_adapter_raises_type_error(): + """load_peft_adapter() must reject a composed Embedded adapter with TypeError. + + Regression: a composed Embedded adapter lives only in _composed_adapters, + never _added_adapters (see add_adapter's composed-Adapter branch). Without + a check there, load_peft_adapter's `_added_adapters.get(...)` miss raised + ValueError("was not previously added") instead of the documented + TypeError explaining it's activated by the chat template, not PEFT — + misleading, since the adapter genuinely was added. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding, + Identity, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=EmbeddedBinding(), + ) + backend.add_adapter(composed) + + with pytest.raises(TypeError, match="cannot load embedded adapter"): + backend.load_peft_adapter("answerability_alora") + + +def test_add_adapter_shim_refuses_name_already_claimed_by_composed_adapter(): + """The legacy/shim add_adapter path must see composed-adapter registrations too. + + Regression: the duplicate-name guard on the shim path only checked + `_added_adapters`, not `_composed_adapters` — a composed Adapter already + registered under a qualified name did not stop a later shim + `EmbeddedIntrinsicAdapter` (or bare `LocalFileBinding`) from silently + claiming the same name, defeating the "adapter loading is not + idempotent" invariant the warning message itself describes. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding as _EmbeddedBinding, + Identity as _Identity, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + composed = _AdapterCore( + identity=_Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=_EmbeddedBinding(), + ) + backend.add_adapter(composed) + assert "answerability_alora" in backend._composed_adapters + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + shim = EmbeddedIntrinsicAdapter("answerability", config={}, technology="alora") + + backend.add_adapter(shim) + + assert shim.backend is None + assert "answerability_alora" not in backend._added_adapters + + +def test_register_embedded_adapter_model_refused_duplicate_does_not_clobber_cached_config(): + """register_embedded_adapter_model() must not overwrite a live adapter's cached + config, or falsely report it as (re-)registered, when add_adapter() refuses + a duplicate name. + + Regression: add_adapter() silently refuses (logs a warning, returns) rather + than raising for an already-registered qualified name. The loop here used to + write `discovered`'s config into `_composed_adapter_configs` and append the + name unconditionally, so a second discovery call for the same adapter name + would overwrite the first, still-registered adapter's config with whatever + the second (possibly different) discovery produced, and falsely list it as + registered in the returned names. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding as _EmbeddedBinding, + Identity as _Identity, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + + def _make_composed(): + return _AdapterCore( + identity=_Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=_EmbeddedBinding(), + ) + + first_config = {"version": "first"} + with patch( + "mellea.backends.huggingface._discover_embedded_adapters", + return_value=[(_make_composed(), first_config)], + ): + names = backend.register_embedded_adapter_model( + "some/repo", intrinsic_name="answerability" + ) + assert names == ["answerability"] + assert backend._composed_adapter_configs["answerability_alora"] is first_config + + second_config = {"version": "second"} + with patch( + "mellea.backends.huggingface._discover_embedded_adapters", + return_value=[(_make_composed(), second_config)], + ): + names = backend.register_embedded_adapter_model( + "some/other-repo", intrinsic_name="answerability" + ) + + assert names == [] + assert backend._composed_adapter_configs["answerability_alora"] is first_config + + def test_add_adapter_after_remove_adapter_allows_a_fresh_registration(): """#1528: removing an adapter frees its qualified_name for a different adapter object to register under — the name is no longer burned for the diff --git a/test/cli/test_alora_readme_template.py b/test/cli/test_alora_readme_template.py new file mode 100644 index 0000000000..6bdbb6eec8 --- /dev/null +++ b/test/cli/test_alora_readme_template.py @@ -0,0 +1,82 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for the generated `cli/alora/README_TEMPLATE.jinja` code sample. + +Nothing renders or executes this template today outside the LLM-assisted +`readme_generator.py` flow, so a construction bug in it (e.g. a missing +required argument) ships silently to every user who trains a custom aLoRA +adapter. These tests render it with representative variables, extract the +fenced Python sample, and execute the adapter/intrinsic construction it +generates for a non-catalog adapter name. +""" + +import os +import re + +import pytest + +pytest.importorskip("jinja2", reason="jinja2 not installed") + +_TEMPLATE_VARS = { + "base_model": "ibm-granite/granite-4.1-3b", + "adapter_name": "Stembolts", + "high_level_description": "Detects defective stembolts from inspection notes.", + "dataset_description": "JSONL rows of inspection notes and defect labels.", + "samples": [{"input": "note text", "output": "defective"}], + "userid": "acme", + "intrinsic_name": "stembolts", + "intrinsic_name_camelcase": "Stembolts", + "arglist": "description: str", + "arglist_without_type_annotations": "description", + "arglist_as_kwargs": "description=description", + "example_call_kwargs": "description='a cracked stembolt'", +} + + +def _render_template() -> str: + from jinja2 import Environment, FileSystemLoader + + template_dir = os.path.join(os.path.dirname(__file__), "..", "..", "cli", "alora") + env = Environment(loader=FileSystemLoader(template_dir)) + template = env.get_template("README_TEMPLATE.jinja") + return template.render(**_TEMPLATE_VARS) + + +def _extract_python_sample(readme_content: str) -> str: + match = re.search(r"```python\n(.*?)\n```", readme_content, re.DOTALL) + assert match is not None, "README template has no fenced python code sample" + code = match.group(1) + # The `if __name__ == "__main__":` block calls the generated function against + # a real LocalHFBackend — exercise only the adapter/intrinsic construction, + # not model loading or generation. + return code.split('if __name__ == "__main__":')[0] + + +@pytest.mark.unit +def test_readme_template_renders_and_python_sample_compiles(): + """The generated README's fenced Python sample must be syntactically valid.""" + code = _extract_python_sample(_render_template()) + compile(code, "", "exec") + + +@pytest.mark.unit +def test_readme_template_constructs_composed_adapter_for_non_catalog_name(): + """The generated composed `Adapter`/`Intrinsic` must construct for a non-catalog name. + + Regression guard: the generated `LocalFileBinding(...)` previously omitted + `revision=`, and the generated `Intrinsic.__init__(...)` previously omitted + `adapter_types=`. Since a trained user adapter is never in Mellea's + intrinsics catalog, both omissions raised + `ValueError: Unknown intrinsic name '...'` the first time a user followed + this exact generated code. + """ + code = _extract_python_sample(_render_template()) + namespace: dict = {} + exec(code, namespace) + + adapter = namespace["_stembolts_adapter"]() + assert adapter.weights.revision is not None + + intrinsic = namespace["StemboltsIntrinsic"](description="a cracked stembolt") + assert intrinsic.metadata.name == "stembolts" From cef10fd702eccc15b3a880aed756c522c007affc Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 17:38:22 +0100 Subject: [PATCH 11/19] fix(backends): fix Bob-round-2 findings on composed-Adapter path (#1144) Fixes two blockers and two real correctness gaps found by an independent review of PR1, each verified against source and covered by a regression test proven to fail pre-fix: - cli/alora/README_TEMPLATE.jinja: the generated async_() wrapper passed an extra positional intrinsic-name argument to its Intrinsic subclass constructor (the sync wrapper right below it was correct) -- TypeError on every call. - OpenAIBackend.register_embedded_adapter_model: missing the same identity guard already fixed on LocalHFBackend's twin -- a refused duplicate registration silently clobbered a live adapter's cached io.yaml config and falsely reported it as registered. - LocalHFBackend.add_adapter: a LocalFileBinding registered standalone blocked a later composed Adapter wrapping that same binding, since the duplicate-key guard didn't distinguish "same binding, composing it" from "a different object already claimed this name" -- silently dropping the composed Adapter's identity/io_contract with no error. Also verified and refuted (with reasoning) two findings that don't hold up against source: register_embedded_adapter_model's documented ValueError for an unmatched intrinsic_name IS raised today, just by the underlying from_model_directory()/from_hub() the discovery helper delegates to, not by the caller itself; and the AdapterMixin base docstring gap is a documentation-placement nit, not an inaccuracy, since the concrete override already documents its own behaviour correctly. Applies two safe cleanups (vacuous EmbeddedIntrinsicAdapter | _AdapterCore union simplified to _AdapterCore, since the former is a subclass of the latter) and a docstring clarification (Intrinsic.metadata.repo_id is "" for a non-catalog synthetic entry), plus two missing regression tests for existing, already-correct code paths (remove_adapter's composed LocalFile cleanup; the uncached-composed-Embedded ValueError). Assisted-by: Claude Code Signed-off-by: Nigel Jones --- cli/alora/README_TEMPLATE.jinja | 2 +- mellea/backends/huggingface.py | 9 +- mellea/backends/openai.py | 19 ++- .../stdlib/components/intrinsic/intrinsic.py | 5 + test/backends/test_huggingface_unit.py | 120 ++++++++++++++++++ test/backends/test_openai_intrinsics_unit.py | 56 ++++++++ test/cli/test_alora_readme_template.py | 30 +++++ 7 files changed, 236 insertions(+), 5 deletions(-) diff --git a/cli/alora/README_TEMPLATE.jinja b/cli/alora/README_TEMPLATE.jinja index 872f1a30e1..70e3cffd51 100644 --- a/cli/alora/README_TEMPLATE.jinja +++ b/cli/alora/README_TEMPLATE.jinja @@ -95,7 +95,7 @@ async def async_{{ intrinsic_name }}({{ arglist }}, ctx: Context, backend: Backe # Backend.add_adapter should be idempotent, but we'll go ahead and check just in case. if _INTRINSIC_QUALIFIED_NAME not in backend.list_adapters(): backend.add_adapter(_{{ intrinsic_name }}_adapter()) - action = {{ intrinsic_name_camelcase }}Intrinsic("{{ intrinsic_name }}", {{ arglist_without_type_annotations }}) + action = {{ intrinsic_name_camelcase }}Intrinsic({{ arglist_without_type_annotations }}) mot = await backend.generate_from_context(action, ctx) return mot diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 278a387654..6c5346cafa 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -2618,7 +2618,14 @@ def add_adapter(self, adapter: AdapterInput) -> None: adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter) ): key = _composed_adapter_key(adapter) - if key in self._added_adapters or key in self._composed_adapters: + # A LocalFileBinding registered standalone (bare add_adapter(binding)) + # lands in _added_adapters under this same key — that's not a + # collision if it's the very binding this composed Adapter wraps, + # only if some *other* object already claimed the name. + existing_added = self._added_adapters.get(key) + if ( + existing_added is not None and existing_added is not adapter.weights + ) or key in self._composed_adapters: MelleaLogger.get_logger().warning( f"Client code attempted to add {adapter.identity.name} with type " f"{adapter.identity.adapter_type} but {key!r} is already " diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 2416a42d65..dd6319beb4 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -286,7 +286,9 @@ def __init__( self._client_cache = ClientCache(2) - self._added_adapters: dict[str, EmbeddedIntrinsicAdapter | _AdapterCore] = {} + # EmbeddedIntrinsicAdapter is itself an _AdapterCore subclass, so this + # single type covers both the shim and composed-Adapter realities. + self._added_adapters: dict[str, _AdapterCore] = {} # Raw io.yaml config for composed Adapter instances (Epic #929, issue # #1144), keyed by _composed_adapter_key(). A composed Adapter has no # `.config` field (that's shim-only state); see @@ -468,8 +470,17 @@ def register_embedded_adapter_model( ) names = [] for adapter, config in discovered: + key = _composed_adapter_key(adapter) self.add_adapter(adapter) - self._composed_adapter_configs[_composed_adapter_key(adapter)] = config + # add_adapter() silently refuses (logs a warning, returns) rather than + # raising when `key` is already registered — compare identity to + # confirm *this* adapter is the one that's actually registered before + # caching its config, or a refused call would overwrite the live + # adapter's config with this one's and falsely report it as + # registered below (mirrors LocalHFBackend's identical guard). + if self._added_adapters.get(key) is not adapter: + continue + self._composed_adapter_configs[key] = config names.append(adapter.identity.name) return names @@ -871,7 +882,9 @@ async def _generate_from_intrinsic( # TODO: OpenAIBackend only supports EmbeddedAdapters. # It should be refactored into a specific adapter.transform() function. - if not isinstance(adapter, (EmbeddedIntrinsicAdapter, _AdapterCore)): + # EmbeddedIntrinsicAdapter is itself an _AdapterCore subclass, so checking + # for _AdapterCore alone covers both the shim and composed-Adapter realities. + if not isinstance(adapter, _AdapterCore): raise TypeError( "OpenAIBackend only supports EmbeddedIntrinsicAdapter or a composed " f"Adapter, got: {type(adapter).__name__}" diff --git a/mellea/stdlib/components/intrinsic/intrinsic.py b/mellea/stdlib/components/intrinsic/intrinsic.py index 8a146b4a8e..599e681d97 100644 --- a/mellea/stdlib/components/intrinsic/intrinsic.py +++ b/mellea/stdlib/components/intrinsic/intrinsic.py @@ -38,6 +38,11 @@ class Intrinsic(Component[str]): Attributes: metadata: The intrinsic metadata fetched from the catalog, or a synthetic entry for a name outside it (see `adapter_types` above). + For that synthetic entry, `metadata.repo_id` is `""` and + `metadata.revision` is `"not-a-catalog-entry"` — this class never + reads either field itself, but code that does (e.g. after + reaching in via `intrinsic.metadata.repo_id`) should treat an + empty `repo_id` as "no catalog entry", not as a real value. intrinsic_kwargs (dict): Keyword arguments passed to the intrinsic. intrinsic_name (str): User-visible name of this intrinsic (property). adapter_types (tuple[AdapterType, ...]): Available adapter types that diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index e3e47d5c9d..1d13450757 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -1569,6 +1569,46 @@ def test_load_peft_adapter_on_composed_embedded_adapter_raises_type_error(): backend.load_peft_adapter("answerability_alora") +def test_add_adapter_allows_composed_wrapper_of_a_standalone_registered_binding(): + """A composed Adapter wrapping an already-standalone-registered LocalFileBinding + must register into _composed_adapters, not be silently refused. + + Regression: the duplicate-key guard on the composed branch checked + `key in self._added_adapters` unconditionally — a LocalFileBinding + registered standalone (bare `add_adapter(binding)`) lands there under the + same key, so a subsequent `add_adapter(composed_wrapping_that_binding)` + hit the guard and was silently dropped: the binding's weights stayed + loaded and functional, but the composed Adapter carrying `identity`/ + `io_contract` never made it into `_composed_adapters`, so + `_find_adapter(capability)` could never find this capability. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + LocalFileBinding as _LocalFileBinding, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + binding = _LocalFileBinding( + name="answerability", adapter_type=AdapterType.ALORA, repo_id="fake/repo" + ) + binding.get_local_hf_path = lambda base_model_name: "/fake/path" + backend.add_adapter(binding) + assert backend._added_adapters["answerability_alora"] is binding + + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=binding, + ) + backend.add_adapter(composed) + + assert backend._composed_adapters["answerability_alora"] is composed + assert backend._find_adapter("answerability") is composed + + def test_add_adapter_shim_refuses_name_already_claimed_by_composed_adapter(): """The legacy/shim add_adapter path must see composed-adapter registrations too. @@ -1662,6 +1702,86 @@ def _make_composed(): assert backend._composed_adapter_configs["answerability_alora"] is first_config +def test_remove_adapter_deregisters_composed_local_file_adapter(): + """remove_adapter() must clean up a composed LocalFile adapter's entries too. + + Coverage gap noted in review: only the composed-Embedded path (above) had + a regression test for this cleanup; the composed-LocalFile branch at the + same call site (which additionally clears the bare binding's + `.backend`/`.path`) had none. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + LocalFileBinding as _LocalFileBinding, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + binding = _LocalFileBinding( + name="answerability", + adapter_type=AdapterType.ALORA, + repo_id="fake/repo", + revision="fake0000000000000000000000000000000000000", + ) + binding.backend = backend + binding.path = "/fake/path" + binding._loaded = True + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=binding, + ) + key = "answerability_alora" + # Simulates the post-add_adapter() state directly, bypassing the real + # PEFT-loading side effects add_adapter() would otherwise trigger. + backend._added_adapters[key] = binding + backend._composed_adapters[key] = composed + backend._composed_adapter_configs[key] = {"parameters": {}} + + backend.remove_adapter(key) + + assert key not in backend.list_adapters() + assert key not in backend._added_adapters + assert key not in backend._composed_adapters + assert key not in backend._composed_adapter_configs + assert binding.backend is None + assert binding.path is None + + +def test_intrinsic_adapter_name_and_config_raises_for_uncached_composed_embedded(): + """`_intrinsic_adapter_name_and_config` must raise its documented ValueError + when a composed Embedded adapter has no cached config. + + Coverage gap noted in review: the happy path (config present) was + tested; this error branch — reachable if a composed Embedded adapter was + registered via bare `add_adapter()` rather than + `register_embedded_adapter_model()`/`resolve_adapter()`, which are the + only call sites that populate `_composed_adapter_configs` for it — was not. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding as _EmbeddedBinding, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=_EmbeddedBinding(), + ) + backend.add_adapter(composed) + assert "answerability_alora" in backend._composed_adapters + assert "answerability_alora" not in backend._composed_adapter_configs + + with pytest.raises(ValueError, match=r"No io\.yaml config cached"): + backend._intrinsic_adapter_name_and_config(composed) + + def test_add_adapter_after_remove_adapter_allows_a_fresh_registration(): """#1528: removing an adapter frees its qualified_name for a different adapter object to register under — the name is no longer burned for the diff --git a/test/backends/test_openai_intrinsics_unit.py b/test/backends/test_openai_intrinsics_unit.py index af84ba8459..10c63d5a7d 100644 --- a/test/backends/test_openai_intrinsics_unit.py +++ b/test/backends/test_openai_intrinsics_unit.py @@ -811,3 +811,59 @@ def get_temperature(location: str) -> int: assert tools is not None assert len(tools) == 1 assert tools[0]["function"]["name"] == "get_temperature" + + +def test_register_embedded_adapter_model_refused_duplicate_does_not_clobber_cached_config(): + """register_embedded_adapter_model() must not overwrite a live adapter's cached + config, or falsely report it as (re-)registered, when add_adapter() refuses + a duplicate name. + + Regression: add_adapter() silently refuses (logs a warning, returns) rather + than raising for an already-registered qualified name. The loop here used to + write `discovered`'s config into `_composed_adapter_configs` and append the + name unconditionally — mirrors the identical bug fixed on LocalHFBackend. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding as _EmbeddedBinding, + Identity as _Identity, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = OpenAIBackend( + model_id="granite-switch", + api_key="fake-key", + base_url="http://localhost:9999/v1", + ) + + def _make_composed(): + return _AdapterCore( + identity=_Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=_EmbeddedBinding(), + ) + + first_config = {"version": "first"} + with patch( + "mellea.backends.openai._discover_embedded_adapters", + return_value=[(_make_composed(), first_config)], + ): + names = backend.register_embedded_adapter_model( + "some/repo", intrinsic_name="answerability" + ) + assert names == ["answerability"] + assert backend._composed_adapter_configs["answerability_alora"] is first_config + + second_config = {"version": "second"} + with patch( + "mellea.backends.openai._discover_embedded_adapters", + return_value=[(_make_composed(), second_config)], + ): + names = backend.register_embedded_adapter_model( + "some/other-repo", intrinsic_name="answerability" + ) + + assert names == [] + assert backend._composed_adapter_configs["answerability_alora"] is first_config diff --git a/test/cli/test_alora_readme_template.py b/test/cli/test_alora_readme_template.py index 6bdbb6eec8..dd715fcc06 100644 --- a/test/cli/test_alora_readme_template.py +++ b/test/cli/test_alora_readme_template.py @@ -13,6 +13,7 @@ import os import re +from unittest.mock import AsyncMock, MagicMock import pytest @@ -80,3 +81,32 @@ def test_readme_template_constructs_composed_adapter_for_non_catalog_name(): intrinsic = namespace["StemboltsIntrinsic"](description="a cracked stembolt") assert intrinsic.metadata.name == "stembolts" + + +@pytest.mark.unit +async def test_readme_template_async_wrapper_constructs_intrinsic_correctly(): + """The generated `async_` wrapper must construct its `Intrinsic` subclass + with only the arglist, not an extra positional intrinsic-name argument. + + Regression guard: `async_stembolts(...)` previously called + `StemboltsIntrinsic("stembolts", description)` — an extra leading + positional the sync `stembolts(...)` wrapper (right below it) never had + — raising `TypeError: __init__() takes 2 positional arguments but 3 were + given` for every user who called the generated async wrapper. + """ + code = _extract_python_sample(_render_template()) + namespace: dict = {} + exec(code, namespace) + + backend = MagicMock() + backend.list_adapters.return_value = ["stembolts_alora"] + backend.generate_from_context = AsyncMock(return_value="mocked output") + + result = await namespace["async_stembolts"]( + description="a cracked stembolt", ctx=MagicMock(), backend=backend + ) + + assert result == "mocked output" + backend.generate_from_context.assert_awaited_once() + action = backend.generate_from_context.await_args.args[0] + assert isinstance(action, namespace["StemboltsIntrinsic"]) From 9d63a7726dab2218de63d13d43497bff4a28c9dc Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Thu, 3 Sep 2026 22:51:30 +0100 Subject: [PATCH 12/19] fix(backends): require config for composed Embedded-adapter registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_adapter() accepted a composed Adapter with EmbeddedBinding weights directly, registering it into list_adapters()/_find_adapter() with no way to cache its io.yaml config — only register_embedded_adapter_model() and resolve_adapter() did that, as a separate step after add_adapter(). A caller using the composed-Adapter path add_adapter() itself advertises as supported got an adapter that registered fine and then died at generation time with "No io.yaml config cached...". add_adapter() now takes an optional config kwarg. For a composed EmbeddedBinding adapter it's required: missing, it raises before any registration happens; given, it's cached into _composed_adapter_configs atomically with registration. LocalFileBinding rejects a config argument. register_embedded_adapter_model()/resolve_adapter() now pass their discovered config straight into add_adapter() instead of writing to _composed_adapter_configs afterward -- which also closes a latent, unguarded version of the clobber bug cef10fd7 fixed for register_embedded_adapter_model(), present in resolve_adapter() too. Updates the composed-adapter registration tests on both backends (new test for the no-config-raises case) and intrinsics.md, which implied bare add_adapter() was sufficient for an Embedded adapter. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- docs/docs/advanced/intrinsics.md | 12 +++-- mellea/backends/adapters/adapter.py | 33 ++++++++---- mellea/backends/huggingface.py | 53 +++++++++++++++---- mellea/backends/openai.py | 45 ++++++++++++---- .../test_adapters/test_embedded_adapter.py | 19 +++++-- test/backends/test_adapters/test_shims.py | 2 +- test/backends/test_huggingface_unit.py | 51 +++++++++++++----- test/backends/test_openai_intrinsics_unit.py | 4 +- 8 files changed, 163 insertions(+), 56 deletions(-) diff --git a/docs/docs/advanced/intrinsics.md b/docs/docs/advanced/intrinsics.md index 5a36509481..5147123653 100644 --- a/docs/docs/advanced/intrinsics.md +++ b/docs/docs/advanced/intrinsics.md @@ -284,7 +284,10 @@ weights binding into a single, inspectable object. Both `LocalHFBackend` and dispatching on the weights binding's reality (`LocalFileBinding` for LocalFile/PEFT, `EmbeddedBinding` for Embedded/Granite Switch) — alongside the deprecated shim classes, which remain functional for now (Epic #929, -issue #1144). +issue #1144). An `EmbeddedBinding` adapter additionally requires `add_adapter`'s +`config=` argument (the raw io.yaml mapping) — `add_adapter` raises `ValueError` +without it, since that reality's config cannot be cheaply re-derived later; see +below. Each weights binding models how its deployment turns an adapter on. `LocalFileBinding` downloads and loads LoRA/aLoRA weights, so it exposes a @@ -311,7 +314,8 @@ the served base model — so it exposes a single method, `apply_activation`, that edits the outgoing request instead of a lifecycle. Its `io.yaml` config comes from the served checkpoint's `adapter_index.json`, not from anything you can construct by hand, so registration goes through -`register_embedded_adapter_model` rather than a bare `add_adapter` call: +`register_embedded_adapter_model` (or `resolve_adapter`) rather than a bare +`add_adapter(adapter)` call with no `config=`: ```python from mellea.backends.openai import OpenAIBackend @@ -336,8 +340,8 @@ Weights-binding support by backend today: | Backend | `LocalFileBinding` (LocalFile/PEFT) | `EmbeddedBinding` (Embedded/Granite Switch) | `ServerMediatedBinding` | | --- | --- | --- | --- | -| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a composed `Adapter` or a bare `LocalFileBinding` directly | ✅ shipping — `load_embedded_adapters=True`, or `add_adapter`/`register_embedded_adapter_model` with a composed `Adapter` | — | -| `OpenAIBackend` | — | ✅ shipping — `load_embedded_adapters=True`, or `add_adapter`/`register_embedded_adapter_model` with a composed `Adapter` | — | +| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a composed `Adapter` or a bare `LocalFileBinding` directly | ✅ shipping — `load_embedded_adapters=True`, or `add_adapter(adapter, config=...)`/`register_embedded_adapter_model` with a composed `Adapter` | — | +| `OpenAIBackend` | — | ✅ shipping — `load_embedded_adapters=True`, or `add_adapter(adapter, config=...)`/`register_embedded_adapter_model` with a composed `Adapter` | — | `ServerMediatedBinding` has no backend implementation yet — see discussion #1486. Discovering *multiple* embedded adapters from a Granite Switch checkpoint or diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 44fcab489f..8e6be7acbc 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -539,7 +539,7 @@ def base_model_name(self) -> str: """ @abc.abstractmethod - def add_adapter(self, adapter: AdapterInput) -> None: + def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> None: """Register an adapter with this backend so it can be loaded later. The adapter must not already have been added to a different backend. @@ -548,12 +548,25 @@ def add_adapter(self, adapter: AdapterInput) -> None: backend rejects an embedded adapter), so a statically valid call may still be rejected at runtime. + `config` is the raw io.yaml mapping for a composed `Adapter`/ + `_AdapterCore` whose `weights` is an `EmbeddedBinding` — that reality's + config cannot be cheaply re-derived later, so it must be supplied here + (see `_discover_embedded_adapters`/`resolve_adapter`), rather than + being fetched lazily the way a `LocalFileBinding`'s io.yaml is. + Args: adapter (AdapterInput): The adapter to register with this backend. + config (dict | None): Raw io.yaml config for a composed + `EmbeddedBinding` adapter. Ignored (and rejected) for every + other adapter reality. Raises: TypeError: If `adapter` belongs to a reality this backend does not - support. + support, or `config` is given for a reality other than a + composed `EmbeddedBinding` adapter. + ValueError: If `adapter.weights` is an `EmbeddedBinding` and + `config` is not given — registering it without a config would + make it discoverable but permanently unable to generate. """ @abc.abstractmethod @@ -757,17 +770,17 @@ def resolve_adapter(self, name: str) -> _AdapterCore: # add_adapter supports the Embedded/Granite Switch reality # (currently OpenAIBackend and LocalHFBackend when configured # with load_embedded_adapters=True). - configs = getattr(self, "_composed_adapter_configs", None) + # + # Passing config= lets add_adapter() cache it atomically with + # registration, gated behind its own duplicate-key guard — a + # refused duplicate (a different object already holds the + # key) therefore never reaches the config write, so this + # can't clobber a live adapter's cached config the way a + # register-then-separately-cache sequence could. for a, config in _discover_embedded_adapters( repo_id, intrinsic_name=name ): - # Register first, then cache the config: if add_adapter - # refuses the registration (e.g. a duplicate key), no - # orphaned config entry is left behind for a key that - # was never actually stored in the registry. - self.add_adapter(a) - if configs is not None: - configs[_composed_adapter_key(a)] = config + self.add_adapter(a, config=config) else: # AdapterType.LORA is the pre-Phase-1 default (mirrors old _util.py). # Every current catalog entry supports LORA. Phase 2 (see epic #929) diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 6c5346cafa..ccc00b5e17 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -2579,7 +2579,7 @@ def base_model_name(self): """Returns the base_model_id of the model used by the backend. For example, `granite-3.3-8b-instruct` for `ibm-granite/granite-3.3-8b-instruct`.""" return pathlib.PurePath(self._model_id).name - def add_adapter(self, adapter: AdapterInput) -> None: + def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> None: """Register an adapter function with this backend. Downloads the adapter weights (via `adapter.get_local_hf_path`) and records @@ -2595,9 +2595,19 @@ def add_adapter(self, adapter: AdapterInput) -> None: adapter (AdapterInput): The adapter to register. Must be a `LocalHFAdapter`, `LocalFileBinding`, `EmbeddedIntrinsicAdapter`, or a composed `Adapter`; other adapter realities are rejected. + config (dict | None): Raw io.yaml config for a composed `Adapter` + whose `weights` is an `EmbeddedBinding`. Required for that + shape (its config cannot be cheaply re-derived later, unlike a + `LocalFileBinding`'s); rejected for every other shape. Raises: - TypeError: If `adapter` is not a supported local or embedded adapter. + TypeError: If `adapter` is not a supported local or embedded + adapter, or `config` is given for a shape other than a + composed `EmbeddedBinding` adapter. + ValueError: If `adapter` is a composed `Adapter` whose `weights` is + an `EmbeddedBinding` and `config` is not given — registering it + without one would make it discoverable but permanently unable + to generate. Exception: If `adapter` has already been added to a different backend. """ if not isinstance( @@ -2617,6 +2627,26 @@ def add_adapter(self, adapter: AdapterInput) -> None: if isinstance(adapter, _AdapterCore) and not isinstance( adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter) ): + # Validated ahead of the duplicate-registration guard below: a + # malformed config= argument must raise even when the call would + # otherwise be a silently-refused duplicate. + if isinstance(adapter.weights, EmbeddedBinding): + if config is None: + raise ValueError( + f"No io.yaml config given for composed embedded adapter " + f"{adapter.identity.name!r}; registering it without one " + "would leave it discoverable but unable to generate. " + "Pass config=, or register it via " + "register_embedded_adapter_model() or resolve_adapter()." + ) + elif config is not None: + raise TypeError( + "config= is only accepted for a composed Adapter whose " + f"weights is an EmbeddedBinding; got " + f"{type(adapter.weights).__name__}, which derives its " + "config lazily from io.yaml on first use." + ) + key = _composed_adapter_key(adapter) # A LocalFileBinding registered standalone (bare add_adapter(binding)) # lands in _added_adapters under this same key — that's not a @@ -2634,8 +2664,10 @@ def add_adapter(self, adapter: AdapterInput) -> None: ) return if isinstance(adapter.weights, EmbeddedBinding): + assert config is not None # validated above adapter.weights.source = self.base_model_name self._composed_adapters[key] = adapter + self._composed_adapter_configs[key] = config return if isinstance(adapter.weights, LocalFileBinding): binding = adapter.weights @@ -2737,17 +2769,16 @@ def register_embedded_adapter_model( names = [] for adapter, config in discovered: key = _composed_adapter_key(adapter) - self.add_adapter(adapter) - # add_adapter() silently refuses (logs a warning, returns) rather than - # raising when `key` is already registered — checking `key in - # self._composed_adapters` isn't enough, since a prior registration - # already left it there; compare identity to confirm *this* adapter - # is the one that's actually registered before caching its config, - # or a refused call would overwrite the live adapter's config with - # this one's and falsely report it as registered below. + # add_adapter() caches config atomically with registration now, so + # a refused duplicate (a different object already holding `key`) + # never reaches that write — no separate clobber guard needed for + # the config. The identity check below is still required, though: + # add_adapter() returns None on both success and silent refusal, + # so this is the only way to know whether *this* adapter is the + # one that actually ended up registered, for the `names` result. + self.add_adapter(adapter, config=config) if self._composed_adapters.get(key) is not adapter: continue - self._composed_adapter_configs[key] = config names.append(adapter.identity.name) return names diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index dd6319beb4..226f8090bc 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -325,7 +325,7 @@ def __str__(self) -> str: # AdapterMixin implementation # ------------------------------------------------------------------ - def add_adapter(self, adapter: AdapterInput) -> None: + def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> None: """Register an adapter with this backend. Accepts the full `AdapterInput` union to honour the mixin contract, but @@ -339,11 +339,24 @@ def add_adapter(self, adapter: AdapterInput) -> None: adapter (AdapterInput): The adapter to register. Must be an `EmbeddedIntrinsicAdapter` or a composed `Adapter` whose `weights` is an `EmbeddedBinding`. + config (dict | None): Raw io.yaml config for a composed `Adapter`. + Required for that shape (its config cannot be cheaply + re-derived later); rejected for `EmbeddedIntrinsicAdapter`, + which already carries its own `.config`. Raises: - TypeError: If `adapter` is not a supported Embedded adapter. + TypeError: If `adapter` is not a supported Embedded adapter, or + `config` is given for an `EmbeddedIntrinsicAdapter`. + ValueError: If `adapter` is a composed `Adapter` and `config` is + not given — registering it without one would make it + discoverable but permanently unable to generate. """ if isinstance(adapter, EmbeddedIntrinsicAdapter): + if config is not None: + raise TypeError( + "config= is not accepted for an EmbeddedIntrinsicAdapter; " + "it already carries its own .config." + ) if adapter.qualified_name in self._added_adapters: MelleaLogger.get_logger().warning( f"attempted to add adapter {adapter.qualified_name!r} but it is " @@ -362,6 +375,17 @@ def add_adapter(self, adapter: AdapterInput) -> None: "OpenAIBackend only supports the Embedded/Granite Switch " f"reality for a composed Adapter; got {type(adapter.weights).__name__}." ) + # Validated ahead of the duplicate-registration guard below: a + # malformed config= argument must raise even when the call would + # otherwise be a silently-refused duplicate. + if config is None: + raise ValueError( + f"No io.yaml config given for composed embedded adapter " + f"{adapter.identity.name!r}; registering it without one " + "would leave it discoverable but unable to generate. " + "Pass config=, or register it via " + "register_embedded_adapter_model() or resolve_adapter()." + ) key = _composed_adapter_key(adapter) if key in self._added_adapters: MelleaLogger.get_logger().warning( @@ -371,6 +395,7 @@ def add_adapter(self, adapter: AdapterInput) -> None: return adapter.weights.source = self.base_model_name self._added_adapters[key] = adapter + self._composed_adapter_configs[key] = config return raise TypeError( @@ -471,16 +496,16 @@ def register_embedded_adapter_model( names = [] for adapter, config in discovered: key = _composed_adapter_key(adapter) - self.add_adapter(adapter) - # add_adapter() silently refuses (logs a warning, returns) rather than - # raising when `key` is already registered — compare identity to - # confirm *this* adapter is the one that's actually registered before - # caching its config, or a refused call would overwrite the live - # adapter's config with this one's and falsely report it as - # registered below (mirrors LocalHFBackend's identical guard). + # add_adapter() caches config atomically with registration now, so + # a refused duplicate (a different object already holding `key`) + # never reaches that write — no separate clobber guard needed for + # the config. The identity check below is still required, though: + # add_adapter() returns None on both success and silent refusal, + # so this is the only way to know whether *this* adapter is the + # one that actually ended up registered, for the `names` result. + self.add_adapter(adapter, config=config) if self._added_adapters.get(key) is not adapter: continue - self._composed_adapter_configs[key] = config names.append(adapter.identity.name) return names diff --git a/test/backends/test_adapters/test_embedded_adapter.py b/test/backends/test_adapters/test_embedded_adapter.py index 0edd3afc78..d84e9de007 100644 --- a/test/backends/test_adapters/test_embedded_adapter.py +++ b/test/backends/test_adapters/test_embedded_adapter.py @@ -676,10 +676,19 @@ def _make_composed_adapter( def test_add_composed_adapter(self, backend): adapter = self._make_composed_adapter() - backend.add_adapter(adapter) + backend.add_adapter(adapter, config={"parameters": {}}) assert "answerability_alora" in backend._added_adapters assert backend._added_adapters["answerability_alora"] is adapter assert adapter.weights.source == backend.base_model_name # type: ignore[union-attr] + assert backend._composed_adapter_configs["answerability_alora"] == { + "parameters": {} + } + + def test_add_composed_adapter_without_config_raises(self, backend): + adapter = self._make_composed_adapter() + with pytest.raises(ValueError, match=r"No io\.yaml config given"): + backend.add_adapter(adapter) + assert "answerability_alora" not in backend._added_adapters def test_add_composed_adapter_rejects_non_embedded_weights(self, backend): from mellea.backends.adapters._core import Adapter, Identity, LocalFileBinding @@ -695,17 +704,17 @@ def test_add_composed_adapter_rejects_non_embedded_weights(self, backend): def test_add_composed_adapter_refuses_duplicate_name(self, backend): first = self._make_composed_adapter() second = self._make_composed_adapter() - backend.add_adapter(first) - backend.add_adapter(second) + backend.add_adapter(first, config={"parameters": {}}) + backend.add_adapter(second, config={"parameters": {}}) assert backend._added_adapters["answerability_alora"] is first def test_composed_adapter_discoverable_by_capability(self, backend): adapter = self._make_composed_adapter() - backend.add_adapter(adapter) + backend.add_adapter(adapter, config={"parameters": {}}) assert backend._find_adapter("answerability") is adapter def test_list_adapters_includes_composed_adapter(self, backend): - backend.add_adapter(self._make_composed_adapter()) + backend.add_adapter(self._make_composed_adapter(), config={"parameters": {}}) backend.add_adapter( EmbeddedIntrinsicAdapter( "citations", config=_CITATIONS_CONFIG, technology="lora" diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index e5705590f0..cb5a64edc1 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -829,7 +829,7 @@ def test_resolve_adapter_holds_activation_lock_once_across_embedded_loop(): tracking_lock = _TrackingLock() mock_backend._adapter_activation_lock.return_value = tracking_lock - def fake_add_adapter(a): + def fake_add_adapter(a, config=None): mock_backend._added_adapters[_composed_adapter_key(a)] = a mock_backend.add_adapter.side_effect = fake_add_adapter diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index 1d13450757..bc1f257fbf 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -1526,10 +1526,9 @@ def test_remove_adapter_deregisters_composed_embedded_adapter(): io_contract=get_io_contract("answerability"), weights=EmbeddedBinding(), ) - backend.add_adapter(composed) + backend.add_adapter(composed, config={"parameters": {}}) key = "answerability_alora" assert key in backend.list_adapters() - backend._composed_adapter_configs[key] = {"parameters": {}} backend.remove_adapter(key) @@ -1563,7 +1562,7 @@ def test_load_peft_adapter_on_composed_embedded_adapter_raises_type_error(): io_contract=get_io_contract("answerability"), weights=EmbeddedBinding(), ) - backend.add_adapter(composed) + backend.add_adapter(composed, config={"parameters": {}}) with pytest.raises(TypeError, match="cannot load embedded adapter"): backend.load_peft_adapter("answerability_alora") @@ -1634,7 +1633,7 @@ def test_add_adapter_shim_refuses_name_already_claimed_by_composed_adapter(): io_contract=get_io_contract("answerability"), weights=_EmbeddedBinding(), ) - backend.add_adapter(composed) + backend.add_adapter(composed, config={"parameters": {}}) assert "answerability_alora" in backend._composed_adapters with warnings.catch_warnings(): @@ -1750,15 +1749,45 @@ def test_remove_adapter_deregisters_composed_local_file_adapter(): assert binding.path is None +def test_add_adapter_raises_for_composed_embedded_adapter_without_config(): + """add_adapter() must reject a composed Embedded adapter with no config. + + Registering one without a config would make it discoverable + (list_adapters()/_find_adapter()) but permanently unable to generate — + add_adapter() is now the only call site that registers this shape, so it + must refuse rather than defer the failure to generation time. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + EmbeddedBinding as _EmbeddedBinding, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + composed = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=_EmbeddedBinding(), + ) + + with pytest.raises(ValueError, match=r"No io\.yaml config given"): + backend.add_adapter(composed) + + assert "answerability_alora" not in backend._composed_adapters + assert "answerability_alora" not in backend._composed_adapter_configs + + def test_intrinsic_adapter_name_and_config_raises_for_uncached_composed_embedded(): """`_intrinsic_adapter_name_and_config` must raise its documented ValueError when a composed Embedded adapter has no cached config. - Coverage gap noted in review: the happy path (config present) was - tested; this error branch — reachable if a composed Embedded adapter was - registered via bare `add_adapter()` rather than - `register_embedded_adapter_model()`/`resolve_adapter()`, which are the - only call sites that populate `_composed_adapter_configs` for it — was not. + Simulates the post-add_adapter() state directly rather than through the + public API: add_adapter() itself now refuses to register this shape + without a config (see the test above), so the only way to reach this + branch is a registry that got into this state some other way — this + keeps the branch covered without depending on that being possible. """ from mellea.backends.adapters._core import ( Adapter as _AdapterCore, @@ -1774,9 +1803,7 @@ def test_intrinsic_adapter_name_and_config_raises_for_uncached_composed_embedded io_contract=get_io_contract("answerability"), weights=_EmbeddedBinding(), ) - backend.add_adapter(composed) - assert "answerability_alora" in backend._composed_adapters - assert "answerability_alora" not in backend._composed_adapter_configs + backend._composed_adapters["answerability_alora"] = composed with pytest.raises(ValueError, match=r"No io\.yaml config cached"): backend._intrinsic_adapter_name_and_config(composed) diff --git a/test/backends/test_openai_intrinsics_unit.py b/test/backends/test_openai_intrinsics_unit.py index 10c63d5a7d..8b6d871604 100644 --- a/test/backends/test_openai_intrinsics_unit.py +++ b/test/backends/test_openai_intrinsics_unit.py @@ -188,7 +188,6 @@ def _make_backend_with_composed_adapter(config: dict) -> OpenAIBackend: EmbeddedBinding, Identity, ) - from mellea.backends.adapters.adapter import _composed_adapter_key from mellea.backends.adapters.io_contracts import get_io_contract backend = OpenAIBackend( @@ -203,8 +202,7 @@ def _make_backend_with_composed_adapter(config: dict) -> OpenAIBackend: io_contract=get_io_contract("answerability"), weights=EmbeddedBinding(), ) - backend.add_adapter(adapter) - backend._composed_adapter_configs[_composed_adapter_key(adapter)] = config + backend.add_adapter(adapter, config=config) return backend From af9bf9b2e1c3b322c11341d2aa404ef10bec8ce0 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Fri, 4 Sep 2026 19:58:32 +0100 Subject: [PATCH 13/19] fix(backends): address code-review findings on composed-Adapter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the 3-reviewer panel on PR #1619: - Enforce the identity/weights adapter_type cross-check that NOTE(#1516) deferred until the shims retire: LocalHFBackend.add_adapter now rejects a composed Adapter whose identity.adapter_type disagrees with its LocalFileBinding's own adapter_type, instead of silently registering a binding under a different key than lookups will use. - Move the composed-LocalFile io.yaml fetch from lazy (inline inside the async _generate_from_intrinsic, on the event loop) to eager, alongside the weights download add_adapter's LocalFileBinding branch already does synchronously. _intrinsic_adapter_name_and_config is now a pure cache read for both LocalFile and Embedded realities. - Drop the accidental keyword-only `*` from Intrinsic.__init__'s adapter_types parameter — it was positional-or-keyword before this PR and the change was undocumented as breaking. - Fix the CLI-generated async wrapper template to return the (ModelOutputThunk, Context) tuple generate_from_context actually returns, instead of the bare thunk; fix the test that mocked the wrong return shape and so didn't catch it. - Correct metrics.md: the parse_failures "known gap" only applies to the LocalFile/PEFT reality, not Embedded/Granite Switch, which fires schema_error directly with no adapter_scope() in that path. Also note Embedded's always-"unpinned" revision label. - Take the adapter-activation lock across register_embedded_adapter_model on both backends now that it is the documented post-construction registration path, not just a construction-time call. - Add a comment at the binding.prepare() recursion into add_adapter, and fix a stale "ALORA and then LORA" comment (catalog default is the reverse). Assisted-by: Claude Code Signed-off-by: Nigel Jones --- cli/alora/README_TEMPLATE.jinja | 4 +- docs/docs/observability/metrics.md | 31 +++- docs/examples/intrinsics/intrinsics.py | 2 +- mellea/backends/adapters/_core.py | 13 +- mellea/backends/adapters/adapter.py | 9 +- mellea/backends/huggingface.py | 175 +++++++++++------- mellea/backends/openai.py | 55 +++--- .../stdlib/components/intrinsic/intrinsic.py | 1 - .../test_local_file_integration.py | 44 ++++- test/backends/test_huggingface_unit.py | 81 ++++++-- test/cli/test_alora_readme_template.py | 6 +- 11 files changed, 282 insertions(+), 139 deletions(-) diff --git a/cli/alora/README_TEMPLATE.jinja b/cli/alora/README_TEMPLATE.jinja index 70e3cffd51..bf95191883 100644 --- a/cli/alora/README_TEMPLATE.jinja +++ b/cli/alora/README_TEMPLATE.jinja @@ -96,8 +96,8 @@ async def async_{{ intrinsic_name }}({{ arglist }}, ctx: Context, backend: Backe if _INTRINSIC_QUALIFIED_NAME not in backend.list_adapters(): backend.add_adapter(_{{ intrinsic_name }}_adapter()) action = {{ intrinsic_name_camelcase }}Intrinsic({{ arglist_without_type_annotations }}) - mot = await backend.generate_from_context(action, ctx) - return mot + mot, ctx = await backend.generate_from_context(action, ctx) + return mot, ctx def {{ intrinsic_name }}({{ arglist }}, ctx: Context, backend: Backend | AdapterMixin): diff --git a/docs/docs/observability/metrics.md b/docs/docs/observability/metrics.md index 0a4067c97a..adbd933c0b 100644 --- a/docs/docs/observability/metrics.md +++ b/docs/docs/observability/metrics.md @@ -314,7 +314,7 @@ hooks fired by `AdapterMixin.adapter_scope()` and | ----------- | ---- | ---------- | ----------- | | `mellea.adapter_function.invocations` | Counter | `name`, `revision`, `binding_type`, `adapter_type`, `outcome` | Calls, split by outcome (`success`, `schema_error`, `error`) | | `mellea.adapter_function.phase_duration` | Histogram | `name`, `phase` | Lifecycle phase duration, in seconds | -| `mellea.adapter_function.parse_failures` | Counter | `name`, `revision` | Calls that raised `AdapterSchemaMismatchError` *while still inside* `adapter_scope()` — see the known gap below | +| `mellea.adapter_function.parse_failures` | Counter | `name`, `revision` | LocalFile/PEFT: calls that raised `AdapterSchemaMismatchError` *while still inside* `adapter_scope()` — see the known gap below. Embedded/Granite Switch: not subject to that gap — a malformed (non-JSON) response fires `outcome="schema_error"` directly, with no `adapter_scope()` involved | `revision` reports the *resolved* revision actually used, not the raw value passed in: for a `LocalFileBinding` constructed with `revision=None` (the @@ -325,7 +325,11 @@ The `"unpinned"` label only appears when no revision could be resolved at all — for a `LocalFileBinding`, that means resolution itself failed (a non-catalog adapter with no explicit `revision` — see [Adding a custom adapter function](../tutorials/07-custom-adapter-function.md)). -It is not a general filter for "running without a pinned revision." +It is not a general filter for "running without a pinned revision." The +Embedded/Granite Switch reality is the exception: weights are already baked +into the served model, so there is no revision to resolve at all, and every +Embedded invocation reports `revision="unpinned"` regardless of resolution +success. `phase` is one of `"prepare"`, `"activate"`, `"generate"`, `"parse"`, or `"deactivate"` — though as of this writing only three are actually emitted: @@ -337,15 +341,22 @@ reality (`EmbeddedBinding.apply_activation()`, which has no lifecycle to deactivate). `"generate"`/`"parse"` phase timing is tracked as future work in issue #1466. -> **Known gap:** every production caller (`core.requirement_check()`, -> `call_intrinsic()`, `ALoraRequirement`) parses the model's output *after* -> `adapter_scope()` has already exited and fired `outcome="success"` — so a -> real `AdapterSchemaMismatchError` from one of those call paths is correctly -> raised to your code, but is not yet reflected in `parse_failures` or in -> `invocations{outcome="schema_error"}`. Tracked in issue #1611. Until that's -> fixed, catch `AdapterSchemaMismatchError` in application code (see +> **Known gap (LocalFile/PEFT only):** every production caller +> (`core.requirement_check()`, `call_intrinsic()`, `ALoraRequirement`) parses +> the model's output *after* `adapter_scope()` has already exited and fired +> `outcome="success"` — so a real `AdapterSchemaMismatchError` from one of +> those call paths is correctly raised to your code, but is not yet reflected +> in `parse_failures` or in `invocations{outcome="schema_error"}`. Tracked in +> issue #1611. Until that's fixed, catch `AdapterSchemaMismatchError` in +> application code (see > [Adapter schema migrations](../tutorials/08-adapter-schema-migrations.md)) -> rather than relying on these metrics to surface schema drift. +> rather than relying on these metrics to surface schema drift for that +> reality. +> +> This gap does not apply to the Embedded/Granite Switch reality: a malformed +> response there fires `outcome="schema_error"` (and increments +> `parse_failures`) directly from the response-processing code, with no +> `adapter_scope()` in that path to have already exited. ### Adapter function dashboard queries diff --git a/docs/examples/intrinsics/intrinsics.py b/docs/examples/intrinsics/intrinsics.py index 76c5ae6f50..2a499a8014 100644 --- a/docs/examples/intrinsics/intrinsics.py +++ b/docs/examples/intrinsics/intrinsics.py @@ -56,7 +56,7 @@ ctx = ctx.add(Message("assistant", "Hello; yes! What can I help with?")) # Generate from an intrinsic with the same name as the adapter. By default, it will look for -# ALORA and then LORA adapters. +# LORA and then ALORA adapters. out, new_ctx = mfuncs.act( Intrinsic( "requirement-check", diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index 6131107eff..26e806d192 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -979,8 +979,11 @@ class Adapter: # right invariant — the two feed different lookup paths (registration and the # verbs key on the binding's `qualified_name`; `_find_adapter` scans on the # identity) and both return `None` on a miss, so a disagreement surfaces as - # "adapter not found" far from its cause. But it cannot be enforced yet: - # the deprecated shims carry a `_ShimWeightsBinding` with no `adapter_type` - # to compare at all (their identity tracks the configured type). Enforce - # the check once those constructions carry real, typed bindings (the shims - # retire in #1144). + # "adapter not found" far from its cause. It could not be enforced here + # because the deprecated shims carry a `_ShimWeightsBinding` with no + # `adapter_type` to compare at all (their identity tracks the configured + # type) — that no longer blocks the LocalFile/PEFT reality now that the + # shims retire in #1144, so `LocalHFBackend.add_adapter` enforces it there + # instead, at registration time. `EmbeddedBinding` has no `adapter_type` of + # its own, so the Embedded/Granite Switch reality still has nothing to + # cross-check against. diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 8e6be7acbc..59d893cb33 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -308,10 +308,11 @@ def _composed_adapter_key(adapter: "_AdapterCore") -> str: backend's `_added_adapters` (keyed on the binding) and `_composed_adapters` (keyed on this function) agree — which holds because `Identity.adapter_type` is the `Literal["lora", "alora"]` string and `AdapterType.LORA.value`/ - `AdapterType.ALORA.value` are those same two strings. Not enforced by any - runtime check (see the `NOTE(#1516)` on the `Adapter` dataclass) — a - composed `Adapter` whose `identity.adapter_type` disagrees with its own - `weights.adapter_type` produces two different keys instead of one. + `AdapterType.ALORA.value` are those same two strings. `LocalHFBackend.add_adapter` + enforces this at registration time for a `LocalFileBinding` (see the + `NOTE(#1516)` on the `Adapter` dataclass); a composed `Adapter` whose + `identity.adapter_type` disagrees with its own `weights.adapter_type` + otherwise produces two different keys instead of one. Args: adapter: The composed adapter to key. diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index ccc00b5e17..9fb0f0149a 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -873,19 +873,16 @@ def _intrinsic_adapter_name_and_config( The shims (`IntrinsicAdapter`/`EmbeddedIntrinsicAdapter`) carry both directly (`.name`/`.config`). A composed `Adapter` carries neither — `io_contract` does not yet drive `IntrinsicsRewriter`/ - `IntrinsicsResultProcessor` (Epic #929, issue #1144) — so this derives - the same io.yaml config a shim would have loaded: - - - LocalFile/PEFT reality: derived via `obtain_io_yaml` from the - binding's own `repo_id`/`revision` on first use, mirroring - `IntrinsicAdapter.__init__` — then cached in - `_composed_adapter_configs`, the same dict the Embedded reality - uses, so a Hugging Face Hub round trip and a YAML re-parse don't - happen on every generation call. - - Embedded/Granite Switch reality: looked up from - `_composed_adapter_configs`, cached at registration time (see - `add_adapter`/`register_embedded_adapter_model`) since it comes from - `adapter_index.json`/`io.yaml` and cannot be cheaply re-derived. + `IntrinsicsResultProcessor` (Epic #929, issue #1144) — so this is a + pure cache read from `_composed_adapter_configs` for both realities. + The LocalFile/PEFT reality is cached by `add_adapter`'s composed + branch (via `_obtain_local_file_io_yaml_config`), alongside the + weights download `binding.prepare()` already does there; the + Embedded/Granite Switch reality is cached at registration time (see + `add_adapter`/`register_embedded_adapter_model`). Deliberately not + fetched here: this method runs inline inside the async + `_generate_from_intrinsic`, and `obtain_io_yaml`'s Hugging Face Hub + round trip must not block the event loop. Args: adapter: The adapter to resolve a name and config for. @@ -895,44 +892,22 @@ def _intrinsic_adapter_name_and_config( io.yaml config. Raises: - ValueError: A composed Embedded adapter has no cached config - (never registered via `add_adapter`/`register_embedded_adapter_model`), - or a composed LocalFile adapter's `io.yaml` did not parse to a - mapping. + ValueError: A composed LocalFile or Embedded adapter has no + cached config (never registered via + `add_adapter`/`register_embedded_adapter_model`). TypeError: A composed Adapter's `weights` is neither a `LocalFileBinding` nor an `EmbeddedBinding`. """ if isinstance(adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter)): return adapter.name, adapter.config - if isinstance(adapter.weights, LocalFileBinding): - binding = adapter.weights - key = _composed_adapter_key(adapter) - config = self._composed_adapter_configs.get(key) - if config is None: - io_yaml_path = intrinsics.obtain_io_yaml( - binding.name, - self.base_model_name, - binding.repo_id, - revision=binding.resolved_revision(), - alora=binding.adapter_type is AdapterType.ALORA, - ) - with open(io_yaml_path, encoding="utf-8") as f: - loaded = yaml.safe_load(f) - if not isinstance(loaded, dict): - raise ValueError( - f"io.yaml for adapter {binding.name!r} at {io_yaml_path} " - f"did not parse to a mapping (got {type(loaded).__name__})." - ) - config = loaded - self._composed_adapter_configs[key] = config - return adapter.identity.name, config - if isinstance(adapter.weights, EmbeddedBinding): + if isinstance(adapter.weights, (LocalFileBinding, EmbeddedBinding)): key = _composed_adapter_key(adapter) config = self._composed_adapter_configs.get(key) if config is None: raise ValueError( - f"No io.yaml config cached for composed embedded adapter {key!r}; " - "register it via register_embedded_adapter_model() or resolve_adapter()." + f"No io.yaml config cached for composed adapter {key!r}; " + "register it via add_adapter(), " + "register_embedded_adapter_model(), or resolve_adapter()." ) return adapter.identity.name, config raise TypeError( @@ -940,6 +915,39 @@ def _intrinsic_adapter_name_and_config( f"{type(adapter.weights).__name__}" ) + def _obtain_local_file_io_yaml_config(self, binding: LocalFileBinding) -> dict: + """Download and parse `binding`'s io.yaml, mirroring `IntrinsicAdapter.__init__`. + + Called synchronously from `add_adapter`'s composed-`LocalFileBinding` + branch, alongside the (larger) weights download `binding.prepare()` + already does there — so the Hugging Face Hub round trip this performs + never runs on the event loop inside `_generate_from_intrinsic`. + + Args: + binding: The `LocalFileBinding` to load `io.yaml` for. + + Returns: + dict: The parsed `io.yaml` mapping. + + Raises: + ValueError: `io.yaml` did not parse to a mapping. + """ + io_yaml_path = intrinsics.obtain_io_yaml( + binding.name, + self.base_model_name, + binding.repo_id, + revision=binding.resolved_revision(), + alora=binding.adapter_type is AdapterType.ALORA, + ) + with open(io_yaml_path, encoding="utf-8") as f: + loaded = yaml.safe_load(f) + if not isinstance(loaded, dict): + raise ValueError( + f"io.yaml for adapter {binding.name!r} at {io_yaml_path} " + f"did not parse to a mapping (got {type(loaded).__name__})." + ) + return loaded + async def _generate_from_intrinsic( self, action: Intrinsic, @@ -2607,7 +2615,11 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N ValueError: If `adapter` is a composed `Adapter` whose `weights` is an `EmbeddedBinding` and `config` is not given — registering it without one would make it discoverable but permanently unable - to generate. + to generate. Also raised if a composed `Adapter`'s + `identity.adapter_type` disagrees with its own + `weights.adapter_type` — the two feed different lookup paths and + a mismatch surfaces as a spurious "adapter not found" far from + its cause. Exception: If `adapter` has already been added to a different backend. """ if not isinstance( @@ -2671,6 +2683,14 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N return if isinstance(adapter.weights, LocalFileBinding): binding = adapter.weights + if adapter.identity.adapter_type != binding.adapter_type.value: + raise ValueError( + f"Composed Adapter {adapter.identity.name!r} has " + f"identity.adapter_type={adapter.identity.adapter_type!r} but " + f"weights.adapter_type={binding.adapter_type.value!r}; the two " + "must agree, or registration and lookup key on different " + "strings for the same adapter (NOTE(#1516))." + ) # bind_backend() itself raises if binding.backend is already # a *different* backend, and no-ops if it's already self — # calling it unconditionally (rather than guarding on @@ -2680,7 +2700,20 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N # `self` while the binding's weights stayed activated # against whatever backend it was really bound to. binding.bind_backend(self) + # Recurses into self.add_adapter(binding) below (the bare- + # LocalFileBinding branch further down this method) to + # populate `_added_adapters` for this binding — see + # `LocalFileBinding.prepare()`'s own docstring/comments for + # why that call is there. binding.prepare() + # Fetched here, alongside the (far larger) weights download + # `prepare()` just did, rather than lazily on first generation + # call: `_intrinsic_adapter_name_and_config` runs inline inside + # the async `_generate_from_intrinsic`, and this Hugging Face + # Hub round trip must not happen on that event loop. + self._composed_adapter_configs[key] = ( + self._obtain_local_file_io_yaml_config(binding) + ) self._composed_adapters[key] = adapter return raise TypeError( @@ -2757,30 +2790,38 @@ def register_embedded_adapter_model( ValueError: If the source has no matching embedded adapter functions. TypeError: If an adapter from the source has an unsupported binding. """ - # No lock here: both call sites (here, and this class's __init__) run - # single-threaded during construction, before the backend is exposed to - # any other thread — see the matching note on OpenAIBackend's twin. - discovered = _discover_embedded_adapters( - source, - revision=revision, - cache_dir=cache_dir, - intrinsic_name=intrinsic_name, - ) - names = [] - for adapter, config in discovered: - key = _composed_adapter_key(adapter) - # add_adapter() caches config atomically with registration now, so - # a refused duplicate (a different object already holding `key`) - # never reaches that write — no separate clobber guard needed for - # the config. The identity check below is still required, though: - # add_adapter() returns None on both success and silent refusal, - # so this is the only way to know whether *this* adapter is the - # one that actually ended up registered, for the `names` result. - self.add_adapter(adapter, config=config) - if self._composed_adapters.get(key) is not adapter: - continue - names.append(adapter.identity.name) - return names + # Locked (unlike __init__'s call to this, which still runs + # single-threaded during construction, before the backend is exposed + # to any other thread): this method is now also the documented + # post-construction replacement for `EmbeddedIntrinsicAdapter.from_hub()` + # on a live backend, so a caller here can race another thread's + # `add_adapter`/`resolve_adapter` — both `add_adapter`'s + # read-then-write across `_composed_adapters` and + # `_discover_embedded_adapters`'s mutation of global `warnings` filter + # state need the same lock `resolve_adapter` already takes around its + # own `add_adapter` calls. + with self._adapter_activation_lock(): + discovered = _discover_embedded_adapters( + source, + revision=revision, + cache_dir=cache_dir, + intrinsic_name=intrinsic_name, + ) + names = [] + for adapter, config in discovered: + key = _composed_adapter_key(adapter) + # add_adapter() caches config atomically with registration now, so + # a refused duplicate (a different object already holding `key`) + # never reaches that write — no separate clobber guard needed for + # the config. The identity check below is still required, though: + # add_adapter() returns None on both success and silent refusal, + # so this is the only way to know whether *this* adapter is the + # one that actually ended up registered, for the `names` result. + self.add_adapter(adapter, config=config) + if self._composed_adapters.get(key) is not adapter: + continue + names.append(adapter.identity.name) + return names def load_peft_adapter(self, adapter_qualified_name: str) -> None: """Load a previously registered adapter into the underlying Hugging Face model. diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index d18a99e527..0bf2d1b157 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -497,30 +497,37 @@ def register_embedded_adapter_model( ValueError: If the source has no matching embedded adapter functions. TypeError: If an adapter from the source has an unsupported binding. """ - # No lock here: both call sites (here, and this class's __init__) run - # single-threaded during construction, before the backend is exposed to - # any other thread — see the matching note on LocalHFBackend's twin. - discovered = _discover_embedded_adapters( - source, - revision=revision, - cache_dir=cache_dir, - intrinsic_name=intrinsic_name, - ) - names = [] - for adapter, config in discovered: - key = _composed_adapter_key(adapter) - # add_adapter() caches config atomically with registration now, so - # a refused duplicate (a different object already holding `key`) - # never reaches that write — no separate clobber guard needed for - # the config. The identity check below is still required, though: - # add_adapter() returns None on both success and silent refusal, - # so this is the only way to know whether *this* adapter is the - # one that actually ended up registered, for the `names` result. - self.add_adapter(adapter, config=config) - if self._added_adapters.get(key) is not adapter: - continue - names.append(adapter.identity.name) - return names + # Locked (unlike __init__'s call to this, which still runs + # single-threaded during construction, before the backend is exposed + # to any other thread): this method is now also the documented + # post-construction replacement for `EmbeddedIntrinsicAdapter.from_hub()` + # on a live backend, so a caller here can race another thread's + # `add_adapter`/`resolve_adapter` — both `add_adapter`'s + # read-then-write across `_added_adapters` and `_discover_embedded_adapters`' + # mutation of global `warnings` filter state need the same lock + # `add_adapter` itself would take via `resolve_adapter`. + with self._adapter_activation_lock(): + discovered = _discover_embedded_adapters( + source, + revision=revision, + cache_dir=cache_dir, + intrinsic_name=intrinsic_name, + ) + names = [] + for adapter, config in discovered: + key = _composed_adapter_key(adapter) + # add_adapter() caches config atomically with registration now, so + # a refused duplicate (a different object already holding `key`) + # never reaches that write — no separate clobber guard needed for + # the config. The identity check below is still required, though: + # add_adapter() returns None on both success and silent refusal, + # so this is the only way to know whether *this* adapter is the + # one that actually ended up registered, for the `names` result. + self.add_adapter(adapter, config=config) + if self._added_adapters.get(key) is not adapter: + continue + names.append(adapter.identity.name) + return names @property def _async_client(self) -> openai.AsyncOpenAI: diff --git a/mellea/stdlib/components/intrinsic/intrinsic.py b/mellea/stdlib/components/intrinsic/intrinsic.py index 599e681d97..f52fad1927 100644 --- a/mellea/stdlib/components/intrinsic/intrinsic.py +++ b/mellea/stdlib/components/intrinsic/intrinsic.py @@ -58,7 +58,6 @@ def __init__( self, intrinsic_name: str, intrinsic_kwargs: dict | None = None, - *, adapter_types: tuple[AdapterType, ...] | None = None, ) -> None: """Initialize Intrinsic by fetching metadata for the named intrinsic from the catalog.""" diff --git a/test/backends/test_adapters/test_local_file_integration.py b/test/backends/test_adapters/test_local_file_integration.py index 721190af50..663353eec3 100644 --- a/test/backends/test_adapters/test_local_file_integration.py +++ b/test/backends/test_adapters/test_local_file_integration.py @@ -13,7 +13,7 @@ the spans from a plugin). """ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch import pytest @@ -171,9 +171,17 @@ def test_add_adapter_registers_composed_adapter_via_backend(): binding = _make_binding() adapter = _make_adapter(binding) - with patch( - "mellea.formatters.granite.intrinsics.obtain_lora", - return_value="/fake/local/adapter/path", + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ), + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), ): backend.add_adapter(adapter) @@ -203,9 +211,17 @@ def test_add_adapter_rejects_second_composed_registration_for_same_capability(): other_binding = _make_binding() other_adapter = _make_adapter(other_binding) - with patch( - "mellea.formatters.granite.intrinsics.obtain_lora", - return_value="/fake/local/adapter/path", + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ), + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), ): backend.add_adapter(adapter) backend.add_adapter(other_adapter) @@ -231,9 +247,17 @@ def test_add_adapter_rejects_binding_already_bound_to_a_different_backend(): binding = _make_binding() adapter = _make_adapter(binding) - with patch( - "mellea.formatters.granite.intrinsics.obtain_lora", - return_value="/fake/local/adapter/path", + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ), + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), ): backend_a.add_adapter(adapter) assert binding.backend is backend_a diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index bc1f257fbf..f18bb9bdf8 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -862,6 +862,7 @@ async def test_composed_adapter_drives_generate_from_intrinsic(stub_backend): ) backend._added_adapters = {} backend._composed_adapters = {"answerability_alora": composed} + backend._composed_adapter_configs = {"answerability_alora": {"parameters": {}}} backend.base_model_name = "granite-4.1-3b" def fake_transformers_inputs(request, tokenizer, model, ll_tokenizer=None): @@ -920,15 +921,16 @@ def transform(self, chunk, rewritten): assert processed, "the composed local-file path must produce a response" -def test_composed_local_file_config_is_cached_after_first_derivation(): - """`_intrinsic_adapter_name_and_config` must not re-derive (re-download, - re-parse) a composed LocalFile adapter's io.yaml on every call. +def test_composed_local_file_config_is_derived_once_at_registration_not_on_the_loop(): + """A composed LocalFile adapter's io.yaml must be fetched once, synchronously, + during `add_adapter` — never lazily from `_intrinsic_adapter_name_and_config`. - Regression: the first version of this helper called - `intrinsics.obtain_io_yaml` (a Hugging Face Hub round trip) and re-parsed - the file on *every* invocation, unlike the shim it replaces (which loads - once in `IntrinsicAdapter.__init__`). Every composed-LocalFile intrinsic - call — the default path via `resolve_adapter` — paid that cost. + Regression: an earlier version derived io.yaml lazily, inline inside the + async `_generate_from_intrinsic` (via `_intrinsic_adapter_name_and_config`), + unlike the shim it replaces (`IntrinsicAdapter.__init__`, which loads once + at sync construction time). That put a blocking Hugging Face Hub round + trip on the asyncio event loop on first use of every composed-LocalFile + intrinsic — including the default path via `resolve_adapter`. """ from mellea.backends.adapters._core import ( Adapter as _AdapterCore, @@ -939,7 +941,6 @@ def test_composed_local_file_config_is_cached_after_first_derivation(): from mellea.backends.adapters.io_contracts import get_io_contract backend = _make_backend() - backend._composed_adapter_configs = {} binding = LocalFileBinding( name="answerability", adapter_type=AdapterType.ALORA, @@ -955,6 +956,10 @@ def test_composed_local_file_config_is_cached_after_first_derivation(): ) with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ), patch( "mellea.formatters.granite.intrinsics.obtain_io_yaml", return_value="/fake/adapter.yaml", @@ -962,12 +967,16 @@ def test_composed_local_file_config_is_cached_after_first_derivation(): patch("builtins.open", mock_open(read_data="key: value")), patch("yaml.safe_load", return_value={"parameters": {}}), ): + backend.add_adapter(composed) + + mock_obtain.assert_called_once() + + # A pure cache read from here on: no further obtain_io_yaml calls, + # even off the event loop. name1, config1 = backend._intrinsic_adapter_name_and_config(composed) name2, config2 = backend._intrinsic_adapter_name_and_config(composed) - assert mock_obtain.call_count == 1, ( - "obtain_io_yaml must only run once; the second call should hit the cache" - ) + mock_obtain.assert_called_once() assert name1 == name2 == "answerability" assert config1 is config2 @@ -1602,12 +1611,58 @@ def test_add_adapter_allows_composed_wrapper_of_a_standalone_registered_binding( io_contract=get_io_contract("answerability"), weights=binding, ) - backend.add_adapter(composed) + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), + ): + backend.add_adapter(composed) assert backend._composed_adapters["answerability_alora"] is composed assert backend._find_adapter("answerability") is composed +def test_add_adapter_rejects_composed_adapter_with_mismatched_identity_and_weights_type(): + """A composed Adapter whose `identity.adapter_type` disagrees with its + own `weights.adapter_type` must be refused at registration, not silently + accepted (NOTE(#1516)). + + Regression: `_composed_adapter_key` (registration/lookup) keys on + `identity.adapter_type`, but the PEFT lifecycle keys on + `weights.qualified_name` (`weights.adapter_type`). A mismatch used to + register a real binding whose weights don't match the adapter variant a + caller asked for by name — silent wrong-variant activation, a duplicate + `list_adapters()` entry, and a registry leak on `remove_adapter`. + """ + from mellea.backends.adapters._core import ( + Adapter as _AdapterCore, + Identity, + LocalFileBinding, + ) + from mellea.backends.adapters.io_contracts import get_io_contract + + backend = _make_backend() + binding = LocalFileBinding( + name="answerability", adapter_type=AdapterType.LORA, repo_id="fake/repo" + ) + mismatched = _AdapterCore( + identity=Identity( + name="answerability", adapter_type="alora", capability="answerability" + ), + io_contract=get_io_contract("answerability"), + weights=binding, + ) + + with pytest.raises(ValueError, match="must agree"): + backend.add_adapter(mismatched) + + assert backend._find_adapter("answerability") is None + assert binding.backend is None + + def test_add_adapter_shim_refuses_name_already_claimed_by_composed_adapter(): """The legacy/shim add_adapter path must see composed-adapter registrations too. diff --git a/test/cli/test_alora_readme_template.py b/test/cli/test_alora_readme_template.py index dd715fcc06..de53f61275 100644 --- a/test/cli/test_alora_readme_template.py +++ b/test/cli/test_alora_readme_template.py @@ -100,13 +100,15 @@ async def test_readme_template_async_wrapper_constructs_intrinsic_correctly(): backend = MagicMock() backend.list_adapters.return_value = ["stembolts_alora"] - backend.generate_from_context = AsyncMock(return_value="mocked output") + fake_ctx = MagicMock() + backend.generate_from_context = AsyncMock(return_value=("mocked output", fake_ctx)) - result = await namespace["async_stembolts"]( + result, result_ctx = await namespace["async_stembolts"]( description="a cracked stembolt", ctx=MagicMock(), backend=backend ) assert result == "mocked output" + assert result_ctx is fake_ctx backend.generate_from_context.assert_awaited_once() action = backend.generate_from_context.await_args.args[0] assert isinstance(action, namespace["StemboltsIntrinsic"]) From 3f0639cff4c45a1289a6acc70fe1508b164aae6c Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Sun, 6 Sep 2026 17:20:39 +0100 Subject: [PATCH 14/19] fix(backends): address Codex's code-review findings on composed-Adapter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix a genuine backward-compatibility break: AdapterMixin.add_adapter (public API) gained a config= keyword-only parameter, but resolve_adapter()'s embedded branch already called add_adapter with the older, pre-existing _uses_embedded_adapters flag. A third-party AdapterMixin subclass with the old add_adapter(self, adapter) signature raised TypeError on an unexpected keyword. Route through a new _add_embedded_adapter_compat() that detects (via signature inspection, not exception handling) whether add_adapter accepts config= and falls back to constructing the deprecated EmbeddedIntrinsicAdapter shim otherwise. - Fix a partial-registration/poisoning bug in my own prior fix: the composed-LocalFile io.yaml fetch now runs before bind_backend()/ prepare(), not after — a failed fetch used to leave a real, prepared LocalFileBinding registered with no matching _composed_adapters entry, permanently blocking that capability's name on a later resolve_adapter()/add_adapter() call. - Also check identity.name against weights.name in the same cross-check (my prior fix only checked adapter_type, not name) — same key-disagreement bug class, other half. - Guard both backends' composed-adapter registration branches (add_adapter) with the adapter-activation lock: a direct add_adapter call, unlike resolve_adapter()'s own calls, was an unguarded check-then-write, so two concurrent callers registering under the same key could pair one call's adapter with the other's config. - Honour an ALoraRequirement's explicit adapter_types override in the requirement-check dispatcher on both backends, instead of always hardcoding ("alora",) — a LoRA-only custom adapter could never be found by that search, silently falling back to regular generation. - Fix two stale docs: the CLI template's "define an adapter class" prose (the generated code is now a factory function, not a shim class) and the aLora example README's reference to a nonexistent StemboltAdapter class. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- cli/alora/README_TEMPLATE.jinja | 5 +- docs/examples/aLora/README.md | 2 +- mellea/backends/adapters/_core.py | 27 ++-- mellea/backends/adapters/adapter.py | 46 ++++++- mellea/backends/huggingface.py | 159 +++++++++++++--------- mellea/backends/openai.py | 40 ++++-- test/backends/test_adapters/test_shims.py | 73 ++++++++++ test/backends/test_huggingface_unit.py | 40 ++++++ 8 files changed, 300 insertions(+), 92 deletions(-) diff --git a/cli/alora/README_TEMPLATE.jinja b/cli/alora/README_TEMPLATE.jinja index bf95191883..95d7b0a006 100644 --- a/cli/alora/README_TEMPLATE.jinja +++ b/cli/alora/README_TEMPLATE.jinja @@ -27,8 +27,9 @@ tags: ## How to Use -To use this intrinsic adapter in your application, you need to define an adapter class -and an intrinsic component. The adapter connects to the HuggingFace model repository +To use this intrinsic adapter in your application, you need to compose an adapter +(a factory function returning it, rather than a dedicated shim class) and an intrinsic +component. The adapter connects to the HuggingFace model repository where the trained weights are stored, and the intrinsic wraps it as a composable Mellea component that can be used with any compatible backend. diff --git a/docs/examples/aLora/README.md b/docs/examples/aLora/README.md index bcfb5ba59e..3bc46b9b99 100644 --- a/docs/examples/aLora/README.md +++ b/docs/examples/aLora/README.md @@ -161,7 +161,7 @@ Helper module for loading and calling the fully custom `stembolts` adapter (no standalone entry point — `102_example.py` consumes it). Demonstrates: -- Custom adapter class definition (`StemboltAdapter`) +- Custom adapter composition (`_stembolt_adapter()`) and intrinsic definition (`StemboltIntrinsic`) - Registering the adapter on a backend, guarded by qualified name - Calling the intrinsic directly via `mfuncs.act` diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index 26e806d192..e92f900ef5 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -974,16 +974,17 @@ class Adapter: io_contract: IOContract 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 - # right invariant — the two feed different lookup paths (registration and the - # verbs key on the binding's `qualified_name`; `_find_adapter` scans on the - # identity) and both return `None` on a miss, so a disagreement surfaces as - # "adapter not found" far from its cause. It could not be enforced here - # because the deprecated shims carry a `_ShimWeightsBinding` with no - # `adapter_type` to compare at all (their identity tracks the configured - # type) — that no longer blocks the LocalFile/PEFT reality now that the - # shims retire in #1144, so `LocalHFBackend.add_adapter` enforces it there - # instead, at registration time. `EmbeddedBinding` has no `adapter_type` of - # its own, so the Embedded/Granite Switch reality still has nothing to - # cross-check against. + # NOTE(#1516): a construction-time cross-check that `weights` (`name` and + # `adapter_type`) agrees with `identity` was tried here and backed out. It + # is the right invariant — the two feed different lookup paths + # (registration and the verbs key on the binding's `qualified_name`; + # `_find_adapter` scans on the identity) and both return `None` on a miss, + # so a disagreement surfaces as "adapter not found" far from its cause. It + # could not be enforced here because the deprecated shims carry a + # `_ShimWeightsBinding` with no `name`/`adapter_type` to compare at all + # (their identity tracks the configured name/type) — that no longer + # blocks the LocalFile/PEFT reality now that the shims retire in #1144, so + # `LocalHFBackend.add_adapter` enforces it there instead, at registration + # time. `EmbeddedBinding` has no `name`/`adapter_type` of its own, so the + # Embedded/Granite Switch reality still has nothing to cross-check + # against. diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 59d893cb33..dc4980ce35 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -17,6 +17,7 @@ import abc import contextlib import hashlib +import inspect import pathlib import re import shutil @@ -703,6 +704,44 @@ def _adapter_activation_lock( """ return contextlib.nullcontext() + def _add_embedded_adapter_compat( + self, adapter: "_AdapterCore", config: dict + ) -> None: + """Register a composed embedded Adapter, tolerating a pre-#1144 `add_adapter`. + + `AdapterMixin.add_adapter` gained a `config` keyword-only parameter in + Epic #929, issue #1144 (needed because a composed `Adapter` has no + field to carry a shim's `.config`). `_uses_embedded_adapters` predates + that: a third-party `AdapterMixin` subclass written before this + parameter existed, but already supporting the Embedded reality, is + reachable here and would raise + `TypeError: add_adapter() got an unexpected keyword argument 'config'`. + + Detected via signature inspection rather than `try`/`except TypeError` + around the call: a `TypeError` `add_adapter` itself legitimately + raises (e.g. an unsupported weights reality) must propagate, not be + misread as this compatibility gap. + + Args: + adapter: The composed `Adapter` to register. + config: Raw io.yaml config for `adapter`. + """ + params = inspect.signature(self.add_adapter).parameters + accepts_config = "config" in params or any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + if accepts_config: + self.add_adapter(adapter, config=config) + return + # Legacy subclass predating the composed-Adapter contract: it only + # knows the deprecated EmbeddedIntrinsicAdapter shim, which carries + # its own .config and needs no config= parameter. + self.add_adapter( + EmbeddedIntrinsicAdapter( + adapter.identity.name, config, technology=adapter.identity.adapter_type + ) + ) + def resolve_adapter(self, name: str) -> _AdapterCore: """Find or lazily register an adapter by capability name. @@ -777,11 +816,14 @@ def resolve_adapter(self, name: str) -> _AdapterCore: # refused duplicate (a different object already holds the # key) therefore never reaches the config write, so this # can't clobber a live adapter's cached config the way a - # register-then-separately-cache sequence could. + # register-then-separately-cache sequence could. Routed + # through _add_embedded_adapter_compat, not called directly, + # for a third-party AdapterMixin subclass whose add_adapter + # predates config= (see that method's docstring). for a, config in _discover_embedded_adapters( repo_id, intrinsic_name=name ): - self.add_adapter(a, config=config) + self._add_embedded_adapter_compat(a, config) else: # AdapterType.LORA is the pre-Phase-1 default (mirrors old _util.py). # Every current catalog entry supports LORA. Phase 2 (see epic #929) diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 9fb0f0149a..b7e12bb85c 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -651,8 +651,18 @@ async def _generate_from_context( alora_action = ALoraRequirement(action.description, adapter_name) # Check if a requirement-check (or AloraRequirement specified) adapter - # exists. - alora_req_adapter = self._find_adapter(adapter_name, ("alora",)) + # exists. An explicit adapter_types override (Epic #929, issue + # #1144) is honoured here — e.g. a custom, LoRA-only adapter + # would never be found by the ("alora",)-only default search, + # silently falling back to regular generation regardless of + # what the caller asked for. + explicit_types = getattr(alora_action, "_adapter_types", None) + search_types = ( + tuple(t.value for t in explicit_types) + if explicit_types + else ("alora",) + ) + alora_req_adapter = self._find_adapter(adapter_name, search_types) if alora_req_adapter is None: # Log a warning if using an AloraRequirement but no adapter fit. if reroute_to_alora and isinstance(action, ALoraRequirement): @@ -919,9 +929,12 @@ def _obtain_local_file_io_yaml_config(self, binding: LocalFileBinding) -> dict: """Download and parse `binding`'s io.yaml, mirroring `IntrinsicAdapter.__init__`. Called synchronously from `add_adapter`'s composed-`LocalFileBinding` - branch, alongside the (larger) weights download `binding.prepare()` - already does there — so the Hugging Face Hub round trip this performs - never runs on the event loop inside `_generate_from_intrinsic`. + branch, before `binding.prepare()`'s (larger) weights download runs + there — so the Hugging Face Hub round trip this performs never runs + on the event loop inside `_generate_from_intrinsic`, and a failure + here leaves nothing registered (retryable), rather than leaving a + real, prepared `LocalFileBinding` with no matching composed-adapter + entry. Args: binding: The `LocalFileBinding` to load `io.yaml` for. @@ -2660,67 +2673,85 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N ) key = _composed_adapter_key(adapter) - # A LocalFileBinding registered standalone (bare add_adapter(binding)) - # lands in _added_adapters under this same key — that's not a - # collision if it's the very binding this composed Adapter wraps, - # only if some *other* object already claimed the name. - existing_added = self._added_adapters.get(key) - if ( - existing_added is not None and existing_added is not adapter.weights - ) or key in self._composed_adapters: - MelleaLogger.get_logger().warning( - f"Client code attempted to add {adapter.identity.name} with type " - f"{adapter.identity.adapter_type} but {key!r} is already " - f"registered on {self.__class__.__name__}. The backend is " - "refusing to do this, because adapter loading is not idempotent." - ) - return - if isinstance(adapter.weights, EmbeddedBinding): - assert config is not None # validated above - adapter.weights.source = self.base_model_name - self._composed_adapters[key] = adapter - self._composed_adapter_configs[key] = config - return - if isinstance(adapter.weights, LocalFileBinding): - binding = adapter.weights - if adapter.identity.adapter_type != binding.adapter_type.value: - raise ValueError( - f"Composed Adapter {adapter.identity.name!r} has " - f"identity.adapter_type={adapter.identity.adapter_type!r} but " - f"weights.adapter_type={binding.adapter_type.value!r}; the two " - "must agree, or registration and lookup key on different " - "strings for the same adapter (NOTE(#1516))." + # Locked: a direct add_adapter() call (unlike resolve_adapter(), + # which already holds this lock around its own add_adapter() + # calls) is otherwise an unguarded check-then-write across + # _added_adapters/_composed_adapters — two concurrent callers + # registering under the same key could each pass the duplicate + # check below before either writes, then pair one call's adapter + # with the other's config. Reentrant, so a caller already holding + # it (resolve_adapter()) is unaffected. + with self._adapter_activation_lock(): + # A LocalFileBinding registered standalone (bare + # add_adapter(binding)) lands in _added_adapters under this + # same key — that's not a collision if it's the very binding + # this composed Adapter wraps, only if some *other* object + # already claimed the name. + existing_added = self._added_adapters.get(key) + if ( + existing_added is not None and existing_added is not adapter.weights + ) or key in self._composed_adapters: + MelleaLogger.get_logger().warning( + f"Client code attempted to add {adapter.identity.name} with " + f"type {adapter.identity.adapter_type} but {key!r} is already " + f"registered on {self.__class__.__name__}. The backend is " + "refusing to do this, because adapter loading is not idempotent." ) - # bind_backend() itself raises if binding.backend is already - # a *different* backend, and no-ops if it's already self — - # calling it unconditionally (rather than guarding on - # `binding.backend is None`) is what makes that raise - # reachable; the guard used to skip straight to prepare() - # (a silent no-op once loaded), registering this adapter on - # `self` while the binding's weights stayed activated - # against whatever backend it was really bound to. - binding.bind_backend(self) - # Recurses into self.add_adapter(binding) below (the bare- - # LocalFileBinding branch further down this method) to - # populate `_added_adapters` for this binding — see - # `LocalFileBinding.prepare()`'s own docstring/comments for - # why that call is there. - binding.prepare() - # Fetched here, alongside the (far larger) weights download - # `prepare()` just did, rather than lazily on first generation - # call: `_intrinsic_adapter_name_and_config` runs inline inside - # the async `_generate_from_intrinsic`, and this Hugging Face - # Hub round trip must not happen on that event loop. - self._composed_adapter_configs[key] = ( - self._obtain_local_file_io_yaml_config(binding) + return + if isinstance(adapter.weights, EmbeddedBinding): + assert config is not None # validated above + adapter.weights.source = self.base_model_name + self._composed_adapters[key] = adapter + self._composed_adapter_configs[key] = config + return + if isinstance(adapter.weights, LocalFileBinding): + binding = adapter.weights + if ( + adapter.identity.name != binding.name + or adapter.identity.adapter_type != binding.adapter_type.value + ): + raise ValueError( + f"Composed Adapter has identity.name={adapter.identity.name!r}/" + f"adapter_type={adapter.identity.adapter_type!r} but " + f"weights.name={binding.name!r}/" + f"adapter_type={binding.adapter_type.value!r}; the two " + "must agree on both, or registration and lookup key on " + "different strings for the same adapter (NOTE(#1516))." + ) + # Fetched before bind_backend()/prepare() below, not + # after: a failed fetch here leaves nothing registered, so + # the same composed Adapter can simply be retried. + # Fetching it after prepare() (which this used to do) + # left a real, registered LocalFileBinding with no + # matching _composed_adapters entry on failure — a later + # resolve_adapter()/add_adapter() for the same name then + # hit the duplicate-key guard above and permanently + # refused to (re)register the capability. + io_yaml_config = self._obtain_local_file_io_yaml_config(binding) + # bind_backend() itself raises if binding.backend is + # already a *different* backend, and no-ops if it's + # already self — calling it unconditionally (rather than + # guarding on `binding.backend is None`) is what makes + # that raise reachable; the guard used to skip straight + # to prepare() (a silent no-op once loaded), registering + # this adapter on `self` while the binding's weights + # stayed activated against whatever backend it was + # really bound to. + binding.bind_backend(self) + # Recurses into self.add_adapter(binding) below (the + # bare-LocalFileBinding branch further down this method) + # to populate `_added_adapters` for this binding — see + # `LocalFileBinding.prepare()`'s own docstring/comments + # for why that call is there. + binding.prepare() + self._composed_adapter_configs[key] = io_yaml_config + self._composed_adapters[key] = adapter + return + raise TypeError( + "LocalHFBackend does not support the " + f"{type(adapter.weights).__name__} weights reality for a " + "composed Adapter." ) - self._composed_adapters[key] = adapter - return - raise TypeError( - "LocalHFBackend does not support the " - f"{type(adapter.weights).__name__} weights reality for a " - "composed Adapter." - ) if adapter.backend is not None: if adapter.backend is self: diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 0bf2d1b157..7a61bf4597 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -400,15 +400,24 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N "register_embedded_adapter_model() or resolve_adapter()." ) key = _composed_adapter_key(adapter) - if key in self._added_adapters: - MelleaLogger.get_logger().warning( - f"attempted to add adapter {key!r} but it is already " - "registered; refusing to overwrite it." - ) - return - adapter.weights.source = self.base_model_name - self._added_adapters[key] = adapter - self._composed_adapter_configs[key] = config + # Locked: a direct add_adapter() call (unlike resolve_adapter(), + # which already holds this lock around its own add_adapter() + # calls) is otherwise an unguarded check-then-write across + # _added_adapters/_composed_adapter_configs — two concurrent + # callers registering under the same key could each pass the + # duplicate check below before either writes, then pair one + # call's adapter with the other's config. Reentrant, so a caller + # already holding it (resolve_adapter()) is unaffected. + with self._adapter_activation_lock(): + if key in self._added_adapters: + MelleaLogger.get_logger().warning( + f"attempted to add adapter {key!r} but it is already " + "registered; refusing to overwrite it." + ) + return + adapter.weights.source = self.base_model_name + self._added_adapters[key] = adapter + self._composed_adapter_configs[key] = config return raise TypeError( @@ -832,7 +841,18 @@ async def _generate_from_context( ) alora_action = ALoraRequirement(action.description, adapter_name) - alora_req_adapter = self._find_adapter(adapter_name, ("alora",)) + # An explicit adapter_types override (Epic #929, issue #1144) + # is honoured here — e.g. a custom, LoRA-only adapter would + # never be found by the ("alora",)-only default search, + # silently falling back to regular generation regardless of + # what the caller asked for. + explicit_types = getattr(alora_action, "_adapter_types", None) + search_types = ( + tuple(t.value for t in explicit_types) + if explicit_types + else ("alora",) + ) + alora_req_adapter = self._find_adapter(adapter_name, search_types) if alora_req_adapter is None: if reroute_to_alora and isinstance(action, ALoraRequirement): MelleaLogger.get_logger().warning( diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index cb5a64edc1..c94ba8f093 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -19,6 +19,7 @@ from mellea.backends.adapters import ( Adapter, + EmbeddedBinding, EmbeddedIntrinsicAdapter, IntrinsicAdapter, get_io_contract, @@ -836,6 +837,13 @@ def fake_add_adapter(a, config=None): mock_backend._find_adapter.side_effect = lambda cap, types=None: ( AdapterMixin._find_adapter(mock_backend, cap, types) ) + # resolve_adapter() routes through this compat wrapper, not add_adapter() + # directly (Epic #929, issue #1144) — a MagicMock(spec=...) auto-mocks it + # too, so it must be wired to the real implementation to actually reach + # fake_add_adapter above. + mock_backend._add_embedded_adapter_compat.side_effect = lambda a, config: ( + AdapterMixin._add_embedded_adapter_compat(mock_backend, a, config) + ) with patch( "mellea.backends.adapters.adapter.EmbeddedIntrinsicAdapter.from_source", @@ -950,3 +958,68 @@ def call(index: int) -> None: assert results[0] is results[1] is registrations[0], ( "both concurrent callers must resolve to the single registered adapter" ) + + +def test_add_embedded_adapter_compat_falls_back_to_shim_for_legacy_add_adapter(): + """A third-party `AdapterMixin` subclass predating `config=` (Epic #929, + issue #1144) must not break when `resolve_adapter()`'s embedded branch + tries to register a discovered adapter. + + Regression: `_uses_embedded_adapters` predates the `config` parameter + `add_adapter` gained in this PR — a legacy `add_adapter(self, adapter)` + override (no `config` parameter, no `**kwargs`) raised + `TypeError: add_adapter() got an unexpected keyword argument 'config'` + when `resolve_adapter()` called `self.add_adapter(a, config=config)` + directly. + """ + registered = [] + + def legacy_add_adapter(adapter): + registered.append(adapter) + + mock_backend = MagicMock(spec=AdapterMixin) + mock_backend.add_adapter = legacy_add_adapter + + composed = Adapter( + identity=Identity(name="answerability", adapter_type="alora"), + io_contract=get_io_contract("answerability"), + weights=EmbeddedBinding(), + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + AdapterMixin._add_embedded_adapter_compat( + mock_backend, composed, {"parameters": {}} + ) + + assert len(registered) == 1 + shim = registered[0] + assert isinstance(shim, EmbeddedIntrinsicAdapter) + assert shim.intrinsic_name == "answerability" + assert shim.technology == "alora" + assert shim.config == {"parameters": {}} + + +def test_add_embedded_adapter_compat_passes_config_directly_for_modern_add_adapter(): + """A backend whose `add_adapter` accepts `config=` gets the composed + `Adapter` directly — the deprecated-shim fallback is only for a legacy + override that cannot accept it.""" + calls = [] + + def modern_add_adapter(adapter, *, config=None): + calls.append((adapter, config)) + + mock_backend = MagicMock(spec=AdapterMixin) + mock_backend.add_adapter = modern_add_adapter + + composed = Adapter( + identity=Identity(name="answerability", adapter_type="alora"), + io_contract=get_io_contract("answerability"), + weights=EmbeddedBinding(), + ) + + AdapterMixin._add_embedded_adapter_compat( + mock_backend, composed, {"parameters": {}} + ) + + assert calls == [(composed, {"parameters": {}})] diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index f18bb9bdf8..5a258b1006 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -3458,3 +3458,43 @@ async def test_generate_from_raw_raw_response_none_for_beam_outputs(): assert result.raw.response is None, ( f"item {item_idx}: raw.response must stay None for beam-search outputs" ) + + +@pytest.mark.asyncio +async def test_alora_requirement_dispatch_honors_explicit_adapter_types_override(): + """A custom `ALoraRequirement`'s `adapter_types` override must reach + `_find_adapter`, not the hardcoded `("alora",)` default (Epic #929, + issue #1144). + + Regression: the requirement-check dispatcher in `_generate_from_context` + hardcoded `self._find_adapter(adapter_name, ("alora",))` regardless of + what `adapter_types` the caller passed to `ALoraRequirement` — a custom, + LoRA-only adapter could never be found this way, silently falling back + to regular generation. + """ + from mellea.stdlib.requirements.requirement import ALoraRequirement + + backend = _make_backend() + action = ALoraRequirement( + "custom check", "my-custom-check", adapter_types=(AdapterType.LORA,) + ) + ctx = ChatContext().add(Message("user", "hi")) + + captured: dict[str, object] = {} + + def fake_find_adapter(name, adapter_types=None): + captured["name"] = name + captured["adapter_types"] = adapter_types + return None # not registered; falls back to regular generation below + + with ( + patch.object(backend, "_find_adapter", side_effect=fake_find_adapter), + patch.object( + LocalHFBackend, "_generate_from_context_standard", new_callable=AsyncMock + ) as mock_standard, + ): + mock_standard.return_value = (MagicMock(), ctx) + await backend._generate_from_context(action, ctx, model_options={}) + + assert captured["name"] == "my-custom-check" + assert captured["adapter_types"] == ("lora",) From 8991196a65d08a0c39c3025f225d1d8ec1dcc875 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Sun, 6 Sep 2026 19:02:20 +0100 Subject: [PATCH 15/19] fix(backends): fix deadlock and unsound compat detection in composed-Adapter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two BLOCKERs Codex found in the previous round's own fixes (my concurrency-race fix introduced a lock-order deadlock; the legacy-compat fix used signature inspection, which can't distinguish "accepts a config= keyword" from "understands a composed Adapter object"), plus a related WARNING (a load failure inside prepare() could still orphan a registration). Designed with an independent Opus review before implementing, given two consecutive rounds had each introduced a new bug while fixing the last one. - Replace `_add_embedded_adapter_compat`'s signature-inspection heuristic with an explicit `_supports_composed_adapters` ClassVar opt-in on AdapterMixin, set True on both concrete backends. Signature inspection read a `**kwargs` catch-all (present for unrelated reasons) or a pre-existing `config` parameter (meaning something else) as "this backend understands composed Adapters" — it can't actually tell that apart from "merely accepts the keyword", and would hand a composed Adapter's three-field shape to code expecting the deprecated shim's attributes, risking AttributeError or silent misuse. - Add a new `_adapter_resolve_lock()`, separate from `_adapter_activation_lock()`. `resolve_adapter()` and both `register_embedded_adapter_model()`s now hold this lock instead — the activation lock is taken *inside* every WeightsBinding lifecycle verb and inside add_adapter's dict writes, so holding it across the whole discover-and-register loop (which calls add_adapter, which can call binding.prepare()) inverted the established lock order (lifecycle-then-activation) and could deadlock against a concurrent prepare()/release() on the same binding. - Restructure LocalHFBackend.add_adapter's composed-LocalFileBinding branch into claim/work/commit phases: claim the key under the activation lock (no I/O, no lifecycle verb), do the io.yaml fetch and bind_backend()/prepare() with no backend lock held at all, then commit the dict writes under the activation lock again. A `_composed_registrations_in_flight` set keeps the duplicate-registration guard exclusive across the unlocked middle phase. - Add LocalFileBinding._rollback_registration(), called from prepare() when the weights load fails after this call's own registration succeeded: undoes the registration so a retry re-registers cleanly, instead of leaving an orphaned binding with no composed-Adapter entry that permanently blocks a later resolve_adapter() for the same name. - Update the two register_embedded_adapter_model post-add_adapter identity checks to re-take the activation lock briefly for the read, since they no longer inherit that protection from holding the (now-different) resolve lock across the whole method. Verified: full suite 4343 passed, 0 failed; ruff format/check and mypy clean. Added a regression test asserting the activation lock is not held while prepare() runs (the exact pattern that deadlocks), and rewrote the prepare()-retry test for the new rollback-then-retry semantics. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/_core.py | 61 ++++- mellea/backends/adapters/adapter.py | 122 +++++++--- mellea/backends/huggingface.py | 212 ++++++++++++------ mellea/backends/openai.py | 28 ++- .../test_adapters/test_local_file_binding.py | 40 ++-- .../test_local_file_integration.py | 44 ++++ test/backends/test_adapters/test_shims.py | 77 ++++--- 7 files changed, 432 insertions(+), 152 deletions(-) diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index e92f900ef5..cdd0485e7e 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -486,6 +486,7 @@ def prepare(self) -> None: the registration. """ started_at = time.monotonic() + registered_here = False with self._lifecycle_lock: if self._released: raise RuntimeError( @@ -529,16 +530,62 @@ def prepare(self) -> None: "the backend's warning log — another adapter is already registered " "under this qualified name." ) - # `load_peft_adapter` mutates the backend's underlying PEFT model, the - # same shared state `activate_peft_adapter`/`deactivate_peft_adapter` - # document "must be called while holding `_generation_lock`" for. - # `prepare()`/`release()` aren't driven through `adapter_scope`, so - # nothing else takes this lock on their behalf. - with self.backend._adapter_activation_lock(): - self.backend.load_peft_adapter(self.qualified_name) + registered_here = True + try: + # `load_peft_adapter` mutates the backend's underlying PEFT model, the + # same shared state `activate_peft_adapter`/`deactivate_peft_adapter` + # document "must be called while holding `_generation_lock`" for. + # `prepare()`/`release()` aren't driven through `adapter_scope`, so + # nothing else takes this lock on their behalf. + with self.backend._adapter_activation_lock(): + self.backend.load_peft_adapter(self.qualified_name) + except BaseException: + # Keeps prepare() all-or-nothing for a call that did its own + # registration above: without this, a load failure leaves a + # registered binding with no way for the caller to distinguish + # it from a fully-prepared one, and the qualified name stays + # claimed — permanently refusing a *different* binding for + # the same capability (exactly what a fresh resolve_adapter() + # call constructs on retry). A call that instead retried an + # *already*-registered binding (registered_here False) isn't + # this call's registration to undo. + if registered_here: + self._rollback_registration() + raise self._loaded = True self._fire_phase_complete("prepare", time.monotonic() - started_at) + def _rollback_registration(self) -> None: + """Undoes this call's own `add_adapter` after the weights load failed. + + Called from `prepare()` with `_lifecycle_lock` already held, on the + failure path right after `self._staged_backend.add_adapter(self)` + succeeded but `load_peft_adapter` then raised. Takes only the + activation lock — re-entering `_lifecycle_lock` here would + self-deadlock, since it is a plain `threading.Lock`, not an `RLock` + (this is also why `release()`, which acquires `_lifecycle_lock` + itself, cannot be reused here). + """ + backend = self.backend + assert backend is not None + try: + with backend._adapter_activation_lock(): + backend.remove_adapter(self.qualified_name) + except NotImplementedError: + # State the observable fact, not the presumed cause: a real + # implementation can raise NotImplementedError internally for + # reasons unrelated to this rollback. + MelleaLogger.get_logger().warning( + f"{type(backend).__name__}.remove_adapter() raised " + f"NotImplementedError; {self.qualified_name!r} stays registered " + "after a failed load. Retrying prepare() on this same binding " + "still works; a different binding for the same capability " + "will be refused." + ) + return + self.backend = None + self.path = None + def activate(self) -> None: """Selects already-loaded adapter weights for generation. diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index dc4980ce35..ab71c7fbe1 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -17,7 +17,6 @@ import abc import contextlib import hashlib -import inspect import pathlib import re import shutil @@ -25,7 +24,7 @@ import time import warnings from collections.abc import Callable -from typing import Literal, TypeAlias, TypeVar, cast +from typing import ClassVar, Literal, TypeAlias, TypeVar, cast import yaml @@ -529,6 +528,21 @@ class AdapterMixin(Backend, abc.ABC): `"ibm-granite/granite-3.3-8b-instruct"`). """ + _supports_composed_adapters: ClassVar[bool] = False + """Whether this backend's `add_adapter` understands a composed `Adapter`. + + Opt-in, defaulting to `False`: `_uses_embedded_adapters` predates the + composed `Adapter` contract (Epic #929, issue #1144), so a third-party + subclass can already be reachable from `resolve_adapter()`'s embedded + branch while its `add_adapter` only knows the deprecated + `EmbeddedIntrinsicAdapter` shim's attribute shape. Signature inspection + cannot distinguish "modern" from "legacy" here — a `**kwargs` catch-all + present for unrelated reasons, or a pre-existing `config` parameter + meaning something else entirely, both read as "accepts config=". Only an + explicit declaration can carry that fact; `LocalHFBackend` and + `OpenAIBackend` both set this to `True`. + """ + # ---- Universal verbs (every adapter reality) ---- @property @@ -683,24 +697,60 @@ def deactivate_peft_adapter(self, adapter_qualified_name: str) -> None: def _adapter_activation_lock( self, ) -> contextlib.AbstractContextManager[bool | None]: - """Exclusivity lock to hold while calling activate/deactivate and registration verbs. + """Exclusivity lock to hold while calling activate/deactivate and dict writes. Default is a no-op (`contextlib.nullcontext()`). Backends whose activation verbs mutate shared, non-thread-safe state (e.g. `LocalHFBackend`'s underlying PEFT model) override this to return their own lock, so callers like `LocalFileBinding.activate()` get the same exclusivity `_generate_with_adapter_lock` relies on. - `resolve_adapter()` also holds it for its own registration path. + `add_adapter()`'s registration-dict writes also hold it, briefly. + + Innermost in the adapter lock order + (`_adapter_resolve_lock` -> `binding._lifecycle_lock` -> + `_adapter_activation_lock`). Never hold this lock across a + `WeightsBinding` lifecycle verb (`prepare`/`activate`/`deactivate`/ + `release`) or across any I/O — those take `_lifecycle_lock`, and + holding activation across them inverts the order and can deadlock + against a concurrent lifecycle call on the same binding. A code path already holding this lock can re-enter it: on `LocalHFBackend`, `_generate_intrinsic_with_adapter_scope` holds `_generation_lock` for the whole generation, and the `_IntrinsicPeftBinding` verbs it drives through `adapter_scope()` - take `_adapter_activation_lock()` again on the same thread. (No - production caller currently reaches `resolve_adapter()` this way, but - nothing prevents one.) An override must therefore return a reentrant - lock (`threading.RLock`, as `LocalHFBackend` does) — a plain - `threading.Lock` here is a real deadlock hazard. + take `_adapter_activation_lock()` again on the same thread. An + override must therefore return a reentrant lock (`threading.RLock`, + as `LocalHFBackend` does) — a plain `threading.Lock` here is a real + deadlock hazard. + """ + return contextlib.nullcontext() + + def _adapter_resolve_lock(self) -> contextlib.AbstractContextManager[bool | None]: + """Exclusivity lock for discovery-plus-registration orchestration. + + Default is a no-op (`contextlib.nullcontext()`). Held by + `resolve_adapter()` and `register_embedded_adapter_model()` across + their catalogue/Hugging Face Hub discovery I/O and their + `add_adapter()` calls. + + Outermost in the adapter lock order + (`_adapter_resolve_lock` -> `binding._lifecycle_lock` -> + `_adapter_activation_lock`). Deliberately a separate lock from + `_adapter_activation_lock`, not a reuse of it: the activation lock is + taken *inside* every `WeightsBinding` lifecycle verb, so holding it + across `add_adapter()` — which can call those verbs — would invert + the order and deadlock against a concurrent `prepare()`/`release()` + on the same binding. + + Must not be acquired while already holding + `_adapter_activation_lock()` — that reverses the order. No + production caller does: `resolve_adapter()` runs before the backend + takes its generation lock, not after (see + `mellea/stdlib/components/intrinsic/_util.py`). + + An override must return a reentrant lock (`threading.RLock`) for the + same same-thread-reentry reason `_adapter_activation_lock()` + documents. """ return contextlib.nullcontext() @@ -715,22 +765,20 @@ def _add_embedded_adapter_compat( that: a third-party `AdapterMixin` subclass written before this parameter existed, but already supporting the Embedded reality, is reachable here and would raise - `TypeError: add_adapter() got an unexpected keyword argument 'config'`. - - Detected via signature inspection rather than `try`/`except TypeError` - around the call: a `TypeError` `add_adapter` itself legitimately - raises (e.g. an unsupported weights reality) must propagate, not be - misread as this compatibility gap. + `TypeError: add_adapter() got an unexpected keyword argument 'config'` + — or worse, silently misbehave, if it happens to already accept a + `config=`/`**kwargs` for unrelated reasons and gets handed a composed + `Adapter` its body doesn't know how to read (it has no + `.qualified_name`/`.config`/`.technology`, unlike the shim). Gated on + `_supports_composed_adapters`, not signature inspection, for exactly + that reason: a parameter name can't tell you whether the method body + understands the new object shape. Args: adapter: The composed `Adapter` to register. config: Raw io.yaml config for `adapter`. """ - params = inspect.signature(self.add_adapter).parameters - accepts_config = "config" in params or any( - p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() - ) - if accepts_config: + if self._supports_composed_adapters: self.add_adapter(adapter, config=config) return # Legacy subclass predating the composed-Adapter contract: it only @@ -760,14 +808,16 @@ def resolve_adapter(self, name: str) -> _AdapterCore: KeyError: If the adapter cannot be found after registration. Note: - On backends with a real `_adapter_activation_lock()` override - (e.g. `LocalHFBackend`), a cold first resolve holds that lock - across any Hugging Face Hub download it triggers, so it can stall - every other caller of the same lock (activation, generation, - other resolves) — including a resolve on the asyncio event-loop - thread inside a gathered set of coroutines. This is a deliberate - trade-off (reusing the existing lock rather than adding a second - one); a warm cache after the first resolve avoids the download. + A cold first resolve holds `_adapter_resolve_lock()` across any + Hugging Face Hub download it triggers, so it can stall every + other caller of *that* lock (other resolves, + `register_embedded_adapter_model()`) — including a resolve on + the asyncio event-loop thread inside a gathered set of + coroutines. It does **not** stall generation or activation: + `_adapter_resolve_lock` is a separate lock from + `_adapter_activation_lock`, deliberately (see the lock-order note + on `_adapter_activation_lock()`). A warm cache after the first + resolve avoids the download either way. """ found = self._find_adapter(name) if found is not None: @@ -782,14 +832,18 @@ def resolve_adapter(self, name: str) -> _AdapterCore: # add_adapter()'s own duplicate check is an unguarded read-then-write on # _added_adapters, and catch_warnings() below mutates thread-unsafe global # filter state — both race under concurrent first-time resolves for the - # same name. `_adapter_activation_lock()` closes both: a no-op by default, - # LocalHFBackend's reentrant `_generation_lock` and OpenAIBackend's own - # lock otherwise. Not closed here: LocalFileBinding.prepare() (own - # `_lifecycle_lock`) and register_embedded_adapter_model() (no lock, but - # constructor-only today, so not currently reachable concurrently). + # same name. `_adapter_resolve_lock()` closes both: a no-op by default, + # and each concrete backend's reentrant lock otherwise. Deliberately not + # `_adapter_activation_lock()`: that lock is taken *inside* + # `add_adapter()`'s composed-LocalFileBinding branch around the dict + # writes only, and inside every `WeightsBinding` lifecycle verb — holding + # it here, across the whole discover-and-register loop below (which calls + # `add_adapter()`, which can call `binding.prepare()`), would invert the + # adapter lock order and deadlock against a concurrent + # `prepare()`/`release()` on the same binding. # Suppress DeprecationWarning: the shim constructors warn user-facing code, # not internal registration paths. - with self._adapter_activation_lock(), warnings.catch_warnings(): + with self._adapter_resolve_lock(), warnings.catch_warnings(): # Re-check now the lock is held: a concurrent resolve may have already # registered this name. Without this, the loser redundantly re-fetches # and then hits the backend's own duplicate guard, which logs a diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index b7e12bb85c..e11f806e84 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -432,6 +432,7 @@ class LocalHFBackend(FormatterBackend, AdapterMixin): """ _cached_blocks: dict[str, DynamicCache] = dict() + _supports_composed_adapters = True def __init__( self, @@ -580,6 +581,21 @@ def __init__( A plain `Lock` deadlocks on that same-thread re-acquisition (#1465). """ + self._adapter_resolve_lock_obj = threading.RLock() + """Backs `_adapter_resolve_lock()`. Deliberately a separate lock from + `_generation_lock`/`_adapter_activation_lock()` — see that method's + docstring for the deadlock this avoids. Reentrant for the same + same-thread-reentry reason `_generation_lock` is. + """ + + # Keys claimed by an in-flight composed LocalFile registration in + # add_adapter(). The slow part (io.yaml fetch, weights download, PEFT + # load) must not run under `_adapter_activation_lock()` — see that + # method's lock-order note — so this set keeps the + # duplicate-registration guard exclusive across that unlocked window + # instead. + self._composed_registrations_in_flight: set[str] = set() + if load_embedded_adapters: self.register_embedded_adapter_model(self._adapter_source or self._model_id) @@ -2673,24 +2689,35 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N ) key = _composed_adapter_key(adapter) - # Locked: a direct add_adapter() call (unlike resolve_adapter(), - # which already holds this lock around its own add_adapter() - # calls) is otherwise an unguarded check-then-write across - # _added_adapters/_composed_adapters — two concurrent callers - # registering under the same key could each pass the duplicate - # check below before either writes, then pair one call's adapter - # with the other's config. Reentrant, so a caller already holding - # it (resolve_adapter()) is unaffected. + + # Phase 1: claim the key. Under the activation lock, but no I/O + # and no WeightsBinding lifecycle verb — see the lock-order note + # on `_adapter_activation_lock()`. Never held across `prepare()`: + # that method takes `binding._lifecycle_lock` then, internally, + # this same activation lock — holding the latter here across + # `prepare()` would acquire them in the opposite order and + # deadlock against a concurrent `prepare()`/`release()` on the + # same binding (from another thread, or via a direct call on the + # binding itself). with self._adapter_activation_lock(): # A LocalFileBinding registered standalone (bare # add_adapter(binding)) lands in _added_adapters under this # same key — that's not a collision if it's the very binding # this composed Adapter wraps, only if some *other* object - # already claimed the name. + # already claimed the name. `_composed_registrations_in_flight` + # is the same guard for a LocalFileBinding registration + # that's between phases (its I/O/lifecycle work isn't done + # under this lock, so the dicts alone can't tell a concurrent + # caller "already claimed"). existing_added = self._added_adapters.get(key) if ( - existing_added is not None and existing_added is not adapter.weights - ) or key in self._composed_adapters: + ( + existing_added is not None + and existing_added is not adapter.weights + ) + or key in self._composed_adapters + or key in self._composed_registrations_in_flight + ): MelleaLogger.get_logger().warning( f"Client code attempted to add {adapter.identity.name} with " f"type {adapter.identity.adapter_type} but {key!r} is already " @@ -2703,55 +2730,81 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N adapter.weights.source = self.base_model_name self._composed_adapters[key] = adapter self._composed_adapter_configs[key] = config - return - if isinstance(adapter.weights, LocalFileBinding): - binding = adapter.weights - if ( - adapter.identity.name != binding.name - or adapter.identity.adapter_type != binding.adapter_type.value - ): - raise ValueError( - f"Composed Adapter has identity.name={adapter.identity.name!r}/" - f"adapter_type={adapter.identity.adapter_type!r} but " - f"weights.name={binding.name!r}/" - f"adapter_type={binding.adapter_type.value!r}; the two " - "must agree on both, or registration and lookup key on " - "different strings for the same adapter (NOTE(#1516))." - ) - # Fetched before bind_backend()/prepare() below, not - # after: a failed fetch here leaves nothing registered, so - # the same composed Adapter can simply be retried. - # Fetching it after prepare() (which this used to do) - # left a real, registered LocalFileBinding with no - # matching _composed_adapters entry on failure — a later - # resolve_adapter()/add_adapter() for the same name then - # hit the duplicate-key guard above and permanently - # refused to (re)register the capability. - io_yaml_config = self._obtain_local_file_io_yaml_config(binding) - # bind_backend() itself raises if binding.backend is - # already a *different* backend, and no-ops if it's - # already self — calling it unconditionally (rather than - # guarding on `binding.backend is None`) is what makes - # that raise reachable; the guard used to skip straight - # to prepare() (a silent no-op once loaded), registering - # this adapter on `self` while the binding's weights - # stayed activated against whatever backend it was - # really bound to. - binding.bind_backend(self) - # Recurses into self.add_adapter(binding) below (the - # bare-LocalFileBinding branch further down this method) - # to populate `_added_adapters` for this binding — see - # `LocalFileBinding.prepare()`'s own docstring/comments - # for why that call is there. - binding.prepare() - self._composed_adapter_configs[key] = io_yaml_config - self._composed_adapters[key] = adapter - return - raise TypeError( - "LocalHFBackend does not support the " - f"{type(adapter.weights).__name__} weights reality for a " - "composed Adapter." - ) + return # atomic: no lifecycle verb, no I/O + if not isinstance(adapter.weights, LocalFileBinding): + raise TypeError( + "LocalHFBackend does not support the " + f"{type(adapter.weights).__name__} weights reality for a " + "composed Adapter." + ) + binding = adapter.weights + if ( + adapter.identity.name != binding.name + or adapter.identity.adapter_type != binding.adapter_type.value + ): + raise ValueError( + f"Composed Adapter has identity.name={adapter.identity.name!r}/" + f"adapter_type={adapter.identity.adapter_type!r} but " + f"weights.name={binding.name!r}/" + f"adapter_type={binding.adapter_type.value!r}; the two " + "must agree on both, or registration and lookup key on " + "different strings for the same adapter (NOTE(#1516))." + ) + self._composed_registrations_in_flight.add(key) + + # Phase 2: do the slow, unlocked work. No backend lock held here — + # that's what makes the nested lifecycle-lock acquisitions inside + # `bind_backend()`/`prepare()` legal under the adapter lock order + # (resolve -> lifecycle -> activation). The two calls are + # sequential, not nested: `binding._lifecycle_lock` is a plain + # `threading.Lock`, not reentrant. + try: + # Fetched before bind_backend()/prepare() below, not after: a + # failed fetch here leaves nothing registered (the in-flight + # claim aside), so the same composed Adapter can simply be + # retried. Fetching it after prepare() (which this used to + # do) left a real, registered LocalFileBinding with no + # matching _composed_adapters entry on failure — a later + # resolve_adapter()/add_adapter() for the same name then hit + # the duplicate-key guard above and permanently refused to + # (re)register the capability. `prepare()` itself closes the + # equivalent gap for its own weights-load failure (see its + # docstring's rollback note) — this ordering only has to + # cover the fetch this method adds ahead of it. + io_yaml_config = self._obtain_local_file_io_yaml_config(binding) + # bind_backend() itself raises if binding.backend is already + # a *different* backend, and no-ops if it's already self — + # calling it unconditionally (rather than guarding on + # `binding.backend is None`) is what makes that raise + # reachable; the guard used to skip straight to prepare() (a + # silent no-op once loaded), registering this adapter on + # `self` while the binding's weights stayed activated against + # whatever backend it was really bound to. + binding.bind_backend(self) + # Recurses into self.add_adapter(binding) below (the + # bare-LocalFileBinding branch further down this method) to + # populate `_added_adapters` for this binding — see + # `LocalFileBinding.prepare()`'s own docstring/comments for + # why that call is there. On a weights-load failure inside + # `prepare()`, that same docstring's rollback note applies: + # the binding un-registers itself, so this method's own + # in-flight claim (released in the `except` below) is the + # only remaining state to clean up. + binding.prepare() + except BaseException: + with self._adapter_activation_lock(): + self._composed_registrations_in_flight.discard(key) + raise + + # Phase 3: commit and release the claim in one acquisition. Not + # split into two: a gap between them would let another thread + # claim a key whose registration has already succeeded, then get + # refused at phase 1 against a half-visible state. + with self._adapter_activation_lock(): + self._composed_adapter_configs[key] = io_yaml_config + self._composed_adapters[key] = adapter + self._composed_registrations_in_flight.discard(key) + return if adapter.backend is not None: if adapter.backend is self: @@ -2830,8 +2883,12 @@ def register_embedded_adapter_model( # read-then-write across `_composed_adapters` and # `_discover_embedded_adapters`'s mutation of global `warnings` filter # state need the same lock `resolve_adapter` already takes around its - # own `add_adapter` calls. - with self._adapter_activation_lock(): + # own `add_adapter` calls. `_adapter_resolve_lock()`, not + # `_adapter_activation_lock()`: see the lock-order note on the latter + # — this method's own I/O (this Hub discovery call) must not run + # under the lock `add_adapter()`'s composed-LocalFileBinding branch + # briefly takes around its dict writes. + with self._adapter_resolve_lock(): discovered = _discover_embedded_adapters( source, revision=revision, @@ -2849,7 +2906,14 @@ def register_embedded_adapter_model( # so this is the only way to know whether *this* adapter is the # one that actually ended up registered, for the `names` result. self.add_adapter(adapter, config=config) - if self._composed_adapters.get(key) is not adapter: + # Under the activation lock, not just the resolve lock this + # whole method holds: a concurrent release() pops + # _composed_adapters without taking the resolve lock (it isn't + # a resolve-orchestration caller), so this read needs its own + # protection against racing that pop. + with self._adapter_activation_lock(): + registered = self._composed_adapters.get(key) is adapter + if not registered: continue names.append(adapter.identity.name) return names @@ -3066,8 +3130,11 @@ def _adapter_activation_lock( the lock caller 1 (and, on the outer level, caller 3's driver) takes, it is the only thing satisfying the precondition on those paths. - A fourth consumer, unrelated to that precondition: `resolve_adapter()` - (base class) also takes this lock, to serialize registration. + A fourth consumer, unrelated to that precondition: `add_adapter()`'s + composed-`Adapter` branches also take this lock, briefly, around + their registration-dict writes — never across a `WeightsBinding` + lifecycle verb or I/O (see `_adapter_resolve_lock()` below and the + lock-order note on the base class's `_adapter_activation_lock()`). Caller 3's verb acquisitions therefore nest inside its driver's `_generation_lock` hold on the same thread (see @@ -3078,6 +3145,21 @@ def _adapter_activation_lock( """ return self._generation_lock + def _adapter_resolve_lock(self) -> contextlib.AbstractContextManager[bool | None]: + """A separate, reentrant lock for `resolve_adapter()`/registration orchestration. + + Not `_generation_lock` (the lock `_adapter_activation_lock()` + returns): that lock is taken *inside* `add_adapter()`'s + composed-`LocalFileBinding` branch and inside every `LocalFileBinding` + lifecycle verb, so holding it across `resolve_adapter()`'s whole + discover-and-register loop — which calls `add_adapter()`, which can + call `binding.prepare()` — would invert the adapter lock order + (`_adapter_resolve_lock` -> `binding._lifecycle_lock` -> + `_adapter_activation_lock`) and deadlock against a concurrent + `prepare()`/`release()` on the same binding. + """ + return self._adapter_resolve_lock_obj + def list_adapters(self) -> list[str]: """List the qualified names of all adapters registered with this backend. diff --git a/mellea/backends/openai.py b/mellea/backends/openai.py index 7a61bf4597..1f684326d4 100644 --- a/mellea/backends/openai.py +++ b/mellea/backends/openai.py @@ -130,6 +130,8 @@ class OpenAIBackend(FormatterBackend, AdapterMixin): `OPENAI_API_KEY` is set, or if `model_id` is a `ModelIdentifier` with no `openai_name` set. """ + _supports_composed_adapters = True + def __init__( self, model_id: str | ModelIdentifier = model_ids.OPENAI_GPT_5_1, @@ -308,6 +310,10 @@ def __init__( # _intrinsic_adapter_name_and_config. self._composed_adapter_configs: dict[str, dict] = {} self._adapter_lock = threading.RLock() + # Backs _adapter_resolve_lock() — deliberately separate from + # _adapter_lock (`_adapter_activation_lock()`); see that method's + # docstring for the deadlock a shared lock would risk. + self._adapter_resolve_lock_obj = threading.RLock() # Call once to create an async_client and populate the cache. _ = self._async_client @@ -436,9 +442,19 @@ def list_adapters(self) -> list[str]: def _adapter_activation_lock( self, ) -> contextlib.AbstractContextManager[bool | None]: - """Serialize `add_adapter()` / registration for this backend.""" + """Serialize `add_adapter()`'s registration-dict writes for this backend.""" return self._adapter_lock + def _adapter_resolve_lock(self) -> contextlib.AbstractContextManager[bool | None]: + """A separate lock for `resolve_adapter()`/registration orchestration. + + Not `_adapter_lock` (the lock `_adapter_activation_lock()` returns): + see that method's docstring, and the base class's + `_adapter_resolve_lock()` docstring, for the deadlock a shared lock + would risk. + """ + return self._adapter_resolve_lock_obj + # ------------------------------------------------------------------ # Convenience registration helpers # ------------------------------------------------------------------ @@ -515,7 +531,11 @@ def register_embedded_adapter_model( # read-then-write across `_added_adapters` and `_discover_embedded_adapters`' # mutation of global `warnings` filter state need the same lock # `add_adapter` itself would take via `resolve_adapter`. - with self._adapter_activation_lock(): + # `_adapter_resolve_lock()`, not `_adapter_activation_lock()`: see the + # lock-order note on the latter — this method's own I/O (this Hub + # discovery call) must not run under the lock `add_adapter()` briefly + # takes around its dict writes. + with self._adapter_resolve_lock(): discovered = _discover_embedded_adapters( source, revision=revision, @@ -533,7 +553,9 @@ def register_embedded_adapter_model( # so this is the only way to know whether *this* adapter is the # one that actually ended up registered, for the `names` result. self.add_adapter(adapter, config=config) - if self._added_adapters.get(key) is not adapter: + with self._adapter_activation_lock(): + registered = self._added_adapters.get(key) is adapter + if not registered: continue names.append(adapter.identity.name) return names diff --git a/test/backends/test_adapters/test_local_file_binding.py b/test/backends/test_adapters/test_local_file_binding.py index ef7bf25720..41d17f7be8 100644 --- a/test/backends/test_adapters/test_local_file_binding.py +++ b/test/backends/test_adapters/test_local_file_binding.py @@ -240,15 +240,26 @@ def test_prepare_ignores_phase_hook_dispatch_failure(): backend.activate_peft_adapter.assert_called_once_with(binding.qualified_name) -def test_prepare_retries_only_the_load_after_a_load_failure(): - """A failed load must be retryable without re-registering. - - Regression guard: `add_adapter` sets `.backend` (registration) before - `prepare()` calls `load_peft_adapter` (the load). If the load raised, - `.backend` was already non-None, so the old idempotency guard - (`if self.backend is not None: return`) made every retry a silent no-op — - the caller got no error and no adapter, forever. The fix tracks the load - separately from registration so a retry redoes only the failed step. +def test_prepare_rolls_back_registration_after_a_load_failure(): + """A failed load must fully undo this call's own registration, then be retryable. + + Regression guard, in two parts: + + 1. `add_adapter` sets `.backend` (registration) before `prepare()` calls + `load_peft_adapter` (the load). If the load raised, `.backend` was + already non-None, so the old idempotency guard + (`if self.backend is not None: return`) made every retry a silent + no-op — the caller got no error and no adapter, forever. + 2. An earlier fix tracked the load separately from registration so a + retry redid only the failed step — but that left a load failure's + *first* attempt as a registered binding with `._loaded` still False + forever if the caller gave up rather than retried, and — worse, for a + composed `Adapter` wrapping this binding — no way for + `LocalHFBackend.add_adapter`'s composed branch to tell "registered but + not usable" from "not registered", permanently orphaning the + qualified name. `prepare()` now rolls its own registration back on a + load failure, so a binding that never successfully prepares is never + left half-registered, and a retry re-registers cleanly. """ backend = _fake_backend() backend.load_peft_adapter.side_effect = [ @@ -261,15 +272,16 @@ def test_prepare_retries_only_the_load_after_a_load_failure(): with pytest.raises(RuntimeError, match="transient load failure"): binding.prepare() - # Registration succeeded (that's why .backend is set); the load did not. - # A binding in this state must not look "already prepared". - assert binding.backend is backend + # The failed load's own registration was rolled back: no half-registered + # state survives for a caller to mistake as usable. + assert binding.backend is None + backend.remove_adapter.assert_called_once_with(binding.qualified_name) with pytest.raises(RuntimeError, match="prepare"): binding.activate() - binding.prepare() # retry: must not re-register, must retry the load + binding.prepare() # retry: re-registers (rolled back above) and retries the load - backend.add_adapter.assert_called_once() + assert backend.add_adapter.call_count == 2 assert backend.load_peft_adapter.call_count == 2 binding.activate() backend.activate_peft_adapter.assert_called_once_with(binding.qualified_name) diff --git a/test/backends/test_adapters/test_local_file_integration.py b/test/backends/test_adapters/test_local_file_integration.py index 663353eec3..019b635d9e 100644 --- a/test/backends/test_adapters/test_local_file_integration.py +++ b/test/backends/test_adapters/test_local_file_integration.py @@ -270,5 +270,49 @@ def test_add_adapter_rejects_binding_already_bound_to_a_different_backend(): assert backend_b._find_adapter("answerability") is None +def test_add_adapter_does_not_hold_activation_lock_during_prepare(): + """`add_adapter`'s composed-`LocalFileBinding` branch must not hold the + activation lock while `binding.prepare()` runs. + + Regression: `prepare()` acquires `binding._lifecycle_lock` first, then + (internally, around the weights load) the backend's activation lock — + lifecycle-then-activation. If `add_adapter` held the activation lock + across the whole call to `prepare()`, that's activation-then-lifecycle, + the opposite order — a classic AB-BA deadlock against a concurrent + `prepare()`/`release()` call on the same binding from another thread. + """ + backend = _make_backend() + binding = _make_binding() + adapter = _make_adapter(binding) + + lock_held_during_prepare = [] + orig_prepare = LocalFileBinding.prepare + + def spying_prepare(self): + lock_held_during_prepare.append(backend._generation_lock._is_owned()) + return orig_prepare(self) + + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ), + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), + patch.object(LocalFileBinding, "prepare", spying_prepare), + ): + backend.add_adapter(adapter) + + assert lock_held_during_prepare == [False], ( + "add_adapter must not hold the activation lock while prepare() runs" + ) + # The registration itself must still have succeeded normally. + assert backend._find_adapter("answerability") is adapter + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index c94ba8f093..da9ba58c63 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -758,12 +758,19 @@ def test_resolve_adapter_survives_reentrant_activation_lock(): assert result.identity.name == "answerability" -def test_resolve_adapter_holds_activation_lock_during_lora_registration(): - """resolve_adapter's single-adapter (LORA) path must run inside `_adapter_activation_lock()`. +def test_resolve_adapter_holds_resolve_lock_during_lora_registration(): + """resolve_adapter's single-adapter (LORA) path must run inside `_adapter_resolve_lock()`. Issue #1562: `add_adapter()` is an unguarded read-then-write on - `_added_adapters`; every other verb that touches it already holds this - lock (#1465). Pin the lock's use here so it can't regress silently. + `_added_adapters`; every other verb that touches it already holds a lock + (#1465). Pin the lock's use here so it can't regress silently. + + Not `_adapter_activation_lock()` (Epic #929, issue #1144): that lock is + taken *inside* `add_adapter()`'s composed-`LocalFileBinding` branch and + inside every `LocalFileBinding` lifecycle verb, so holding it across this + whole discover-and-register call would invert the adapter lock order and + risk deadlocking against a concurrent `prepare()`/`release()`. See + `_adapter_resolve_lock()`'s docstring for the full lock order. """ mock_catalog_entry = IntrinsicsCatalogEntry( name="answerability", @@ -777,11 +784,11 @@ def test_resolve_adapter_holds_activation_lock_during_lora_registration(): mock_backend._added_adapters = {} tracking_lock = _TrackingLock() - mock_backend._adapter_activation_lock.return_value = tracking_lock + mock_backend._adapter_resolve_lock.return_value = tracking_lock def fake_add_adapter(a): assert tracking_lock.enter_count == 1 and tracking_lock.exit_count == 0, ( - "add_adapter must run while the activation lock is held" + "add_adapter must run while the resolve lock is held" ) mock_backend._added_adapters[_composed_adapter_key(a)] = a @@ -799,20 +806,21 @@ def fake_add_adapter(a): assert tracking_lock.enter_count == 1 assert tracking_lock.exit_count == 1 assert tracking_lock.filter_restored_at_exit == [True], ( - "warnings.catch_warnings() must be nested inside the activation lock " + "warnings.catch_warnings() must be nested inside the resolve lock " "(lock outermost) so its filter restoration runs before the lock " "releases — swapping the nesting order reopens the pre-existing " "filter-restoration race between concurrent first-time resolves" ) -def test_resolve_adapter_holds_activation_lock_once_across_embedded_loop(): - """The embedded-adapter loop (#1018) must hold the lock across all iterations. +def test_resolve_adapter_holds_resolve_lock_once_across_embedded_loop(): + """The embedded-adapter loop (#1018) must hold the resolve lock across all iterations. `resolve_adapter()` calls `add_adapter()` once per adapter discovered by `EmbeddedIntrinsicAdapter.from_source()`. The lock must be acquired once for the whole loop, not re-acquired per adapter (that would reopen the - race between iterations). + race between iterations). `_adapter_resolve_lock()`, not + `_adapter_activation_lock()` — see the lock-order note on the latter. """ with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) @@ -828,7 +836,8 @@ def test_resolve_adapter_holds_activation_lock_once_across_embedded_loop(): mock_backend._added_adapters = {} tracking_lock = _TrackingLock() - mock_backend._adapter_activation_lock.return_value = tracking_lock + mock_backend._adapter_resolve_lock.return_value = tracking_lock + mock_backend._supports_composed_adapters = True def fake_add_adapter(a, config=None): mock_backend._added_adapters[_composed_adapter_key(a)] = a @@ -867,7 +876,7 @@ def test_resolve_adapter_concurrent_first_use_does_not_double_register(): check (`existing = registry.get(qualified_name); ...; registry[key] = adapter`) with an injected delay between the read and the write, which makes the unguarded race deterministic: without - `_adapter_activation_lock()` serializing the two threads, both would + `_adapter_resolve_lock()` serializing the two threads, both would read `existing is None` before either writes, and both would overwrite the registry entry independently rather than one of them reusing the other's registration. @@ -887,7 +896,7 @@ def test_resolve_adapter_concurrent_first_use_does_not_double_register(): mock_backend.base_model_name = "ibm-granite/granite-4.1-3b" mock_backend._uses_embedded_adapters = False mock_backend._added_adapters = registry - mock_backend._adapter_activation_lock.return_value = real_lock + mock_backend._adapter_resolve_lock.return_value = real_lock mock_backend._find_adapter.side_effect = lambda cap, types=None: ( AdapterMixin._find_adapter(mock_backend, cap, types) ) @@ -960,24 +969,33 @@ def call(index: int) -> None: ) -def test_add_embedded_adapter_compat_falls_back_to_shim_for_legacy_add_adapter(): - """A third-party `AdapterMixin` subclass predating `config=` (Epic #929, - issue #1144) must not break when `resolve_adapter()`'s embedded branch - tries to register a discovered adapter. - - Regression: `_uses_embedded_adapters` predates the `config` parameter - `add_adapter` gained in this PR — a legacy `add_adapter(self, adapter)` - override (no `config` parameter, no `**kwargs`) raised - `TypeError: add_adapter() got an unexpected keyword argument 'config'` - when `resolve_adapter()` called `self.add_adapter(a, config=config)` - directly. +def test_add_embedded_adapter_compat_falls_back_to_shim_when_not_supported(): + """A third-party `AdapterMixin` subclass predating composed Adapters + (Epic #929, issue #1144) must not break when `resolve_adapter()`'s + embedded branch tries to register a discovered adapter. + + Regression: `_uses_embedded_adapters` predates the composed-`Adapter` + contract — a legacy `add_adapter` only knows the shim's attribute shape + (`.qualified_name`/`.config`/`.technology`), not a composed `Adapter`'s + (`.identity`/`.io_contract`/`.weights`). The compat method is gated on + the explicit `_supports_composed_adapters` opt-in, not on whether + `add_adapter` merely *accepts* a `config=` keyword — signature inspection + can't tell "accepts the keyword" apart from "understands the object + shape", and a subclass with an unrelated `**kwargs` catch-all would + otherwise be handed a composed `Adapter` its body can't read, raising + `AttributeError` rather than the more diagnosable `TypeError` a bare + signature mismatch would. """ registered = [] - def legacy_add_adapter(adapter): + def legacy_add_adapter(adapter, **kwargs): + # Even a **kwargs catch-all (unrelated to config=) must not fool the + # compat method into thinking this body understands a composed + # Adapter — only _supports_composed_adapters governs that. registered.append(adapter) mock_backend = MagicMock(spec=AdapterMixin) + mock_backend._supports_composed_adapters = False mock_backend.add_adapter = legacy_add_adapter composed = Adapter( @@ -1000,16 +1018,17 @@ def legacy_add_adapter(adapter): assert shim.config == {"parameters": {}} -def test_add_embedded_adapter_compat_passes_config_directly_for_modern_add_adapter(): - """A backend whose `add_adapter` accepts `config=` gets the composed - `Adapter` directly — the deprecated-shim fallback is only for a legacy - override that cannot accept it.""" +def test_add_embedded_adapter_compat_passes_composed_adapter_when_supported(): + """A backend that declares `_supports_composed_adapters = True` gets the + composed `Adapter` directly — the deprecated-shim fallback is only for a + subclass that hasn't opted in.""" calls = [] def modern_add_adapter(adapter, *, config=None): calls.append((adapter, config)) mock_backend = MagicMock(spec=AdapterMixin) + mock_backend._supports_composed_adapters = True mock_backend.add_adapter = modern_add_adapter composed = Adapter( From 3a893290e9c0aefb17bd1f8733d89f62a2e0e9ac Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Sun, 6 Sep 2026 20:11:45 +0100 Subject: [PATCH 16/19] fix(backends): fix four regressions Codex found in the round-2 lock redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 in this same area: Codex re-reviewed the round-2 deadlock/compat fix and found four new BLOCKERs plus a WARNING, all independently verified against source and reproduced by an Opus subagent (including a 12-thread stress harness confirming no deadlock) before implementing anything. - BLOCKER: resolve_adapter()'s LocalFile/LORA branch (the default, non-embedded path — hit on every ordinary catalog resolution, not an edge case) called self.add_adapter() with a bare composed Adapter unconditionally, with no _supports_composed_adapters check. Only the embedded branch had one. A legacy AdapterMixin subclass now gets the same IntrinsicAdapter shim v0.7.0 passed here, gated the same way. - BLOCKER: the _composed_registrations_in_flight claim-set added in round 2 made a concurrent resolve_adapter() for the same capability see an in-progress direct add_adapter() as a permanent refusal, then raise KeyError — even though the in-progress registration was about to succeed. Deleted it: the composed-LocalFileBinding branch now holds _adapter_resolve_lock() (reentrant, outermost in the lock order) across its own I/O and lifecycle-verb calls, so a concurrent caller for the same key blocks until it commits instead of racing a partial claim. - BLOCKER: a gap between prepare() returning and the registration commit held no lock at all, so a concurrent binding.release() could release the binding in that window before the composed Adapter wrapping it was published — discoverable via _find_adapter() but permanently unusable. Added LocalFileBinding.hold_prepared(), which takes the binding's own lifecycle lock and re-checks it's still genuinely prepared immediately before the commit. - WARNING (silent wrong-weights risk): load_peft_adapter recorded _loaded_adapters after calling self._model.set_adapter([]), not before. A set_adapter([]) failure after a successful load_adapter() left PEFT holding an adapter's weights with no matching Mellea-level record, bypassing remove_adapter()'s still-loaded guard and letting a later same-name registration silently keep serving the orphaned weights under the new registration's identity. Reordered the record before the call, and made prepare()'s failure rollback unload PEFT state before removing the registration (mirroring release()'s own unload-then-remove order). Verified: full suite 4348 passed, 0 failed; ruff format/check and mypy clean. Added regression tests for each: the legacy-shim fallback on the LORA branch, a two-thread test proving a concurrent resolve blocks and succeeds rather than raising KeyError, a test proving hold_prepared refuses to publish around a binding released out from under it, and two tests proving the PEFT-state-consistency fix (both the _loaded_adapters ordering and the rollback's unload-then-remove order). Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/_core.py | 42 ++++ mellea/backends/adapters/adapter.py | 50 +++-- mellea/backends/huggingface.py | 205 +++++++++--------- .../test_adapters/test_local_file_binding.py | 34 +++ .../test_local_file_integration.py | 103 +++++++++ test/backends/test_adapters/test_shims.py | 55 +++++ test/backends/test_huggingface_unit.py | 33 +++ 7 files changed, 402 insertions(+), 120 deletions(-) diff --git a/mellea/backends/adapters/_core.py b/mellea/backends/adapters/_core.py index cdd0485e7e..40e45ae3cf 100644 --- a/mellea/backends/adapters/_core.py +++ b/mellea/backends/adapters/_core.py @@ -33,10 +33,12 @@ """ import abc +import contextlib import json import threading import time import warnings +from collections.abc import Iterator from dataclasses import dataclass from typing import TYPE_CHECKING, Any, ClassVar, Literal @@ -570,6 +572,15 @@ def _rollback_registration(self) -> None: assert backend is not None try: with backend._adapter_activation_lock(): + # Unload before removing, mirroring release()'s own + # unload-then-remove sequence: `remove_adapter()` refuses a + # still-loaded name, and `load_peft_adapter()` now records the + # load before its own `set_adapter([])` call, so a + # `set_adapter([])` failure there leaves a real PEFT-level + # load for this to undo. Already a no-op (logs and returns) + # when nothing was actually loaded — the ordinary case, where + # `load_adapter()` itself is what failed. + backend.unload_peft_adapter(self.qualified_name) backend.remove_adapter(self.qualified_name) except NotImplementedError: # State the observable fact, not the presumed cause: a real @@ -586,6 +597,37 @@ def _rollback_registration(self) -> None: self.backend = None self.path = None + @contextlib.contextmanager + def hold_prepared(self, backend: "AdapterMixin") -> Iterator[None]: + """Holds this binding's lifecycle lock while a caller publishes it. + + Used by a composed `Adapter`'s registration (`LocalHFBackend.add_adapter`) + to commit its registry entries only while this binding is still + genuinely prepared for `backend`. Blocks `prepare()`/`release()` for + the duration, so a composed `Adapter` can never be published wrapping + a binding that a concurrent `release()` — running in the gap after + `prepare()` returned but before the caller's commit — has already + made terminal. + + Args: + backend: The backend the caller is about to publish this binding + as registered with. Must match `self.backend`. + + Raises: + RuntimeError: This binding is no longer prepared for `backend` + (released, unloaded, or reassigned since the caller's own + `prepare()` call returned). + """ + with self._lifecycle_lock: + if self._released or not self._loaded or self.backend is not backend: + raise RuntimeError( + f"LocalFileBinding {self.qualified_name!r} is no longer " + f"prepared for {type(backend).__name__} (concurrent " + "release()?); refusing to publish a composed Adapter " + "around it." + ) + yield + def activate(self) -> None: """Selects already-loaded adapter weights for generation. diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index ab71c7fbe1..1c71ab1cca 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -882,25 +882,41 @@ def resolve_adapter(self, name: str) -> _AdapterCore: # AdapterType.LORA is the pre-Phase-1 default (mirrors old _util.py). # Every current catalog entry supports LORA. Phase 2 (see epic #929) # will select the type from catalog availability instead of hardcoding. - # Composed Adapter, not the deprecated IntrinsicAdapter shim - # (Epic #929, issue #1144). metadata = fetch_intrinsic_metadata(name) - self.add_adapter( - _AdapterCore( - identity=Identity( - name=name, - adapter_type="lora", - capability=metadata.effective_capability, - ), - io_contract=get_io_contract(name), - weights=LocalFileBinding( - name=name, - adapter_type=AdapterType.LORA, - repo_id=metadata.repo_id, - revision=metadata.revision, - ), + if self._supports_composed_adapters: + # Composed Adapter, not the deprecated IntrinsicAdapter shim + # (Epic #929, issue #1144). + self.add_adapter( + _AdapterCore( + identity=Identity( + name=name, + adapter_type="lora", + capability=metadata.effective_capability, + ), + io_contract=get_io_contract(name), + weights=LocalFileBinding( + name=name, + adapter_type=AdapterType.LORA, + repo_id=metadata.repo_id, + revision=metadata.revision, + ), + ) + ) + else: + # Legacy subclass predating the composed-Adapter contract + # (same reasoning as _add_embedded_adapter_compat): its + # add_adapter only knows the deprecated IntrinsicAdapter + # shim's attribute shape, not a composed Adapter's. This + # is exactly what this branch passed before Epic #929, + # issue #1144 — IntrinsicAdapter is dual-shaped (both + # LocalHFAdapter, for `.qualified_name`/`.backend`/`.path`, + # and _AdapterCore, so `_find_adapter` below and this + # method's own `-> _AdapterCore` return type still hold). + self.add_adapter( + IntrinsicAdapter( + name, adapter_type=AdapterType.LORA, base_model_name=base + ) ) - ) found = self._find_adapter(name) if found is not None: diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index e11f806e84..4ceb77ac9e 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -588,14 +588,6 @@ def __init__( same-thread-reentry reason `_generation_lock` is. """ - # Keys claimed by an in-flight composed LocalFile registration in - # add_adapter(). The slow part (io.yaml fetch, weights download, PEFT - # load) must not run under `_adapter_activation_lock()` — see that - # method's lock-order note — so this set keeps the - # duplicate-registration guard exclusive across that unlocked window - # instead. - self._composed_registrations_in_flight: set[str] = set() - if load_embedded_adapters: self.register_embedded_adapter_model(self._adapter_source or self._model_id) @@ -2690,87 +2682,85 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N key = _composed_adapter_key(adapter) - # Phase 1: claim the key. Under the activation lock, but no I/O - # and no WeightsBinding lifecycle verb — see the lock-order note - # on `_adapter_activation_lock()`. Never held across `prepare()`: - # that method takes `binding._lifecycle_lock` then, internally, - # this same activation lock — holding the latter here across - # `prepare()` would acquire them in the opposite order and - # deadlock against a concurrent `prepare()`/`release()` on the - # same binding (from another thread, or via a direct call on the - # binding itself). - with self._adapter_activation_lock(): - # A LocalFileBinding registered standalone (bare - # add_adapter(binding)) lands in _added_adapters under this - # same key — that's not a collision if it's the very binding - # this composed Adapter wraps, only if some *other* object - # already claimed the name. `_composed_registrations_in_flight` - # is the same guard for a LocalFileBinding registration - # that's between phases (its I/O/lifecycle work isn't done - # under this lock, so the dicts alone can't tell a concurrent - # caller "already claimed"). - existing_added = self._added_adapters.get(key) - if ( - ( + # Held across the whole branch, including the unlocked-looking + # I/O and lifecycle-verb calls below: `_adapter_resolve_lock()` is + # outermost in the adapter lock order + # (resolve -> lifecycle -> activation), so a caller here may + # freely acquire `binding._lifecycle_lock` (via bind_backend()/ + # prepare()) and, inside that, the activation lock, without + # inverting anything. A concurrent caller for the same key — + # whether a direct add_adapter() call or resolve_adapter()'s own + # construction of a fresh composed Adapter — now simply blocks + # here until this registration finishes, rather than racing a + # partially-visible state (this replaces an earlier + # `_composed_registrations_in_flight` claim-set design, which + # made a concurrent resolve_adapter() see the claim as a + # permanent refusal and raise a spurious KeyError instead of + # waiting). Reentrant, so resolve_adapter()'s own hold of this + # same lock is a same-thread no-op, not double-blocking. + with self._adapter_resolve_lock(): + with self._adapter_activation_lock(): + # A LocalFileBinding registered standalone (bare + # add_adapter(binding)) lands in _added_adapters under + # this same key — that's not a collision if it's the very + # binding this composed Adapter wraps, only if some + # *other* object already claimed the name. + existing_added = self._added_adapters.get(key) + if ( existing_added is not None and existing_added is not adapter.weights - ) - or key in self._composed_adapters - or key in self._composed_registrations_in_flight - ): - MelleaLogger.get_logger().warning( - f"Client code attempted to add {adapter.identity.name} with " - f"type {adapter.identity.adapter_type} but {key!r} is already " - f"registered on {self.__class__.__name__}. The backend is " - "refusing to do this, because adapter loading is not idempotent." - ) - return - if isinstance(adapter.weights, EmbeddedBinding): - assert config is not None # validated above - adapter.weights.source = self.base_model_name - self._composed_adapters[key] = adapter - self._composed_adapter_configs[key] = config - return # atomic: no lifecycle verb, no I/O - if not isinstance(adapter.weights, LocalFileBinding): - raise TypeError( - "LocalHFBackend does not support the " - f"{type(adapter.weights).__name__} weights reality for a " - "composed Adapter." - ) - binding = adapter.weights - if ( - adapter.identity.name != binding.name - or adapter.identity.adapter_type != binding.adapter_type.value - ): - raise ValueError( - f"Composed Adapter has identity.name={adapter.identity.name!r}/" - f"adapter_type={adapter.identity.adapter_type!r} but " - f"weights.name={binding.name!r}/" - f"adapter_type={binding.adapter_type.value!r}; the two " - "must agree on both, or registration and lookup key on " - "different strings for the same adapter (NOTE(#1516))." - ) - self._composed_registrations_in_flight.add(key) - - # Phase 2: do the slow, unlocked work. No backend lock held here — - # that's what makes the nested lifecycle-lock acquisitions inside - # `bind_backend()`/`prepare()` legal under the adapter lock order - # (resolve -> lifecycle -> activation). The two calls are - # sequential, not nested: `binding._lifecycle_lock` is a plain - # `threading.Lock`, not reentrant. - try: - # Fetched before bind_backend()/prepare() below, not after: a - # failed fetch here leaves nothing registered (the in-flight - # claim aside), so the same composed Adapter can simply be - # retried. Fetching it after prepare() (which this used to - # do) left a real, registered LocalFileBinding with no - # matching _composed_adapters entry on failure — a later - # resolve_adapter()/add_adapter() for the same name then hit - # the duplicate-key guard above and permanently refused to - # (re)register the capability. `prepare()` itself closes the - # equivalent gap for its own weights-load failure (see its - # docstring's rollback note) — this ordering only has to - # cover the fetch this method adds ahead of it. + ) or key in self._composed_adapters: + MelleaLogger.get_logger().warning( + f"Client code attempted to add {adapter.identity.name} " + f"with type {adapter.identity.adapter_type} but {key!r} " + f"is already registered on {self.__class__.__name__}. " + "The backend is refusing to do this, because adapter " + "loading is not idempotent." + ) + return + if isinstance(adapter.weights, EmbeddedBinding): + assert config is not None # validated above + adapter.weights.source = self.base_model_name + self._composed_adapters[key] = adapter + self._composed_adapter_configs[key] = config + return # atomic: no lifecycle verb, no I/O + if not isinstance(adapter.weights, LocalFileBinding): + raise TypeError( + "LocalHFBackend does not support the " + f"{type(adapter.weights).__name__} weights reality " + "for a composed Adapter." + ) + binding = adapter.weights + if ( + adapter.identity.name != binding.name + or adapter.identity.adapter_type != binding.adapter_type.value + ): + raise ValueError( + f"Composed Adapter has identity.name={adapter.identity.name!r}/" + f"adapter_type={adapter.identity.adapter_type!r} but " + f"weights.name={binding.name!r}/" + f"adapter_type={binding.adapter_type.value!r}; the two " + "must agree on both, or registration and lookup key on " + "different strings for the same adapter (NOTE(#1516))." + ) + + # Slow work, still under the resolve lock but not the + # activation lock: released above, so bind_backend()/ + # prepare()'s own lifecycle-then-activation acquisitions + # below don't nest inside it. + # + # Fetched before bind_backend()/prepare(), not after: a + # failed fetch here leaves nothing registered, so the same + # composed Adapter can simply be retried. Fetching it after + # prepare() (which this used to do) left a real, registered + # LocalFileBinding with no matching _composed_adapters entry + # on failure — a later resolve_adapter()/add_adapter() for + # the same name then hit the duplicate-key guard above and + # permanently refused to (re)register the capability. + # `prepare()` itself closes the equivalent gap for its own + # weights-load failure (see its docstring's rollback note) — + # this ordering only has to cover the fetch this method adds + # ahead of it. io_yaml_config = self._obtain_local_file_io_yaml_config(binding) # bind_backend() itself raises if binding.backend is already # a *different* backend, and no-ops if it's already self — @@ -2787,23 +2777,23 @@ def add_adapter(self, adapter: AdapterInput, *, config: dict | None = None) -> N # `LocalFileBinding.prepare()`'s own docstring/comments for # why that call is there. On a weights-load failure inside # `prepare()`, that same docstring's rollback note applies: - # the binding un-registers itself, so this method's own - # in-flight claim (released in the `except` below) is the - # only remaining state to clean up. + # the binding un-registers itself, leaving nothing here to + # clean up beyond letting the exception propagate (this + # resolve-lock hold releases normally on the way out). binding.prepare() - except BaseException: - with self._adapter_activation_lock(): - self._composed_registrations_in_flight.discard(key) - raise - # Phase 3: commit and release the claim in one acquisition. Not - # split into two: a gap between them would let another thread - # claim a key whose registration has already succeeded, then get - # refused at phase 1 against a half-visible state. - with self._adapter_activation_lock(): - self._composed_adapter_configs[key] = io_yaml_config - self._composed_adapters[key] = adapter - self._composed_registrations_in_flight.discard(key) + # Commit under the binding's own lifecycle lock, then the + # activation lock (`hold_prepared` takes lifecycle; + # resolve -> lifecycle -> activation is still the order) — + # not just the activation lock alone: prepare() above + # released `_lifecycle_lock` on return, and without + # re-acquiring it here, a concurrent `binding.release()` + # could run in the gap between prepare() returning and this + # commit, releasing (terminal, weights unloaded) the exact + # binding this is about to publish as registered. + with binding.hold_prepared(self), self._adapter_activation_lock(): + self._composed_adapter_configs[key] = io_yaml_config + self._composed_adapters[key] = adapter return if adapter.backend is not None: @@ -2970,12 +2960,21 @@ def load_peft_adapter(self, adapter_qualified_name: str) -> None: ): raise e + # Recorded before set_adapter([]) below, not after: from this point on + # PEFT holds this adapter's weights loaded, so remove_adapter()'s + # still-loaded guard must be able to see that — otherwise a + # set_adapter([]) failure here leaves PEFT holding weights that + # _loaded_adapters never records, bypassing that guard and letting a + # later same-name registration silently serve these stale weights + # under the new registration's identity (PEFT's own "already exists" + # error at the `self._model.load_adapter` call above is swallowed). + self._loaded_adapters[adapter.qualified_name] = adapter + # Loading an adapter activates it. We disable adapters immediately after. # Prefer this over `.disable_adapters()`; the disable function doesn't always # seem to work. self._model.set_adapter([]) # self._model.disable_adapters() - self._loaded_adapters[adapter.qualified_name] = adapter def unload_peft_adapter(self, adapter_qualified_name: str) -> None: """Unload a previously loaded adapter from the underlying Hugging Face model. diff --git a/test/backends/test_adapters/test_local_file_binding.py b/test/backends/test_adapters/test_local_file_binding.py index 41d17f7be8..eec7832e8e 100644 --- a/test/backends/test_adapters/test_local_file_binding.py +++ b/test/backends/test_adapters/test_local_file_binding.py @@ -631,5 +631,39 @@ def test_release_does_not_fire_phase_complete_metric(): mock_run.assert_not_called() +def test_prepare_rollback_unloads_peft_before_removing(): + """`_rollback_registration` must unload PEFT state before removing the + registration, mirroring `release()`'s own unload-then-remove order. + + Regression: rollback previously called only `remove_adapter()`, never + `unload_peft_adapter()`. If PEFT itself ends up holding an adapter's + weights loaded (`load_adapter()` succeeded) even though the load as a + whole failed (a later step, e.g. `set_adapter([])`, raised), that left + PEFT holding weights under a name Mellea's own bookkeeping no longer + tracks — invisible until a later same-name registration's own load call + interacted with whatever state PEFT was actually in. + """ + backend = _fake_backend() + backend.load_peft_adapter.side_effect = RuntimeError("load-adjacent failure") + + calls: list[tuple[str, str]] = [] + backend.unload_peft_adapter.side_effect = lambda name: calls.append( + ("unload", name) + ) + backend.remove_adapter.side_effect = lambda name: calls.append(("remove", name)) + + binding = LocalFileBinding(name="answerability") + binding.bind_backend(backend) + + with pytest.raises(RuntimeError, match="load-adjacent failure"): + binding.prepare() + + assert calls == [ + ("unload", binding.qualified_name), + ("remove", binding.qualified_name), + ], "rollback must unload before removing, not remove alone" + assert binding.backend is None + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/test/backends/test_adapters/test_local_file_integration.py b/test/backends/test_adapters/test_local_file_integration.py index 019b635d9e..161a827bba 100644 --- a/test/backends/test_adapters/test_local_file_integration.py +++ b/test/backends/test_adapters/test_local_file_integration.py @@ -13,6 +13,7 @@ the spans from a plugin). """ +import threading from unittest.mock import MagicMock, mock_open, patch import pytest @@ -314,5 +315,107 @@ def spying_prepare(self): assert backend._find_adapter("answerability") is adapter +def test_concurrent_resolve_waits_for_in_progress_direct_registration(): + """A concurrent `resolve_adapter()` racing an in-progress direct + `add_adapter()` for the same capability must wait for it, not fail. + + Regression: an earlier design used a `_composed_registrations_in_flight` + claim-set to guard the unlocked window between claiming a key and + committing it. A concurrent caller for the same key saw the claim as a + permanent refusal — `add_adapter` returned silently, and the caller + (`resolve_adapter`, checking `_find_adapter` afterwards) raised + `KeyError`, even though the in-progress registration succeeded a moment + later and would have satisfied the request. Holding + `_adapter_resolve_lock()` across the whole registration (not just a + claim-set) makes a concurrent caller block until it commits, instead of + observing a "claimed but not yet found" state at all. + """ + backend = _make_backend() + binding = _make_binding() + adapter = _make_adapter(binding) + + reached_slow_section = threading.Event() + release_thread_a = threading.Event() + + def slow_obtain_io_yaml(*args, **kwargs): + reached_slow_section.set() + assert release_thread_a.wait(timeout=5), "thread B never released thread A" + return "/fake/adapter.yaml" + + errors: list[tuple[str, Exception]] = [] + resolved: dict[str, object] = {} + + def thread_a_direct_add_adapter(): + try: + with ( + patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ), + patch( + "mellea.formatters.granite.intrinsics.obtain_io_yaml", + side_effect=slow_obtain_io_yaml, + ), + patch("builtins.open", mock_open(read_data="key: value")), + patch("yaml.safe_load", return_value={"parameters": {}}), + ): + backend.add_adapter(adapter) + except Exception as e: # pragma: no cover - surfaced via errors list + errors.append(("A", e)) + + def thread_b_resolve(): + assert reached_slow_section.wait(timeout=5), ( + "thread A never reached the locked slow section" + ) + release_thread_a.set() + try: + resolved["b"] = backend.resolve_adapter("answerability") + except Exception as e: # pragma: no cover - surfaced via errors list + errors.append(("B", e)) + + t_a = threading.Thread(target=thread_a_direct_add_adapter, daemon=True) + t_b = threading.Thread(target=thread_b_resolve, daemon=True) + t_a.start() + t_b.start() + t_a.join(timeout=10) + t_b.join(timeout=10) + + assert not t_a.is_alive(), "thread A did not finish within the join timeout" + assert not t_b.is_alive(), "thread B did not finish within the join timeout" + assert not errors, f"resolve_adapter/add_adapter raised under concurrency: {errors}" + assert resolved["b"] is adapter, ( + "the concurrent resolve must return the in-progress registration's " + "own adapter, not fail or construct a competing one" + ) + + +def test_hold_prepared_raises_if_binding_released_before_publish(): + """`LocalFileBinding.hold_prepared()` must refuse to let a caller publish + a composed Adapter around a binding a concurrent `release()` has already + made terminal. + + Regression: `add_adapter`'s composed-`LocalFileBinding` branch used to + have an unguarded window between `prepare()` returning and the commit of + `_composed_adapters[key] = adapter` — nothing prevented a concurrent + `binding.release()` from running in that gap. `_find_adapter()` would + then report the published adapter as registered, but `activate()` on it + would raise, since the binding backing it was already released. + """ + backend = _make_backend() + binding = _make_binding() + + with patch( + "mellea.formatters.granite.intrinsics.obtain_lora", + return_value="/fake/local/adapter/path", + ): + binding.bind_backend(backend) + binding.prepare() + binding.release() + + with pytest.raises(RuntimeError, match="no longer prepared"): + with binding.hold_prepared(backend): + pytest.fail("hold_prepared must raise before yielding") + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/test/backends/test_adapters/test_shims.py b/test/backends/test_adapters/test_shims.py index da9ba58c63..b781bd7c58 100644 --- a/test/backends/test_adapters/test_shims.py +++ b/test/backends/test_adapters/test_shims.py @@ -1042,3 +1042,58 @@ def modern_add_adapter(adapter, *, config=None): ) assert calls == [(composed, {"parameters": {}})] + + +def test_resolve_adapter_lora_branch_falls_back_to_shim_when_not_supported(): + """resolve_adapter's LocalFile/LORA branch (the default, non-embedded + path — hit on every ordinary catalog resolution) must gate on + `_supports_composed_adapters` too, not just the embedded branch. + + Regression: only `_add_embedded_adapter_compat` (the embedded branch) + checked `_supports_composed_adapters`. The LORA branch — the common + case, since it's what runs whenever `_uses_embedded_adapters` is unset — + called `self.add_adapter(_AdapterCore(...))` unconditionally. A + third-party AdapterMixin subclass predating composed Adapters, whose + add_adapter reads `.qualified_name`, breaks on essentially every + resolve_adapter() call, not just embedded discovery. + """ + mock_catalog_entry = IntrinsicsCatalogEntry( + name="answerability", + repo_id="ibm-granite/granitelib-rag-r1.0", + revision="abc123", + adapter_types=(AdapterType.ALORA, AdapterType.LORA), + ) + registered = [] + + def legacy_add_adapter(adapter): + registered.append(adapter) + adapter.backend = mock_backend + mock_backend._added_adapters[adapter.qualified_name] = adapter + + mock_backend = MagicMock(spec=AdapterMixin) + mock_backend.base_model_name = "ibm-granite/granite-4.1-3b" + mock_backend._uses_embedded_adapters = False + mock_backend._supports_composed_adapters = False + mock_backend._added_adapters = {} + mock_backend.add_adapter = legacy_add_adapter + mock_backend._find_adapter.side_effect = lambda cap, types=None: ( + AdapterMixin._find_adapter(mock_backend, cap, types) + ) + + with ( + patch( + "mellea.backends.adapters.adapter.fetch_intrinsic_metadata", + return_value=mock_catalog_entry, + ), + patch( + "mellea.backends.adapters.adapter.intrinsics.obtain_io_yaml", + return_value="/fake/adapter.yaml", + ), + patch("builtins.open", mock_open(read_data="key: value")), + ): + result = AdapterMixin.resolve_adapter(mock_backend, "answerability") + + assert len(registered) == 1 + assert isinstance(registered[0], IntrinsicAdapter) + assert registered[0].qualified_name == "answerability_lora" + assert result is registered[0] diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index 5a258b1006..47f010a2d4 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -1960,6 +1960,39 @@ def test_remove_adapter_raises_if_still_loaded(): assert adapter.qualified_name not in backend._added_adapters +def test_load_peft_adapter_records_loaded_state_before_set_adapter_failure(): + """`_loaded_adapters` must be recorded before `set_adapter([])`, not after. + + Regression: `_loaded_adapters[...] = adapter` used to run *after* + `self._model.set_adapter([])`. If `self._model.load_adapter(...)` + succeeded (PEFT now holds the weights) but the subsequent + `set_adapter([])` call raised, `_loaded_adapters` was never updated — + which meant `remove_adapter()`'s still-loaded guard couldn't see the + load, `_rollback_registration()` could remove the registration cleanly, + and PEFT was left holding the adapter's weights under a name Mellea's + own bookkeeping no longer tracked. The *next* registration under that + name then hit PEFT's own "already exists" error — silently swallowed by + `load_peft_adapter()` — and appeared to succeed while actually serving + the old, orphaned weights. + """ + backend = _make_backend() + adapter = _make_intrinsic_adapter_stub() + adapter.backend = None + adapter.get_local_hf_path = lambda base_model_name: "/fake/path" + backend.add_adapter(adapter) + backend._model.set_adapter.side_effect = RuntimeError("PEFT set_adapter failure") # type: ignore[union-attr] + + with pytest.raises(RuntimeError, match="PEFT set_adapter failure"): + backend.load_peft_adapter(adapter.qualified_name) + + # The load itself succeeded (load_adapter was called); the bookkeeping + # must reflect that even though the call as a whole raised, so a later + # remove_adapter() correctly refuses to free a name PEFT still holds. + assert adapter.qualified_name in backend._loaded_adapters + with pytest.raises(ValueError, match="still loaded"): + backend.remove_adapter(adapter.qualified_name) + + def test_seed_forces_do_sample_true(stub_backend): """Issue #40: a seed alone must flip do_sample=True so it isn't ignored.""" out = _call(stub_backend, {ModelOption.SEED: 42}) From 457e0b1de931810beb0913763d6a327726d1b1d7 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Sun, 6 Sep 2026 21:00:29 +0100 Subject: [PATCH 17/19] docs(backends): document the ADAPTER_FUNCTION_PHASE_COMPLETE hook-reentrancy hazard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's final re-review found one more real issue: LocalFileBinding.prepare() fires this hook via a blocking call onto a dedicated event-loop thread, while resolve_adapter()/register_embedded_adapter_model()/add_adapter()'s composed branch hold _adapter_resolve_lock() across their whole body (added in the previous round's fix for the concurrent-resolve KeyError and release-race bugs). A hook subscriber that synchronously re-enters one of those methods on the same backend deadlocks against the thread still waiting for the hook. No shipped caller does this, and fixing it properly means either making the hook dispatch non-blocking or replacing the single global resolve lock with a per-key wait primitive — both larger changes than this constraint warrants on their own. Documenting it as a known limitation, per agreement with both reviewers (Codex: "mergeable with a tracked follow-up, not a blocker"; Bob independently confirmed the rest of the round-3 fix has no gaps). Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/adapter.py | 13 +++++++++++++ mellea/plugins/hooks/adapter_function.py | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 1c71ab1cca..13c3184b0b 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -751,6 +751,19 @@ def _adapter_resolve_lock(self) -> contextlib.AbstractContextManager[bool | None An override must return a reentrant lock (`threading.RLock`) for the same same-thread-reentry reason `_adapter_activation_lock()` documents. + + Known limitation (tracked in a follow-up issue, not fixed here): a + `HookType.ADAPTER_FUNCTION_PHASE_COMPLETE` subscriber must not + synchronously call `resolve_adapter()`, `register_embedded_adapter_model()`, + or `add_adapter()` with a composed `Adapter` on this same backend. + `LocalFileBinding.prepare()` fires that hook via + `_run_async_in_thread`, which blocks the calling thread on a + dedicated event-loop thread's result — if this lock is held at that + point (as it is for the whole body of the three methods above) and + the hook's handler re-enters one of them on the same backend, the + event-loop thread blocks on this lock while the original thread + blocks waiting for the event-loop thread: deadlock. No shipped + caller does this today. """ return contextlib.nullcontext() diff --git a/mellea/plugins/hooks/adapter_function.py b/mellea/plugins/hooks/adapter_function.py index 896917e3a4..5f43582aca 100644 --- a/mellea/plugins/hooks/adapter_function.py +++ b/mellea/plugins/hooks/adapter_function.py @@ -57,6 +57,19 @@ class AdapterFunctionPhaseCompletePayload(MelleaBasePayload): phase: Lifecycle phase (`"prepare"`, `"activate"`, `"generate"`, `"parse"`, or `"deactivate"`). duration_ms: Wall-clock duration of the phase in milliseconds. + + Warning: + A subscriber to this hook must not synchronously call + `resolve_adapter()`, `register_embedded_adapter_model()`, or + `add_adapter()` with a composed `Adapter` on the same backend that + fired it. `LocalFileBinding.prepare()` dispatches this hook via a + blocking call onto a dedicated event-loop thread; those three + methods hold a backend-level lock across their whole body, so a + handler that re-enters one of them from that thread deadlocks + against the thread that's still waiting for the hook to return. See + `AdapterMixin._adapter_resolve_lock()`'s docstring for the full + mechanism (tracked as a follow-up; not fixed as of Epic #929, issue + #1144). """ name: str From b3cff6ff9ba1069f7fe35a3cffca0adb0c7219e2 Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Sun, 6 Sep 2026 21:21:49 +0100 Subject: [PATCH 18/19] docs(backends): drop follow-up-issue framing from the hook-reentrancy note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No issue is being filed for this: no shipped plugin subscribes to ADAPTER_FUNCTION_PHASE_COMPLETE at all, so the deadlock this documents requires a future plugin author to both subscribe to an unused hook and have it synchronously re-enter adapter registration — not a live risk, just a documented constraint for whoever eventually writes one. Assisted-by: Claude Code Signed-off-by: Nigel Jones --- mellea/backends/adapters/adapter.py | 21 ++++++++++----------- mellea/plugins/hooks/adapter_function.py | 3 +-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 13c3184b0b..935daf1467 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -752,17 +752,16 @@ def _adapter_resolve_lock(self) -> contextlib.AbstractContextManager[bool | None same same-thread-reentry reason `_adapter_activation_lock()` documents. - Known limitation (tracked in a follow-up issue, not fixed here): a - `HookType.ADAPTER_FUNCTION_PHASE_COMPLETE` subscriber must not - synchronously call `resolve_adapter()`, `register_embedded_adapter_model()`, - or `add_adapter()` with a composed `Adapter` on this same backend. - `LocalFileBinding.prepare()` fires that hook via - `_run_async_in_thread`, which blocks the calling thread on a - dedicated event-loop thread's result — if this lock is held at that - point (as it is for the whole body of the three methods above) and - the hook's handler re-enters one of them on the same backend, the - event-loop thread blocks on this lock while the original thread - blocks waiting for the event-loop thread: deadlock. No shipped + Known limitation: a `HookType.ADAPTER_FUNCTION_PHASE_COMPLETE` + subscriber must not synchronously call `resolve_adapter()`, + `register_embedded_adapter_model()`, or `add_adapter()` with a + composed `Adapter` on this same backend. `LocalFileBinding.prepare()` + fires that hook via `_run_async_in_thread`, which blocks the calling + thread on a dedicated event-loop thread's result — if this lock is + held at that point (as it is for the whole body of the three methods + above) and the hook's handler re-enters one of them on the same + backend, the event-loop thread blocks on this lock while the original + thread blocks waiting for the event-loop thread: deadlock. No shipped caller does this today. """ return contextlib.nullcontext() diff --git a/mellea/plugins/hooks/adapter_function.py b/mellea/plugins/hooks/adapter_function.py index 5f43582aca..b70de03f84 100644 --- a/mellea/plugins/hooks/adapter_function.py +++ b/mellea/plugins/hooks/adapter_function.py @@ -68,8 +68,7 @@ class AdapterFunctionPhaseCompletePayload(MelleaBasePayload): handler that re-enters one of them from that thread deadlocks against the thread that's still waiting for the hook to return. See `AdapterMixin._adapter_resolve_lock()`'s docstring for the full - mechanism (tracked as a follow-up; not fixed as of Epic #929, issue - #1144). + mechanism. """ name: str From 0f920b096f912399d042a578c47cfb81ee9e310e Mon Sep 17 00:00:00 2001 From: Nigel Jones Date: Mon, 7 Sep 2026 09:07:27 +0100 Subject: [PATCH 19/19] fix(backends): fix two genuine bugs Codex found in a fresh re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both real, both verified against source before fixing, neither related to the concurrency/locking work of the prior four rounds: - The CLI-generated custom-adapter template built its Intrinsic subclass without intrinsic_kwargs, storing the caller's arguments only via SimpleComponent. _generate_from_intrinsic explicitly never adds the action to the context ("Intrinsics modify the context through their rewriters") — the only path from an Intrinsic's own data to the rewritten request is rewriter.transform(request_json, **action.intrinsic_kwargs). Every user-supplied argument to a generated custom adapter was silently dropped before reaching the model. This bug predates this session's work (present in the human-approved baseline); fixed by also forwarding the arguments as intrinsic_kwargs. - LocalHFBackend.unload_peft_adapter() was a pure bookkeeping-gated no-op: if _loaded_adapters had no entry for a name, it returned without touching the underlying PEFT model at all. But PEFT's load_adapter() injects a name's modules into the model before applying the checkpoint's weights, and _loaded_adapters is only written after load_adapter() returns successfully — so a failure partway through it (e.g. a corrupt/incomplete checkpoint) leaves PEFT holding a real, injected adapter under a name nothing in Mellea's own bookkeeping tracks. prepare()'s rollback then saw "not loaded" and skipped cleanup entirely, and a later registration under the same name hit PEFT's own "already exists" error, silently swallowed by load_peft_adapter(), serving the corrupt weights under the new registration's identity. Fixed by attempting a best-effort direct self._model.delete_adapter() call when bookkeeping has no record, tolerating the ordinary case where PEFT genuinely has nothing registered either. Verified: full suite 4351 passed, 0 failed; ruff format/check and mypy clean. Two new regression tests: one confirming the generated Intrinsic forwards its arguments as intrinsic_kwargs, one confirming unload_peft_adapter attempts real cleanup for a name absent from bookkeeping (plus one confirming it still tolerates PEFT also having nothing registered). Assisted-by: Claude Code Signed-off-by: Nigel Jones --- cli/alora/README_TEMPLATE.jinja | 5 ++++ mellea/backends/huggingface.py | 19 +++++++++++-- test/backends/test_huggingface_unit.py | 39 ++++++++++++++++++++++++++ test/cli/test_alora_readme_template.py | 25 +++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/cli/alora/README_TEMPLATE.jinja b/cli/alora/README_TEMPLATE.jinja index 95d7b0a006..35c8659500 100644 --- a/cli/alora/README_TEMPLATE.jinja +++ b/cli/alora/README_TEMPLATE.jinja @@ -86,6 +86,11 @@ class {{ intrinsic_name_camelcase }}Intrinsic(Intrinsic, SimpleComponent): self, intrinsic_name=_INTRINSIC_ADAPTER_NAME, adapter_types=(AdapterType.ALORA,), + # `_generate_from_intrinsic` never adds `action` to the context — + # intrinsics reach the model only through their rewriter, driven + # by `intrinsic_kwargs` — so these arguments must be passed here, + # not just stored on the SimpleComponent side below. + intrinsic_kwargs=dict({{ arglist_as_kwargs }}), ) SimpleComponent.__init__(self, {{ arglist_as_kwargs }}) diff --git a/mellea/backends/huggingface.py b/mellea/backends/huggingface.py index 4ceb77ac9e..8fe5c6d708 100644 --- a/mellea/backends/huggingface.py +++ b/mellea/backends/huggingface.py @@ -2989,9 +2989,22 @@ def unload_peft_adapter(self, adapter_qualified_name: str) -> None: # Check if the backend knows about this adapter. adapter = self._loaded_adapters.get(adapter_qualified_name, None) if adapter is None: - MelleaLogger.get_logger().info( - f"could not unload adapter {adapter_qualified_name} for backend {self}: adapter is not loaded" - ) + # `_loaded_adapters` is only written after `self._model.load_adapter()` + # returns (see `load_peft_adapter`), but PEFT injects the adapter's + # modules into the model *before* it finishes loading weights — + # `inject_adapter_in_model()` runs, then the checkpoint state dict is + # applied. A failure in the latter step (e.g. a corrupt/incomplete + # checkpoint) leaves PEFT holding a real, injected-but-unloaded + # adapter under this name, with nothing in our own bookkeeping to + # say so. Best-effort direct cleanup covers that gap: harmless if + # PEFT never registered the name either, in which case its own + # `delete_adapter` raises and this logs instead of propagating. + try: + self._model.delete_adapter(adapter_qualified_name) + except ValueError: + MelleaLogger.get_logger().info( + f"could not unload adapter {adapter_qualified_name} for backend {self}: adapter is not loaded" + ) return self._model.delete_adapter(adapter.qualified_name) diff --git a/test/backends/test_huggingface_unit.py b/test/backends/test_huggingface_unit.py index 47f010a2d4..ba25932bba 100644 --- a/test/backends/test_huggingface_unit.py +++ b/test/backends/test_huggingface_unit.py @@ -1993,6 +1993,45 @@ def test_load_peft_adapter_records_loaded_state_before_set_adapter_failure(): backend.remove_adapter(adapter.qualified_name) +def test_unload_peft_adapter_cleans_up_partial_injection_not_in_bookkeeping(): + """`unload_peft_adapter` must attempt real PEFT-level cleanup even when + `_loaded_adapters` has no record of the name. + + Regression: PEFT's own `load_adapter()` injects an adapter's modules into + the model (`inject_adapter_in_model()`) *before* it finishes applying the + checkpoint's weights. A failure partway through that call (e.g. a + corrupt/incomplete checkpoint) leaves PEFT holding a real, injected + adapter under this name — but `_loaded_adapters` is only written after + `self._model.load_adapter(...)` returns successfully, so + `_rollback_registration()`'s call to `unload_peft_adapter()` used to see + "not loaded" and skip cleanup entirely (a pure bookkeeping-gated + no-op) — leaving the injected adapter in place. A later registration + under the same name then hit PEFT's own "already exists" error, which + `load_peft_adapter()` swallows, silently serving the corrupt weights + under the new registration's identity. + """ + backend = _make_backend() + + # No entry in `_loaded_adapters` — this is the "load_adapter() itself + # raised before returning" case, not the "set_adapter([]) failed after + # load_adapter() returned" case the sibling test above covers. + assert "answerability_alora" not in backend._loaded_adapters + + backend.unload_peft_adapter("answerability_alora") + + backend._model.delete_adapter.assert_called_once_with("answerability_alora") # type: ignore[union-attr] + + +def test_unload_peft_adapter_tolerates_peft_also_having_nothing_registered(): + """The best-effort direct cleanup must not raise when PEFT genuinely + never registered the name either (the ordinary, common case: nothing to + clean up at all).""" + backend = _make_backend() + backend._model.delete_adapter.side_effect = ValueError("adapter not found") # type: ignore[union-attr] + + backend.unload_peft_adapter("never-registered_alora") # must not raise + + def test_seed_forces_do_sample_true(stub_backend): """Issue #40: a seed alone must flip do_sample=True so it isn't ignored.""" out = _call(stub_backend, {ModelOption.SEED: 42}) diff --git a/test/cli/test_alora_readme_template.py b/test/cli/test_alora_readme_template.py index de53f61275..9e9e1b0564 100644 --- a/test/cli/test_alora_readme_template.py +++ b/test/cli/test_alora_readme_template.py @@ -83,6 +83,31 @@ def test_readme_template_constructs_composed_adapter_for_non_catalog_name(): assert intrinsic.metadata.name == "stembolts" +@pytest.mark.unit +def test_readme_template_generated_intrinsic_forwards_args_as_intrinsic_kwargs(): + """The generated `Intrinsic` subclass must pass its constructor arguments + through as `intrinsic_kwargs`, not only store them on the `SimpleComponent` + side. + + Regression guard: `_generate_from_intrinsic` explicitly never adds the + action itself to the context ("Intrinsics modify the context through + their rewriters") — the *only* path from an `Intrinsic` action's own data + to the rewritten request is `rewriter.transform(request_json, + **action.intrinsic_kwargs)`. The generated class used to call + `Intrinsic.__init__` with no `intrinsic_kwargs=` at all and store the + caller's arguments only via `SimpleComponent.__init__`, whose + `format_for_llm()` is never invoked for an `Intrinsic` — every + user-supplied argument to a generated custom adapter was silently + dropped before reaching the model. + """ + code = _extract_python_sample(_render_template()) + namespace: dict = {} + exec(code, namespace) + + intrinsic = namespace["StemboltsIntrinsic"](description="a cracked stembolt") + assert intrinsic.intrinsic_kwargs == {"description": "a cracked stembolt"} + + @pytest.mark.unit async def test_readme_template_async_wrapper_constructs_intrinsic_correctly(): """The generated `async_` wrapper must construct its `Intrinsic` subclass