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
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Canonical write boundary for lilbee configuration."""
3from __future__ import annotations
5import types
6from dataclasses import dataclass
7from pathlib import Path
8from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
10from pydantic_core import PydanticUndefined
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)
26if TYPE_CHECKING:
27 from lilbee.modelhub.registry import ModelRegistry
29_MIN_CHUNK_SIZE = 64
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"})
39@dataclass(frozen=True)
40class SettingInfo:
41 """Externally-facing description of a single writable setting."""
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
54@dataclass(frozen=True)
55class SettingsUpdateResult:
56 """Outcome of an ``apply_settings_update`` call."""
58 updated: list[str]
59 reindex_required: bool
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)
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)
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
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
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))
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 )
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 )
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))
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))
162def _is_settable(key: str) -> bool:
163 return key in WRITABLE_CONFIG_FIELDS or key in MODEL_ROLE_FIELDS
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
173def _as_int_setting(value: Any) -> int | None:
174 """Coerce a settings value to int the way pydantic will, or None if not numeric.
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
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 )
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
225 return validate_model_task_assignment(key, value)
226 return value
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
256def _restore_snapshot(snapshot: dict[str, Any]) -> None:
257 for key, value in snapshot.items():
258 setattr(cfg, key, value)
261def _reload_changed_roles(changed_keys: set[str]) -> None:
262 """Off-thread reload for each changed model-role server; full off-thread drop otherwise.
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
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
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()
291def requires_services_reset(updates: dict[str, Any]) -> bool:
292 """True if applying *updates* would tear down and rebuild the Services singleton.
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)
302def provider_reset_refused_message(action: str) -> str:
303 """Shared user-facing refusal for a provider *action* on the HTTP server.
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 )
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
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
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
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
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
352 reapply_thread_pool_ceiling()
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.
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.
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 )
422def apply_ephemeral_model_swap(field: str, ref: str) -> None:
423 """Apply a chat/embedding model swap to cfg for this process only.
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)
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
445 get_services().store.initialize_meta_if_legacy()
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``).
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
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
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
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
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 )
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.
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)