Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9af0479
feat(backends): accept composed Adapter directly in add_adapter/resol…
planetf1 Sep 3, 2026
cc331c5
test(backends): add composed-Adapter coverage alongside the deprecate…
planetf1 Sep 3, 2026
263ddc4
feat(cli): generate composed-Adapter scaffolding for trained aLoRA ad…
planetf1 Sep 3, 2026
5f7b50c
feat(stdlib): allow Intrinsic/ALoraRequirement to target a non-catalo…
planetf1 Sep 3, 2026
746c92d
docs(intrinsics): replace deprecated shim classes with composed Adapter
planetf1 Sep 3, 2026
a043ed2
docs(alora): compose the stembolts adapter instead of subclassing the…
planetf1 Sep 3, 2026
0807476
docs(tutorials): add three adapter-function tutorials for issue #1144
planetf1 Sep 3, 2026
e47f9f5
fix(backends): address code-review findings on the composed-Adapter path
planetf1 Sep 3, 2026
f7ef6e0
fix(backends): address Bob's code-review findings on PR #1619
planetf1 Sep 3, 2026
67990a2
fix(backends): fix Qwen-review findings on composed-Adapter path (#1144)
planetf1 Sep 3, 2026
cef10fd
fix(backends): fix Bob-round-2 findings on composed-Adapter path (#1144)
planetf1 Sep 3, 2026
9d63a77
fix(backends): require config for composed Embedded-adapter registration
planetf1 Sep 3, 2026
6eeeb2d
Merge remote-tracking branch 'upstream/main' into issue-1144
planetf1 Sep 4, 2026
af9bf9b
fix(backends): address code-review findings on composed-Adapter path
planetf1 Sep 4, 2026
3f0639c
fix(backends): address Codex's code-review findings on composed-Adapt…
planetf1 Sep 6, 2026
8991196
fix(backends): fix deadlock and unsound compat detection in composed-…
planetf1 Sep 6, 2026
3a89329
fix(backends): fix four regressions Codex found in the round-2 lock r…
planetf1 Sep 6, 2026
457e0b1
docs(backends): document the ADAPTER_FUNCTION_PHASE_COMPLETE hook-ree…
planetf1 Sep 6, 2026
b3cff6f
docs(backends): drop follow-up-issue framing from the hook-reentrancy…
planetf1 Sep 6, 2026
0f920b0
fix(backends): fix two genuine bugs Codex found in a fresh re-review
planetf1 Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 51 additions & 21 deletions cli/alora/README_TEMPLATE.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ 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.

> **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 }}/<base-model>/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.

Expand All @@ -41,45 +49,67 @@ 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 }}"


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,
)
_INTRINSIC_QUALIFIED_NAME = f"{_INTRINSIC_ADAPTER_NAME}_{AdapterType.ALORA.value}"


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,
# 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,),
# `_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 }})

def format_for_llm(self):
return SimpleComponent.format_for_llm(self)

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))
action = {{ intrinsic_name_camelcase }}Intrinsic("{{ intrinsic_name }}", {{ arglist_without_type_annotations }})
mot = await backend.generate_from_context(action, ctx)
return mot
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, ctx = await backend.generate_from_context(action, ctx)
return mot, ctx


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)

Expand Down
120 changes: 72 additions & 48 deletions docs/docs/advanced/intrinsics.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,24 +214,41 @@ 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.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

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).
# 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",
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,
revision=_metadata.revision,
),
)
backend.add_adapter(req_adapter)

Expand All @@ -255,80 +272,82 @@ 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": <float>}}`.

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/07-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). 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
`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, 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
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` (or `resolve_adapter`) rather than a bare
`add_adapter(adapter)` call with no `config=`:

```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,
)
switch_adapter = Adapter(
identity=Identity(name="answerability", adapter_type="alora"),
io_contract=AnswerabilityContract(),
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(
IBM_GRANITE_SWITCH_4_1_3B_PREVIEW.hf_model_name, intrinsic_name="answerability"
)
```

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(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
Hub repo (rather than one already-known name) still goes through
`register_embedded_adapter_model`, which builds the composed `Adapter`
instances for you.

---

Expand All @@ -338,3 +357,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) |
[Adapter function metrics](../observability/metrics.md#adapter-function-metrics)
47 changes: 25 additions & 22 deletions docs/docs/advanced/lora-and-alora-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand All @@ -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": <float>}}` 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": <float>}}`
response schema; label-only adapter output is not compatible with
`ALoraRequirement`.

## How automatic routing works

Expand Down
9 changes: 4 additions & 5 deletions docs/docs/integrations/openai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading