Coverage for src / lilbee / modelhub / model_manager / validation.py: 100%
68 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"""Validate persisted chat/embedding refs against current installation state.
3Persisted refs in ``~/.lilbee/config.toml`` (or any other config source)
4become stale when the user removes a GGUF, swaps providers, or moves
5between machines. The TUI / server / CLI all read these refs at startup
6and should not get a "model not found" error from the very first prompt.
8The helpers here are pure and side-effect-free: callers decide what to
9do with the result (swap in-memory ``cfg`` field, surface a banner, log
10a warning, etc.). The persisted file is never rewritten, so the user's
11declared intent is preserved across reinstalls.
12"""
14from __future__ import annotations
16import logging
17from dataclasses import dataclass
19from lilbee.core.config import cfg
20from lilbee.modelhub.model_manager.discovery import discover_api_models
21from lilbee.modelhub.model_manager.types import ValidationResult
22from lilbee.modelhub.registry import ModelRegistry
23from lilbee.providers.model_ref import format_remote_ref, parse_model_ref
24from lilbee.providers.sdk_backend import PROVIDER_API_KEY_FIELD, get_provider_api_key
26log = logging.getLogger(__name__)
29@dataclass(frozen=True)
30class CanonicalRef:
31 """Result of canonicalizing a persisted ref.
33 ``effective`` is what callers should use this session. ``original``
34 is what the user persisted; if it differs from ``effective`` the
35 caller should surface the swap.
36 """
38 original: str
39 effective: str
40 status: ValidationResult
43def _is_local_installed(ref: str) -> bool:
44 """True iff ``ref`` resolves to an installed GGUF in the local registry."""
45 try:
46 registry = ModelRegistry(cfg.models_dir)
47 installed = {m.ref for m in registry.list_installed()} | {
48 m.hf_repo for m in registry.list_installed()
49 }
50 return ref in installed
51 except Exception: # pragma: no cover - defensive for fresh installs
52 log.debug("Local registry probe failed for %r", ref, exc_info=True)
53 return False
56def _is_api_ref_with_key(ref: str) -> ValidationResult:
57 """Classify a non-local ref. Returns OK / NO_KEY / UNKNOWN.
59 OK when the parsed provider matches a configured API key. NO_KEY
60 when the provider is recognized but the user hasn't set the key.
61 UNKNOWN for malformed strings or unfamiliar providers.
62 """
63 try:
64 parsed = parse_model_ref(ref)
65 except Exception:
66 return ValidationResult.UNKNOWN
67 provider = (parsed.provider or "").lower()
68 if provider not in PROVIDER_API_KEY_FIELD:
69 return ValidationResult.UNKNOWN
70 return ValidationResult.OK if get_provider_api_key(provider) else ValidationResult.NO_KEY
73def validate_persisted_model(ref: str) -> ValidationResult:
74 """Classify a persisted chat/embedding ref against current state.
76 Pure function. Reads cfg and the local registry; never mutates.
77 """
78 if not ref:
79 return ValidationResult.UNKNOWN
80 if _is_local_installed(ref):
81 return ValidationResult.OK
82 return _is_api_ref_with_key(ref)
85def _first_available_api_chat_ref() -> str | None:
86 """Return the first cloud chat ref backed by a configured API key, or ``None``."""
87 try:
88 groups = discover_api_models()
89 except Exception:
90 log.debug("discover_api_models failed during canonicalization", exc_info=True)
91 return None
92 for _provider, models in groups.items():
93 if models:
94 first = models[0]
95 return format_remote_ref(first.name, first.provider)
96 return None
99def _first_installed_local_ref() -> str | None:
100 """Return the first installed local ref, or ``None`` if none."""
101 try:
102 registry = ModelRegistry(cfg.models_dir)
103 installed = list(registry.list_installed())
104 except Exception:
105 log.debug("Local registry probe failed during canonicalization", exc_info=True)
106 return None
107 return installed[0].ref if installed else None
110def _canonicalize(original: str, *, allow_api: bool) -> CanonicalRef:
111 """Resolve a persisted ref to its effective session value.
113 ``allow_api`` controls the fallback chain: chat allows an API
114 fallback first; embedding is local-only because most providers
115 have no embedding equivalent.
116 """
117 status = validate_persisted_model(original)
118 if status == ValidationResult.OK:
119 return CanonicalRef(original=original, effective=original, status=status)
120 candidates: list[str | None] = []
121 if allow_api:
122 candidates.append(_first_available_api_chat_ref())
123 candidates.append(_first_installed_local_ref())
124 effective = next((c for c in candidates if c), original)
125 return CanonicalRef(original=original, effective=effective, status=status)
128def canonicalize_chat_model() -> CanonicalRef:
129 """Effective chat ref for this session, falling back API -> local -> original."""
130 return _canonicalize(cfg.chat_model, allow_api=True)
133def canonicalize_embedding_model() -> CanonicalRef:
134 """Effective embedding ref for this session, falling back local -> original."""
135 return _canonicalize(cfg.embedding_model, allow_api=False)