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

138 statements  

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

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

2 

3import logging 

4import time 

5from collections.abc import Callable 

6from functools import lru_cache 

7from threading import Lock 

8 

9import httpx 

10 

11from lilbee.app.services import get_services 

12from lilbee.catalog.formatting import agent_model_id 

13from lilbee.catalog.query import ( 

14 EMBEDDING_NAME_PATTERNS, 

15 RERANKER_NAME_PATTERNS, 

16 VISION_NAME_PATTERNS, 

17) 

18from lilbee.catalog.types import KeyStatus, ModelTask 

19from lilbee.modelhub.install_state import InstallState, install_state 

20from lilbee.modelhub.model_manager.types import ApiModelGroup, RemoteModel 

21from lilbee.providers.backend_names import BackendName 

22from lilbee.providers.base import LLMProvider 

23from lilbee.providers.key_check import provider_key_set, provider_key_statuses 

24from lilbee.providers.local_servers import ( 

25 LM_STUDIO, 

26 OLLAMA, 

27 LocalServerSpec, 

28 openai_models_url, 

29) 

30from lilbee.providers.local_servers.config_urls import configured_local_servers 

31from lilbee.providers.model_ref import format_remote_ref 

32from lilbee.providers.sdk_backend import PROVIDER_KEYS 

33 

34log = logging.getLogger(__name__) 

35 

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

37 

38_CLASSIFY_DEFAULT_TIMEOUT_S = 5.0 

39 

40 

41@lru_cache(maxsize=1) 

42def _discovery_client() -> httpx.Client: 

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

44 

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

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

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

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

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

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

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

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

53 """ 

54 return httpx.Client() 

55 

56 

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

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

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

60 

61 

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

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

64 

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

66 servers like LM Studio that report no family. 

67 """ 

68 name_lower = name.lower() 

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

70 return ModelTask.RERANK 

71 family_lower = family.lower() 

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

73 ep in name_lower for ep in EMBEDDING_NAME_PATTERNS 

74 ): 

75 return ModelTask.EMBEDDING 

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

77 return ModelTask.VISION 

78 return ModelTask.CHAT 

79 

80 

81def classify_remote_models( 

82 base_url: str, 

83 spec: LocalServerSpec, 

84 *, 

85 timeout: float = _CLASSIFY_DEFAULT_TIMEOUT_S, 

86) -> list[RemoteModel]: 

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

88 

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

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

91 classified correctly. A transport failure or a non-JSON body yields ``[]`` 

92 so read-only callers stay responsive when the backend is down. A listing 

93 the strategy cannot walk raises: the parse runs outside the request guard. 

94 """ 

95 discover = _DISCOVERY_BY_KEY[spec.key] 

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

97 

98 

99def classify_all_remote_models( 

100 *, 

101 timeout: float = _CLASSIFY_DEFAULT_TIMEOUT_S, 

102) -> list[RemoteModel]: 

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

104 result: list[RemoteModel] = [] 

105 for spec, base_url in configured_local_servers(): 

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

107 return result 

108 

109 

110def _discover_via_ollama_tags( 

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

112) -> list[RemoteModel]: 

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

114 try: 

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

116 resp.raise_for_status() 

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

118 except Exception: 

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

120 return [] 

121 

122 result: list[RemoteModel] = [] 

123 for model in raw_models: 

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

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

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

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

128 task = _classify_remote_task(name, family) 

129 result.append( 

130 RemoteModel( 

131 name=name, 

132 task=task, 

133 family=family, 

134 parameter_size=param_size, 

135 provider=provider, 

136 ) 

137 ) 

138 return result 

139 

140 

141def _discover_via_openai_models( 

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

143) -> list[RemoteModel]: 

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

145 

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

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

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

149 is intentionally not filtered to locally-downloaded models. 

150 """ 

151 try: 

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

153 resp.raise_for_status() 

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

155 except Exception: 

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

157 return [] 

158 

159 result: list[RemoteModel] = [] 

160 for model in raw_models: 

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

162 if not name: 

163 continue 

164 task = _classify_remote_task(name, "") 

165 result.append( 

166 RemoteModel( 

167 name=name, 

168 task=task, 

169 family="", 

170 parameter_size="", 

171 provider=provider, 

172 ) 

173 ) 

174 return result 

175 

176 

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

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

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

180 OLLAMA.key: _discover_via_ollama_tags, 

181 LM_STUDIO.key: _discover_via_openai_models, 

182} 

183 

184 

185def _chat_models_for(provider: LLMProvider, prov: str, display_name: str) -> list[RemoteModel]: 

186 """The backend's chat models for hosted provider *prov*, labeled *display_name*.""" 

187 return [ 

188 RemoteModel( 

189 name=model_name, 

190 task=ModelTask.CHAT, 

191 family="", 

192 parameter_size="", 

193 provider=display_name, 

194 ) 

195 for model_name in provider.list_chat_models(prov) 

196 ] 

197 

198 

199def discover_api_model_groups() -> list[ApiModelGroup]: 

200 """Hosted chat models for every provider with a key set, with the key's status. 

201 

202 Short-circuits before touching the SDK when no keys are present. Only 

203 providers that list models have their key checked. 

204 """ 

205 keyed = [(prov, label) for prov, _cfg, _env, label in PROVIDER_KEYS if provider_key_set(prov)] 

206 if not keyed: 

207 return [] 

208 provider = get_services().provider 

209 listed = [ 

210 (prov, label, models) 

211 for prov, label in keyed 

212 if (models := _chat_models_for(provider, prov, label)) 

213 ] 

214 statuses = provider_key_statuses([prov for prov, _label, _models in listed]) 

215 return [ 

216 ApiModelGroup(provider=prov, display_name=label, key_status=statuses[prov], models=models) 

217 for prov, label, models in listed 

218 ] 

219 

220 

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

222 """Hosted chat models grouped by provider display name, for accepted keys only.""" 

223 return { 

224 group.display_name: group.models 

225 for group in discover_api_model_groups() 

226 if group.key_status is KeyStatus.READY 

227 } 

228 

229 

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

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

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

233 

234 

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

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

237 

238 A local server or API that is down contributes an empty subset. An 

239 unreadable native registry raises ``OSError`` instead: answering from the 

240 remote sources alone would route a request for an installed local model 

241 to a hosted provider. 

242 """ 

243 refs = {m.ref for m in get_services().registry.list_installed()} 

244 for rm in classify_all_remote_models(): 

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

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

247 for rm in models: 

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

249 return refs 

250 

251 

252class KnownModelCache: 

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

254 

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

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

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

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

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

260 """ 

261 

262 DEFAULT_TTL_S = 30.0 

263 

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

265 self._ttl_s = ttl_s 

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

267 self._expires_at: float = 0.0 

268 self._generation: int = 0 

269 self._lock = Lock() 

270 

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

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

273 with self._lock: 

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

275 return self._refs 

276 captured_generation = self._generation 

277 fresh = frozenset(gather_known_model_refs()) 

278 with self._lock: 

279 self._refs = fresh 

280 if self._generation == captured_generation: 

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

282 return self._refs 

283 

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

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

286 

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

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

289 the full GGUF path, and an absolute path to a GGUF file on disk, which the 

290 fleet loads without a manifest. 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 install_state(model, get_services().registry) is InstallState.LOOSE_FILE: 

298 return model 

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

300 prefixed = OLLAMA.qualify(model) 

301 if prefixed in refs: 

302 return prefixed 

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

304 if len(aliased) == 1: 

305 return aliased[0] 

306 return None 

307 

308 def invalidate(self) -> None: 

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

310 with self._lock: 

311 self._expires_at = 0.0 

312 self._generation += 1