Coverage for src/lilbee/app/settings.py: 100%

271 statements  

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

1"""Canonical write boundary for lilbee configuration.""" 

2 

3from __future__ import annotations 

4 

5import types 

6from dataclasses import dataclass 

7from pathlib import Path 

8from typing import TYPE_CHECKING, Any, Union, get_args, get_origin 

9 

10from pydantic_core import PydanticUndefined 

11 

12from lilbee.app.settings_map import SETTINGS_MAP, SettingDef, SettingGroup 

13from lilbee.config_meta import ( 

14 MODEL_ROLE_FIELDS, 

15 REINDEX_FIELDS, 

16 WRITABLE_CONFIG_FIELDS, 

17) 

18from lilbee.core import settings as persistent_settings 

19from lilbee.core.config import Config, cfg 

20from lilbee.core.config.keys import ( 

21 LOAD_AFFECTING_KEYS, 

22 PROVIDER_API_KEYS, 

23 PROVIDER_SWITCHING_KEYS, 

24) 

25 

26if TYPE_CHECKING: 

27 from lilbee.modelhub.registry import ModelRegistry 

28 

29_MIN_CHUNK_SIZE = 64 

30 

31# Path-typed writable fields whose pydantic "default" is the unresolved 

32# sentinel ``Path()`` (a literal "."). The actual default is computed by 

33# the model_validator at process start (data_root/documents, vault_base 

34# stays as None). Resetting these via the boundary would corrupt the 

35# install, so they are refused at the reset gate. 

36_NO_RESET_FIELDS: frozenset[str] = frozenset({"documents_dir"}) 

37 

38 

39@dataclass(frozen=True) 

40class SettingInfo: 

41 """Externally-facing description of a single writable setting.""" 

42 

43 key: str 

44 value: Any 

45 default: Any 

46 type: str 

47 nullable: bool 

48 group: SettingGroup 

49 help_text: str 

50 choices: tuple[str, ...] | None 

51 reindex_required: bool 

52 

53 

54@dataclass(frozen=True) 

55class SettingsUpdateResult: 

56 """Outcome of an ``apply_settings_update`` call.""" 

57 

58 updated: list[str] 

59 reindex_required: bool 

60 

61 

62_SCALAR_TYPE_NAMES: dict[type, str] = { 

63 bool: "bool", 

64 int: "int", 

65 float: "float", 

66 str: "str", 

67 Path: "str", 

68 type(None): "null", 

69} 

70_COLLECTION_ORIGINS = (list, frozenset, set, tuple) 

71_UNION_ORIGINS = (Union, types.UnionType) 

72 

73 

74def _annotation_name(annotation: Any) -> str: 

75 """Render a pydantic field annotation as a short MCP-friendly type string.""" 

76 origin = get_origin(annotation) 

77 if origin in _UNION_ORIGINS: 

78 return "|".join(_annotation_name(a) for a in get_args(annotation)) 

79 scalar = _SCALAR_TYPE_NAMES.get(annotation) 

80 if scalar is not None: 

81 return scalar 

82 if origin in _COLLECTION_ORIGINS: 

83 return "list" 

84 return getattr(annotation, "__name__", None) or str(annotation) 

85 

86 

87def _setting_default(key: str) -> Any: 

88 """Return the pydantic default for ``key``, or ``None`` if unset.""" 

89 info = Config.model_fields[key] 

90 if info.default_factory is not None: 

91 return info.default_factory() # type: ignore[call-arg] 

92 if info.default is PydanticUndefined: 

93 return None 

94 return info.default 

95 

96 

97def _is_write_only(key: str) -> bool: 

98 """Return True for fields persisted but never read back (API keys, hf_token).""" 

99 extra = Config.model_fields[key].json_schema_extra 

100 if isinstance(extra, dict): 

101 return bool(extra.get("write_only", False)) 

102 return False 

103 

104 

105def _public_writable_keys() -> list[str]: 

106 """Names of every writable config field minus write-only secrets.""" 

107 keys = set(WRITABLE_CONFIG_FIELDS) | set(MODEL_ROLE_FIELDS) 

108 return sorted(k for k in keys if not _is_write_only(k)) 

109 

110 

111def _setting_info(key: str, definition: SettingDef | None) -> SettingInfo: 

112 field_info = Config.model_fields[key] 

113 nullable = _is_nullable(key) 

114 group = definition.group if definition else SettingGroup.MODELS 

115 help_text = definition.help_text if definition else "" 

116 choices = definition.choices if definition else None 

117 return SettingInfo( 

118 key=key, 

119 value=getattr(cfg, key), 

120 default=_setting_default(key), 

121 type=_annotation_name(field_info.annotation), 

122 nullable=nullable, 

123 group=group, 

124 help_text=help_text, 

125 choices=choices, 

126 reindex_required=key in REINDEX_FIELDS, 

127 ) 

128 

129 

130def _parse_group(group: SettingGroup | str) -> SettingGroup: 

131 """Resolve a group value or label to a ``SettingGroup``. Case-insensitive on the value.""" 

132 if isinstance(group, SettingGroup): 

133 return group 

134 normalized = group.strip().lower() 

135 for candidate in SettingGroup: 

136 if candidate.value.lower() == normalized: 

137 return candidate 

138 raise ValueError( 

139 f"Unknown setting group: {group!r}. Valid groups: " 

140 f"{', '.join(g.value for g in SettingGroup)}" 

141 ) 

142 

143 

144def list_settings(group: SettingGroup | str | None = None) -> list[SettingInfo]: 

145 """List every writable non-secret setting, optionally filtered by group (case-insensitive).""" 

146 infos = [_setting_info(key, SETTINGS_MAP.get(key)) for key in _public_writable_keys()] 

147 if group is not None: 

148 wanted = _parse_group(group) 

149 infos = [info for info in infos if info.group == wanted] 

150 return sorted(infos, key=lambda info: (info.group.value, info.key)) 

151 

152 

153def get_setting(key: str) -> SettingInfo: 

154 """Return the ``SettingInfo`` for one writable non-secret key.""" 

155 if not _is_settable(key): 

156 raise KeyError(f"Unknown or read-only setting: {key}") 

157 if _is_write_only(key): 

158 raise KeyError(f"Setting '{key}' is write-only and cannot be read back") 

159 return _setting_info(key, SETTINGS_MAP.get(key)) 

160 

161 

162def _is_settable(key: str) -> bool: 

163 return key in WRITABLE_CONFIG_FIELDS or key in MODEL_ROLE_FIELDS 

164 

165 

166def _is_nullable(key: str) -> bool: 

167 """Return True if ``key`` accepts ``None`` to clear the persisted entry.""" 

168 if key in WRITABLE_CONFIG_FIELDS: 

169 return WRITABLE_CONFIG_FIELDS[key] 

170 return False 

171 

172 

173def _as_int_setting(value: Any) -> int | None: 

174 """Coerce a settings value to int the way pydantic will, or None if not numeric. 

175 

176 MCP settings_set forwards raw JSON, so a numeric setting can arrive as a 

177 string (``{"chunk_overlap": "1000"}``). The cross-field guards must compare 

178 the coerced int, not skip on the string and let pydantic accept an 

179 unvalidated value downstream. ``bool`` is excluded (it is not a meaningful 

180 chunk size) and non-numeric strings fall through to pydantic's type error. 

181 """ 

182 if isinstance(value, bool): 

183 return None 

184 if isinstance(value, int): 

185 return value 

186 if isinstance(value, str): 

187 try: 

188 return int(value.strip()) 

189 except ValueError: 

190 return None 

191 return None 

192 

193 

194def _validate(updates: dict[str, Any]) -> None: 

195 """Reject unknown keys, null on non-nullable, and out-of-range chunk sizes.""" 

196 for key, value in updates.items(): 

197 if not _is_settable(key): 

198 raise ValueError(f"Unknown or read-only setting: {key}") 

199 if value is None and not _is_nullable(key): 

200 raise ValueError(f"Setting '{key}' does not accept null") 

201 new_ttl = _as_int_setting(updates.get("engine_idle_ttl_minutes")) 

202 if new_ttl is not None and new_ttl < 0: 

203 raise ValueError("engine_idle_ttl_minutes must be >= 0 (0 keeps weights loaded)") 

204 new_chunk_size = _as_int_setting(updates.get("chunk_size")) 

205 if new_chunk_size is not None and new_chunk_size < _MIN_CHUNK_SIZE: 

206 raise ValueError(f"chunk_size must be >= {_MIN_CHUNK_SIZE}") 

207 effective_chunk_size = new_chunk_size if new_chunk_size is not None else cfg.chunk_size 

208 new_overlap = _as_int_setting(updates.get("chunk_overlap")) 

209 # Compare the effective overlap against the effective chunk_size so that 

210 # lowering chunk_size alone (below the already-persisted overlap) is caught, 

211 # not just an explicit new overlap. 

212 effective_overlap = new_overlap if new_overlap is not None else cfg.chunk_overlap 

213 if effective_overlap >= effective_chunk_size: 

214 raise ValueError( 

215 f"chunk_overlap ({effective_overlap}) must be < chunk_size ({effective_chunk_size})" 

216 ) 

217 

218 

219def _coerce_value(key: str, value: Any) -> Any: 

220 """Canonicalize value before cfg assignment; model-role slots run task validation.""" 

221 if key in MODEL_ROLE_FIELDS and isinstance(value, str): 

222 # heavy: role_validator pulls catalog + modelhub transitively (~300 ms) 

223 from lilbee.modelhub.role_validator import validate_model_task_assignment 

224 

225 return validate_model_task_assignment(key, value) 

226 return value 

227 

228 

229def _apply_with_rollback( 

230 updates: dict[str, Any], 

231) -> tuple[dict[str, Any], list[str], dict[str, Any]]: 

232 """Set each key on cfg with snapshot/rollback. Returns (persist, delete, snapshot).""" 

233 snapshot = {k: getattr(cfg, k) for k in updates} 

234 to_persist: dict[str, Any] = {} 

235 to_delete: list[str] = [] 

236 try: 

237 for key, raw in updates.items(): 

238 if raw is None: 

239 setattr(cfg, key, None) 

240 to_delete.append(key) 

241 continue 

242 setattr(cfg, key, _coerce_value(key, raw)) 

243 normalized = getattr(cfg, key) 

244 if isinstance(normalized, list): 

245 to_persist[key] = "\n".join(str(x) for x in normalized) 

246 else: 

247 # Hand the scalar over with its type intact so config.toml holds 

248 # `true` and `2560`, not `"True"` and `"2560"`. 

249 to_persist[key] = normalized 

250 except Exception: 

251 _restore_snapshot(snapshot) 

252 raise 

253 return to_persist, to_delete, snapshot 

254 

255 

256def _restore_snapshot(snapshot: dict[str, Any]) -> None: 

257 for key, value in snapshot.items(): 

258 setattr(cfg, key, value) 

259 

260 

261def _reload_changed_roles(changed_keys: set[str]) -> None: 

262 """Off-thread reload for each changed model-role server; full off-thread drop otherwise. 

263 

264 A model-role change (chat_model/embedding_model/reranker_model/vision_model) 

265 respawns only that role's server via the per-role reload, so unrelated roles 

266 keep serving uninterrupted. A genuinely role-agnostic load key (num_ctx, 

267 kv_cache_type) has no single owning role, so it falls back to dropping the 

268 whole fleet. Both paths run off the caller's thread, so the settings write 

269 never blocks on a slow stop-and-respawn. 

270 """ 

271 from lilbee.app.services import peek_services 

272 from lilbee.providers.roles import MODEL_FIELD_TO_ROLE 

273 

274 services = peek_services() 

275 if services is None: 

276 return 

277 changed_role_fields = changed_keys & MODEL_ROLE_FIELDS 

278 for field in changed_role_fields: 

279 services.reload_role(MODEL_FIELD_TO_ROLE[field]) 

280 if "vision_model" in changed_role_fields: 

281 # Register/unregister lilbee's xberg OCR backend on any vision-model 

282 # change (REST/MCP/TUI/CLI all funnel here), not just the REST route. 

283 from lilbee.data.extract.backends import BackendKind, sync_xberg_backend 

284 

285 sync_xberg_backend(BackendKind.OCR, services.provider) 

286 role_agnostic = (changed_keys & LOAD_AFFECTING_KEYS) - MODEL_ROLE_FIELDS 

287 if role_agnostic: 

288 services.provider.drop_loaded_models_async() 

289 

290 

291def requires_services_reset(updates: dict[str, Any]) -> bool: 

292 """True if applying *updates* would tear down and rebuild the Services singleton. 

293 

294 A provider switch reconstructs the provider via ``create_provider``, which 

295 only runs at services init, so it forces a full ``reset_services()``. Callers 

296 on the shared HTTP daemon use this to refuse the swap rather than tear the 

297 singleton down under concurrent in-flight handlers. 

298 """ 

299 return bool(set(updates) & PROVIDER_SWITCHING_KEYS) 

300 

301 

302def provider_reset_refused_message(action: str) -> str: 

303 """Shared user-facing refusal for a provider *action* on the HTTP server. 

304 

305 *action* is the verb shown to the user, e.g. ``"Switching"`` or 

306 ``"Resetting"``. Kept in one place so the daemon entry points (MCP 

307 settings_set / settings_reset, REST config) cannot drift apart. 

308 """ 

309 return ( 

310 f"{action} the model provider is unavailable on the HTTP server: it rebuilds " 

311 "the shared engine for every connected client. Change it from the CLI." 

312 ) 

313 

314 

315def _invalidate_caches(changed_keys: set[str]) -> None: 

316 """Drop every read-side cache whose freshness depends on a changed setting.""" 

317 if not changed_keys: 

318 return 

319 if changed_keys & MODEL_ROLE_FIELDS: 

320 # heavy: model_info reads GGUF headers with the gguf parser (~130 ms) 

321 from lilbee.modelhub.model_info import invalidate_cache as invalidate_arch_cache 

322 

323 invalidate_arch_cache() 

324 if changed_keys & LOAD_AFFECTING_KEYS: 

325 # heavy: app.services pulls the provider stack + lancedb (~70 ms) 

326 _reload_changed_roles(changed_keys) 

327 if "token_sizing" in changed_keys: 

328 # Register/unregister lilbee's xberg tokenizer backend when token_sizing is 

329 # toggled (via any settings path), so chunk sizing picks up the change 

330 # without waiting for a services rebuild. 

331 from lilbee.app.services import peek_services 

332 from lilbee.data.extract.backends import BackendKind, sync_xberg_backend 

333 

334 services = peek_services() 

335 if services is not None: 

336 sync_xberg_backend(BackendKind.TOKENIZER, services.provider) 

337 if changed_keys & PROVIDER_API_KEYS: 

338 # heavy: sdk_llm_provider pulls litellm fanout (~145 ms) 

339 from lilbee.providers.sdk_llm_provider import inject_provider_keys 

340 

341 inject_provider_keys() 

342 if changed_keys & PROVIDER_SWITCHING_KEYS: 

343 # Swap requires reconstructing the provider singleton via 

344 # providers.factory.create_provider, only called at services init. 

345 from lilbee.app.services import reset_services 

346 

347 reset_services() 

348 if "mcp_tool_threads" in changed_keys: 

349 # Resize the running server's thread pool now instead of only at startup. 

350 from lilbee.server.app import reapply_thread_pool_ceiling 

351 

352 reapply_thread_pool_ceiling() 

353 

354 

355def apply_settings_update( 

356 updates: dict[str, Any], 

357 *, 

358 allow_model_roles: bool = True, 

359) -> SettingsUpdateResult: 

360 """Validate, apply, persist, and invalidate caches for a batch of updates. 

361 

362 Atomic on validation: a rejection rolls every field back and writes 

363 nothing. Atomic on disk failure: an ``OSError`` from the TOML write, or a 

364 parse error reloading a corrupt config.toml, restores the in-memory 

365 snapshot before re-raising. Cache invalidation runs only after a 

366 successful persist. 

367 

368 Pass ``allow_model_roles=False`` to reject ``chat_model`` / 

369 ``embedding_model`` / ``vision_model`` / ``reranker_model`` at the 

370 boundary; the HTTP PATCH /api/config surface uses this to route role 

371 writes through PUT /api/models/<role>. 

372 """ 

373 if not allow_model_roles: 

374 rejected = MODEL_ROLE_FIELDS & set(updates) 

375 if rejected: 

376 offender = sorted(rejected)[0] 

377 raise ValueError( 

378 f"'{offender}' must be set through the dedicated model route, " 

379 "not the general settings update." 

380 ) 

381 _validate(updates) 

382 embed_in_batch = "embedding_model" in updates 

383 # Derived (not user-writable) fields applied alongside the validated batch. 

384 effective_updates = dict(updates) 

385 if embed_in_batch: 

386 # Pin the OLD ref into store meta before mutation, otherwise the 

387 # next read lazy-initializes meta from the NEW cfg and silently 

388 # hides the dimension drift. Runs even when the value is unchanged 

389 # so a legacy meta row is always canonicalized on the first swap 

390 # attempt. 

391 _pin_legacy_store_meta() 

392 # Track the new embedder's output width so a fresh index is built at 

393 # the right dimension (embedding_dim is derived, not in SETTINGS_MAP). 

394 dim = _embedder_dim_from_gguf(updates["embedding_model"]) 

395 if dim is not None: 

396 effective_updates["embedding_dim"] = dim 

397 to_persist, to_delete, snapshot = _apply_with_rollback(effective_updates) 

398 # embedding_dim is derived and applied to cfg in-memory, but the overlay 

399 # loader ignores it on reload (it is re-derived), so don't write it to disk. 

400 to_persist.pop("embedding_dim", None) 

401 try: 

402 if to_persist: 

403 persistent_settings.update_values(cfg.data_root, to_persist) 

404 if to_delete: 

405 persistent_settings.delete_values(cfg.data_root, to_delete) 

406 except (OSError, ValueError): 

407 # OSError from the write, or a TOMLDecodeError (ValueError) when 

408 # update/delete reloads a corrupt on-disk config.toml: either way the 

409 # in-memory snapshot must be restored so cfg matches what was persisted. 

410 _restore_snapshot(snapshot) 

411 raise 

412 _invalidate_caches(set(effective_updates)) 

413 reindex_required = bool(REINDEX_FIELDS & set(updates)) 

414 if embed_in_batch: 

415 reindex_required = reindex_required or _embed_reindex_required(updates["embedding_model"]) 

416 return SettingsUpdateResult( 

417 updated=sorted(updates), 

418 reindex_required=reindex_required, 

419 ) 

420 

421 

422def apply_ephemeral_model_swap(field: str, ref: str) -> None: 

423 """Apply a chat/embedding model swap to cfg for this process only. 

424 

425 Performs the same embedding side effects as the persisted path (legacy 

426 store meta pinned under the OLD ref first, then embedding_dim re-derived 

427 for the new one) so the mismatch gate and table width stay correct, but 

428 never writes config.toml. 

429 """ 

430 if field == "embedding_model": 

431 _pin_legacy_store_meta() 

432 dim = _embedder_dim_from_gguf(ref) 

433 setattr(cfg, field, ref) 

434 if dim is not None: 

435 cfg.embedding_dim = dim 

436 return 

437 setattr(cfg, field, ref) 

438 

439 

440def _pin_legacy_store_meta() -> None: 

441 """Pin the current embedding ref into store meta before swapping it.""" 

442 # heavy: ~100ms (lance + store init); only paid when embedding_model is in the batch. 

443 from lilbee.app.services import get_services 

444 

445 get_services().store.initialize_meta_if_legacy() 

446 

447 

448def _embedder_dim_from_gguf(ref: str, registry: ModelRegistry | None = None) -> int | None: 

449 """The embedder's output width from its GGUF header (``<arch>.embedding_length``). 

450 

451 None when the model can't be resolved or the header lacks the field. Cheap: a 

452 cached header read, no load. *registry* is forwarded to resolve the GGUF without 

453 ``get_services()`` (callers running inside its construction). 

454 """ 

455 from lilbee.providers.base import ProviderError 

456 from lilbee.providers.engine_params import resolve_model_path 

457 from lilbee.providers.gguf_meta import read_gguf_metadata 

458 

459 try: 

460 # resolve_model_path raises ProviderError for a non-native (ollama/SDK) ref, 

461 # which has no local GGUF -- those embedders carry no width to derive here. 

462 meta = read_gguf_metadata(resolve_model_path(ref, registry)) 

463 except (ProviderError, ValueError, OSError, RuntimeError, TypeError): 

464 return None 

465 raw = meta.get("embedding_length") if meta else None 

466 if not raw: 

467 return None 

468 try: 

469 dim = int(raw) 

470 except (TypeError, ValueError): 

471 return None 

472 return dim if dim > 0 else None 

473 

474 

475def reconcile_embedding_dim(registry: ModelRegistry | None = None) -> None: 

476 """Pin ``cfg.embedding_dim`` to the native embedder's GGUF width before the store 

477 is built; no-op for non-native embedders or an already-matching dim.""" 

478 dim = _embedder_dim_from_gguf(cfg.embedding_model, registry) 

479 if dim is not None and dim != cfg.embedding_dim: 

480 cfg.embedding_dim = dim 

481 

482 

483def _embed_reindex_required(new_ref: str) -> bool: 

484 """Compare *new_ref* to the persisted store meta; True if rebuild needed.""" 

485 from lilbee.app.services import get_services 

486 from lilbee.data.store.lance_helpers import refs_compatible 

487 

488 store = get_services().store 

489 store.canonicalize_meta_if_legacy() 

490 meta = store.get_meta() 

491 if meta is None: 

492 return False 

493 return not refs_compatible( 

494 meta["embedding_model"], new_ref, meta["embedding_dim"], meta["embedding_dim"] 

495 ) 

496 

497 

498def reset_settings(keys: list[str], *, skip_unresettable: bool = False) -> SettingsUpdateResult: 

499 """Reset each key to its pydantic default and apply through the write boundary. 

500 

501 Fields whose default is a known sentinel (currently ``documents_dir``, 

502 which resolves to ``data_root/documents`` at process start) are 

503 refused so a reset doesn't write the literal sentinel back. Pass 

504 ``skip_unresettable=True`` for bulk-reset gestures that should drop 

505 those fields rather than failing the whole batch. 

506 """ 

507 for key in keys: 

508 if not _is_settable(key): 

509 raise ValueError(f"Unknown or read-only setting: {key}") 

510 if key in _NO_RESET_FIELDS and not skip_unresettable: 

511 raise ValueError( 

512 f"'{key}' has no resettable default; pass an explicit value via settings_set." 

513 ) 

514 updates: dict[str, Any] = {} 

515 for key in keys: 

516 if key in _NO_RESET_FIELDS: 

517 continue 

518 default = _setting_default(key) 

519 if default is None and _is_nullable(key): 

520 updates[key] = None 

521 else: 

522 updates[key] = default 

523 return apply_settings_update(updates)