Coverage for src/lilbee/modelhub/model_manager/discovery.py: 100%

144 statements  

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

1"""Remote model discovery and task classification.""" 

2 

3import logging 

4import os 

5import time 

6from collections.abc import Callable 

7from functools import lru_cache 

8from threading import Lock 

9 

10import httpx 

11 

12from lilbee.app.services import get_services 

13from lilbee.catalog.formatting import agent_model_id 

14from lilbee.catalog.query import ( 

15 EMBEDDING_NAME_PATTERNS, 

16 RERANKER_NAME_PATTERNS, 

17 VISION_NAME_PATTERNS, 

18) 

19from lilbee.catalog.types import ModelTask 

20from lilbee.core.config.model import cfg 

21from lilbee.modelhub.model_manager.types import RemoteModel 

22from lilbee.providers.backend_names import BackendName 

23from lilbee.providers.local_servers import ( 

24 LM_STUDIO, 

25 OLLAMA, 

26 LocalServerSpec, 

27 openai_models_url, 

28) 

29from lilbee.providers.local_servers.config_urls import configured_local_servers 

30from lilbee.providers.model_ref import format_remote_ref 

31from lilbee.providers.sdk_backend import PROVIDER_KEYS 

32 

33log = logging.getLogger(__name__) 

34 

35_EMBEDDING_FAMILIES = frozenset({"bert", "nomic-bert", "e5", "bge"}) 

36 

37_CLASSIFY_DEFAULT_TIMEOUT_S = 5.0 

38 

39 

40@lru_cache(maxsize=1) 

41def _discovery_client() -> httpx.Client: 

42 """One shared client for the local-server discovery probes. 

43 

44 ``httpx.get`` builds a fresh ``Client`` per call, and every ``Client`` 

45 construction creates an SSL context that loads the system CA bundle. The 

46 catalog re-runs discovery on tab activations and refreshes, which made 

47 ``ssl.create_default_context`` 16% of a real-terminal TUI py-spy session. 

48 One client builds it once and reuses connections between probes (same 

49 pattern as the engine probes in ``fleet.swap_manager``). Unlike those 

50 loopback-only probes, ``trust_env`` stays on: these base URLs are 

51 user-configurable and a LAN server may sit behind a proxy. 

52 """ 

53 return httpx.Client() 

54 

55 

56def _http_get(url: str, *, timeout: float) -> httpx.Response: 

57 """GET via the shared discovery client (module seam; tests stub here).""" 

58 return _discovery_client().get(url, timeout=timeout) 

59 

60 

61def _classify_remote_task(name: str, family: str) -> ModelTask: 

62 """Classify a remote model as rerank, embedding, vision, or chat (in that order). 

63 

64 Embedding matches by family tag or name pattern; the name path covers 

65 servers like LM Studio that report no family. 

66 """ 

67 name_lower = name.lower() 

68 if any(rp in name_lower for rp in RERANKER_NAME_PATTERNS): 

69 return ModelTask.RERANK 

70 family_lower = family.lower() 

71 if any(ef in family_lower for ef in _EMBEDDING_FAMILIES) or any( 

72 ep in name_lower for ep in EMBEDDING_NAME_PATTERNS 

73 ): 

74 return ModelTask.EMBEDDING 

75 if any(vp in name_lower for vp in VISION_NAME_PATTERNS): 

76 return ModelTask.VISION 

77 return ModelTask.CHAT 

78 

79 

80def classify_remote_models( 

81 base_url: str, 

82 spec: LocalServerSpec, 

83 *, 

84 timeout: float = _CLASSIFY_DEFAULT_TIMEOUT_S, 

85) -> list[RemoteModel]: 

86 """Discover and classify all models from one local server by task. 

87 

88 The strategy and provider label come from *spec* (Ollama ``/api/tags`` vs 

89 LM Studio ``/v1/models``), so a server reached at a non-default host is 

90 classified correctly. Returns ``[]`` on any error so read-only callers stay 

91 responsive when the backend is down. 

92 """ 

93 discover = _DISCOVERY_BY_KEY[spec.key] 

94 return discover(base_url, spec.display_name, timeout) 

95 

96 

97def classify_all_remote_models( 

98 *, 

99 timeout: float = _CLASSIFY_DEFAULT_TIMEOUT_S, 

100) -> list[RemoteModel]: 

101 """Classify models across every configured local server, source-labeled.""" 

102 result: list[RemoteModel] = [] 

103 for spec, base_url in configured_local_servers(): 

104 result.extend(classify_remote_models(base_url, spec, timeout=timeout)) 

105 return result 

106 

107 

108def _discover_via_ollama_tags( 

109 base_url: str, provider: BackendName, timeout: float 

110) -> list[RemoteModel]: 

111 """Classify models from Ollama's ``/api/tags`` using family metadata.""" 

112 try: 

113 resp = _http_get(f"{base_url}/api/tags", timeout=timeout) 

114 resp.raise_for_status() 

115 raw_models = resp.json().get("models", []) 

116 except Exception: 

117 log.debug("Failed to classify remote models", exc_info=True) 

118 return [] 

119 

120 result: list[RemoteModel] = [] 

121 for model in raw_models: 

122 name = model.get("name", "") 

123 details = model.get("details", {}) 

124 family = details.get("family", "") 

125 param_size = details.get("parameter_size", "") 

126 task = _classify_remote_task(name, family) 

127 result.append( 

128 RemoteModel( 

129 name=name, 

130 task=task, 

131 family=family, 

132 parameter_size=param_size, 

133 provider=provider, 

134 ) 

135 ) 

136 return result 

137 

138 

139def _discover_via_openai_models( 

140 base_url: str, provider: BackendName, timeout: float 

141) -> list[RemoteModel]: 

142 """Classify models from an OpenAI-compatible ``/v1/models`` endpoint. 

143 

144 These servers report only ids (no family), so task detection runs off the 

145 name patterns, which LM Studio ids usually carry. Every id is surfaced: LM 

146 Studio presents LM Link remote/cloud models here as if local, so the list 

147 is intentionally not filtered to locally-downloaded models. 

148 """ 

149 try: 

150 resp = _http_get(openai_models_url(base_url), timeout=timeout) 

151 resp.raise_for_status() 

152 raw_models = resp.json().get("data", []) 

153 except Exception: 

154 log.debug("Failed to classify remote models", exc_info=True) 

155 return [] 

156 

157 result: list[RemoteModel] = [] 

158 for model in raw_models: 

159 name = model.get("id", "") 

160 if not name: 

161 continue 

162 task = _classify_remote_task(name, "") 

163 result.append( 

164 RemoteModel( 

165 name=name, 

166 task=task, 

167 family="", 

168 parameter_size="", 

169 provider=provider, 

170 ) 

171 ) 

172 return result 

173 

174 

175# Listing strategy per local-server routing key. Module-level so it stays a 

176# single source of truth as servers are added to the registry. 

177_DISCOVERY_BY_KEY: dict[str, Callable[[str, BackendName, float], list[RemoteModel]]] = { 

178 OLLAMA.key: _discover_via_ollama_tags, 

179 LM_STUDIO.key: _discover_via_openai_models, 

180} 

181 

182 

183def _has_provider_key(cfg_field: str, env_var: str) -> bool: 

184 """Return True if a usable API key exists via env var or lilbee config.""" 

185 if os.environ.get(env_var): 

186 return True 

187 return bool(getattr(cfg, cfg_field, "")) 

188 

189 

190def discover_api_models() -> dict[str, list[RemoteModel]]: 

191 """Return frontier chat models grouped by provider. 

192 

193 Returns whatever the active provider's backend exposes for each 

194 configured API key, no curation. Short-circuits before touching 

195 the SDK when no keys are present. 

196 """ 

197 active = [ 

198 (prov, cfg_f, env, label) 

199 for prov, cfg_f, env, label in PROVIDER_KEYS 

200 if _has_provider_key(cfg_f, env) 

201 ] 

202 if not active: 

203 return {} 

204 

205 provider = get_services().provider 

206 

207 result: dict[str, list[RemoteModel]] = {} 

208 for prov, _cfg_field, _env_var, display_name in active: 

209 chat_models = [ 

210 RemoteModel( 

211 name=model_name, 

212 task=ModelTask.CHAT, 

213 family="", 

214 parameter_size="", 

215 provider=display_name, 

216 ) 

217 for model_name in provider.list_chat_models(prov) 

218 ] 

219 if chat_models: 

220 result[display_name] = chat_models 

221 return result 

222 

223 

224def detect_remote_embedding_models() -> list[str]: 

225 """Return embedding-model names across every configured local server.""" 

226 return [m.name for m in classify_all_remote_models() if m.task == ModelTask.EMBEDDING] 

227 

228 

229def _installed_native_refs() -> set[str]: 

230 """Canonical refs from the native registry; empty set if the walk fails.""" 

231 try: 

232 return {m.ref for m in get_services().registry.list_installed()} 

233 except Exception: 

234 log.warning("Native registry walk failed; contributing no installed refs", exc_info=True) 

235 return set() 

236 

237 

238def gather_known_model_refs() -> set[str]: 

239 """Canonical refs from the native registry, every configured local server, and APIs. 

240 

241 Each primitive swallows its own failures, so a backend being down contributes an 

242 empty subset rather than raising. 

243 """ 

244 refs = _installed_native_refs() 

245 for rm in classify_all_remote_models(): 

246 refs.add(format_remote_ref(rm.name, rm.provider)) 

247 for models in discover_api_models().values(): 

248 for rm in models: 

249 refs.add(format_remote_ref(rm.name, rm.provider)) 

250 return refs 

251 

252 

253class KnownModelCache: 

254 """TTL-cached union of native + remote + frontier model refs. 

255 

256 Not a ``cachetools.TTLCache``: the generation counter is the point. A 

257 fan-out already in flight when :meth:`invalidate` runs would otherwise 

258 install its pre-pull answer with a full TTL, hiding a freshly pulled model 

259 for 30s; bumping the generation makes that late writer publish without 

260 renewing the expiry. The fan-out runs off the lock because it hits network. 

261 """ 

262 

263 DEFAULT_TTL_S = 30.0 

264 

265 def __init__(self, ttl_s: float = DEFAULT_TTL_S) -> None: 

266 self._ttl_s = ttl_s 

267 self._refs: frozenset[str] = frozenset() 

268 self._expires_at: float = 0.0 

269 self._generation: int = 0 

270 self._lock = Lock() 

271 

272 def refs(self) -> frozenset[str]: 

273 """Cached canonical-ref set, refreshing past the TTL (fan-out runs off the lock).""" 

274 with self._lock: 

275 if time.monotonic() < self._expires_at: 

276 return self._refs 

277 captured_generation = self._generation 

278 fresh = frozenset(gather_known_model_refs()) 

279 with self._lock: 

280 self._refs = fresh 

281 if self._generation == captured_generation: 

282 self._expires_at = time.monotonic() + self._ttl_s 

283 return self._refs 

284 

285 def resolve(self, model: str) -> str | None: 

286 """Resolve *model* to its canonical ref, or None if unknown. 

287 

288 Accepts the canonical ref, an Ollama ``name:tag`` shorthand, and the clean 

289 agent-facing id (:func:`agent_model_id`) an agent config pins in place of 

290 the full GGUF path. The clean id resolves only when exactly one known ref 

291 produces it, so two same-labelled quants stay unresolved rather than 

292 routing to the wrong file. 

293 """ 

294 refs = self.refs() 

295 if model in refs: 

296 return model 

297 if "/" not in model and ":" in model: 

298 prefixed = OLLAMA.qualify(model) 

299 if prefixed in refs: 

300 return prefixed 

301 aliased = [ref for ref in refs if agent_model_id(ref) == model] 

302 if len(aliased) == 1: 

303 return aliased[0] 

304 return None 

305 

306 def invalidate(self) -> None: 

307 """Force the next ``refs()`` call to re-probe.""" 

308 with self._lock: 

309 self._expires_at = 0.0 

310 self._generation += 1