Coverage for src/lilbee/app/settings.py: 100%
570 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Canonical write boundary for lilbee configuration."""
3from __future__ import annotations
5import errno
6import logging
7import os
8import threading
9from collections.abc import Callable, Collection, Iterable, Iterator, Mapping
10from contextlib import contextmanager
11from dataclasses import dataclass, replace
12from enum import StrEnum
13from typing import TYPE_CHECKING, Any, TypeVar
15from pydantic import ValidationError
17from lilbee.app.settings_map import CHUNK_LIMIT_KEY, SETTINGS_MAP, SettingDef, SettingGroup
18from lilbee.config_meta import (
19 MODEL_ROLE_FIELDS,
20 PUBLIC_CONFIG_FIELDS,
21 REINDEX_FIELDS,
22 WRITABLE_CONFIG_FIELDS,
23)
24from lilbee.core import settings as persistent_settings
25from lilbee.core.config import CONFIG_FILE_NAME, Config, cfg
26from lilbee.core.config.defaults import SKIP_TOML_ENV
27from lilbee.core.config.enums import OcrMode, SettingSource
28from lilbee.core.config.keys import (
29 LOAD_AFFECTING_KEYS,
30 PROVIDER_SWITCHING_KEYS,
31)
32from lilbee.core.config.login_host import refuse_unless_host
33from lilbee.core.config.model import (
34 canonical_model_ref,
35 checked_write,
36 default_refusal,
37 refusal_sentence,
38 root_folders,
39)
40from lilbee.core.config.resolve import (
41 PROFILE_FIELDS,
42 Resolved,
43 SettingLayers,
44 builtin_value,
45 read_layers,
46 resolve,
47 resolve_all,
48)
49from lilbee.core.config.schema import field_type_name
50from lilbee.core.crawl_patterns import exclude_patterns_refusal
51from lilbee.core.project_state import dismiss_tip
52from lilbee.providers.reload_keys import FLEET_RELOAD_KEYS
53from lilbee.providers.roles import (
54 MODEL_FIELD_TO_ROLE,
55 ROLE_GATE_FIELD_TO_ROLE,
56)
57from lilbee.runtime.progress import OcrBackendUsed
59if TYPE_CHECKING:
60 from lilbee.modelhub.registry import ModelRegistry
62log = logging.getLogger(__name__)
64_MIN_CHUNK_SIZE = 64
66# Keys that decide whether scanned pages are read, and by which engine.
67OCR_SETTING_KEYS = frozenset({"ocr", "vision_model"})
68SCANNED_PAGES_LABEL = "Scanned pages"
69_SCANNED_PAGES_OFF = "skipped (ocr = off)"
70_SCANNED_PAGES_READ = "read by {engine}"
71_SCANNED_PAGES_ALL = "every page read by {engine} (ocr = all)"
72_TESSERACT_ENGINE = "Tesseract ({languages})"
73OCR_OFF_VISION_NOTICE = (
74 "Scanned pages stay skipped because ocr is off. Set ocr to auto to read them with {model}."
75)
77# Path-typed writable fields whose pydantic "default" is the unresolved
78# sentinel ``Path()`` (a literal "."). The actual default is computed by
79# the model_validator at process start (data_root/documents, vault_base
80# stays as None). Resetting these via the boundary would corrupt the
81# install, so they are refused at the reset gate.
82_NO_RESET_FIELDS: frozenset[str] = frozenset({"documents_dir"})
84# Serializes a whole settings update (apply, save, rollback, settle) across
85# threads. Held only inside _settings_transaction, so a plain lock (not
86# reentrant) is enough.
87_settings_write_lock = threading.Lock()
90class _LayerChange(StrEnum):
91 """A change to the setting layers whose resolved values are checked before it is written."""
93 RESET = "reset"
94 APPLY = "apply"
97@dataclass(frozen=True)
98class SettingInfo:
99 """Externally-facing description of a single writable setting."""
101 key: str
102 value: Any
103 default: Any
104 type: str
105 nullable: bool
106 group: SettingGroup
107 help_text: str
108 choices: tuple[str, ...] | None
109 reindex_required: bool
110 source: SettingSource
111 advanced: bool
114@dataclass(frozen=True)
115class SettingsUpdateResult:
116 """Outcome of an ``apply_settings_update`` call."""
118 updated: list[str]
119 reindex_required: bool
120 warnings: tuple[str, ...] = ()
123def scanned_pages_state() -> str:
124 """What happens to scanned pages under the current ocr and vision_model settings."""
125 backend = OcrBackendUsed.chosen(cfg.ocr, cfg.vision_model)
126 if backend is OcrBackendUsed.NONE:
127 return _SCANNED_PAGES_OFF
128 engine = (
129 cfg.vision_model
130 if backend is OcrBackendUsed.VISION
131 else _TESSERACT_ENGINE.format(languages="+".join(cfg.ocr_language))
132 )
133 template = _SCANNED_PAGES_ALL if cfg.ocr is OcrMode.ALL else _SCANNED_PAGES_READ
134 return template.format(engine=engine)
137def scanned_pages_line() -> str:
138 """The one status line for scanned pages, shown on every surface."""
139 return f"{SCANNED_PAGES_LABEL}: {scanned_pages_state()}"
142def ocr_off_vision_notice() -> tuple[str, ...]:
143 """The notice for a vision model that ocr = off keeps unused; empty otherwise."""
144 if cfg.vision_model and cfg.ocr is OcrMode.OFF:
145 return (OCR_OFF_VISION_NOTICE.format(model=cfg.vision_model),)
146 return ()
149def list_setting_value(raw: str) -> list[str]:
150 """The items of a list setting typed as text: one per line, trimmed, blank lines dropped.
152 The CLI and both Settings views use this parse. A comma stays in the item; a
153 field validator that accepts commas splits it on write.
154 """
155 return [line.strip() for line in raw.split("\n") if line.strip()]
158def _update_warnings(changed_keys: set[str]) -> tuple[str, ...]:
159 """The notice for a vision model picked while ocr = off keeps it unused."""
160 return ocr_off_vision_notice() if "vision_model" in changed_keys else ()
163def _is_write_only(key: str) -> bool:
164 """Return True for fields persisted but never read back (API keys, hf_token)."""
165 extra = Config.model_fields[key].json_schema_extra
166 if isinstance(extra, dict):
167 return bool(extra.get("write_only", False))
168 return False
171def _secret_refusal(key: str, error: ValidationError) -> str:
172 """What the field's validation says about a secret value, without the value itself."""
173 reasons = "; ".join(detail["msg"] for detail in error.errors(include_input=False))
174 return f"Setting '{key}' is refused: {reasons}"
177def _public_writable_keys() -> list[str]:
178 """Names of every writable config field minus write-only secrets and non-public fields."""
179 keys = set(WRITABLE_CONFIG_FIELDS) | set(MODEL_ROLE_FIELDS)
180 return sorted(k for k in keys if k in PUBLIC_CONFIG_FIELDS and not _is_write_only(k))
183def _setting_help(key: str, definition: SettingDef | None) -> str:
184 """The one documented description for *key*.
186 ``SettingDef.help_text`` wins because it is what the TUI already shows;
187 a field with no settings-map entry falls back to its own description.
188 """
189 if definition is not None and definition.help_text:
190 return definition.help_text
191 return Config.model_fields[key].description or ""
194def setting_sources() -> dict[str, SettingSource]:
195 """The source of every Config field's effective value, from one read of config.toml."""
196 return _sources(read_layers(cfg.data_root))
199def _sources(layers: SettingLayers) -> dict[str, SettingSource]:
200 """The source of every Config field's effective value under *layers*."""
201 return {key: _true_source(key, entry) for key, entry in resolve_all(layers).items()}
204def _true_source(key: str, entry: Resolved) -> SettingSource:
205 """The source of *entry*; a live value that differs from the built-in is not a built-in."""
206 if entry.source is SettingSource.BUILT_IN and getattr(cfg, key) != entry.value:
207 return SettingSource.AUTO
208 return entry.source
211def _reset_target(key: str, layers: SettingLayers) -> Any:
212 """The value a reset falls back to without an env var: the profile's, else the built-in."""
213 return layers.profile[key] if key in layers.profile else builtin_value(key)
216def profile_defaults() -> dict[str, Any]:
217 """The value the active profile sets for each setting it names.
219 A reset falls back to it instead of the built-in, so it is the default every surface shows.
220 """
221 return dict(read_layers(cfg.data_root).profile)
224def _setting_info(key: str, layers: SettingLayers, source: SettingSource) -> SettingInfo:
225 definition = SETTINGS_MAP.get(key)
226 nullable = _is_nullable(key)
227 group = definition.group if definition else SettingGroup.MODELS
228 help_text = _setting_help(key, definition)
229 choices = definition.choices if definition else None
230 advanced = definition.advanced if definition else False
231 return SettingInfo(
232 key=key,
233 value=getattr(cfg, key),
234 default=_reset_target(key, layers),
235 type=field_type_name(key),
236 nullable=nullable,
237 group=group,
238 help_text=help_text,
239 choices=choices,
240 reindex_required=key in REINDEX_FIELDS,
241 source=source,
242 advanced=advanced,
243 )
246def _parse_group(group: SettingGroup | str) -> SettingGroup:
247 """Resolve a group value or label to a ``SettingGroup``. Case-insensitive on the value."""
248 if isinstance(group, SettingGroup):
249 return group
250 normalized = group.strip().lower()
251 for candidate in SettingGroup:
252 if candidate.value.lower() == normalized:
253 return candidate
254 raise ValueError(
255 f"Unknown setting group: {group!r}. Valid groups: "
256 f"{', '.join(g.value for g in SettingGroup)}"
257 )
260def list_settings(group: SettingGroup | str | None = None) -> list[SettingInfo]:
261 """List every writable non-secret setting, optionally filtered by group (case-insensitive)."""
262 layers = read_layers(cfg.data_root)
263 sources = _sources(layers)
264 infos = [_setting_info(key, layers, sources[key]) for key in _public_writable_keys()]
265 if group is not None:
266 wanted = _parse_group(group)
267 infos = [info for info in infos if info.group == wanted]
268 return sorted(infos, key=lambda info: (info.group.value, info.key))
271def get_setting(key: str) -> SettingInfo:
272 """Return the ``SettingInfo`` for one writable non-secret key."""
273 if not _is_settable(key):
274 raise ValueError(f"Unknown or read-only setting: {key}")
275 if _is_write_only(key):
276 raise ValueError(f"Setting '{key}' is write-only and cannot be read back")
277 layers = read_layers(cfg.data_root)
278 return _setting_info(key, layers, _sources(layers)[key])
281def _is_settable(key: str) -> bool:
282 return key in WRITABLE_CONFIG_FIELDS or key in MODEL_ROLE_FIELDS
285def _refuse_unsettable(key: str) -> None:
286 """Raise ValueError for a key no writer accepts."""
287 if not _is_settable(key):
288 raise ValueError(f"Unknown or read-only setting: {key}")
291def _is_nullable(key: str) -> bool:
292 """Return True if ``key`` accepts ``None`` to clear the persisted entry."""
293 if key in WRITABLE_CONFIG_FIELDS:
294 return WRITABLE_CONFIG_FIELDS[key]
295 return False
298def _as_int_setting(value: Any) -> int | None:
299 """Coerce a settings value to int the way pydantic will, or None if not numeric.
301 MCP settings_set forwards raw JSON, so a numeric setting can arrive as a
302 string (``{"chunk_overlap": "1000"}``). The cross-field guards must compare
303 the coerced int, not skip on the string and let pydantic accept an
304 unvalidated value downstream. ``bool`` is excluded (it is not a meaningful
305 chunk size) and non-numeric strings fall through to pydantic's type error.
306 """
307 if isinstance(value, bool):
308 return None
309 if isinstance(value, int):
310 return value
311 if isinstance(value, str):
312 try:
313 return int(value.strip())
314 except ValueError:
315 return None
316 return None
319_CRAWL_LOGIN_KEYS = {
320 "crawl_authorization": "authorization",
321 "crawl_username": "username",
322 "crawl_password": "password",
323 "crawl_cookie": "cookie",
324}
327def _validate_crawl_login(updates: dict[str, Any]) -> None:
328 """Reject a stored crawl login that CrawlLogin refuses once *updates* apply."""
329 from lilbee.crawler.models import CrawlLogin
331 host = updates.get("crawl_login_host")
332 if isinstance(host, str):
333 refuse_unless_host(host)
334 # A value that is not text is left to the field's own type check, as for any string setting.
335 if not _CRAWL_LOGIN_KEYS.keys() & updates.keys() or not all(
336 isinstance(updates[key], str) for key in _CRAWL_LOGIN_KEYS if key in updates
337 ):
338 return
339 CrawlLogin(
340 **{
341 field: updates[key] if key in updates else getattr(cfg, key)
342 for key, field in _CRAWL_LOGIN_KEYS.items()
343 }
344 )
347def _validate(updates: dict[str, Any]) -> None:
348 """Reject unknown keys, null on non-nullable, and out-of-range chunk sizes."""
349 for key, value in updates.items():
350 _refuse_unsettable(key)
351 if value is None and not _is_nullable(key):
352 raise ValueError(f"Setting '{key}' does not accept null")
353 refusal = exclude_patterns_refusal(updates.get("crawl_exclude_patterns"))
354 if refusal is not None:
355 raise ValueError(refusal)
356 _validate_crawl_login(updates)
357 new_ttl = _as_int_setting(updates.get("engine_idle_ttl_minutes"))
358 if new_ttl is not None and new_ttl < 0:
359 raise ValueError("engine_idle_ttl_minutes must be >= 0 (0 keeps weights loaded)")
360 new_chunk_size = _as_int_setting(updates.get("chunk_size"))
361 if new_chunk_size is not None and new_chunk_size < _MIN_CHUNK_SIZE:
362 raise ValueError(f"chunk_size must be >= {_MIN_CHUNK_SIZE}")
363 effective_chunk_size = new_chunk_size if new_chunk_size is not None else cfg.chunk_size
364 new_overlap = _as_int_setting(updates.get("chunk_overlap"))
365 # Compare the effective overlap against the effective chunk_size so that
366 # lowering chunk_size alone (below the already-persisted overlap) is caught,
367 # not just an explicit new overlap.
368 effective_overlap = new_overlap if new_overlap is not None else cfg.chunk_overlap
369 if effective_overlap >= effective_chunk_size:
370 raise ValueError(
371 f"chunk_overlap ({effective_overlap}) must be < chunk_size ({effective_chunk_size})"
372 )
375def _coerce_value(key: str, value: Any) -> Any:
376 """Canonicalize value before cfg assignment; model-role slots run task validation."""
377 if key in MODEL_ROLE_FIELDS and isinstance(value, str):
378 # heavy: role_validator pulls catalog + modelhub transitively (~300 ms)
379 from lilbee.modelhub.role_validator import validate_model_task_assignment
381 return validate_model_task_assignment(key, value)
382 return checked_write(key, value)
385_T = TypeVar("_T")
388def _commit(change: Callable[[Config], _T]) -> _T:
389 """Make *change* on a copy of cfg, then publish every field it moved onto cfg in one step.
391 Each assignment validates on the copy, so a refusal raises before cfg moves. The
392 publish is one ``dict.update`` of keys cfg already holds: with the GIL, no other
393 thread runs inside it. A reader that reads two fields in two lookups can still read
394 one on each side of a commit. The whole field dict is compared, because the model
395 validator can re-derive a field the change does not name. It is compared against
396 cfg as it was at the copy, by value: a field another thread writes meanwhile keeps
397 its new value, and a re-derived equal value is not published.
399 ``model_copy`` is shallow, so *change* must assign a field and never mutate a
400 mutable value in place: an in-place change would reach cfg before the publish.
401 """
402 draft = cfg.model_copy()
403 base = dict(draft.__dict__)
404 result = change(draft)
405 moved = {
406 key: value
407 for key, value in draft.__dict__.items()
408 if value is not base[key] and value != base[key]
409 }
410 cfg.__dict__.update(moved)
411 cfg.__pydantic_fields_set__.update(moved)
412 return result
415def _set_each(config: Config, values: Mapping[str, Any]) -> None:
416 for key in values:
417 setattr(config, key, values[key])
420def _restore_values(values: Mapping[str, Any]) -> None:
421 """Put *values* back on cfg in one step."""
422 _commit(lambda draft: _set_each(draft, values))
425def _restore_in_memory(snapshot: SettingsSnapshot) -> None:
426 """Put the values and the embedding dimension of *snapshot* back on cfg; config.toml stays."""
427 _restore_values({**snapshot.values, "embedding_dim": snapshot.embedding_dim})
430def _apply_updates(config: Config, updates: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
431 """Set each key on *config*; raise on the first refusal. Returns (persist, delete)."""
432 to_persist: dict[str, Any] = {}
433 to_delete: list[str] = []
434 for key, raw in updates.items():
435 if raw is None:
436 setattr(config, key, None)
437 to_delete.append(key)
438 continue
439 try:
440 setattr(config, key, _coerce_value(key, raw))
441 except ValidationError as exc:
442 if _is_write_only(key):
443 raise ValueError(_secret_refusal(key, exc)) from None
444 raise ValueError(refusal_sentence(key, raw)) from None
445 normalized = getattr(config, key)
446 if isinstance(normalized, list):
447 to_persist[key] = "\n".join(str(x) for x in normalized)
448 else:
449 # Hand the scalar over with its type intact so config.toml holds
450 # `true` and `2560`, not `"True"` and `"2560"`.
451 to_persist[key] = normalized
452 return to_persist, to_delete
455def _reload_changed_roles(changed_keys: set[str]) -> None:
456 """Re-plan the fleet off-thread, so only the servers whose launch changed restart.
458 A model-role change (chat_model/embedding_model/reranker_model/vision_model) or
459 a setting that gates a role (ocr) names its role. A role-agnostic load key
460 (num_ctx, kv_cache_type, chunk_size) names none, and the same re-plan finds the
461 roles it moves. The reload applies current cfg, so one write needs one reload.
462 It runs off the caller's thread: the write never blocks on a slow respawn.
463 """
464 from lilbee.app.services import peek_services
466 services = peek_services()
467 if services is None:
468 return
469 changed_role_fields = changed_keys & MODEL_ROLE_FIELDS
470 field_to_role = MODEL_FIELD_TO_ROLE | ROLE_GATE_FIELD_TO_ROLE
471 reloaded = {field_to_role[field] for field in changed_keys & field_to_role.keys()}
472 for role in sorted(reloaded):
473 services.reload_role(role)
474 if "vision_model" in changed_role_fields:
475 # Register/unregister lilbee's xberg OCR backend on any vision-model
476 # change (REST/MCP/TUI/CLI all funnel here), not just the REST route.
477 from lilbee.data.extract.backends import BackendKind, sync_xberg_backend
479 sync_xberg_backend(BackendKind.OCR, services.provider)
480 role_agnostic = (changed_keys & LOAD_AFFECTING_KEYS) - MODEL_ROLE_FIELDS
481 if role_agnostic and not reloaded:
482 services.provider.replan_fleet()
485def requires_services_reset(updates: Iterable[str]) -> bool:
486 """True if applying *updates* would tear down and rebuild the Services singleton.
488 A provider switch reconstructs the provider via ``create_provider``, which
489 only runs at services init, so it forces a full ``reset_services()``. Callers
490 on the shared HTTP daemon use this to refuse the swap rather than tear the
491 singleton down under concurrent in-flight handlers.
492 """
493 return bool(set(updates) & PROVIDER_SWITCHING_KEYS)
496def provider_reset_refused_message(action: str) -> str:
497 """Shared user-facing refusal for a provider *action* on the HTTP server.
499 *action* is the verb shown to the user, e.g. ``"Switching"`` or
500 ``"Resetting"``. Kept in one place so the daemon entry points (MCP
501 settings_set / settings_reset, REST config) cannot drift apart.
502 """
503 return (
504 f"{action} the model provider is unavailable on the HTTP server: it rebuilds "
505 "the shared engine for every connected client. Change it from the CLI."
506 )
509def config_write_failure_message(exc: OSError) -> str:
510 """User-facing text for a failed config write; names the fix when the file is locked."""
511 return _config_failure_message(exc, "write")
514def config_read_failure_message(exc: OSError) -> str:
515 """User-facing text for a failed config read; names the fix when the file is locked."""
516 return _config_failure_message(exc, "read")
519def _config_failure_message(exc: OSError, verb: str) -> str:
520 if isinstance(exc, persistent_settings.ReadOnlyConfigError):
521 # The error text already names the file by its full path.
522 return f"{exc}."
523 detail = f"Could not {verb} {CONFIG_FILE_NAME}: {exc}."
524 if exc.errno in (errno.EACCES, errno.EPERM):
525 detail += f" Close the program that holds {CONFIG_FILE_NAME} open and try again."
526 return detail
529def _invalidate_caches(changed_keys: set[str]) -> None:
530 """Drop every read-side cache whose freshness depends on a changed setting."""
531 if not changed_keys:
532 return
533 if changed_keys & MODEL_ROLE_FIELDS:
534 # heavy: model_info reads GGUF headers with the gguf parser (~130 ms)
535 from lilbee.modelhub.model_info import invalidate_cache as invalidate_arch_cache
537 invalidate_arch_cache()
538 if changed_keys & FLEET_RELOAD_KEYS:
539 # heavy: app.services pulls the provider stack + lancedb (~70 ms)
540 _reload_changed_roles(changed_keys)
541 if "token_sizing" in changed_keys:
542 # Unregister lilbee's xberg tokenizer backend when token_sizing is turned
543 # off (via any settings path); the chunker binds it on demand when on.
544 from lilbee.app.services import peek_services
545 from lilbee.data.extract.backends import BackendKind, sync_xberg_backend
547 services = peek_services()
548 if services is not None:
549 sync_xberg_backend(BackendKind.TOKENIZER, services.provider)
550 if "mcp_tool_threads" in changed_keys:
551 # Resize the running server's thread pool now instead of only at startup.
552 from lilbee.server.app import reapply_thread_pool_ceiling
554 reapply_thread_pool_ceiling()
555 if "include_uncensored" in changed_keys:
556 # The picks memoize per process; drop them so the toggle takes
557 # effect on the next read instead of the next restart.
558 from lilbee.catalog.picks import reset_picks
560 reset_picks()
563def _run_deferred_provider_reset(needed: bool, *, after_failure: bool = False) -> None:
564 """Tear down the Services singleton for a provider switch, once the lock has released.
566 Blocks on the whole fleet stopping (each engine's stop-then-reap can
567 take a few seconds), which is why ``_settings_transaction`` defers it
568 until after ``_settings_write_lock`` releases.
569 """
570 if not needed:
571 return
572 from lilbee.app.services import reset_services
574 if not after_failure:
575 reset_services()
576 return
577 try:
578 reset_services()
579 except Exception:
580 log.exception("Provider reset failed while handling another exception")
583@dataclass
584class _SettingsTransaction:
585 """State one locked settings update carries to its exit-time provider reset."""
587 needs_provider_reset: bool = False
590@contextmanager
591def _settings_transaction() -> Iterator[_SettingsTransaction]:
592 """Hold ``_settings_write_lock`` for one settings update.
594 The body sets ``needs_provider_reset`` on the yielded transaction before
595 returning. The reset then runs after the lock releases: if the body
596 raised, a reset failure is logged and swallowed so the body's exception
597 reaches the caller unchanged; otherwise a reset failure propagates
598 normally.
599 """
600 txn = _SettingsTransaction()
601 try:
602 with _settings_write_lock:
603 yield txn
604 except Exception:
605 _run_deferred_provider_reset(txn.needs_provider_reset, after_failure=True)
606 raise
607 else:
608 _run_deferred_provider_reset(txn.needs_provider_reset)
611def apply_settings_update(
612 updates: dict[str, Any],
613 *,
614 allow_model_roles: bool = True,
615) -> SettingsUpdateResult:
616 """Validate, apply, persist, and invalidate caches for a batch of updates.
618 Atomic on validation: the batch validates on a copy of cfg, so a
619 rejection leaves cfg untouched and writes nothing. Atomic on disk
620 failure: an ``OSError`` from the TOML write, or a parse error
621 reloading a corrupt config.toml, restores the in-memory snapshot
622 before re-raising. Cache invalidation runs only after a
623 successful persist.
625 Pass ``allow_model_roles=False`` to reject ``chat_model`` /
626 ``embedding_model`` / ``vision_model`` / ``reranker_model`` at the
627 boundary; the HTTP PATCH /api/config surface uses this to route role
628 writes through PUT /api/models/<role>.
630 Runs inside ``_settings_transaction``, which holds the lock and owns
631 the provider-reset exception contract.
632 """
633 with _settings_transaction() as txn:
634 if not allow_model_roles:
635 _refuse_model_roles(updates)
636 _validate(updates)
637 null_keys = [key for key, value in updates.items() if value is None]
638 if null_keys:
639 _refuse_invalid_fallbacks(_values_after_reset(null_keys), _LayerChange.APPLY)
640 embed_in_batch = "embedding_model" in updates
641 if embed_in_batch:
642 # Pin the OLD ref into store meta before mutation, otherwise the
643 # next read lazy-initializes meta from the NEW cfg and silently
644 # hides the dimension drift. Runs even when the value is unchanged
645 # so a legacy meta row is always canonicalized on the first swap
646 # attempt.
647 _pin_legacy_store_meta()
648 before = snapshot_settings(updates)
650 def change(draft: Config) -> tuple[dict[str, Any], list[str]]:
651 # The embedder and its dim publish together, before config.toml takes the batch.
652 result = _apply_updates(draft, updates)
653 _rederive_from_resolved(draft, set(updates))
654 return result
656 to_persist, to_delete = _commit(change)
657 written = False
658 try:
659 # A write can fail part way: the update lands and the delete raises.
660 if to_persist:
661 persistent_settings.update_values(cfg.data_root, to_persist)
662 written = True
663 if to_delete:
664 persistent_settings.delete_values(cfg.data_root, to_delete)
665 except Exception as exc:
666 if written or not isinstance(exc, persistent_settings.ConfigChangedWhileReadError):
667 # No cache saw the new values yet, so none is dropped.
668 _undo_update(before, invalidate_caches=False)
669 else:
670 # The read refused before any write: the file stays the editor's.
671 _restore_in_memory(before)
672 raise
673 # Decide the reset now, before settle's other steps can raise.
674 txn.needs_provider_reset = bool(set(updates) & PROVIDER_SWITCHING_KEYS)
675 try:
676 file_unread_values = _values_when_file_unread(updates)
677 return _settle(set(updates), embed_in_batch=embed_in_batch, values=file_unread_values)
678 except Exception:
679 # A store error at the verdict comes after the write, so config.toml must go back too.
680 _undo_update(before, invalidate_caches=True)
681 raise
684def _values_when_file_unread(updates: dict[str, Any]) -> dict[str, Any] | None:
685 """The values to settle on under ``LILBEE_SKIP_TOML_CONFIG=1``; None when the file is read.
687 The resolver does not read config.toml under the switch, so it cannot see the value just
688 written. cfg holds the update, except where a variable names the setting (it still wins)
689 or the update clears it (the resolver gives the default).
690 """
691 if os.environ.get(SKIP_TOML_ENV) != "1":
692 return None
693 resolved = resolve_all(read_layers(cfg.data_root))
694 return {
695 key: resolved[key].value
696 if raw is None or resolved[key].source is SettingSource.ENV
697 else getattr(cfg, key)
698 for key, raw in updates.items()
699 }
702def _undo_update(before: SettingsSnapshot, *, invalidate_caches: bool) -> None:
703 """Put a failed update back; cfg goes back even when config.toml refuses the undo."""
704 try:
705 _put_back(before, invalidate_caches=invalidate_caches)
706 except Exception:
707 # The caller sees the update fail, so no field may keep its new value.
708 log.exception("Could not put the settings back after a failed update")
709 _restore_in_memory(before)
712@dataclass(frozen=True)
713class SettingsSnapshot:
714 """Settings as they stood: the values in cfg and the entries in config.toml."""
716 values: dict[str, Any]
717 saved: dict[str, Any]
718 # Derived from the embedder, and not always from a header a restore can read again.
719 embedding_dim: int
722def snapshot_settings(keys: Collection[str]) -> SettingsSnapshot:
723 """Record *keys* for ``restore_settings``. A key with no config.toml entry stays without one."""
724 user = read_layers(cfg.data_root).user
725 return SettingsSnapshot(
726 values={key: getattr(cfg, key) for key in keys},
727 saved={key: user[key] for key in keys if key in user},
728 embedding_dim=cfg.embedding_dim,
729 )
732def restore_settings(snapshot: SettingsSnapshot) -> None:
733 """Put cfg and config.toml back as *snapshot* holds them, so each source is as it was.
735 Raises ``OSError`` or ``ValueError`` when config.toml refuses the write, with cfg
736 left alone so that it still agrees with the file.
737 """
738 with _settings_transaction() as txn:
739 txn.needs_provider_reset = bool(snapshot.values.keys() & PROVIDER_SWITCHING_KEYS)
740 _put_back(snapshot)
743def _put_back(snapshot: SettingsSnapshot, *, invalidate_caches: bool = True) -> None:
744 """The body of ``restore_settings``. Caller holds ``_settings_write_lock``."""
745 if snapshot.saved:
746 persistent_settings.update_values(cfg.data_root, snapshot.saved)
747 persistent_settings.delete_values(
748 cfg.data_root, [key for key in snapshot.values if key not in snapshot.saved]
749 )
750 _restore_in_memory(snapshot)
751 if invalidate_caches:
752 _invalidate_caches(set(snapshot.values))
755def _refuse_model_roles(keys: Iterable[str]) -> None:
756 """Refuse model-role keys, which the dedicated model route owns."""
757 rejected = MODEL_ROLE_FIELDS & set(keys)
758 if rejected:
759 offender = sorted(rejected)[0]
760 raise ValueError(
761 f"'{offender}' must be set through the dedicated model route "
762 "(on the command line, lilbee model), not the general settings update."
763 )
766def _settle(
767 keys: set[str],
768 *,
769 embed_in_batch: bool,
770 changed: set[str] | None = None,
771 reindex_changed: set[str] | None = None,
772 values: Mapping[str, Any] | None = None,
773) -> SettingsUpdateResult:
774 """Set *keys* on cfg; rederive, invalidate and report.
776 The values come from the resolver, or from *values* when the caller has read them.
777 *changed* (default *keys*) is the keys that differ in cfg; *reindex_changed*
778 (default *changed*) is the keys that differ in config.toml.
779 """
780 effective = keys if changed is None else changed
781 reindex_effective = effective if reindex_changed is None else reindex_changed
783 def change(draft: Config) -> None:
784 if values is None:
785 persistent_settings.sync_from_resolver(draft, keys)
786 else:
787 _set_each(draft, {key: values[key] for key in keys})
788 _rederive_from_resolved(draft, effective)
790 try:
791 _commit(change)
792 finally:
793 _invalidate_caches(effective)
794 reindex_required = bool((REINDEX_FIELDS - inert_reindex_keys()) & reindex_effective)
795 if reindex_required or embed_in_batch:
796 if not index_has_chunks():
797 reindex_required = False
798 elif not reindex_required:
799 reindex_required = _embed_reindex_required()
800 return SettingsUpdateResult(
801 updated=sorted(keys),
802 reindex_required=reindex_required,
803 warnings=_update_warnings(effective),
804 )
807def apply_ephemeral_model_swap(field: str, ref: str) -> None:
808 """Apply a chat/embedding model swap to cfg for this process only.
810 Performs the same embedding side effects as the persisted path (legacy
811 store meta pinned under the OLD ref first, then embedding_dim re-derived
812 for the new one) so the mismatch gate and table width stay correct, but
813 never writes config.toml.
814 """
815 if field == "embedding_model":
816 _pin_legacy_store_meta()
817 dim = _embedder_dim_from_gguf(ref)
818 setattr(cfg, field, ref)
819 if dim is not None:
820 cfg.embedding_dim = dim
821 return
822 setattr(cfg, field, ref)
825def _pin_legacy_store_meta() -> None:
826 """Pin the current embedding ref into store meta before swapping it."""
827 # heavy: ~100ms (lance + store init); only paid when embedding_model is in the batch.
828 from lilbee.app.services import get_services
830 get_services().store.initialize_meta_if_legacy()
833def _embedder_dim_from_gguf(ref: str, registry: ModelRegistry | None = None) -> int | None:
834 """The embedder's output width from its GGUF header (``<arch>.embedding_length``).
836 None when the model can't be resolved or the header lacks the field. Cheap: a
837 cached header read, no load. *registry* is forwarded to resolve the GGUF without
838 ``get_services()`` (callers running inside its construction).
839 """
840 from lilbee.providers.base import ProviderError
841 from lilbee.providers.engine_params import resolve_model_path
842 from lilbee.providers.gguf_meta import read_gguf_metadata
844 try:
845 # resolve_model_path raises ProviderError for a non-native (ollama/SDK) ref,
846 # which has no local GGUF -- those embedders carry no width to derive here.
847 meta = read_gguf_metadata(resolve_model_path(ref, registry))
848 except (ProviderError, ValueError, OSError, RuntimeError, TypeError):
849 return None
850 raw = meta.get("embedding_length") if meta else None
851 if not raw:
852 return None
853 try:
854 dim = int(raw)
855 except (TypeError, ValueError):
856 return None
857 return dim if dim > 0 else None
860def reconcile_embedding_dim(
861 registry: ModelRegistry | None = None, config: Config | None = None
862) -> None:
863 """Pin the embedding dim of *config* (default cfg) to the native embedder's GGUF width;
864 no-op for non-native embedders or an already-matching dim."""
865 target = cfg if config is None else config
866 dim = _embedder_dim_from_gguf(target.embedding_model, registry)
867 if dim is not None and dim != target.embedding_dim:
868 target.embedding_dim = dim
871def _rederive_from_resolved(config: Config, keys: set[str]) -> None:
872 """Recompute on *config* each setting derived from one of *keys*."""
873 if "embedding_model" in keys:
874 reconcile_embedding_dim(config=config)
877def inert_reindex_keys() -> set[str]:
878 """Reindex keys that change no extraction output under the effective config.
880 ``table_model`` is read only inside layout detection, and ``topic_threshold``
881 only inside semantic chunking, so a change to either while its gating flag is
882 off is not worth a rebuild.
883 """
884 inert: set[str] = set()
885 if not cfg.layout_detection:
886 inert.add("table_model")
887 if not cfg.semantic_chunking:
888 inert.add("topic_threshold")
889 return inert
892def reindex_values() -> dict[str, Any]:
893 """The value in force of each setting that changes what the index holds.
895 That is each of ``REINDEX_FIELDS`` and the chunk limit, which holds files out.
896 """
897 return {key: getattr(cfg, key) for key in REINDEX_FIELDS | {CHUNK_LIMIT_KEY}}
900def chunk_limit_loosened(before: Mapping[str, Any]) -> bool:
901 """Whether the chunk limit now in force admits more chunks than the one in *before*."""
902 old, new = before.get(CHUNK_LIMIT_KEY), cfg.max_chunks_per_file
903 return old is not None and old != 0 and (new == 0 or new > old)
906def reindex_values_differ(before: Mapping[str, Any]) -> bool:
907 """Whether the index, built with *before*, needs a rebuild for the values now in force.
909 The same rule as a single save: a changed setting counts unless it is inert under the
910 effective config, and an index with no chunks has nothing to rebuild.
911 """
912 changed = {key for key, value in reindex_values().items() if before.get(key) != value}
913 return bool((REINDEX_FIELDS - inert_reindex_keys()) & changed) and index_has_chunks()
916def index_has_chunks() -> bool:
917 """True when the index holds at least one chunk; a rebuild has nothing to change otherwise.
919 The store answers without opening the library when the library has no folder,
920 and no provider is built to ask.
921 """
922 from lilbee.app.services import chunks_in_store
924 return chunks_in_store()
927def _embed_reindex_required() -> bool:
928 """True when the persisted index was built with another embedder than cfg now names.
930 Runs after the swap is applied, so the store compares its meta row against
931 the new ref and the new model's width: the same verdict search refuses on.
932 """
933 from lilbee.app.services import get_services
935 store = get_services().store
936 store.canonicalize_meta_if_legacy()
937 return store.index_mismatch() is not None
940def embed_pick_needs_rebuild(ref: str) -> bool:
941 """True when picking *ref* as the embedder leaves the index unsearchable.
943 The verdict ``_embed_reindex_required`` reads after a swap, asked before it:
944 the store's meta row against *ref* and its width, with cfg left alone.
945 """
946 from lilbee.app.services import get_services
948 store = get_services().store
949 store.initialize_meta_if_legacy()
950 try:
951 canonical = canonical_model_ref(_coerce_value("embedding_model", ref))
952 except ValueError: # the write refuses this ref on its own
953 canonical = ref
954 dim = _embedder_dim_from_gguf(canonical) or cfg.embedding_dim
955 return store.index_mismatch(model=canonical, dim=dim) is not None
958def reset_settings(
959 keys: list[str], *, skip_unresettable: bool = False, allow_model_roles: bool = True
960) -> SettingsUpdateResult:
961 """Remove each key from config.toml and set cfg to the value the resolver then gives.
963 ``documents_dir`` has no default to fall back to, so it is refused; pass
964 ``skip_unresettable=True`` for bulk gestures that skip it instead.
966 Runs inside ``_settings_transaction``, which holds the lock and owns
967 the provider-reset exception contract.
968 """
969 with _settings_transaction() as txn:
970 if not allow_model_roles:
971 _refuse_model_roles(keys)
972 for key in keys:
973 _refuse_unsettable(key)
974 if key in _NO_RESET_FIELDS and not skip_unresettable:
975 raise ValueError(f"'{key}' has no default to reset to; set a folder path instead.")
976 targets = [key for key in keys if key not in _NO_RESET_FIELDS]
977 before = _values_before_reset(targets)
978 fallbacks = _values_after_reset(targets)
979 coerced = _refuse_invalid_fallbacks(fallbacks, _LayerChange.RESET)
980 _validate({key: entry.value for key, entry in fallbacks.items()})
981 # compared in cfg's own terms
982 changed = {key for key in targets if getattr(cfg, key) != coerced[key]}
983 # compared on the file's resolved value
984 reindex_changed = {key for key in targets if before[key].value != fallbacks[key].value}
985 if "embedding_model" in changed:
986 _pin_legacy_store_meta()
987 persistent_settings.delete_values(cfg.data_root, targets)
988 # Decide the reset now, before settle's other steps can raise.
989 txn.needs_provider_reset = bool(changed & PROVIDER_SWITCHING_KEYS)
990 return _settle(
991 set(targets),
992 embed_in_batch="embedding_model" in reindex_changed,
993 changed=changed,
994 reindex_changed=reindex_changed,
995 )
998def _values_before_reset(keys: list[str]) -> dict[str, Resolved]:
999 """The value and source each of *keys* currently resolves to, saved value included."""
1000 layers = read_layers(cfg.data_root)
1001 return {key: resolve(key, layers) for key in keys}
1004def _values_after_reset(keys: list[str]) -> dict[str, Resolved]:
1005 """The value and source each of *keys* resolves to once its user entry is gone."""
1006 layers = read_layers(cfg.data_root)
1007 remaining = replace(
1008 layers, user={key: value for key, value in layers.user.items() if key not in keys}
1009 )
1010 return {key: resolve(key, remaining) for key in keys}
1013def _refuse_invalid_fallbacks(
1014 fallbacks: dict[str, Resolved], action: _LayerChange
1015) -> dict[str, Any]:
1016 """Refuse an *action* when a key would resolve to a value its field rejects.
1018 Returns each key's value after the same coercion a cfg assignment applies
1019 (e.g. an env var's raw string cast to int), so a caller can compare it
1020 against cfg's current value in the same terms.
1021 """
1022 trial = cfg.model_copy()
1023 for key, entry in fallbacks.items():
1024 value = builtin_value(key) if default_refusal(key, entry.value) else entry.value
1025 try:
1026 setattr(trial, key, value)
1027 except ValueError as exc:
1028 raise ValueError(
1029 f"Cannot {action.value} '{key}': its {entry.source.value} value {entry.value!r} is "
1030 "invalid. Fix or remove that value first."
1031 ) from exc
1032 return {key: getattr(trial, key) for key in fallbacks}
1035def apply_profile_layer(
1036 name: str,
1037 values: Mapping[str, Any],
1038 *,
1039 absorb: Collection[str] = (),
1040 write_first: Callable[[], object] | None = None,
1041) -> SettingsUpdateResult:
1042 """Record *name* and *values* as the project's profile and set cfg from the new layers.
1044 Every key the old or new profile holds is validated at the value it resolves to
1045 under the new profile before anything is written; user and env values keep winning.
1046 Each *absorb* key, which *values* must hold, leaves config.toml in the same write.
1047 Applying a profile hides the analyze tip.
1048 *write_first* runs once validation passes, before config.toml changes.
1050 Runs inside ``_settings_transaction``, which holds the lock and owns
1051 the provider-reset exception contract.
1052 """
1053 with _settings_transaction() as txn:
1054 refused = sorted(set(values) - set(PROFILE_FIELDS))
1055 if refused:
1056 raise ValueError(f"Profiles cannot set {refused[0]}")
1057 stray = sorted(set(absorb) - set(values))
1058 if stray:
1059 raise ValueError(f"The profile does not hold {stray[0]}, so it cannot take it over")
1060 layers = read_layers(cfg.data_root)
1061 user = {key: value for key, value in layers.user.items() if key not in absorb}
1062 after = replace(layers, user=user, profile=dict(values))
1063 keys = set(layers.profile) | set(values)
1064 resolved = {key: resolve(key, after) for key in sorted(keys)}
1065 checked = _refuse_invalid_fallbacks(resolved, _LayerChange.APPLY)
1066 _validate({key: entry.value for key, entry in resolved.items()})
1067 if write_first is not None:
1068 write_first()
1069 persistent_settings.write_profile_table(cfg.data_root, name, values, drop=absorb)
1070 _hide_analyze_tip()
1071 # Decide the reset now, before settle's other steps can raise.
1072 txn.needs_provider_reset = bool(keys & PROVIDER_SWITCHING_KEYS)
1073 # Under LILBEE_SKIP_TOML_CONFIG=1 the resolver cannot read the profile just written,
1074 # so settle on the checked values resolved from the layers built above.
1075 file_unread = os.environ.get(SKIP_TOML_ENV) == "1"
1076 settled = checked if file_unread else None
1077 return replace(
1078 _settle(keys, embed_in_batch=False, values=settled),
1079 warnings=ocr_off_vision_notice(),
1080 )
1083@dataclass(frozen=True)
1084class ConfigReading:
1085 """What config.toml set when this process last read it.
1087 ``values`` is what the startup overlay sets from the file, by key. ``held`` is the
1088 value a held key takes when the file stops setting it, as at startup: a folder
1089 derived from the data root, or the model cfg held when the TUI started.
1090 """
1092 stamp: tuple[int, int, int] | None
1093 values: Mapping[str, Any]
1094 held: Mapping[str, Any]
1097# The source registry has its own reader, which a running sync's hold on it stops.
1098_RELOADED_KEYS = (set(WRITABLE_CONFIG_FIELDS) | MODEL_ROLE_FIELDS) - {"linked_roots"}
1099_ABSENT = object()
1102def read_config_state() -> ConfigReading:
1103 """Read config.toml under cfg's data root the way startup does, in one locked read."""
1104 stamp, values = persistent_settings.read_persisted(cfg.data_root)
1105 return ConfigReading(stamp, values or {}, _held_values())
1108def start_config_reading() -> ConfigReading:
1109 """The reading a follower starts from: the one the settings were loaded from.
1111 A write that lands after the CLI loaded config.toml and before the follower starts
1112 is then a change the follower takes. Without a load of cfg's data root, the file is
1113 read now.
1114 """
1115 loaded = persistent_settings.last_overlay(cfg.data_root)
1116 if loaded is None:
1117 return read_config_state()
1118 return ConfigReading(loaded.stamp, loaded.values, _held_values())
1121def _held_values() -> dict[str, Any]:
1122 """The value each held key takes when the file stops setting it, as cfg holds it now."""
1123 folders = root_folders(cfg.data_root)
1124 return {
1125 key: folders[key] if key in folders else getattr(cfg, key)
1126 for key in persistent_settings.HELD_AT_BUILT_IN & _RELOADED_KEYS
1127 }
1130def _registry_refreshed() -> bool:
1131 """Read the source registry again unless a running sync holds it; whether it changed."""
1132 before = cfg.linked_roots
1133 persistent_settings.refresh_registry(cfg)
1134 return cfg.linked_roots != before
1137def adopt_external_changes(
1138 last: ConfigReading,
1139 *,
1140 hold: frozenset[str] = frozenset(),
1141 reset_providers: bool = True,
1142) -> tuple[ConfigReading, SettingsUpdateResult | None]:
1143 """Set on cfg each key another process changed in config.toml since *last*.
1145 Costs two stat() calls when the file is unchanged. The new values come from the
1146 same function startup uses, from one read under the lock a writer takes. A key is
1147 taken only when its value moved since *last* and cfg does not hold it yet, so a
1148 value this process set for itself alone (a one-off command-line flag) stays unless
1149 the file changed that key. A held key the file stops setting goes back to its
1150 startup value. The source registry is read by ``refresh_registry``: while a sync
1151 holds it, it keeps its folders, and a later check takes the change. The keys go
1152 through the same settle step as an update made here. A file that does not parse
1153 changes nothing and keeps *last*, as at startup. A key in *hold* is never taken: a
1154 warning says the process takes it at its next start. With *reset_providers* False a
1155 provider switch leaves the fleet running: the caller stops it with
1156 ``reset_for_provider_switch`` once the lock it holds around this call is free.
1157 Returns the new reading and the update result, or None when no key changed.
1158 """
1159 roots_changed = _registry_refreshed()
1160 if persistent_settings.overlay_stamp(cfg.data_root) == last.stamp and not roots_changed:
1161 return last, None
1162 with _settings_transaction() as txn:
1163 stamp, values = persistent_settings.read_persisted(cfg.data_root)
1164 now = last if values is None else replace(last, stamp=stamp, values=values)
1165 targets: dict[str, Any] = {}
1166 for key in _RELOADED_KEYS:
1167 value = now.values.get(key, _ABSENT)
1168 if value == last.values.get(key, _ABSENT):
1169 continue
1170 if value is _ABSENT:
1171 value = last.held.get(key, _ABSENT)
1172 if value is not _ABSENT and value != getattr(cfg, key):
1173 targets[key] = value
1174 for key in sorted(targets.keys() & hold):
1175 del targets[key]
1176 log.warning("config.toml now sets %s; restart this process to use it.", key)
1177 if not targets and not roots_changed:
1178 return now, None
1179 txn.needs_provider_reset = reset_providers and requires_services_reset(targets)
1180 result = _settle(set(targets), embed_in_batch=False, values=targets)
1181 if roots_changed:
1182 result = replace(result, updated=sorted({*result.updated, "linked_roots"}))
1183 return now, result
1186def reset_for_provider_switch(result: SettingsUpdateResult | None) -> None:
1187 """Stop the fleet when *result*, a take that left the reset to its caller, switched provider."""
1188 _run_deferred_provider_reset(result is not None and requires_services_reset(result.updated))
1191def _hide_analyze_tip() -> None:
1192 """Hide the analyze tip once a profile is applied; a failed write must not fail the apply."""
1193 try:
1194 dismiss_tip(cfg.data_root)
1195 except OSError as exc:
1196 log.warning("Could not record that the analyze tip is hidden: %s", exc)