Coverage for src/lilbee/modelhub/role_validator.py: 100%

82 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Role-slot assignment validation for the four model config fields.""" 

2 

3import logging 

4import os 

5import sys 

6from typing import cast 

7 

8from lilbee.catalog import CatalogModel, find_pick 

9from lilbee.catalog.query import reclassify_by_name 

10from lilbee.catalog.refs import is_bare_hf_repo 

11from lilbee.catalog.types import ModelTask 

12from lilbee.core.config import Config, cfg 

13from lilbee.modelhub.install_state import InstallState, install_state 

14from lilbee.modelhub.registry import ModelRegistry 

15from lilbee.providers.model_ref import PROVIDER_PREFIXES, is_native_gguf_ref 

16from lilbee.providers.roles import MODEL_ROLE_FIELDS 

17 

18log = logging.getLogger(__name__) 

19 

20# Test-only bypass. Both the env var and pytest must be present so a 

21# leaked env var cannot disable validation in production. 

22_SKIP_MODEL_TASK_VALIDATION_ENV = "LILBEE_SKIP_MODEL_TASK_VALIDATION" 

23 

24MODEL_FIELD_TO_TASK: dict[str, str] = { 

25 "chat_model": "chat", 

26 "embedding_model": "embedding", 

27 "vision_model": "vision", 

28 "reranker_model": "rerank", 

29} 

30 

31 

32class TaskMismatchError(ValueError): 

33 """A role slot was assigned a model whose catalog task does not match. 

34 

35 Carries the structured fields so each surface (HTTP, CLI, TUI, MCP) 

36 can format its own user-facing message. The default ``str()`` form is 

37 surface-neutral so it is safe to surface unmodified. 

38 """ 

39 

40 def __init__(self, ref: str, entry_task: ModelTask, expected_task: ModelTask) -> None: 

41 self.ref = ref 

42 self.entry_task = entry_task 

43 self.expected_task = expected_task 

44 super().__init__(f"Model '{ref}' is a {entry_task} model, not {expected_task}.") 

45 

46 

47def _model_task_validation_bypassed() -> bool: 

48 if not os.environ.get(_SKIP_MODEL_TASK_VALIDATION_ENV): 

49 return False 

50 return sys.modules.get("pytest") is not None 

51 

52 

53def _resolve_installed_task(registry: ModelRegistry, ref: str) -> ModelTask | None: 

54 """Return the manifest's ``ModelTask`` for *ref*, name-reclassified, or ``None``.""" 

55 manifest = registry.get_manifest(ref) 

56 if manifest is None: 

57 return None 

58 return ModelTask(reclassify_by_name(ref, manifest.task)) 

59 

60 

61def _is_registry_ref(ref: str) -> bool: 

62 """Whether *ref* names something the model registry can hold.""" 

63 if not ref or not ref.strip(): 

64 return False 

65 return ref.split("/", 1)[0] not in PROVIDER_PREFIXES 

66 

67 

68def _skips_catalog_check(ref: str, *, allow_bypass: bool) -> bool: 

69 """Whether *ref* skips the catalog check.""" 

70 if not _is_registry_ref(ref): 

71 return True 

72 return allow_bypass and _model_task_validation_bypassed() 

73 

74 

75def _canonical_pick_ref(ref: str, entry: CatalogModel, want: ModelTask) -> str: 

76 """Role-check a current pick and choose the canonical ref to persist.""" 

77 if entry.task != want: 

78 raise TaskMismatchError(ref, ModelTask(entry.task), want) 

79 # Keep a full ``<repo>/<file>.gguf`` so resolve_model_path lands on the 

80 # exact installed quant; fall back to the pick's own ref otherwise. 

81 if is_native_gguf_ref(ref): 

82 return ref 

83 canonical: str = entry.ref 

84 return canonical 

85 

86 

87def _installed_ref_and_task(ref: str, want: ModelTask) -> tuple[str, str | None]: 

88 """Canonical installed ref for *ref* and its role-relevant task, task None if absent. 

89 

90 A bare ``<org>/<repo>`` ref canonicalizes to its installed quant's full ref 

91 so the persisted value always names the exact GGUF file. A chat model with 

92 an mmproj projector is a vision model, the same rule the vision picker 

93 uses, so it reports the vision task when *want* is vision. 

94 """ 

95 registry = ModelRegistry(cfg.models_dir) 

96 if is_bare_hf_repo(ref): 

97 ref = registry.installed_ref_for_repo(ref) or ref 

98 task = _resolve_installed_task(registry, ref) 

99 if want is ModelTask.VISION and task is ModelTask.CHAT and registry.has_vision_projector(ref): 

100 task = ModelTask.VISION 

101 return ref, task 

102 

103 

104def _not_installed(ref: str) -> ValueError: 

105 """The error for a ref that is neither a current pick nor installed.""" 

106 return ValueError( 

107 f"Model '{ref}' is not installed. " 

108 "Install it with 'lilbee model pull <ref>' " 

109 "(or POST /api/models/pull) before assigning it to a role." 

110 ) 

111 

112 

113def validate_model_task_assignment(field_name: str, ref: str, *, allow_bypass: bool = True) -> str: 

114 """Check *ref* is assignable to *field_name*; return the canonical ref. 

115 

116 A current pick carries its own task, so it can be role-checked before it is 

117 installed, which is what the catalog UI offers. Anything else is checked 

118 against the installed manifest, the only other thing that can vouch for a 

119 model's role. Raises ``TaskMismatchError`` on role mismatch and 

120 ``ValueError`` when the model is neither a pick nor installed. 

121 """ 

122 if _skips_catalog_check(ref, allow_bypass=allow_bypass): 

123 return ref 

124 want = ModelTask(MODEL_FIELD_TO_TASK[field_name]) 

125 # The manifest is consulted first because it answers without touching the 

126 # network. This runs on the TUI main thread from /model and the model-bar 

127 # picker, where resolving picks would block the UI. 

128 installed_ref, installed_task = _installed_ref_and_task(ref, want) 

129 if installed_task is not None: 

130 if installed_task != want: 

131 raise TaskMismatchError(installed_ref, ModelTask(installed_task), want) 

132 return installed_ref 

133 entry = find_pick(ref) 

134 if entry is not None: 

135 return _canonical_pick_ref(ref, entry, want) 

136 raise _not_installed(installed_ref) 

137 

138 

139_MISSING_ROLE_WARNING = ( 

140 "%s is set to '%s', which the model registry does not hold. No model listing " 

141 "shows it and the HTTP chat route cannot resolve it. Install a model with " 

142 "'lilbee model pull <ref>' (or POST /api/models/pull), then set the role to that ref." 

143) 

144 

145_LOOSE_FILE_ROLE_WARNING = ( 

146 "%s is set to '%s', a GGUF file outside the model registry. The TUI and CLI load it, " 

147 "but no model listing shows it. Install a catalog ref with 'lilbee model pull <ref>' " 

148 "(or POST /api/models/pull) to list it." 

149) 

150 

151 

152def configured_role_refs(config: Config) -> dict[str, str]: 

153 """The model-role fields of *config*, keyed by field name. 

154 

155 The field set comes from the role registry, so a new role is reported 

156 without editing this module. 

157 """ 

158 return cast("dict[str, str]", config.model_dump(include=set(MODEL_ROLE_FIELDS))) 

159 

160 

161def unregistered_role_refs(config: Config, registry: ModelRegistry) -> dict[str, str]: 

162 """Role fields of *config* naming a ref *registry* does not hold. 

163 

164 Blank and provider-prefixed refs are excluded; neither belongs to the 

165 registry. Reporting refuses nothing, so it runs on every start. 

166 """ 

167 return { 

168 field_name: ref 

169 for field_name, ref in configured_role_refs(config).items() 

170 if _is_registry_ref(ref) and install_state(ref, registry) is not InstallState.REGISTERED 

171 } 

172 

173 

174def warn_unregistered_role_refs(config: Config, registry: ModelRegistry) -> None: 

175 """Log one warning per role field of *config* that *registry* does not hold. 

176 

177 A ref that names a GGUF file on disk gets its own message: it loads, so 

178 telling it to pull that path would prescribe a command that cannot run. 

179 The chat role gets none: the chat routes and the model listings serve a 

180 GGUF path through the same install-state check, so nothing is missing. 

181 """ 

182 for field_name, ref in sorted(unregistered_role_refs(config, registry).items()): 

183 loose = install_state(ref, registry) is InstallState.LOOSE_FILE 

184 if loose and MODEL_FIELD_TO_TASK[field_name] == ModelTask.CHAT: 

185 continue 

186 template = _LOOSE_FILE_ROLE_WARNING if loose else _MISSING_ROLE_WARNING 

187 log.warning(template, field_name, ref)