Skip to content

Commit 2ec9206

Browse files
committed
feat(backends): support embedded adapters on LocalHFBackend
Assisted-by: Codex Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
1 parent 5773509 commit 2ec9206

6 files changed

Lines changed: 421 additions & 36 deletions

File tree

docs/docs/advanced/intrinsics.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ reliable than prompting a general-purpose model for these specialized micro-task
1515
> **Backend note:** Adapter functions work with two backends:
1616
>
1717
> - **LocalHFBackend** — loads LoRA/aLoRA adapters from the catalog at runtime.
18-
> All adapter functions are available. Requires a GPU or Apple Silicon Mac.
18+
> A local Granite Switch checkpoint can instead use
19+
> `load_embedded_adapters=True`; then only adapter functions embedded in the
20+
> checkpoint are available. Requires a GPU or Apple Silicon Mac.
1921
> - **OpenAIBackend** — uses a Granite Switch model served via vLLM with
2022
> `load_embedded_adapters=True`. Only adapter functions embedded in the model are
2123
> available — check the model's `adapter_index.json` for the list.
@@ -297,7 +299,7 @@ implementations, not whether a composed `Adapter` can be registered directly:
297299

298300
| Backend | `LocalFileBinding` (LocalFile/PEFT) | `EmbeddedBinding` (Embedded/Granite Switch) | `ServerMediatedBinding` |
299301
| --- | --- | --- | --- |
300-
| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a `LocalFileBinding` directly | 🔜 planned (#1018) ||
302+
| `LocalHFBackend` | ✅ shipping — `add_adapter` accepts a `LocalFileBinding` directly | ✅ shipping — `load_embedded_adapters=True` registers embedded adapter functions ||
301303
| `OpenAIBackend` || ✅ shipping, via the deprecated `EmbeddedIntrinsicAdapter` shim above, which builds an `EmbeddedBinding` internally ||
302304

303305
`ServerMediatedBinding` has no backend implementation yet — see discussion #1486.

mellea/backends/adapters/adapter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -641,10 +641,10 @@ def resolve_adapter(self, name: str) -> _AdapterCore:
641641
for a in EmbeddedIntrinsicAdapter.from_source(
642642
repo_id, intrinsic_name=name
643643
):
644-
# EmbeddedIntrinsicAdapter is only valid for backends whose
645-
# add_adapter accepts the full Adapter type (e.g. OpenAIBackend).
646-
# LocalHFBackend.add_adapter expects LocalHFAdapter; HF backends
647-
# never set _uses_embedded_adapters=True.
644+
# EmbeddedIntrinsicAdapter is valid only for backends whose
645+
# add_adapter supports the Embedded/Granite Switch reality
646+
# (currently OpenAIBackend and LocalHFBackend when configured
647+
# with load_embedded_adapters=True).
648648
self.add_adapter(a)
649649
else:
650650
# AdapterType.LORA is the pre-Phase-1 default (mirrors old _util.py).

mellea/backends/huggingface.py

Lines changed: 139 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,20 @@
7676
from ..stdlib.requirements import ALoraRequirement, LLMaJRequirement
7777
from ..telemetry.context import generate_request_id, with_context
7878
from ._options import resolve_model_options
79-
from .adapters import AdapterMixin, IntrinsicAdapter, LocalHFAdapter
79+
from .adapters import (
80+
AdapterMixin,
81+
EmbeddedActivationRequest,
82+
EmbeddedBinding,
83+
IntrinsicAdapter,
84+
LocalHFAdapter,
85+
)
8086
from .adapters._core import (
8187
Adapter as _AdapterCore,
8288
IOContract,
8389
LocalFileBinding,
8490
WeightsBinding,
8591
)
86-
from .adapters.adapter import AdapterInput
92+
from .adapters.adapter import AdapterInput, EmbeddedIntrinsicAdapter
8793
from .backend import FormatterBackend
8894
from .cache import Cache, SimpleLRUCache
8995
from .model_ids import ModelIdentifier
@@ -339,6 +345,11 @@ class LocalHFBackend(FormatterBackend, AdapterMixin):
339345
tokenizer/model/device; if provided, `model_id` is not used for loading.
340346
default_to_constraint_checking_alora (bool): If `False`, aLoRA constraint
341347
checking is deactivated; mainly for benchmarking and debugging.
348+
load_embedded_adapters (bool): If `True`, register adapter functions
349+
embedded in the Granite Switch checkpoint named by `adapter_source`
350+
(or `model_id` when `adapter_source` is not set).
351+
adapter_source (str | None): Local checkpoint directory or Hugging Face
352+
Hub repository used to discover embedded adapter functions.
342353
model_options (dict | None): Default model options for generation requests.
343354
344355
Attributes:
@@ -363,6 +374,8 @@ def __init__(
363374
cache: Cache | None = None,
364375
custom_config: TransformersTorchConfig | None = None,
365376
default_to_constraint_checking_alora: bool = True,
377+
load_embedded_adapters: bool = False,
378+
adapter_source: str | None = None,
366379
model_options: dict | None = None,
367380
):
368381
"""Load model weights from the given model ID, or from a custom config if provided."""
@@ -454,8 +467,12 @@ def __init__(
454467
)
455468

456469
# Adapters can be made known to the backend (added) and loaded.
457-
self._added_adapters: dict[str, LocalHFAdapter | LocalFileBinding] = {}
470+
self._added_adapters: dict[
471+
str, LocalHFAdapter | LocalFileBinding | EmbeddedIntrinsicAdapter
472+
] = {}
458473
self._loaded_adapters: dict[str, LocalHFAdapter | LocalFileBinding] = {}
474+
self._adapter_source = adapter_source
475+
self._uses_embedded_adapters = load_embedded_adapters
459476

460477
self._generation_lock = threading.RLock()
461478
"""Forces generation requests to be non-concurrent, and guards adapter
@@ -469,6 +486,9 @@ def __init__(
469486
A plain `Lock` deadlocks on that same-thread re-acquisition (#1465).
470487
"""
471488

489+
if load_embedded_adapters:
490+
self.register_embedded_adapter_model(self._adapter_source or self._model_id)
491+
472492
def _make_dc_cache(self, toks, **model_options):
473493
dc = DynamicCache()
474494
with torch.no_grad():
@@ -605,6 +625,27 @@ def _generate_with_adapter_lock(self, generate_func: Callable, *args, **kwargs):
605625
_assert_correct_adapters("", self._model)
606626
return out
607627

628+
def _generate_embedded_with_generation_lock(
629+
self, generate_func: Callable[..., _T], *args: Any, **kwargs: Any
630+
) -> _T:
631+
"""Run embedded-adapter generation while serialising model access.
632+
633+
Embedded adapter functions select their control token while rendering
634+
the chat template. They must not use the PEFT lifecycle, but local
635+
Transformers generation still shares the backend's model and therefore
636+
remains serialised with every other generation path.
637+
638+
Args:
639+
generate_func: The synchronous generation callable to invoke.
640+
*args: Positional arguments forwarded to `generate_func`.
641+
**kwargs: Keyword arguments forwarded to `generate_func`.
642+
643+
Returns:
644+
Whatever `generate_func` returns.
645+
"""
646+
with self._generation_lock:
647+
return generate_func(*args, **kwargs)
648+
608649
def _generate_intrinsic_with_adapter_scope(
609650
self,
610651
adapter: IntrinsicAdapter,
@@ -713,7 +754,9 @@ async def _generate_from_intrinsic(
713754
ValueError: If no adapter is registered for the requested intrinsic,
714755
or if the context contains images or audio; `LocalHFBackend`
715756
does not support multimodal inputs.
716-
TypeError: If the adapter isn't an IntrinsicAdapter.
757+
TypeError: If the adapter is neither an `IntrinsicAdapter` nor an
758+
`EmbeddedIntrinsicAdapter`, or if an embedded adapter does not
759+
have an `EmbeddedBinding`.
717760
"""
718761
if not ctx.is_chat_context:
719762
raise Exception("Does not yet support non-chat contexts.")
@@ -770,9 +813,10 @@ async def _generate_from_intrinsic(
770813

771814
# TODO: Code below this point is mostly specific to RagIntrinsics
772815
# It should be refactored into a specific adapter.transform() function.
773-
if not isinstance(adapter, IntrinsicAdapter):
816+
if not isinstance(adapter, (IntrinsicAdapter, EmbeddedIntrinsicAdapter)):
774817
raise TypeError(
775-
f"LocalHFBackend only supports IntrinsicAdapters, got: {type(adapter).__name__}"
818+
"LocalHFBackend only supports IntrinsicAdapter or "
819+
f"EmbeddedIntrinsicAdapter, got: {type(adapter).__name__}"
776820
)
777821

778822
intrinsic_config = adapter.config
@@ -809,9 +853,26 @@ async def _generate_from_intrinsic(
809853
# so we will have to invalidate the cache on our side. This requires
810854
# us having specific caching for each Component/Message.
811855

856+
rewritten_request = rewritten.model_dump()
857+
if isinstance(adapter, EmbeddedIntrinsicAdapter):
858+
if not isinstance(adapter.weights, EmbeddedBinding):
859+
raise TypeError(
860+
"EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; "
861+
f"got {type(adapter.weights).__name__}. Activation cannot proceed."
862+
)
863+
extra_body = rewritten_request.setdefault("extra_body", {})
864+
if not isinstance(extra_body, dict):
865+
raise TypeError(
866+
"Embedded adapter generation requires extra_body to be a dict."
867+
)
868+
await adapter.weights.apply_activation(
869+
EmbeddedActivationRequest(extra_body=extra_body, api_params={}),
870+
adapter.identity,
871+
)
872+
812873
generate_input, other_input = (
813874
granite_formatters.base.util.chat_completion_request_to_transformers_inputs( # type: ignore
814-
rewritten,
875+
rewritten_request,
815876
self._tokenizer,
816877
self._model,
817878
ll_tokenizer=self._llguidance_tokenizer,
@@ -871,16 +932,27 @@ def __getattr__(self_proxy, name: str) -> Any:
871932

872933
model_arg = _CapturingModelProxy() # type: ignore[assignment]
873934

874-
chat_response = asyncio.to_thread(
875-
self._generate_intrinsic_with_adapter_scope,
876-
adapter,
877-
granite_formatters.base.util.generate_with_transformers, # type: ignore
878-
# Passed as args/kwargs to generate.
879-
self._tokenizer,
880-
model_arg,
881-
generate_input,
882-
other_input,
883-
)
935+
if isinstance(adapter, IntrinsicAdapter):
936+
chat_response = asyncio.to_thread(
937+
self._generate_intrinsic_with_adapter_scope,
938+
adapter,
939+
granite_formatters.base.util.generate_with_transformers, # type: ignore
940+
# Passed as args/kwargs to generate.
941+
self._tokenizer,
942+
model_arg,
943+
generate_input,
944+
other_input,
945+
)
946+
else:
947+
chat_response = asyncio.to_thread(
948+
self._generate_embedded_with_generation_lock,
949+
granite_formatters.base.util.generate_with_transformers, # type: ignore
950+
# Passed as args/kwargs to generate.
951+
self._tokenizer,
952+
model_arg,
953+
generate_input,
954+
other_input,
955+
)
884956

885957
output = ModelOutputThunk(None)
886958
output._gen.start = datetime.datetime.now()
@@ -2081,32 +2153,36 @@ def _filter_for_chat_template(
20812153
@property
20822154
def base_model_name(self):
20832155
"""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`."""
2084-
return self._model_id.split("/")[1]
2156+
return self._model_id.rsplit("/", maxsplit=1)[-1]
20852157

20862158
def add_adapter(self, adapter: AdapterInput) -> None:
2087-
"""Register a LoRA/aLoRA adapter with this backend so it can be loaded later.
2159+
"""Register an adapter function with this backend.
20882160
20892161
Downloads the adapter weights (via `adapter.get_local_hf_path`) and records
20902162
the adapter in the backend's registry. The adapter must not already be
20912163
registered with a different backend.
20922164
20932165
Accepts the full `AdapterInput` union to honour the mixin contract, but
2094-
only the LocalFile/PEFT reality is supported here — other realities are
2095-
rejected at runtime rather than narrowing the signature.
2166+
LocalFile/PEFT and Embedded/Granite Switch realities are supported.
2167+
Embedded adapter functions are already present in the model, so they
2168+
are registered without downloading or loading PEFT weights.
20962169
20972170
Args:
20982171
adapter (AdapterInput): The adapter to register. Must be a
2099-
`LocalHFAdapter` or `LocalFileBinding`; other adapter realities
2100-
are rejected.
2172+
`LocalHFAdapter`, `LocalFileBinding`, or
2173+
`EmbeddedIntrinsicAdapter`; other adapter realities are
2174+
rejected.
21012175
21022176
Raises:
2103-
TypeError: If `adapter` is not a `LocalHFAdapter` or `LocalFileBinding`.
2177+
TypeError: If `adapter` is not a supported local or embedded adapter.
21042178
Exception: If `adapter` has already been added to a different backend.
21052179
"""
2106-
if not isinstance(adapter, (LocalHFAdapter, LocalFileBinding)):
2180+
if not isinstance(
2181+
adapter, (LocalHFAdapter, LocalFileBinding, EmbeddedIntrinsicAdapter)
2182+
):
21072183
raise TypeError(
2108-
f"LocalHFBackend requires a LocalHFAdapter or LocalFileBinding; got "
2109-
f"{type(adapter).__name__}."
2184+
"LocalHFBackend requires a LocalHFAdapter, LocalFileBinding, or "
2185+
f"EmbeddedIntrinsicAdapter; got {type(adapter).__name__}."
21102186
)
21112187
if adapter.backend is not None:
21122188
if adapter.backend is self:
@@ -2131,10 +2207,41 @@ def add_adapter(self, adapter: AdapterInput) -> None:
21312207
)
21322208
return None
21332209

2210+
if isinstance(adapter, EmbeddedIntrinsicAdapter):
2211+
adapter.backend = self
2212+
if not isinstance(adapter.weights, EmbeddedBinding):
2213+
raise TypeError(
2214+
"EmbeddedIntrinsicAdapter.weights must be an EmbeddedBinding; "
2215+
f"got {type(adapter.weights).__name__}."
2216+
)
2217+
adapter.weights.source = self.base_model_name
2218+
self._added_adapters[adapter.qualified_name] = adapter
2219+
return
2220+
21342221
adapter.path = adapter.get_local_hf_path(self.base_model_name)
21352222
adapter.backend = self
21362223
self._added_adapters[adapter.qualified_name] = adapter
21372224

2225+
def register_embedded_adapter_model(
2226+
self, source: str, *, revision: str = "main", cache_dir: str | None = None
2227+
) -> list[str]:
2228+
"""Register embedded adapter functions from a Granite Switch checkpoint.
2229+
2230+
Args:
2231+
source: Local checkpoint directory or Hugging Face Hub repository ID.
2232+
revision: Git revision when loading from the Hub.
2233+
cache_dir: Cache directory for Hub downloads.
2234+
2235+
Returns:
2236+
Names of the registered adapter functions.
2237+
"""
2238+
adapters = EmbeddedIntrinsicAdapter.from_source(
2239+
source, revision=revision, cache_dir=cache_dir
2240+
)
2241+
for adapter in adapters:
2242+
self.add_adapter(adapter)
2243+
return [adapter.intrinsic_name for adapter in adapters]
2244+
21382245
def load_peft_adapter(self, adapter_qualified_name: str) -> None:
21392246
"""Load a previously registered adapter into the underlying Hugging Face model.
21402247
@@ -2154,6 +2261,11 @@ def load_peft_adapter(self, adapter_qualified_name: str) -> None:
21542261
raise ValueError(
21552262
f"could not load adapter {adapter_qualified_name} for backend {self}: adapter was not previously added"
21562263
)
2264+
if isinstance(adapter, EmbeddedIntrinsicAdapter):
2265+
raise TypeError(
2266+
f"cannot load embedded adapter {adapter_qualified_name} through PEFT; "
2267+
"it is activated by the chat template"
2268+
)
21572269

21582270
try:
21592271
# self._model is a HF PreTrainedModel — .load_adapter() here is

mellea/formatters/granite/base/util.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import json
1111
import os
1212
import uuid
13-
from typing import TYPE_CHECKING
13+
from typing import TYPE_CHECKING, Any, cast
1414

1515
# Third Party
1616
import pydantic
@@ -233,6 +233,30 @@ def chat_completion_request_to_transformers_inputs(
233233
):
234234
tokenizer_input["documents"] = request["extra_body"]["documents"]
235235

236+
if request.get("extra_body") is not None:
237+
chat_template_kwargs = request["extra_body"].get("chat_template_kwargs")
238+
if chat_template_kwargs is not None:
239+
if not isinstance(chat_template_kwargs, dict):
240+
raise TypeError(
241+
"extra_body.chat_template_kwargs must be a dict for "
242+
"Hugging Face chat-template rendering"
243+
)
244+
reserved_template_kwargs = {
245+
"conversation",
246+
"tools",
247+
"documents",
248+
"add_generation_prompt",
249+
}
250+
overridden_keys = reserved_template_kwargs.intersection(
251+
chat_template_kwargs
252+
)
253+
if overridden_keys:
254+
raise ValueError(
255+
"extra_body.chat_template_kwargs cannot override Hugging Face "
256+
"chat-template inputs: " + ", ".join(sorted(overridden_keys))
257+
)
258+
tokenizer_input.update(cast(dict[str, Any], chat_template_kwargs))
259+
236260
input_tokens = tokenizer.apply_chat_template(**tokenizer_input, return_tensors="pt") # type: ignore[union-attr]
237261

238262
# Transformers 5 switched the return type of apply_chat_template() from Tensor to

0 commit comments

Comments
 (0)