mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 23:19:40 +02:00
First milestone of the LLM-fallback plan (docs/plans/llm-fallback-aliases.md). Introduces the `mana/<class>` namespace; the registry parses + validates aliases.yaml at startup and reloads on demand. Schema-rejects empty chains, missing provider prefixes, alias names outside the reserved namespace, default→unknown references, etc. Reload semantics: parse error keeps the previous good state in memory so a typo + SIGHUP doesn't take the service down. 5 aliases ship with the initial config: fast-text, long-form, structured, reasoning, vision. Each chain ends with a cloud provider so the system keeps working when the GPU server is offline. 32 unit tests covering happy path, schema validation, namespace check, reload safety, and a guard that the shipped aliases.yaml itself parses. M2 (health-cache + probe-loop) and M3 (router fallback execution) build on this; aliases are not yet wired into the request path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
8 KiB
Python
223 lines
8 KiB
Python
"""Model-alias registry.
|
|
|
|
Loads `aliases.yaml` and exposes a small API the router uses to resolve
|
|
semantic model names like ``mana/long-form`` to an ordered list of
|
|
concrete provider-prefixed model strings (``ollama/gemma3:12b`` →
|
|
``groq/llama-3.3-70b-versatile`` → …).
|
|
|
|
The registry is hot-reloadable: ``reload()`` rebuilds the in-memory
|
|
mapping atomically. Reload errors leave the previous good state intact
|
|
so a typo in the yaml file doesn't take the service down — caller logs
|
|
the error and keeps serving.
|
|
|
|
See docs/plans/llm-fallback-aliases.md for the full design.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Aliases live in this namespace. Anything else passed as `model` is
|
|
# treated as a direct provider/model string (preserves the legal
|
|
# bypass-the-alias-layer escape hatch for tests/debugging).
|
|
ALIAS_PREFIX = "mana/"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Alias:
|
|
"""A resolved alias entry."""
|
|
|
|
name: str
|
|
description: str
|
|
chain: tuple[str, ...]
|
|
|
|
|
|
class AliasConfigError(ValueError):
|
|
"""Raised when the YAML file is malformed or violates schema constraints."""
|
|
|
|
|
|
class UnknownAliasError(KeyError):
|
|
"""Raised when a caller asks for an alias that isn't defined."""
|
|
|
|
|
|
def _validate_chain(name: str, chain: object) -> tuple[str, ...]:
|
|
"""Schema-check a single alias chain. Returns the validated tuple."""
|
|
if not isinstance(chain, list):
|
|
raise AliasConfigError(f"alias '{name}': chain must be a list, got {type(chain).__name__}")
|
|
if not chain:
|
|
raise AliasConfigError(f"alias '{name}': chain must not be empty")
|
|
out: list[str] = []
|
|
for i, entry in enumerate(chain):
|
|
if not isinstance(entry, str) or not entry.strip():
|
|
raise AliasConfigError(
|
|
f"alias '{name}': chain[{i}] must be a non-empty string, got {entry!r}"
|
|
)
|
|
if "/" not in entry:
|
|
raise AliasConfigError(
|
|
f"alias '{name}': chain[{i}] = {entry!r} must include a provider prefix "
|
|
f"(e.g. 'ollama/...', 'groq/...')"
|
|
)
|
|
out.append(entry.strip())
|
|
return tuple(out)
|
|
|
|
|
|
def _validate_name(name: object) -> str:
|
|
"""Aliases must live in the reserved `mana/` namespace."""
|
|
if not isinstance(name, str) or not name.startswith(ALIAS_PREFIX):
|
|
raise AliasConfigError(
|
|
f"alias name {name!r} must start with {ALIAS_PREFIX!r} (the reserved namespace)"
|
|
)
|
|
suffix = name[len(ALIAS_PREFIX) :]
|
|
if not suffix or "/" in suffix:
|
|
raise AliasConfigError(
|
|
f"alias name {name!r} must have exactly one segment after {ALIAS_PREFIX!r}"
|
|
)
|
|
return name
|
|
|
|
|
|
def _parse_document(doc: object) -> tuple[dict[str, Alias], str | None]:
|
|
"""Parse a loaded YAML document into a normalized (aliases, default) pair."""
|
|
if not isinstance(doc, dict):
|
|
raise AliasConfigError(f"yaml root must be a mapping, got {type(doc).__name__}")
|
|
|
|
raw_aliases = doc.get("aliases", {})
|
|
if not isinstance(raw_aliases, dict):
|
|
raise AliasConfigError(
|
|
f"`aliases` must be a mapping, got {type(raw_aliases).__name__}"
|
|
)
|
|
if not raw_aliases:
|
|
raise AliasConfigError("`aliases` is empty — at least one alias is required")
|
|
|
|
parsed: dict[str, Alias] = {}
|
|
for name, body in raw_aliases.items():
|
|
validated_name = _validate_name(name)
|
|
if not isinstance(body, dict):
|
|
raise AliasConfigError(
|
|
f"alias '{validated_name}': body must be a mapping, got {type(body).__name__}"
|
|
)
|
|
description = body.get("description", "")
|
|
if not isinstance(description, str):
|
|
raise AliasConfigError(
|
|
f"alias '{validated_name}': description must be a string"
|
|
)
|
|
chain = _validate_chain(validated_name, body.get("chain"))
|
|
parsed[validated_name] = Alias(
|
|
name=validated_name,
|
|
description=description.strip(),
|
|
chain=chain,
|
|
)
|
|
|
|
default = doc.get("default")
|
|
if default is not None:
|
|
if not isinstance(default, str):
|
|
raise AliasConfigError(f"`default` must be a string, got {type(default).__name__}")
|
|
if default not in parsed:
|
|
raise AliasConfigError(
|
|
f"`default` references unknown alias {default!r} "
|
|
f"(known: {sorted(parsed)})"
|
|
)
|
|
|
|
return parsed, default
|
|
|
|
|
|
class AliasRegistry:
|
|
"""Thread-safe in-memory registry of model aliases.
|
|
|
|
Construct once at startup with the path to the yaml file. Call
|
|
:meth:`reload` to re-read after a SIGHUP. Reads (``resolve``,
|
|
``is_alias``, …) are cheap and lock-free during steady state — they
|
|
snapshot the current mapping reference; only the swap on reload is
|
|
serialized.
|
|
"""
|
|
|
|
def __init__(self, path: Path | str):
|
|
self._path = Path(path)
|
|
self._lock = threading.Lock()
|
|
self._aliases: dict[str, Alias] = {}
|
|
self._default: str | None = None
|
|
self._load()
|
|
|
|
@property
|
|
def path(self) -> Path:
|
|
return self._path
|
|
|
|
def _load(self) -> None:
|
|
"""Initial load — propagates errors so a bad config fails fast at startup."""
|
|
if not self._path.exists():
|
|
raise AliasConfigError(f"alias config not found at {self._path}")
|
|
with self._path.open("r", encoding="utf-8") as f:
|
|
try:
|
|
doc = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
raise AliasConfigError(f"failed to parse {self._path}: {e}") from e
|
|
aliases, default = _parse_document(doc)
|
|
# No lock needed during __init__ — nothing else can read yet.
|
|
self._aliases = aliases
|
|
self._default = default
|
|
logger.info(
|
|
"AliasRegistry loaded %d alias(es) from %s (default=%s)",
|
|
len(aliases),
|
|
self._path,
|
|
default,
|
|
)
|
|
|
|
def reload(self) -> None:
|
|
"""Re-read the yaml file. On parse error, keep the previous state and raise.
|
|
|
|
Designed for SIGHUP: callers should ``try/except AliasConfigError``
|
|
and log; do not crash the service on a typo.
|
|
"""
|
|
with self._path.open("r", encoding="utf-8") as f:
|
|
try:
|
|
doc = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
raise AliasConfigError(f"failed to parse {self._path}: {e}") from e
|
|
aliases, default = _parse_document(doc)
|
|
with self._lock:
|
|
self._aliases = aliases
|
|
self._default = default
|
|
logger.info(
|
|
"AliasRegistry reloaded %d alias(es) from %s (default=%s)",
|
|
len(aliases),
|
|
self._path,
|
|
default,
|
|
)
|
|
|
|
@staticmethod
|
|
def is_alias(name: str) -> bool:
|
|
"""Cheap syntactic check — does this name live in the alias namespace?
|
|
|
|
Static; doesn't require a registry instance. Used by the router to
|
|
decide whether to dispatch to the alias layer or pass through to
|
|
provider-direct routing.
|
|
"""
|
|
return isinstance(name, str) and name.startswith(ALIAS_PREFIX)
|
|
|
|
def resolve(self, name: str) -> Alias:
|
|
"""Look up the named alias. Raises :class:`UnknownAliasError` if absent."""
|
|
try:
|
|
return self._aliases[name]
|
|
except KeyError as e:
|
|
raise UnknownAliasError(
|
|
f"unknown alias {name!r} (known: {sorted(self._aliases)})"
|
|
) from e
|
|
|
|
def resolve_chain(self, name: str) -> tuple[str, ...]:
|
|
"""Sugar for ``resolve(name).chain`` — the form the router actually wants."""
|
|
return self.resolve(name).chain
|
|
|
|
@property
|
|
def default_alias(self) -> str | None:
|
|
"""The alias used when a request arrives with no recognizable model."""
|
|
return self._default
|
|
|
|
def list_aliases(self) -> list[Alias]:
|
|
"""All aliases as a snapshot list — for the GET /v1/aliases debug endpoint."""
|
|
return [self._aliases[k] for k in sorted(self._aliases)]
|