Skip to content

Commit eb21284

Browse files
authored
Merge branch 'main' into feat/audio-input
Signed-off-by: jakelorocco <59755218+jakelorocco@users.noreply.github.com>
2 parents d9c8c71 + 27b96c8 commit eb21284

28 files changed

Lines changed: 2338 additions & 112 deletions

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ mkdir -p .bob && ln -s ../.agents/skills .bob/skills
9797

9898
Pre-commit runs: ruff, mypy, uv-lock, codespell, license-headers
9999

100+
**Pull request template**: opening a PR fills the body from [`.github/pull_request_template.md`](.github/pull_request_template.md), which ends with four type checkboxes - `Component`, `Requirement`, `Sampling Strategy`, `Tool`. If your PR adds or modifies one of those, check the matching box; the `PR Bot` workflow ([`.github/workflows/pr-update.yml`](.github/workflows/pr-update.yml)) then posts a comment with the type-specific review checklist from `.github/PULL_REQUEST_TEMPLATE/`:
101+
102+
| Checked box | Checklist template |
103+
|-------------|--------------------|
104+
| `Component` | `.github/PULL_REQUEST_TEMPLATE/component.md` |
105+
| `Requirement` | `.github/PULL_REQUEST_TEMPLATE/requirement.md` |
106+
| `Sampling Strategy` | `.github/PULL_REQUEST_TEMPLATE/sampling.md` |
107+
| `Tool` | `.github/PULL_REQUEST_TEMPLATE/tool.md` |
108+
109+
This matters when a PR is opened outside the GitHub UI (`gh pr create --body`, from a fork, or by an agent): the template isn't applied automatically. When you open such a PR and it adds or modifies one of the four types, build the body from `.github/pull_request_template.md` with the matching box checked (if relevant) so the bot posts the checklist.
110+
100111
**Review states**: when reviewing a PR, see [CONTRIBUTING.md → Review States](CONTRIBUTING.md#review-states) for when to use `APPROVE` vs `REQUEST CHANGES` vs `COMMENT`.
101112

102113
For AI attribution trailers, see [Section 7 (AI Attribution)](#7-ai-attribution).

docs/docs/advanced/lora-and-alora-adapters.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,10 @@ adapter is preferred whenever one is loaded, with three exceptions:
179179
asking for LLM-as-a-judge regardless of what adapters are loaded.
180180
3. The adapter is unavailable (e.g. cannot be loaded) — Mellea falls back to
181181
LLM-as-a-judge automatically. This is the *only* fallback case: if the
182-
adapter runs but its output fails schema validation, the error propagates
183-
rather than silently falling back.
182+
adapter runs but its output fails schema validation, `validate()` does not
183+
fall back. Instead it surfaces the schema error on `ValidationResult.error`
184+
and fails the check closed (`bool(result)` is `False`), so callers can tell
185+
an unparsable adapter response apart from an ordinary "requirement not met".
184186

185187
If you want to force the adapter path even when using `generate_from_context`
186188
directly (bypassing the normal `validate()` call), use `ALoraRequirement` from

docs/docs/concepts/requirements-system.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ last output.
162162
| `score` | `float \| None` | Optional numeric score from your validator. |
163163
| `thunk` | `ModelOutputThunk \| None` | The model output used, if your validator ran a backend call. |
164164
| `context` | `Context \| None` | The context snapshot at validation time. |
165+
| `error` | `Exception \| None` | The exception raised while parsing validator output, if any. When set, `bool(result)` is `False` (fails closed) and `reason` is `None`; lets callers tell an unparsable response apart from an ordinary "requirement not met". |
165166

166167
The `reason` field is the most useful in practice — a clear reason string helps the
167168
model make a targeted repair rather than regenerating blindly.

docs/examples/aLora/101_example.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
3535
# whose prompt asks for a plain "yes"/"no" answer — but the result is still
3636
# parsed as JSON by requirement_check_to_bool: a plain yes/no reply raises
3737
# json.JSONDecodeError (it is not JSON), and a JSON reply that doesn't match
38-
# the schema raises AdapterSchemaMismatchError — either way the error
39-
# propagates out of validate() instead of returning a failed check. The ALORA
38+
# the schema raises AdapterSchemaMismatchError — either way validate() surfaces
39+
# the error on ValidationResult.error and fails the check closed rather than
40+
# returning an ordinary failed check or propagating the exception. The ALORA
4041
# type is also load-bearing here: routing only looks up ("alora",), so
4142
# registering the LORA variant instead would hit the same failure.
4243
backend.add_adapter(

docs/examples/provider_fields.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# pytest: unit
2+
"""Emit a provider-specific wire field without a core-type change.
3+
4+
A component author can attach `provider_fields` to a `Message` to declare extra
5+
keys that Mellea does not model, targeted at a specific provider. The fields are
6+
merged into the wire message at serialization time. Mellea's known fields always
7+
win on a collision, and a declaration that names a provider the request never
8+
reaches raises a `ValueError` (add `"*"` to opt out of that check).
9+
10+
See issue #1565 for the design.
11+
"""
12+
13+
import pytest
14+
15+
from mellea.helpers.openai_compatible_helpers import message_to_openai_message
16+
from mellea.stdlib.components import Message
17+
18+
19+
def targeted_provider_field() -> dict:
20+
"""Attach an OpenAI-only `prediction` field and serialize for OpenAI.
21+
22+
The `"openai"` key matches the OpenAI wire family (openai, litellm, watsonx,
23+
huggingface), so the field lands on the wire message for any of them.
24+
"""
25+
msg = Message(
26+
"user",
27+
"Refactor this function.",
28+
provider_fields={"openai": {"prediction": {"type": "content"}}},
29+
)
30+
wire = message_to_openai_message(msg, provider="openai")
31+
assert wire["prediction"] == {"type": "content"}
32+
return wire
33+
34+
35+
def portable_field_with_wildcard() -> dict:
36+
"""Use `"*"` to declare a field valid on every backend.
37+
38+
A `"*"` target never raises on a provider mismatch — it is the author's
39+
portability contract that the field is safe to send everywhere.
40+
"""
41+
msg = Message("user", "Hello", provider_fields={"*": {"metadata_tag": "demo"}})
42+
wire = message_to_openai_message(msg, provider="openai")
43+
assert wire["metadata_tag"] == "demo"
44+
return wire
45+
46+
47+
def known_fields_always_win() -> dict:
48+
"""An author key that collides with a Mellea-known field is dropped."""
49+
msg = Message(
50+
"user",
51+
"real content",
52+
provider_fields={"openai": {"content": "hijacked", "extra": "kept"}},
53+
)
54+
wire = message_to_openai_message(msg, provider="openai")
55+
assert wire["content"] == "real content" # Mellea's field wins
56+
assert wire["extra"] == "kept" # non-colliding author field lands
57+
return wire
58+
59+
60+
def provider_mismatch_raises() -> None:
61+
"""Targeting a provider the request never reaches is a hard error."""
62+
msg = Message("user", "Hi", provider_fields={"ollama": {"keep_alive": "5m"}})
63+
with pytest.raises(ValueError):
64+
message_to_openai_message(msg, provider="openai")
65+
66+
67+
if __name__ == "__main__":
68+
print("Targeted field:", targeted_provider_field())
69+
print("Wildcard field:", portable_field_with_wildcard())
70+
print("Known fields win:", known_fields_always_win())
71+
provider_mismatch_raises()
72+
print("Provider mismatch raised as expected.")

mellea/backends/adapters/adapter.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
import abc
1818
import contextlib
19+
import hashlib
1920
import pathlib
2021
import re
22+
import shutil
23+
import tempfile
2124
import time
2225
import warnings
2326
from collections.abc import Callable
@@ -1096,8 +1099,20 @@ def from_hub(
10961099
) -> list["EmbeddedIntrinsicAdapter"]:
10971100
"""Load embedded adapters from a Granite Switch model on Hugging Face Hub.
10981101
1099-
Downloads `adapter_index.json` and the `io_configs/` directory, then
1100-
delegates to :meth:`from_model_directory`.
1102+
Downloads `adapter_index.json` and the `io_configs/` directory into a
1103+
persistent self-contained local directory, then delegates to
1104+
`from_model_directory`.
1105+
1106+
`huggingface_hub.snapshot_download`'s default cache-backed snapshot
1107+
directory populates `io_configs/` with symlinks that resolve into a
1108+
sibling `blobs/` directory *outside* the snapshot root. That breaks the
1109+
contract `from_model_directory` expects (a self-contained model
1110+
directory) and trips its path-escape check. To satisfy that contract,
1111+
the downloaded snapshot is materialised under the Hugging Face cache
1112+
into a self-contained directory keyed by its immutable revision, so
1113+
`io_configs/` contains real files rather than symlinks escaping the
1114+
directory. This preserves standard Hugging Face Hub cache reuse and
1115+
offline loading while preventing stale files from a mutable revision.
11011116
11021117
Args:
11031118
repo_id (str): Hugging Face Hub repository ID
@@ -1118,10 +1133,11 @@ def from_hub(
11181133
`adapter_index.json` (wrong repo/revision, not a Granite Switch
11191134
model, or a stale cache).
11201135
ValueError: If no adapters are found (delegated from
1121-
:meth:`from_model_directory`).
1136+
`from_model_directory`).
11221137
"""
11231138
try:
11241139
import huggingface_hub
1140+
from huggingface_hub.constants import HF_HUB_CACHE
11251141
from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError
11261142
except ImportError as e:
11271143
raise ImportError(
@@ -1130,11 +1146,13 @@ def from_hub(
11301146
) from e
11311147

11321148
try:
1133-
local_root = huggingface_hub.snapshot_download(
1134-
repo_id=repo_id,
1135-
allow_patterns=["adapter_index.json", "io_configs/**"],
1136-
cache_dir=cache_dir,
1137-
revision=revision,
1149+
snapshot_root = pathlib.Path(
1150+
huggingface_hub.snapshot_download(
1151+
repo_id=repo_id,
1152+
allow_patterns=["adapter_index.json", "io_configs/**"],
1153+
cache_dir=cache_dir,
1154+
revision=revision,
1155+
)
11381156
)
11391157
except (GatedRepoError, RepositoryNotFoundError) as e:
11401158
auth_hint = (
@@ -1146,7 +1164,57 @@ def from_hub(
11461164
)
11471165
raise PermissionError(auth_hint) from e
11481166

1167+
cache_root = pathlib.Path(cache_dir or HF_HUB_CACHE)
1168+
cache_key = hashlib.sha256(
1169+
f"{repo_id}\0{snapshot_root.name}".encode()
1170+
).hexdigest()
1171+
local_root = cache_root / "mellea" / "embedded-adapter-configs" / cache_key
1172+
11491173
try:
1174+
if not local_root.is_dir():
1175+
local_root.parent.mkdir(parents=True, exist_ok=True)
1176+
temporary_dir = pathlib.Path(
1177+
tempfile.mkdtemp(dir=local_root.parent, prefix=f"{cache_key}-")
1178+
)
1179+
try:
1180+
import json as _json
1181+
1182+
index_path = snapshot_root / "adapter_index.json"
1183+
with open(index_path, encoding="utf-8") as f:
1184+
index = _json.load(f)
1185+
shutil.copyfile(index_path, temporary_dir / "adapter_index.json")
1186+
1187+
snapshot_cache_root = snapshot_root.parent.parent.resolve()
1188+
for entry in index.get("adapters", []):
1189+
io_config_rel = entry.get("io_config")
1190+
if io_config_rel is None:
1191+
continue
1192+
io_config_path = (snapshot_root / io_config_rel).resolve(
1193+
strict=True
1194+
)
1195+
if not io_config_path.is_relative_to(snapshot_cache_root):
1196+
raise ValueError(
1197+
f"io_config path '{io_config_rel}' escapes "
1198+
"the downloaded Hugging Face snapshot"
1199+
)
1200+
destination = temporary_dir / io_config_rel
1201+
destination.parent.mkdir(parents=True, exist_ok=True)
1202+
shutil.copyfile(io_config_path, destination)
1203+
1204+
adapters = EmbeddedIntrinsicAdapter.from_model_directory(
1205+
temporary_dir, intrinsic_name=intrinsic_name
1206+
)
1207+
try:
1208+
temporary_dir.replace(local_root)
1209+
except OSError:
1210+
if not local_root.is_dir():
1211+
raise
1212+
else:
1213+
return adapters
1214+
finally:
1215+
if temporary_dir.exists():
1216+
shutil.rmtree(temporary_dir)
1217+
11501218
return EmbeddedIntrinsicAdapter.from_model_directory(
11511219
local_root, intrinsic_name=intrinsic_name
11521220
)

0 commit comments

Comments
 (0)