Coverage for src / lilbee / core / config / validators.py: 100%
57 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Catalog/role validation helpers and the :func:`ConfigField` wrapper."""
3import os
4import sys
5from typing import TYPE_CHECKING, Any
7from pydantic import Field
9from lilbee.providers.model_ref import PROVIDER_PREFIXES
11if TYPE_CHECKING:
12 from lilbee.catalog.types import ModelTask
15def ConfigField( # noqa: N802 pydantic Field wrapper; matches Field's PascalCase
16 *args: Any,
17 writable: bool = False,
18 reindex: bool = False,
19 write_only: bool = False,
20 public: bool = True,
21 **kwargs: Any,
22) -> Any:
23 """Wrap pydantic ``Field`` and attach metadata via ``json_schema_extra``."""
24 extra: dict[str, bool] = {}
25 if writable:
26 extra["writable"] = True
27 if reindex:
28 extra["reindex"] = True
29 if write_only:
30 extra["write_only"] = True
31 if not public:
32 extra["public"] = False
33 if extra:
34 kwargs["json_schema_extra"] = extra
35 return Field(*args, **kwargs)
38# Test-only bypass. Both the env var and pytest must be present so a
39# leaked env var cannot disable validation in production.
40_SKIP_MODEL_TASK_VALIDATION_ENV = "LILBEE_SKIP_MODEL_TASK_VALIDATION"
43def _model_task_validation_bypassed() -> bool:
44 if not os.environ.get(_SKIP_MODEL_TASK_VALIDATION_ENV):
45 return False
46 return sys.modules.get("pytest") is not None
49_MODEL_FIELD_TO_TASK: dict[str, str] = {
50 "chat_model": "chat",
51 "embedding_model": "embedding",
52 "vision_model": "vision",
53 "reranker_model": "rerank",
54}
57class TaskMismatchError(ValueError):
58 """A role slot was assigned a model whose catalog task does not match.
60 Carries the structured fields so each surface (HTTP, CLI, TUI, MCP)
61 can format its own user-facing message. The default ``str()`` form is
62 surface-neutral so it is safe to surface unmodified.
63 """
65 def __init__(self, ref: str, entry_task: "ModelTask", expected_task: "ModelTask") -> None:
66 self.ref = ref
67 self.entry_task = entry_task
68 self.expected_task = expected_task
69 super().__init__(f"Model '{ref}' is a {entry_task} model, not {expected_task}.")
72# A native GGUF ref of the form ``<owner>/<repo>/<file>.gguf`` has at least
73# two ``/`` separators; one-slash refs are bare repo IDs.
74_NATIVE_GGUF_REF_MIN_SLASHES = 2
77def _find_model_catalog_entry(ref: str) -> Any:
78 # circular import: catalog imports cfg.
79 from lilbee.catalog import find_catalog_entry
81 return find_catalog_entry(ref)
84def _enforce_role_match(ref: str, entry: Any, field_name: str) -> None:
85 from lilbee.catalog.types import ModelTask
87 want = ModelTask(_MODEL_FIELD_TO_TASK[field_name])
88 if entry.task == want:
89 return
90 raise TaskMismatchError(ref, ModelTask(entry.task), want)
93def _skips_catalog_check(ref: str, *, allow_bypass: bool) -> bool:
94 """Whether *ref* skips the catalog check."""
95 if not ref or not ref.strip():
96 return True
97 if allow_bypass and _model_task_validation_bypassed():
98 return True
99 return ref.split("/", 1)[0] in PROVIDER_PREFIXES
102def validate_model_task_assignment(field_name: str, ref: str, *, allow_bypass: bool = True) -> str:
103 """Check *ref* is a catalog entry whose task matches *field_name*; return the canonical ref."""
104 if _skips_catalog_check(ref, allow_bypass=allow_bypass):
105 return ref
106 entry = _find_model_catalog_entry(ref)
107 if entry is None:
108 raise ValueError(
109 f"Model '{ref}' is not in the featured catalog. "
110 "Pick a featured model for this role, or install one with "
111 "'lilbee model pull <ref>' (or POST /api/models/pull) using a known catalog ref."
112 )
113 _enforce_role_match(ref, entry, field_name)
114 # Keep a full ``<repo>/<file>.gguf`` so resolve_model_path lands on
115 # the exact installed quant; fall back to the catalog ref otherwise.
116 if ref.endswith(".gguf") and ref.count("/") >= _NATIVE_GGUF_REF_MIN_SLASHES:
117 return ref
118 canonical: str = entry.ref
119 return canonical