Coverage for src/lilbee/core/config/model.py: 100%
641 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"""The :class:`Config` dataclass and the ``cfg`` singleton.
3The settings sources and the TOML parser live here too. Every
4``from lilbee.core.config import cfg`` resolves through ``lilbee.core.config.__init__``
5to the same instance defined at module bottom.
6"""
8import logging
9import os
10import re
11from collections.abc import Mapping
12from enum import Enum
13from pathlib import Path
14from typing import Any, ClassVar
16from pydantic import Field, ValidationError, ValidationInfo, field_validator, model_validator
17from pydantic_core import ErrorDetails
18from pydantic_settings import BaseSettings, SettingsConfigDict
20from lilbee.core.system import scaled_chat_ctx_target_default
22from .defaults import (
23 CONFIG_FILE_NAME,
24 DEFAULT_ALLOWED_NER_LABELS,
25 DEFAULT_CORS_ORIGIN_REGEX,
26 DEFAULT_CRAWL_EXCLUDE_PATTERNS,
27 DEFAULT_GENERAL_SYSTEM_PROMPT,
28 DEFAULT_IGNORE_DIRS,
29 DEFAULT_RAG_SYSTEM_PROMPT,
30 ENGINE_IDLE_TTL_MINUTES_DEFAULT,
31 ENV_PREFIX,
32 MAX_CRAWL_CONCURRENCY,
33 MAX_CRAWL_DEPTH,
34 MAX_CRAWL_PAGES,
35 MAX_CRAWL_WAIT_SECONDS,
36 exclude_pattern_lines,
37)
38from .enums import (
39 ChatMode,
40 ClustererBackend,
41 CrawlRenderMode,
42 FtsLanguage,
43 KvCacheType,
44 LlmProvider,
45 OcrMode,
46 OcrPageStrategy,
47 ProfileScope,
48 ReasoningMode,
49 RerankerType,
50 TableModel,
51 WikiEntityMode,
52)
53from .load_warnings import (
54 SHOWN_ENV_VAR,
55 collecting,
56 refuse_variable,
57 shown_marks,
58 warn_on_load,
59)
60from .login_host import refuse_unless_host
61from .parsing import (
62 parse_bool,
63 parse_gpu_device_list,
64 parse_optional_int,
65 parse_tristate_bool,
66)
67from .validators import ConfigField
69log = logging.getLogger(__name__)
71# Sentinel for unset Path-typed fields. ``Field(default=Path())`` produces an
72# instance equal to this, so the model_validator can distinguish "user passed
73# the default" from "user explicitly set a value".
74_UNSET_PATH = Path()
76# A Tesseract language code: ISO 639 letters plus script or orientation suffixes
77# (eng, en, chi_sim, jpn_vert). xberg rejects anything else before extracting.
78_TESSERACT_LANGUAGE_CODE = re.compile(r"[a-z]{2,3}(?:_[a-z]+)*")
80# A control character, which a crawl request header value refuses.
81CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]")
83# Model roles that can be off. An empty LILBEE_<FIELD> or config.toml value
84# clears one of these; on every other field an empty value counts as unset.
85CLEARABLE_MODEL_FIELDS = frozenset({"vision_model", "reranker_model"})
87# What a refused value is not, by pydantic error type. An error outside this
88# map carries its own text.
89_EXPECTED_BY_ERROR: dict[str, str] = {
90 "int_parsing": "a whole number",
91 "int_from_float": "a whole number",
92 "int_type": "a whole number",
93 "float_parsing": "a number",
94 "float_type": "a number",
95 "bool_parsing": "true or false",
96 "bool_type": "true or false",
97 "string_type": "text",
98 "path_type": "a path",
99 "list_type": "a list",
100 "dict_type": "a table of names and values",
101 "greater_than_equal": "{ge} or more",
102 "greater_than": "more than {gt}",
103 "less_than_equal": "{le} or less",
104 "less_than": "less than {lt}",
105}
106_VALUE_ERROR_PREFIX = "Value error, "
107_USES_ITS_DEFAULT = "uses its default"
109# A variable its setting refuses stops the command and a write of one is refused,
110# except on these: each takes its default, automatic or off, with a warning.
111_TAKES_DEFAULT_WHEN_REFUSED = frozenset(
112 {"flash_attention", "n_gpu_layers", "gpu_devices", "semantic_chunking"}
113)
115# A key that is not a setting, by name, and what to use instead. A stored or
116# exported one is refused out loud rather than silently ignored.
117_NOT_SETTINGS: dict[str, str] = {
118 # llama-server reads it only under split mode none, which no fleet launch uses.
119 "main_gpu": "choose the cards with gpu_devices or placement",
120}
122# A field no environment string parses into, by name, and what to use instead.
123# Its variable is ignored with a warning, as the resolver ignores it.
124_NOT_FROM_ENV: dict[str, str] = {
125 "linked_roots": "register a folder with `lilbee add`, or edit linked_roots in config.toml",
126}
129def value_is_set(field_name: str, raw: object) -> bool:
130 """Whether an env or config.toml value is set: not blank, or blank on a clearable model role."""
131 if raw is None:
132 return False
133 # A source hands over a string or any TOML type; only a string can be blank.
134 blank = isinstance(raw, str) and not raw.strip()
135 return not blank or field_name in CLEARABLE_MODEL_FIELDS
138def _as_int(item: Any) -> int:
139 """A force_ocr_pages entry as an int: an int, or a string of digits."""
140 if isinstance(item, str) and item.strip().lstrip("-").isdigit():
141 return int(item)
142 # bool is an int subclass; True is not a page number.
143 if isinstance(item, int) and not isinstance(item, bool):
144 return item
145 raise ValueError(f"force_ocr_pages: {item!r} is not a page number")
148def _split_page_item(item: Any) -> list[Any]:
149 """A string force_ocr_pages item split on commas and newlines; other items as-is."""
150 if isinstance(item, str):
151 return item.replace("\n", ",").split(",")
152 return [item]
155def _page_number(item: Any) -> int:
156 """One force_ocr_pages entry as a 1-indexed page."""
157 page = _as_int(item)
158 if page < 1:
159 raise ValueError(f"force_ocr_pages: page numbers start at 1 (got {page})")
160 return page
163class Config(BaseSettings):
164 """Runtime configuration: one singleton instance, mutated by CLI overrides."""
166 model_config = SettingsConfigDict(
167 env_prefix=ENV_PREFIX,
168 validate_assignment=True,
169 arbitrary_types_allowed=True,
170 extra="ignore",
171 )
173 # Paths: resolved from env/defaults in model_validator(mode='before')
174 data_root: Path = Field(
175 default=Path(),
176 description=(
177 "Root directory for this library. Resolved at start from LILBEE_DATA, "
178 "then a .lilbee/ directory walked up from the working directory, then "
179 "the platform default. Every other path below hangs off it"
180 ),
181 )
182 # Writable so plugin-managed servers can pivot storage to a vault path on
183 # first boot; rebuild the index after migrating.
184 documents_dir: Path = ConfigField(default=Path(), writable=True)
185 # External source roots ``add`` registered, mapping label -> absolute path.
186 # lilbee indexes the files where they live (no copy, no symlink); the label
187 # prefixes their source keys so a root at /data/corpus keys as ``corpus/…``.
188 # Managed by ``add`` / ``remove``, so it is writable (persisted to
189 # config.toml) but not surfaced in the settings UI.
190 linked_roots: dict[str, str] = ConfigField(
191 default_factory=dict,
192 writable=True,
193 public=False,
194 from_env=False,
195 description=(
196 "External source roots that `add` registered, as label -> absolute path. "
197 "`add` and `remove` maintain it; do not edit it by hand"
198 ),
199 )
200 data_dir: Path = Field(
201 default=Path(),
202 description="Directory holding the database. Defaults to data_root/data",
203 )
204 lancedb_dir: Path = Field(
205 default=Path(),
206 description=(
207 "Directory holding the LanceDB vector tables. Defaults to data_root/data/lancedb"
208 ),
209 )
210 models_dir: Path = Field(
211 default=Path(),
212 description=(
213 "Directory holding downloaded model files. Shared across libraries, so a "
214 "model pulled for one is available to all"
215 ),
216 )
217 # Markdown vault root; when set, search results carry a vault-relative
218 # ``vault_path`` so a host UI can deep-link into the vault.
219 vault_base: Path | None = ConfigField(default=None, writable=True)
221 # Human-readable label for the active lilbee. Empty falls back to
222 # "global" for the platform default dir, otherwise the project path
223 # (~-substituted and left-truncated to a hard cap).
224 lilbee_name: str = ConfigField(default="", writable=True)
225 # If True, the status bar pill shows the full absolute path: expands
226 # "global" to the on-disk platform-default path and skips the
227 # ~-substitution / left-truncation for project paths. Toggled by F4.
228 show_lilbee_path: bool = ConfigField(default=False, writable=True)
230 # Whether an agent launcher (opencode, hermes) registers lilbee's MCP search
231 # tool into the agent's config. Per-launch --mcp/--no-mcp overrides it.
232 agent_mcp_enabled: bool = ConfigField(default=True, writable=True)
234 # Empty = not configured, same convention as vision_model. A fresh install
235 # has no models; the catalog assigns these on the first download.
236 chat_model: str = Field(default="")
237 embedding_model: str = Field(default="")
238 # Vision OCR model for scanned PDFs and image-only pages. Empty = disabled;
239 # there is no cross-role fallback onto the chat model even if multimodal.
240 vision_model: str = ConfigField(default="", public=True)
241 embedding_dim: int = Field(
242 default=768,
243 ge=1,
244 description=(
245 "Vector width the index is built with. The embedding model sets it; "
246 "change it only to match a model lilbee cannot introspect"
247 ),
248 )
249 chunk_size: int = ConfigField(
250 default=512, ge=64, writable=True, profile=ProfileScope.INGEST, reindex=True
251 )
252 chunk_overlap: int = ConfigField(
253 default=100, ge=0, writable=True, profile=ProfileScope.INGEST, reindex=True
254 )
255 # A file over this many chunks is skipped before embedding; 0 lifts the ceiling.
256 max_chunks_per_file: int = ConfigField(
257 default=3_000, ge=0, writable=True, profile=ProfileScope.INGEST
258 )
259 # Workers for the parallel discovery/hash planning pass. 0 = auto, sized to
260 # the container-aware CPU budget (see runtime.cpu.available_cpu_count).
261 # `add --max-cpus N` sets this per invocation. Sizes only the planning pass,
262 # not the GPU-fed extract/embed batch.
263 ingest_workers: int = ConfigField(default=0, ge=0, writable=True, derived=True)
264 # Worker PROCESSES for a bulk ingest (distinct from ingest_workers, which sizes
265 # the planning pass's threads). Each owns a GPU and its own slice of the
266 # corpus, and writes that slice to the one index.
267 # 0 = auto: one worker per visible card, used once the corpus is big enough to
268 # pay for them. N pins the count; worker i takes card i % card_count, so more
269 # workers than cards share a card's engine rather than double-booking it.
270 ingest_processes: int = ConfigField(default=0, ge=0, writable=True, derived=True)
271 # Passages packed into one embed request. Larger batches keep a GPU's
272 # continuous-batching slots full: small per-passage requests leave the card
273 # batch-starved (~96% util, low throughput). The engine still re-splits to
274 # its physical batch, so raising this only helps up to the server's --batch.
275 embed_batch_sequences: int = ConfigField(
276 default=64,
277 ge=1,
278 writable=True,
279 description=(
280 "Passages packed into one embed request. Larger batches keep a GPU's "
281 "continuous-batching slots full. The engine re-splits to its physical "
282 "batch, so raising this helps only up to the server's --batch"
283 ),
284 )
285 # Files allowed in their compute phase at once during ingest. 0 = auto: the
286 # ceiling scales with the detected embed fleet (replicas x per-replica
287 # in-flight) so a multi-GPU box is kept fed without a manual cap, falling back
288 # to the CPU quota on a single card. Set a positive value only to override the
289 # auto sizing. Sizes the extract+embed fan-out, not the plan pass.
290 ingest_max_inflight: int = ConfigField(
291 default=0,
292 ge=0,
293 writable=True,
294 description=(
295 "Files allowed in their compute phase at once during ingest. 0 = auto, "
296 "scaled to the detected embed fleet. Sizes the extract and embed fan-out, "
297 "not the planning pass"
298 ),
299 )
300 # Gate for the pre-ask sync; --no-sync overrides per invocation.
301 auto_sync: bool = ConfigField(default=True, writable=True)
302 max_embed_chars: int = Field(
303 default=2000,
304 ge=1,
305 description="Maximum characters sent to the embedding model per chunk. Longer text is cut",
306 )
307 top_k: int = ConfigField(default=12, ge=1, writable=True, profile=ProfileScope.RETRIEVAL)
308 max_distance: float = ConfigField(
309 default=0.75, ge=0.0, writable=True, profile=ProfileScope.RETRIEVAL
310 )
311 # Abstention floor against the [0, 1] fused relevance score (0.0 = no
312 # filtering). When every retrieved chunk falls below it, ask refuses instead
313 # of feeding noise as context. The fused score normalizes against the
314 # configured weight budget (a constant), so an arm's top hit scores a stable
315 # share of it; useful floors start around 0.4. Tune against your own corpus.
316 min_relevance_score: float = ConfigField(
317 default=0.0, ge=0.0, writable=True, profile=ProfileScope.RETRIEVAL
318 )
319 adaptive_threshold: bool = ConfigField(
320 default=False, writable=True, profile=ProfileScope.RETRIEVAL
321 )
322 rag_system_prompt: str = ConfigField(
323 default=DEFAULT_RAG_SYSTEM_PROMPT, min_length=1, writable=True
324 )
325 general_system_prompt: str = ConfigField(
326 default=DEFAULT_GENERAL_SYSTEM_PROMPT, min_length=1, writable=True
327 )
328 chat_mode: ChatMode = ConfigField(default=ChatMode.SEARCH, writable=True)
329 ignore_dirs: frozenset[str] = Field(
330 default=DEFAULT_IGNORE_DIRS,
331 description=(
332 "Directory names ingest never walks (.git, node_modules, and similar). "
333 "Use a .lilbeeignore file for per-library patterns"
334 ),
335 )
336 # Which pages OCR reads; vision_model picks the engine (set: vision, empty: Tesseract).
337 ocr: OcrMode = ConfigField(default=OcrMode.AUTO, writable=True, profile=ProfileScope.INGEST)
338 # Per-page timeout in seconds for vision OCR (0 = no limit). Sized so a dense
339 # full-page scan finishes on modest hardware; a raised vision_ocr_max_tokens
340 # needs matching headroom here.
341 ocr_timeout: float = ConfigField(default=300.0, ge=0.0, writable=True)
342 # Outer wall-clock budget for the streamed pool drain: load grace plus
343 # per_page * pages. Tune up for slow hardware (M1 Pro vision is
344 # ~5min/page) or down for fast hardware. ocr_timeout still governs the
345 # per-page expectation that drives the total budget.
346 vision_load_budget_s: float = ConfigField(default=300.0, ge=0.0, writable=True)
347 # Hard cap on tokens generated per OCR page. A real page is well under this;
348 # the vision request's repeat penalty stops a page looping one line, and the cap
349 # bounds any loop that still escapes it. Raising it lengthens per-page
350 # generation on dense scans, so give ocr_timeout matching headroom.
351 vision_ocr_max_tokens: int = ConfigField(default=4096, ge=256, writable=True)
352 # Pages OCR'd concurrently, and the vision server's continuous-batching slots.
353 # A single-page decode underutilizes a modern GPU (~half SM); batching several
354 # pages raises throughput. Each slot adds KV cache, so lower it on small GPUs.
355 vision_ocr_concurrency: int = ConfigField(default=4, ge=1, writable=True)
357 # Tesseract OCR language codes for the scanned-document fallback (used when no
358 # vision model is set), e.g. ["eng"] or ["eng", "deu"]. Set via env as
359 # LILBEE_OCR_LANGUAGE="eng+deu". xberg requires a non-empty list.
360 ocr_language: list[str] = ConfigField(
361 default_factory=lambda: ["eng"], writable=True, profile=ProfileScope.INGEST
362 )
363 # PDF pages xberg OCRs. auto = pages whose native text fails its quality
364 # check; scanned_pages also OCRs every page graded as a scan.
365 ocr_strategy: OcrPageStrategy = ConfigField(
366 default=OcrPageStrategy.AUTO, writable=True, profile=ProfileScope.INGEST
367 )
368 # Scan-grade threshold for scanned_pages. A slide with a full-bleed
369 # background image grades 0.5, so lower this to OCR such slides too.
370 ocr_scan_confidence: float = ConfigField(
371 default=0.7, ge=0.0, le=1.0, writable=True, profile=ProfileScope.INGEST
372 )
373 # 1-indexed pages that lilbee OCRs in every PDF; while set, it replaces the
374 # ocr_strategy page selection. Env form: LILBEE_FORCE_OCR_PAGES="1,3".
375 force_ocr_pages: list[int] = ConfigField(
376 default_factory=list, writable=True, profile=ProfileScope.INGEST
377 )
378 # Typed entity table for exact counting/cross-referencing; corpus-scale pass, off by default.
379 entity_extraction: bool = ConfigField(default=False, writable=True, profile=ProfileScope.INGEST)
380 semantic_chunking: bool = ConfigField(
381 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True
382 )
383 # Boundary-similarity threshold; only applied when semantic_chunking is on.
384 topic_threshold: float = ConfigField(
385 default=0.75, ge=0.0, le=1.0, writable=True, profile=ProfileScope.INGEST, reindex=True
386 )
387 # Size chunks in real tokens via the embedder's tokenizer backend, not the
388 # chars-per-token heuristic. Plain/heading chunkers only; semantic sizes by chars.
389 token_sizing: bool = ConfigField(
390 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True
391 )
392 # Index each recognized table as its own markdown-serialized chunk.
393 table_extraction: bool = ConfigField(
394 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True
395 )
396 # Layout-aware PDF extraction (reading-order sort, header/footer stripping),
397 # run in xberg's AUTO strategy so detection only fires when it helps. Off by
398 # default: enabling it downloads the ONNX layout and table-structure models
399 # and adds per-page inference, which a CPU-only ingest pays for.
400 layout_detection: bool = ConfigField(
401 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True
402 )
403 # Table structure model; only applied when layout_detection is on.
404 table_model: TableModel = ConfigField(
405 default=TableModel.SLANET_AUTO, writable=True, reindex=True
406 )
407 # Wall-clock cap per file for one xberg extraction, seconds. 0 = no cap.
408 # xberg's own default is 600s and applies on its batch path, so leaving this
409 # unset drops a slow file at ten minutes with no lilbee knob to raise it.
410 # Zero matches the uncapped single-file path, and ingest is background work.
411 extraction_timeout: int = ConfigField(default=0, ge=0, writable=True)
412 # Coalesce concurrent extractions into one xberg extract_batch call.
413 batch_extraction: bool = ConfigField(default=False, writable=True)
414 batch_extraction_size: int = ConfigField(default=8, ge=1, writable=True)
415 # Files analyze extracts; a larger corpus is sampled evenly down to this many.
416 analyze_max_files: int = ConfigField(default=500, ge=1, writable=True)
417 # xberg's shared thread budget: PDF rendering, OCR and ONNX inference. It
418 # also bounds concurrent Tesseract sessions, which xberg further limits to
419 # what free memory holds. 0 = auto, runtime.cpu.cpu_quota() (half the usable
420 # CPUs). The rayon pool is fixed at the first extraction, so a change takes
421 # full effect after a restart.
422 extraction_threads: int = ConfigField(default=0, ge=0, writable=True, derived=True)
423 # Size of anyio's thread pool: synchronous handlers (MCP tools, sync routes)
424 # that may run off the event loop at once. The ceiling on agents one daemon
425 # serves before their calls queue.
426 mcp_tool_threads: int = ConfigField(default=40, ge=1, writable=True)
427 server_host: str = Field(
428 default="127.0.0.1",
429 description=(
430 "Address `lilbee serve` binds. Loopback by default; set 0.0.0.0 to accept "
431 "connections from the network"
432 ),
433 )
434 server_port: int = Field(
435 default=0,
436 ge=0,
437 le=65535,
438 description="Port `lilbee serve` binds. 0 picks a free port and prints it",
439 )
440 cors_origins: list[str] = Field(
441 default_factory=list,
442 description=(
443 "Extra browser origins the HTTP server accepts, in addition to cors_origin_regex"
444 ),
445 )
446 cors_origin_regex: str = Field(
447 default=DEFAULT_CORS_ORIGIN_REGEX,
448 description="Regular expression matching browser origins the HTTP server accepts",
449 )
450 # Seconds between SSE heartbeat events when the producer queue is idle.
451 # Must stay well below the plugin's STREAM_IDLE_TIMEOUT_MS (120s) so a
452 # single long-running vision OCR page can't starve the client into aborting.
453 sse_heartbeat_interval: float = ConfigField(default=30.0, ge=0.0, writable=True)
454 json_mode: bool = Field(
455 default=False,
456 description=(
457 "Emit structured JSON from CLI commands. The --json flag sets it for one invocation"
458 ),
459 )
460 temperature: float | None = ConfigField(default=0.1, ge=0.0, writable=True)
461 top_p: float | None = ConfigField(default=0.9, ge=0.0, le=1.0, writable=True)
462 top_k_sampling: int | None = ConfigField(default=40, ge=1, writable=True)
463 # 1.1 is llama.cpp's default. Leaving this at None caused n-gram loops
464 # ("tire tire tire...") on some open-weights models.
465 repeat_penalty: float | None = ConfigField(default=1.1, ge=0.0, writable=True)
466 num_ctx: int | None = ConfigField(default=None, ge=1, writable=True, derived=True)
467 # None sends no cap: the engine stops at the served context, which the fleet
468 # already fits the prompt to, so a long reasoning answer is not cut at a fixed number.
469 max_tokens: int | None = ConfigField(default=None, ge=1, writable=True)
470 seed: int | None = ConfigField(default=None, writable=True)
471 llm_provider: LlmProvider = ConfigField(default=LlmProvider.AUTO, writable=True)
472 # Path to a llama-server binary. Empty = use the bundled lilbee-engine
473 # wheel binary, else a llama-server on PATH.
474 llama_server_path: str = ConfigField(default="", writable=True)
475 # Per-server local model-manager URLs. Blank means "use the server's spec
476 # default" (resolved in providers.local_servers.config_urls); the default
477 # URL literal lives only in the spec, which core must not import.
478 ollama_base_url: str = ConfigField(default="", writable=True)
479 lm_studio_base_url: str = ConfigField(default="", writable=True)
480 llm_api_key: str = ConfigField(default="", writable=True, write_only=True)
481 openrouter_api_key: str = ConfigField(default="", writable=True, write_only=True)
482 gemini_api_key: str = ConfigField(default="", writable=True, write_only=True)
483 anthropic_api_key: str = ConfigField(default="", writable=True, write_only=True)
484 openai_api_key: str = ConfigField(default="", writable=True, write_only=True)
485 mistral_api_key: str = ConfigField(default="", writable=True, write_only=True)
486 deepseek_api_key: str = ConfigField(default="", writable=True, write_only=True)
487 hf_token: str = ConfigField(default="", writable=True, write_only=True)
489 # Retrieval quality knobs.
491 # Max chunks per source in top-k; prevents one large file monopolizing results.
492 diversity_max_per_source: int = ConfigField(
493 default=5, ge=1, writable=True, profile=ProfileScope.RETRIEVAL
494 )
496 # MMR relevance/diversity tradeoff; 0 = max diversity, 1 = pure relevance
497 # (Carbonell & Goldstein 1998).
498 mmr_lambda: float = ConfigField(
499 default=0.5, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL
500 )
502 # Vector-only search retrieves this many candidates per final result so
503 # MMR reranking has a pool to diversify from. Hybrid search ignores it:
504 # fusion arms stay exactly top_k deep.
505 candidate_multiplier: int = ConfigField(
506 default=3, ge=1, writable=True, profile=ProfileScope.RETRIEVAL
507 )
509 # Third lexical arm in hybrid search: BM25 over document titles, fused with
510 # the vector and chunk arms so a query naming a document by title surfaces
511 # its chunks. Off by default until the eval harness measures it.
512 title_search: bool = ConfigField(default=False, writable=True, profile=ProfileScope.RETRIEVAL)
514 # Title arm weight relative to a full arm in rank fusion (1.0 = equal voice
515 # with the vector and chunk arms).
516 title_search_weight: float = ConfigField(
517 default=0.5, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL
518 )
520 # Lexical (BM25) arm weight relative to the vector arm in rank fusion.
521 # 1.0 gives the two arms equal voice; lowering it lets
522 # a strong dense embedder dominate on corpora where the lexical arm adds
523 # noise rather than signal. The right value is corpus-dependent and set by
524 # the retrieval benchmark, not guessed here.
525 lexical_fusion_weight: float = ConfigField(
526 default=1.0, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL
527 )
529 # Adaptive fusion: scale the BM25 arm per query by vector-arm confidence
530 # instead of a fixed lexical_fusion_weight (a peaked dense ranking downweights
531 # lexical, a flat one keeps it). OFF by default, pending a benchmark run to
532 # confirm it beats the fixed weight. lexical_fusion_weight is the ceiling the
533 # rule scales down from. Set adaptive_fusion=true to enable it.
534 adaptive_fusion: bool = ConfigField(
535 default=False, writable=True, profile=ProfileScope.RETRIEVAL
536 )
538 # Vector-similarity margin at which the lexical arm is fully silenced; smaller
539 # = more aggressive downweighting. 0 disables adaptation entirely (the lexical
540 # arm keeps its full fixed weight).
541 adaptive_fusion_margin: float = ConfigField(
542 default=0.15, ge=0.0, le=2.0, writable=True, profile=ProfileScope.RETRIEVAL
543 )
545 # Stemmer/stop-word language for the BM25 (FTS) indexes, a tantivy language
546 # name ("English", "German", "French", ...). Applied when an index is
547 # (re)built, so changing it needs `lilbee rebuild` on an existing store.
548 # A bad name would otherwise fail index creation quietly and hybrid search
549 # would degrade to vector-only.
550 fts_language: FtsLanguage = ConfigField(
551 default=FtsLanguage.ENGLISH, writable=True, profile=ProfileScope.RETRIEVAL, reindex=True
552 )
554 @field_validator("fts_language", mode="before")
555 @classmethod
556 def _validate_fts_language(cls, value: Any) -> FtsLanguage:
557 """Accept a language name in any casing, with surrounding whitespace."""
558 try:
559 return FtsLanguage(str(value).strip().title())
560 except ValueError as exc:
561 valid = ", ".join(member.value for member in FtsLanguage)
562 raise ValueError(f"fts_language must be one of: {valid}") from exc
564 # Prefix each chunk's document title to its embedding input (the stored
565 # chunk text is unchanged). Changes the embedding space: toggling it needs
566 # `lilbee rebuild`, so it ships off.
567 embed_titles: bool = ConfigField(
568 default=False, writable=True, profile=ProfileScope.RETRIEVAL, reindex=True
569 )
571 # Contextual retrieval: prepend one LLM-written sentence situating each
572 # chunk in its document to the embedding input. One generation per chunk,
573 # so ingest slows substantially; stored text and citations stay verbatim.
574 # Toggling needs `lilbee rebuild`.
575 contextual_enrichment: bool = ConfigField(
576 default=False, writable=True, profile=ProfileScope.RETRIEVAL, reindex=True
577 )
579 # Drop tables-of-contents and classification-banner cover/title pages from
580 # search results. OFF by default; validate per corpus, since the cover-page
581 # heuristic can also fire on short banner-carrying body pages. A query-matched
582 # or top-ranked page is never dropped, so removal is limited to structural
583 # chunks the query did not hit.
584 filter_structural_chunks: bool = ConfigField(
585 default=False, writable=True, profile=ProfileScope.RETRIEVAL
586 )
588 # Chunk count at/above which sync builds an approximate (ANN) vector index
589 # so search stays fast at millions of vectors. Below this, search uses exact
590 # flat scan (faster and exact for small vaults). 0 disables the ANN index.
591 ann_index_threshold: int = ConfigField(
592 default=50_000, ge=0, writable=True, profile=ProfileScope.RETRIEVAL
593 )
595 # Condense a follow-up question into a standalone retrieval query using
596 # the chat history (one LLM call; skipped when there is no history).
597 # Without it, "what about his brother?" is embedded and BM25-matched
598 # with its pronouns.
599 history_rewrite: bool = ConfigField(
600 default=False, writable=True, profile=ProfileScope.RETRIEVAL
601 )
603 # Route questions by shape before top-k retrieval: a question naming a
604 # document resolves to that document's chunks; a count-shaped question
605 # runs a full-corpus scan (a count is a corpus property top-k cannot
606 # answer). Unrecognized shapes take the topical path unchanged.
607 intent_routing: bool = ConfigField(default=True, writable=True, profile=ProfileScope.RETRIEVAL)
609 # Ask the chat model to classify count questions the deterministic
610 # patterns miss (phrasing variants, other languages). Adds one short LLM
611 # call to every turn the patterns don't already route, so it's opt-in.
612 intent_llm: bool = ConfigField(default=False, writable=True, profile=ProfileScope.RETRIEVAL)
614 # LLM-generated alternative queries for expansion. 0 disables.
615 query_expansion_count: int = ConfigField(
616 default=3, ge=0, writable=True, profile=ProfileScope.RETRIEVAL
617 )
619 # Skip LLM expansion when tokenized query length ≤ this. The LLM round-trip
620 # dominates latency on small local models; short queries already have strong
621 # BM25/vector signal. Concept-graph expansion still runs. 0 disables the skip.
622 expansion_short_query_tokens: int = ConfigField(
623 default=2, ge=0, writable=True, profile=ProfileScope.RETRIEVAL
624 )
626 # Cosine-distance step when adaptive-widening retry kicks in.
627 adaptive_threshold_step: float = ConfigField(
628 default=0.2, gt=0.0, writable=True, profile=ProfileScope.RETRIEVAL
629 )
631 # Reject expansion variants below expansion_similarity_threshold.
632 expansion_guardrails: bool = ConfigField(
633 default=True, writable=True, profile=ProfileScope.RETRIEVAL
634 )
636 # Min cosine similarity between question and variant embeddings.
637 expansion_similarity_threshold: float = ConfigField(
638 default=0.5, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL
639 )
641 # Saturating BM25 confidence (s / (s + 5)) above which query expansion is
642 # skipped; 0.8 corresponds to a raw BM25 score of 20.
643 expansion_skip_threshold: float = Field(
644 default=0.8,
645 ge=0.0,
646 le=1.0,
647 description=(
648 "Saturating BM25 confidence above which query expansion is skipped. "
649 "0.8 corresponds to a raw BM25 score of 20"
650 ),
651 )
653 # Min relative BM25 top-1 vs top-2 gap ((top - second) / top) to skip expansion.
654 expansion_skip_gap: float = Field(
655 default=0.15,
656 ge=0.0,
657 le=1.0,
658 description=(
659 "Minimum relative BM25 gap between the top two hits that skips query expansion"
660 ),
661 )
663 # Chunks included in LLM context after adaptive selection.
664 max_context_sources: int = ConfigField(
665 default=8, ge=1, writable=True, profile=ProfileScope.RETRIEVAL
666 )
668 # Adjacent chunks pulled from the same source on each side of every
669 # selected chunk and merged into one contiguous passage, so a hit that
670 # lands mid-argument regains the text before and after it. 0 disables.
671 # Capped: it is a small chunk radius (useful values are single digits), and
672 # the merged text is token-budget-bounded anyway, so a large value only
673 # inflates per-query fetch cost -- and a misread as a token count (e.g.
674 # 50000) would build a megabyte-long IN-predicate per source.
675 neighbor_expansion: int = ConfigField(
676 default=0, ge=0, le=100, writable=True, profile=ProfileScope.RETRIEVAL
677 )
679 # HyDE (Gao et al. 2022): hypothetical-answer embedding search. +~500ms.
680 hyde: bool = ConfigField(default=False, writable=True, profile=ProfileScope.RETRIEVAL)
682 # HyDE result weight relative to real-doc search (0.0-1.0).
683 hyde_weight: float = ConfigField(
684 default=0.7, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL
685 )
687 # HyDE prompt template. Must contain {question} placeholder.
688 hyde_prompt: str = Field(
689 default=(
690 "Write a 50-100 word passage that directly answers this question as if "
691 "it were an excerpt from a real document. Do not include any preamble, "
692 "just write the passage.\n\nQuestion: {question}"
693 ),
694 description=(
695 "Prompt template HyDE uses to write the hypothetical answer. Must contain {question}"
696 ),
697 )
699 # Reranker model ref. Empty disables reranking. Native GGUFs run on
700 # llama-server (rank pooling or LLM logprob scoring); hosted refs
701 # (cohere/voyage/jina/together/hf-tei) need the backend extra.
702 reranker_model: str = ConfigField(default="", public=True)
704 # auto detects cross-encoder vs LLM reranker by GGUF arch; override forces one.
705 reranker_type: RerankerType = ConfigField(
706 default=RerankerType.AUTO, writable=True, public=True, derived=True
707 )
708 # Relevance prompt for LLM rerankers; empty uses the built-in generic template.
709 # A format string with {query} and {document} placeholders.
710 reranker_prompt: str = ConfigField(default="", writable=True, public=True)
712 # Recommend safety-stripped models in Picks and Discover. Off keeps them
713 # in browse and search only; on restores them to the recommendations.
714 include_uncensored: bool = ConfigField(default=False, writable=True)
716 # Long-term chat memory. Off by default (opt-in): when disabled the whole
717 # subsystem is dormant and the write surfaces respond with an enable hint.
718 memory_enabled: bool = ConfigField(default=False, writable=True)
720 # Facts recalled by similarity per turn (preferences are always injected).
721 memory_top_k: int = ConfigField(default=5, ge=0, writable=True)
723 # Cosine-distance ceiling for fact recall; stricter than the document default
724 # because a tiny memory corpus floods at the wider document threshold.
725 memory_max_distance: float = ConfigField(default=0.6, ge=0.0, le=1.0, writable=True)
727 # Char/4 token budget for the injected memory block.
728 memory_token_budget: int = ConfigField(default=512, ge=0, writable=True)
730 # Per-owner soft cap; oldest memories evicted past it (runaway-write guard).
731 memory_max_per_owner: int = ConfigField(default=200, ge=1, writable=True)
733 # Cosine distance below which a new memory is treated as a duplicate of an
734 # existing same-owner memory and updates it in place instead of inserting.
735 memory_dedup_distance: float = ConfigField(default=0.05, ge=0.0, le=1.0, writable=True)
737 # LLM pass that extracts memories from the chat loop. Off by default; extracted
738 # memories are saved directly and recalled like any other memory.
739 memory_auto_extract: bool = ConfigField(default=False, writable=True)
741 # Candidate count sent to the reranker.
742 rerank_candidates: int = ConfigField(
743 default=60, ge=1, writable=True, profile=ProfileScope.RETRIEVAL, public=True
744 )
746 # Blend reranker scores with the retrieval fusion signal (position-aware).
747 # Off = the cross-encoder's own ordering stands unblended, which isolates
748 # the reranker's effect when measuring it.
749 rerank_blend: bool = ConfigField(
750 default=True, writable=True, profile=ProfileScope.RETRIEVAL, public=True
751 )
753 # Drop candidates whose RAW reranker score falls below this; unset = off.
754 # The scale is provider/model specific (bge logits can be negative, hosted
755 # rerankers use 0..1), so set it against observed scores.
756 rerank_min_score: float | None = ConfigField(
757 default=None, writable=True, profile=ProfileScope.RETRIEVAL, public=True
758 )
760 # Date-range filter; only fires when a temporal keyword is detected.
761 temporal_filtering: bool = ConfigField(
762 default=True, writable=True, profile=ProfileScope.RETRIEVAL
763 )
765 # If True, emit <think>…</think> content as separate SSE reasoning events;
766 # if False, strip it silently.
767 show_reasoning: bool = ConfigField(default=False, writable=True)
769 # How /v1/chat/completions presents a reasoning model's thinking. ``separate``
770 # reports it in ``reasoning_content`` (OpenAI-compatible); ``inline`` keeps it
771 # in ``content`` as <think> text for clients that never render
772 # ``reasoning_content``; ``off`` asks the model not to think. A request's
773 # ``reasoning`` field overrides this per call.
774 completions_reasoning: ReasoningMode = ConfigField(
775 default=ReasoningMode.SEPARATE, writable=True
776 )
778 # How /v1/messages presents a reasoning model's thinking. ``separate`` reports
779 # it as a ``thinking`` block (Anthropic-compatible); ``inline`` folds it into
780 # the answer text for clients that never render thinking blocks; ``off`` asks
781 # the model not to think and drops any thinking it produces anyway. A
782 # request's ``thinking`` parameter overrides this per call.
783 messages_reasoning: ReasoningMode = ConfigField(default=ReasoningMode.SEPARATE, writable=True)
785 # Maximum reasoning characters before lilbee forces the model to answer.
786 # Per-model overrides apply on top of this default. Approx N/4 tokens.
787 # 0 disables the cap (unlimited reasoning; accept the runaway-loop risk).
788 max_reasoning_chars: int = ConfigField(default=64_000, ge=0, writable=True)
790 # Web crawling.
792 # How crawls fetch pages. ``http`` (default) uses a plain HTTP client with
793 # no browser, the lightweight path for static / server-rendered sites.
794 # ``browser`` launches a tuned Chromium with JavaScript enabled for sites
795 # that render content client-side, at a much higher memory cost.
796 crawl_render_mode: CrawlRenderMode = ConfigField(default=CrawlRenderMode.HTTP, writable=True)
798 # Extra Chromium launch flags for browser-mode crawls. Defaults trim shared
799 # memory and GPU use; override to pass site- or environment-specific flags.
800 crawl_browser_extra_args: list[str] = ConfigField(
801 default_factory=lambda: ["--disable-dev-shm-usage", "--disable-gpu"],
802 writable=True,
803 )
805 # Milliseconds a browser-mode crawl waits after a page has loaded and stopped changing,
806 # for text a script writes later. 0 = no extra wait, at most ten minutes. HTTP mode ignores it.
807 crawl_browser_extra_wait_ms: int = ConfigField(default=0, ge=0, le=600_000, writable=True)
809 # The user agent of every crawl request. Blank = no override: a browser crawl
810 # sends Chrome's own, an HTTP crawl crawlberg's, the sitemap lookup httpx's.
811 crawl_user_agent: str = ConfigField(default="", writable=True)
813 # The login every crawl of crawl_login_host sends; a crawl of any other host gets none.
814 # CrawlLogin (crawler.models) validates the fields together.
815 crawl_login_host: str = ConfigField(default="", writable=True)
816 crawl_authorization: str = ConfigField(default="", writable=True, write_only=True)
817 crawl_username: str = ConfigField(default="", writable=True)
818 crawl_password: str = ConfigField(default="", writable=True, write_only=True)
819 crawl_cookie: str = ConfigField(default="", writable=True, write_only=True)
821 # Whether crawls skip the pages a site's robots.txt disallows, and every page of a
822 # site whose robots.txt cannot be read.
823 crawl_respect_robots_txt: bool = ConfigField(default=True, writable=True)
825 # Optional global ceilings. None = no ceiling. The bounds keep the engine from getting
826 # a number it cannot hold.
827 crawl_max_depth: int | None = ConfigField(default=None, ge=0, le=MAX_CRAWL_DEPTH, writable=True)
828 crawl_max_pages: int | None = ConfigField(default=None, ge=1, le=MAX_CRAWL_PAGES, writable=True)
830 # Default page bound for an unbounded crawl (no explicit max_pages /
831 # crawl_max_pages), so a hostile site can't exhaust the disk by default.
832 # An explicit limit overrides it; raise this to crawl larger sites unbounded.
833 crawl_safety_max_pages: int = ConfigField(
834 default=5_000, ge=1, le=MAX_CRAWL_PAGES, writable=True
835 )
837 # Per-URL fetch timeout, seconds.
838 crawl_timeout: int = ConfigField(default=30, ge=1, le=3600, writable=True)
840 # 0 = unlimited, default = CPU count.
841 crawl_max_concurrent: int = Field(
842 default=0,
843 ge=0,
844 description="Pages fetched in parallel during a crawl. 0 = unlimited; default = CPU count",
845 )
847 # Seconds between periodic syncs during crawl. 0 = sync only at end.
848 crawl_sync_interval: int = ConfigField(default=30, ge=0, writable=True)
850 # Delay + jitter each concurrent request waits, seconds, at most one hour.
851 crawl_mean_delay: float = ConfigField(
852 default=0.5, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True
853 )
854 crawl_max_delay_range: float = ConfigField(
855 default=0.5, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True
856 )
858 # In-flight requests per crawl, at most 256.
859 crawl_concurrent_requests: int = ConfigField(
860 default=3, ge=1, le=MAX_CRAWL_CONCURRENCY, writable=True
861 )
863 # Per-domain rate-limiter that backs off on HTTP 429/503 and retries.
864 crawl_retry_on_rate_limit: bool = ConfigField(default=True, writable=True)
865 # Retry delays in seconds, at most one hour each.
866 crawl_retry_base_delay_min: float = ConfigField(
867 default=1.0, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True
868 )
869 crawl_retry_base_delay_max: float = ConfigField(
870 default=3.0, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True
871 )
872 crawl_retry_max_backoff: float = ConfigField(
873 default=30.0, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True
874 )
875 crawl_retry_max_attempts: int = ConfigField(default=3, ge=0, writable=True)
877 # Regex patterns dropped at link-discovery time. Defaults block CMS
878 # scaffolding (WordPress admin, archives, feeds, etc.).
879 crawl_exclude_patterns: list[str] = ConfigField(
880 default_factory=lambda: list(DEFAULT_CRAWL_EXCLUDE_PATTERNS),
881 writable=True,
882 )
884 # Fraction of GPU/unified memory reserved for loaded models.
885 gpu_memory_fraction: float = ConfigField(default=0.75, ge=0.1, le=1.0, writable=True)
887 # Share of a card placement may charge, leaving room for allocator
888 # fragmentation and driver overhead. Tunable because it decides admission: at
889 # the default, a 16 GB machine whose chat model needs 12-13 GB can be refused
890 # chat entirely, and the owner is the one who knows whether that card has the
891 # room. Raising it trades safety margin for the ability to serve at all.
892 usable_vram_fraction: float = ConfigField(default=0.9, ge=0.5, le=1.0, writable=True)
894 # RAM held back for the OS when placing against system memory, in GiB. Capped
895 # at a quarter of total RAM either way, so a small host keeps its proportional
896 # reserve however this is set.
897 system_memory_reserve_gb: float = ConfigField(default=4.0, ge=0.0, le=64.0, writable=True)
899 # Data-parallel replicas of the embed / vision role across GPUs: N independent
900 # servers, round-robined, so large-scale ingest fans the embedding / OCR work
901 # across the whole box. 0 means "auto": one replica per detected GPU, capped by
902 # the VRAM left after the persistent query fleet (chat, one embed, rerank, one
903 # vision) is reserved. A positive value pins the count. The extra replicas are
904 # ingest-only and reclaimed when ingest ends; the persistent query embedder /
905 # vision (replica 0) always exists if its model fits.
906 embed_replicas: int = ConfigField(default=0, ge=0, writable=True, derived=True)
907 vision_replicas: int = ConfigField(default=0, ge=0, writable=True, derived=True)
909 # Seconds a model stays loaded after last use. 0 = unload immediately.
910 model_keep_alive: int = ConfigField(default=300, ge=0, writable=True)
912 # Spawn every configured role server at startup instead of on first use.
913 # Trades a slower TUI mount (the role servers cold-start in parallel) for a
914 # responsive first interaction. Roles whose model is unset are skipped, so a
915 # setup with only chat + embed never spawns rerank or vision. Set to false
916 # for headless / scripted use where the first call doesn't need to be fast.
917 worker_pool_eager_start: bool = ConfigField(default=True, writable=True)
919 # On: weights stay loaded while any lilbee process holds the engine, and
920 # unload engine_idle_ttl_minutes after the last one exits; the engine process
921 # stays up so the next launch binds instantly. Off (default): the engine
922 # stops when the last lilbee process exits. A change applies at the next
923 # engine start.
924 keep_engine_warm: bool = ConfigField(default=False, writable=True)
926 # Hugging Face's high-performance transfer mode: more connections and much
927 # larger in-flight buffers. Off by default, those ceilings suit a server
928 # rather than a laptop also holding a model in memory.
929 fast_model_downloads: bool = ConfigField(default=False, writable=True)
931 # Idle minutes before the engine unloads its weights: llama-swap's ttl, or
932 # with keep_engine_warm the time after the last lilbee exit. 0 keeps weights
933 # loaded until the engine stops.
934 engine_idle_ttl_minutes: int = ConfigField(
935 default=ENGINE_IDLE_TTL_MINUTES_DEFAULT, writable=True
936 )
938 # Working n_ctx the dynamic picker aims for. Default scales with
939 # total host RAM (see core.system.chat_ctx_target_for_total_bytes):
940 # <16 GiB -> 8192, 16-32 -> 12288, 32-64 -> 16384, 64-128 -> 24576,
941 # >=128 -> 65536 (an agent-capable window on server-class hosts).
942 # 8192 is the floor; the picker still clamps to training_ctx and
943 # host headroom.
944 chat_n_ctx_target: int = ConfigField(
945 default_factory=scaled_chat_ctx_target_default,
946 ge=512,
947 writable=True,
948 derived=True,
949 )
951 # Condense turns that outgrow chat_n_ctx_target into carried notes instead
952 # of dropping them. Off: zero model calls; the oldest turns drop and the
953 # context chip shows it. On: each firing blocks on a summarize call
954 # (measured: 1.3-2.5s per 60-turn fold on a datacenter GPU, 0.7-2s on an
955 # 8-core CPU with a 0.6B-4B model).
956 chat_compaction: bool = ConfigField(default=False, writable=True)
958 # Persist conversations and expose the Sessions drawer, tab, and commands.
959 # On by default; turning it off stops chats being written to disk, hides the
960 # ctrl+o binding from the footer, and gates the Sessions view behind a notice.
961 # Governs the human surfaces (TUI, HTTP, CLI); agent sessions have their own
962 # flag below, so the two domains the store already separates stay separate.
963 sessions_enabled: bool = ConfigField(default=True, writable=True)
965 # The agent (MCP) half of the same feature, off by default: agent hosts
966 # generally track their own conversation history, and the seven session
967 # tools cost schema on every request whether or not anything uses them.
968 mcp_sessions_enabled: bool = ConfigField(default=False, writable=True)
970 # The profile tools over MCP, off by default for the same schema cost.
971 mcp_profiles_enabled: bool = ConfigField(default=False, writable=True)
973 # Explicit ceiling for the dynamic n_ctx picker. ``None`` (default)
974 # lets the model's training_ctx from GGUF metadata be the ceiling,
975 # so a 128K-context model can reach for it on a host with the RAM
976 # to back it. Set explicitly to cap below the model's training_ctx.
977 num_ctx_max: int | None = ConfigField(default=None, ge=512, writable=True, derived=True)
979 # Flash attention. None (default) = on, True = force on, False = off
980 # for backends or models where it misbehaves.
981 # Resolves the 'padding V cache to 1024' warning on models with
982 # uneven per-layer V dims (e.g. Gemma3) and saves ~25% KV memory.
983 flash_attention: bool | None = ConfigField(default=None, writable=True)
985 # KV cache element type. q8_0 (default) halves cache memory vs f16
986 # with no measurable quality loss for chat; q4_0 quarters it with a
987 # small quality cost. Both apply to K and V together, and only where
988 # lilbee sends flash attention on. On Vulkan and SYCL it sends auto even
989 # when flash_attention is true, so the cache is f16 there.
990 kv_cache_type: KvCacheType = ConfigField(default=KvCacheType.Q8_0, writable=True)
992 # Number of model layers to offload to GPU. None (default) = all
993 # layers, 0 = CPU only, positive int = partial offload. Useful when a
994 # discrete GPU has less VRAM than the model needs.
995 n_gpu_layers: int | None = ConfigField(default=None, writable=True)
997 # Keep a MoE model's expert weights in system memory, attention and shared
998 # layers on the GPU. Lets a sparse model run on a card too small to hold it.
999 # No effect on dense models, which have no expert tensors.
1000 cpu_moe: bool = ConfigField(default=False, writable=True)
1002 # Offload only the first N layers' experts. Takes precedence over cpu_moe;
1003 # a smaller N keeps more of the model resident.
1004 n_cpu_moe: int | None = ConfigField(default=None, writable=True)
1006 # GPU device picker for dual-GPU machines (typical laptop case:
1007 # discrete NVIDIA + integrated Intel/AMD). The Vulkan backend
1008 # enumerates every adapter the system exposes and may pick the
1009 # integrated one first, producing stalls or OOMs that look like
1010 # llama.cpp bugs. Setting ``gpu_devices`` constrains visibility
1011 # before the servers spawn, pinning inference to the chosen device(s).
1012 #
1013 # Accepts a comma-separated list of device indexes ("0", "1",
1014 # "0,1") and applies it to every backend simultaneously:
1015 # ``GGML_VK_VISIBLE_DEVICES`` for Vulkan, ``CUDA_VISIBLE_DEVICES``
1016 # for CUDA, ``HIP_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` for
1017 # ROCm. Setting one variable that the active backend ignores is
1018 # harmless, so we set all four rather than detecting the build.
1019 #
1020 # Must be set before the first llama.cpp call; in practice that
1021 # means via ``LILBEE_GPU_DEVICES`` or ``config.toml`` (TUI edits
1022 # only take effect after a restart). ``None`` (default) hands off
1023 # to the autodetect in ``providers/fleet/gpu_select.py``,
1024 # which parses ``vulkaninfo --summary`` and pins the discrete
1025 # adapter when one is present. The autodetect is silent on failure
1026 # (no vulkaninfo, single device, parse error), leaving the
1027 # Vulkan-loader's default ordering in place.
1028 gpu_devices: str | None = ConfigField(default=None, writable=True, derived=True)
1030 # Manual GPU placement override stored as a JSON scalar (the config.toml store
1031 # is flat, and core must not depend on the provider PlacementSpec type). When
1032 # set, it fully replaces the automatic placement planner: each active role pins
1033 # to the listed device indices, with an optional tensor_split and replica count.
1034 # Edited via the placement CLI/MCP/HTTP/TUI surfaces rather than the generic
1035 # settings list, so public=False. None hands off to the VRAM-aware auto planner.
1036 placement: str | None = ConfigField(
1037 default=None,
1038 writable=True,
1039 public=False,
1040 derived=True,
1041 description=(
1042 "Manual multi-GPU placement spec. It fully replaces the automatic planner: "
1043 "each active role pins to the listed device indices. Edit it with the "
1044 "placement commands, not the settings list. Empty uses the VRAM-aware planner"
1045 ),
1046 )
1048 # Allow PUT/DELETE /api/placement to apply or clear placement over HTTP.
1049 # Off by default because applying placement restarts the shared fleet's moved roles, which
1050 # is unsafe across concurrent HTTP clients. Turn it on (LILBEE_ALLOW_HTTP_PLACEMENT=1)
1051 # only for a single-client / owned deployment: the plugin's managed local
1052 # server, or a personally-owned pod where one operator runs `lilbee serve`.
1053 allow_http_placement: bool = Field(
1054 default=False,
1055 description=(
1056 "Let PUT and DELETE /api/placement change model placement over HTTP. Off by "
1057 "default because applying placement restarts the moved roles, which is unsafe "
1058 "with concurrent clients. Turn it on only for a deployment you alone use"
1059 ),
1060 )
1062 # True = Markdown widget for chat; False = plain Static (faster).
1063 markdown_rendering: bool = Field(
1064 default=True,
1065 description=(
1066 "Render chat replies as Markdown in the TUI. Off draws plain text, which is faster"
1067 ),
1068 )
1070 # TUI theme name; persists the last Ctrl+T pick across sessions.
1071 theme: str = ConfigField(default="rose-pine", writable=True)
1073 # Wiki layer. LLM-maintained synthesis pages with citation provenance.
1074 # Off by default; flip to True (or set LILBEE_WIKI=1) to enable. When off,
1075 # the Wiki view tab and the chat ModelBar's scope picker are both hidden.
1076 wiki: bool = ConfigField(default=False, writable=True)
1077 # Whether a sync regenerates touched wiki pages on its own. Off by
1078 # default: enabling the wiki never starts generating by itself, the
1079 # user wikifies explicitly via `lilbee wiki build` / `wiki update`.
1080 wiki_auto_update: bool = ConfigField(default=False, writable=True)
1081 # Read-only: changing the directory at runtime strands prior wiki pages
1082 # under the old path. Users who want a different location set it via
1083 # LILBEE_WIKI_DIR / config.toml before the first wiki_build.
1084 wiki_dir: str = "wiki"
1085 wiki_prune_raw: bool = ConfigField(default=False, writable=True)
1087 # Minimum cosine similarity between a page body and the mean of its
1088 # source chunk vectors before a page is published (below → drafts).
1089 # Replaces the old LLM-based faithfulness score: mean-of-chunks is a
1090 # deterministic, zero-LLM-call signal that routes topic-drifted
1091 # pages to drafts without the 0.0 to 1.0 ambiguity of a model-emitted
1092 # number. Tuning knob: swap to per-chunk max or top-K-mean if the
1093 # default 0.5 produces false drafts.
1094 wiki_embedding_faithfulness_threshold: float = ConfigField(
1095 default=0.5, ge=0.0, le=1.0, writable=True
1096 )
1098 # Per-call output token cap for wiki generation. Without this a
1099 # reasoning model (Qwen3, DeepSeek-R1) can burn the full context
1100 # window emitting <think> tokens before the actual answer, taking
1101 # minutes per page. Default leaves headroom for a typical reasoning
1102 # budget plus a real response (~1000 output + ~1000 slack).
1103 wiki_summary_max_tokens: int = ConfigField(default=2048, ge=256, writable=True)
1105 # Wiki generation is a structured-output task: the model must emit the
1106 # block separators, the citation footnotes, and verbatim quotes. The
1107 # usual chat default (~0.8) is too creative for that. Lowering the
1108 # sampling temperature makes the model stick to the template and quote
1109 # more faithfully. 0.1 leaves just enough slack to avoid hard loops.
1110 wiki_temperature: float = ConfigField(default=0.1, ge=0.0, le=2.0, writable=True)
1112 # Fraction of citations that must be stale before a wiki page is flagged.
1113 wiki_stale_citation_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
1115 # Fraction of content changed that triggers human-review drift guard.
1116 wiki_drift_threshold: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True)
1118 # LLM prompt templates for wiki page generation: wiki_synthesis_prompt
1119 # for cross-source synthesis pages, wiki_entity_batch_prompt (below)
1120 # for the per-source batched call. Writable so advanced users can
1121 # override them from /settings, config.toml, or ``LILBEE_WIKI_*_PROMPT``
1122 # env vars. Templates must keep the expected ``{placeholders}``. If you
1123 # remove one the generator will crash on first use.
1124 wiki_synthesis_prompt: str = ConfigField(
1125 writable=True,
1126 default=(
1127 "You are a knowledge compiler. Given source chunks from MULTIPLE documents "
1128 "about related concepts, write a synthesis wiki page in markdown that connects "
1129 "ideas across sources.\n\n"
1130 "Rules:\n"
1131 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
1132 "Never cite by chunk label: [Chunk N] labels only organize the "
1133 "chunks below and must not appear in the page.\n"
1134 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
1135 "3. For connections, interpretations, or patterns you identify across sources, "
1136 "mark with [*inference*].\n"
1137 "4. Use blockquotes (>) for directly cited facts.\n"
1138 "5. Reference each source by its filename when drawing connections.\n"
1139 "6. End with a citation block in this format:\n\n"
1140 "---\n"
1141 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
1142 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
1143 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
1144 "Topic: {topic}\n\n"
1145 "Sources:\n{source_list}\n\n"
1146 "Chunks:\n{chunks_text}\n\n"
1147 "Write the synthesis page now. Start with a heading."
1148 ),
1149 )
1151 # Wiki synthesis clusterer backend. CONCEPTS requires the [graph] extra
1152 # and falls back to EMBEDDING when unavailable.
1153 wiki_clusterer: ClustererBackend = ConfigField(
1154 default=ClustererBackend.EMBEDDING, writable=True
1155 )
1157 # Neighborhood size for the mutual-kNN graph. 0 = auto-scale from corpus size.
1158 wiki_clusterer_k: int = ConfigField(default=0, ge=0, writable=True, derived=True)
1160 # LazyGraphRAG-style concept graph. Requires the [graph] extra.
1161 concept_graph: bool = ConfigField(default=True, writable=True, profile=ProfileScope.RETRIEVAL)
1163 # Weight of concept overlap boost relative to vector similarity.
1164 concept_boost_weight: float = ConfigField(
1165 default=0.3, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL
1166 )
1168 # Max noun-phrase concepts extracted per chunk.
1169 concept_max_per_chunk: int = ConfigField(
1170 default=5, ge=1, writable=True, profile=ProfileScope.RETRIEVAL
1171 )
1173 # spaCy NER labels kept by the wiki entity extractor. Anything not
1174 # in this set (QUANTITY, CARDINAL, DATE, TIME, MONEY, PERCENT,
1175 # ORDINAL, ...) is dropped before aggregation. Override via
1176 # LILBEE_CONCEPT_ALLOWED_ENT_TYPES as a comma-separated list.
1177 concept_allowed_ent_types: frozenset[str] = Field(
1178 default=DEFAULT_ALLOWED_NER_LABELS,
1179 description=(
1180 "spaCy NER labels the wiki entity extractor keeps. It drops everything else "
1181 "(QUANTITY, CARDINAL, DATE, and so on) before aggregation"
1182 ),
1183 )
1185 # Strategy used to extract entities for the concept/entity wiki.
1186 # NER_ENTITIES (default) pulls typed NER entities with spaCy; concept
1187 # pages are proposed by the LLM inside the per-source batched call,
1188 # not by the extractor. NER_CONCEPTS_PLUS_LLM_TYPES layers an
1189 # LLM-proposed domain schema on top. LLM_TAGGED asks the LLM to tag
1190 # every chunk (most expensive). Unimplemented modes fall back to
1191 # NER_ENTITIES.
1192 wiki_entity_mode: WikiEntityMode = ConfigField(
1193 default=WikiEntityMode.NER_ENTITIES, writable=True
1194 )
1196 # Minimum distinct chunk mentions before an entity or concept earns
1197 # its own wiki page. Filters one-off noise.
1198 wiki_entity_min_mentions: int = ConfigField(default=3, ge=1, writable=True)
1199 wiki_stub_max_chunk_refs: int = ConfigField(default=50, ge=1, writable=True)
1201 # Auto-update cap: if a single sync touches more than this many
1202 # concept or entity pages, skip the per-slug regeneration and tell
1203 # the user to run `lilbee wiki update` explicitly. Keeps a surprise
1204 # bulk import from firing hundreds of LLM calls.
1205 wiki_ingest_update_cap: int = ConfigField(default=20, ge=1, writable=True)
1207 # Whether the per-source batched call asks the LLM to curate
1208 # concept pages alongside the pre-extracted entity list. False →
1209 # entity sections only, no concept curation (incremental ingest
1210 # path uses this to avoid churning concept slugs per source-touch).
1211 wiki_extract_concepts: bool = ConfigField(default=True, writable=True)
1213 # Minimum chunk count a source must contribute before it is eligible
1214 # for concept curation. Sources below the floor still get a batched
1215 # call when they have entities (the prompt writes entity-only
1216 # sections); sources below the floor with zero entities are skipped
1217 # entirely. Prevents boilerplate / TOC / appendix documents from
1218 # burning an LLM call to invent "concepts".
1219 wiki_batch_min_chunks: int = ConfigField(default=3, ge=1, writable=True)
1221 # Prompt template for the per-source batched call. Placeholders:
1222 # {source}, {entity_list}, {chunks_text}, {concept_instruction}.
1223 # {concept_instruction} is filled with a concept-curation paragraph
1224 # when concepts are requested, or the empty string otherwise.
1225 # Single-entity page written on demand from that entity's chunks across
1226 # every source naming it. The batched prompt above cannot serve this: it
1227 # writes every section for one source in one call.
1228 wiki_entity_page_prompt: str = ConfigField(
1229 writable=True,
1230 default=(
1231 "You are a knowledge compiler. Given source chunks that mention "
1232 "ONE subject, write a wiki page about that subject in markdown.\n\n"
1233 "Rules:\n"
1234 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
1235 "Never cite by chunk label: [Chunk N] labels only organize the "
1236 "chunks below and must not appear in the page.\n"
1237 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
1238 "3. Write only what the chunks support. Mark anything you infer with "
1239 "[*inference*].\n"
1240 "4. Use blockquotes (>) for directly cited facts.\n"
1241 "5. When sources disagree, say so and cite both.\n"
1242 "6. End with a citation block in this format:\n\n"
1243 "---\n"
1244 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
1245 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
1246 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
1247 "Subject: {topic}\n\n"
1248 "Sources:\n{source_list}\n\n"
1249 "Chunks:\n{chunks_text}\n\n"
1250 "Write the page now. Start with a heading naming the subject."
1251 ),
1252 )
1253 wiki_entity_batch_prompt: str = ConfigField(
1254 writable=True,
1255 default=(
1256 "You are writing wiki sections based on these chunks from {source}.\n\n"
1257 "{concept_instruction}"
1258 "Write a wiki section for each of these NER ENTITIES: {entity_list}\n\n"
1259 "Format each section exactly as:\n"
1260 "## Name\n"
1261 "{{content with [^src1]-style citations}}\n\n"
1262 "Rules:\n"
1263 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
1264 "Never cite by chunk label: [Chunk N] labels only organize the "
1265 "chunks below and must not appear in the page.\n"
1266 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
1267 "3. For interpretations or connections not directly stated, mark with [*inference*].\n"
1268 "4. Use blockquotes (>) for directly cited facts.\n"
1269 "5. End the response with a citation block in this format:\n\n"
1270 "---\n"
1271 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
1272 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
1273 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
1274 "Source chunks:\n{chunks_text}\n"
1275 ),
1276 )
1278 # Class variable: not a settings field
1279 _toml_cache: ClassVar[dict[str, Any]] = {}
1281 @field_validator("lilbee_name", mode="after")
1282 @classmethod
1283 def _strip_lilbee_name(cls, value: str) -> str:
1284 """Strip whitespace; an empty string signals 'use the path-derived label'."""
1285 return value.strip()
1287 @field_validator(
1288 "temperature",
1289 "top_p",
1290 "repeat_penalty",
1291 "top_k_sampling",
1292 "num_ctx",
1293 "seed",
1294 mode="before",
1295 )
1296 @classmethod
1297 def _empty_string_to_none(cls, v: Any) -> Any:
1298 if isinstance(v, str) and v.strip() == "":
1299 return None
1300 return v
1302 @field_validator("chat_mode", mode="before")
1303 @classmethod
1304 def _normalize_chat_mode(cls, v: Any) -> ChatMode:
1305 """Coerce chat_mode to a ChatMode value; default ChatMode.SEARCH."""
1306 if v is None or v == "":
1307 return ChatMode.SEARCH
1308 candidate = str(v).strip().lower()
1309 try:
1310 return ChatMode(candidate)
1311 except ValueError as exc:
1312 valid = ", ".join(repr(m.value) for m in ChatMode)
1313 raise ValueError(f"chat_mode must be one of {{{valid}}}, got {v!r}") from exc
1315 @field_validator("ocr_language", mode="before")
1316 @classmethod
1317 def _parse_ocr_language(cls, v: Any) -> list[str]:
1318 """Accept a list or a ``+``/comma/newline-separated string; never empty.
1320 Tesseract joins languages with ``+`` (e.g. ``eng+deu``), so that is the
1321 canonical user-facing form. Commas are also accepted. Newlines are
1322 accepted because ``app.settings`` joins list values with ``\\n`` when it
1323 persists them to config.toml; without splitting on it a multi-language
1324 value would reload as one malformed token. Blank input falls back to
1325 English, since xberg errors on an empty list.
1326 """
1327 if isinstance(v, str):
1328 v = v.replace("+", ",").replace("\n", ",").split(",")
1329 items = v or []
1330 langs = [s.strip() for s in items if isinstance(s, str) and s.strip()]
1331 for lang in langs:
1332 if not _TESSERACT_LANGUAGE_CODE.fullmatch(lang):
1333 raise ValueError(
1334 f"ocr_language: {lang!r} is not a Tesseract language code "
1335 "(examples: eng, deu, chi_sim, jpn_vert)"
1336 )
1337 return langs or ["eng"]
1339 @field_validator("force_ocr_pages", mode="before")
1340 @classmethod
1341 def _parse_force_ocr_pages(cls, v: Any) -> list[int]:
1342 """Accept a list, a string or one int; split string items on commas and newlines.
1344 Newlines are accepted because ``app.settings`` joins list values with
1345 ``\\n`` when it persists them to config.toml. Returns sorted unique pages.
1346 """
1347 items = v if isinstance(v, list) else [v]
1348 parts = [p for item in items for p in _split_page_item(item)]
1349 return sorted({_page_number(part) for part in parts if str(part).strip()})
1351 @field_validator("flash_attention", mode="before")
1352 @classmethod
1353 def _parse_flash_attention(cls, v: Any) -> bool | None:
1354 """Auto/on/off tri-state: empty/auto/none -> None, else parse bool."""
1355 if v is None:
1356 return None
1357 if isinstance(v, bool):
1358 return v
1359 if isinstance(v, str):
1360 try:
1361 return parse_tristate_bool(v)
1362 except ValueError:
1363 raise ValueError("use true, false or auto") from None
1364 return bool(v)
1366 @field_validator("n_gpu_layers", mode="before")
1367 @classmethod
1368 def _parse_n_gpu_layers(cls, v: Any) -> int | None:
1369 """Auto -> None, ``cpu`` alias -> 0, integers parsed verbatim."""
1370 if v is None:
1371 return None
1372 if isinstance(v, str):
1373 try:
1374 return parse_optional_int(v, aliases={"cpu": 0})
1375 except ValueError:
1376 raise ValueError("use a whole number, cpu or auto") from None
1377 return int(v)
1379 @field_validator("gpu_devices", mode="before")
1380 @classmethod
1381 def _parse_gpu_devices(cls, v: Any) -> str | None:
1382 """Normalize device list: strip whitespace, drop empties, keep order."""
1383 if v is None:
1384 return None
1385 if isinstance(v, str):
1386 try:
1387 return parse_gpu_device_list(v)
1388 except ValueError:
1389 raise ValueError("use GPU indexes separated by commas, or auto") from None
1390 return str(v)
1392 @field_validator("placement", mode="before")
1393 @classmethod
1394 def _parse_placement(cls, v: Any) -> str | None:
1395 """Blank/None -> None; validate a JSON string or PlacementSpec; store JSON."""
1396 from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec
1398 if v is None:
1399 return None
1400 if isinstance(v, PlacementSpec):
1401 json_str = v.to_json()
1402 PlacementSpec.from_json(json_str) # re-validate a directly-built spec
1403 return json_str
1404 if isinstance(v, str):
1405 if v.strip() == "":
1406 return None
1407 PlacementSpec.from_json(v)
1408 return v
1409 raise PlacementError("placement must be a JSON string or PlacementSpec")
1411 @field_validator("semantic_chunking", mode="before")
1412 @classmethod
1413 def _parse_semantic_chunking(cls, v: Any) -> bool:
1414 """A bool as it stands, a string through parse_bool."""
1415 if isinstance(v, bool):
1416 return v
1417 if isinstance(v, str):
1418 try:
1419 return parse_bool(v)
1420 except ValueError:
1421 raise ValueError("use true or false") from None
1422 return bool(v)
1424 @field_validator(
1425 "chat_model", "embedding_model", "vision_model", "reranker_model", mode="after"
1426 )
1427 @classmethod
1428 def _normalize_model_tag(cls, v: str, info: ValidationInfo) -> str:
1429 """Validate and canonicalize a model ref; blank means the role is unconfigured."""
1430 return canonical_model_ref(v)
1432 @field_validator("ollama_base_url", "lm_studio_base_url", mode="after")
1433 @classmethod
1434 def _strip_trailing_slash(cls, v: str) -> str:
1435 """Canonicalize a local-server URL once at the write boundary."""
1436 return v.rstrip("/")
1438 @field_validator("cors_origins", mode="before")
1439 @classmethod
1440 def _split_cors_origins(cls, v: Any) -> Any:
1441 if isinstance(v, str):
1442 return [o.strip() for o in v.split(",") if o.strip()]
1443 return v
1445 @field_validator("crawl_browser_extra_args", mode="before")
1446 @classmethod
1447 def _split_crawl_browser_extra_args(cls, v: Any) -> Any:
1448 """Accept a newline-separated string, matching how the field is persisted.
1450 ``app.settings`` joins list values with newlines before writing them to
1451 ``config.toml`` as a scalar string. Without this inverse, reload cannot
1452 coerce that string to ``list[str]`` and the whole config.toml is dropped.
1453 TOML lists and JSON arrays pass through unchanged.
1454 """
1455 if isinstance(v, str):
1456 return [a.strip() for a in v.splitlines() if a.strip()]
1457 return v
1459 @field_validator("crawl_user_agent", mode="after")
1460 @classmethod
1461 def _validate_crawl_user_agent(cls, v: str) -> str:
1462 """The user agent without surrounding whitespace; a control character is refused."""
1463 user_agent = v.strip()
1464 if CONTROL_CHARACTER.search(user_agent):
1465 raise ValueError("crawl_user_agent holds a control character")
1466 return user_agent
1468 @field_validator("crawl_login_host", mode="after")
1469 @classmethod
1470 def _validate_crawl_login_host(cls, v: str) -> str:
1471 """The login host as a bare host name; a URL is refused, as in the settings write."""
1472 refuse_unless_host(v)
1473 return v
1475 @field_validator("crawl_exclude_patterns", mode="before")
1476 @classmethod
1477 def _split_crawl_exclude_patterns(cls, v: Any) -> Any:
1478 """Accept newline-separated strings from env vars / plain-text config.
1480 TOML lists and JSON arrays pass through unchanged. Loading does not ask crawlberg to
1481 compile the patterns: that loads its native library at every start. A settings write
1482 checks them, and a crawl refuses a bad one when it starts.
1483 """
1484 return exclude_pattern_lines(v)
1486 @field_validator("ignore_dirs", mode="before")
1487 @classmethod
1488 def _merge_ignore_dirs(cls, v: Any) -> frozenset[str]:
1489 if isinstance(v, str):
1490 extra = frozenset(name.strip() for name in v.split(",") if name.strip())
1491 return DEFAULT_IGNORE_DIRS | extra
1492 if isinstance(v, (set, frozenset, list)):
1493 return DEFAULT_IGNORE_DIRS | frozenset(v)
1494 return DEFAULT_IGNORE_DIRS
1496 @field_validator("concept_allowed_ent_types", mode="before")
1497 @classmethod
1498 def _parse_ent_types(cls, v: Any) -> frozenset[str]:
1499 """Replace-semantics override: a narrowed set is used as-is,
1500 not unioned with defaults. A user asking for ``PERSON,ORG``
1501 wants exactly those kinds. Accepts comma-separated strings
1502 from env and list / set / frozenset from code. Empty input
1503 falls back to :data:`DEFAULT_ALLOWED_NER_LABELS` so an empty
1504 env var does not silently disable the gate.
1505 """
1506 if isinstance(v, str):
1507 parts = frozenset(name.strip().upper() for name in v.split(",") if name.strip())
1508 return parts or DEFAULT_ALLOWED_NER_LABELS
1509 if isinstance(v, (set, frozenset, list)):
1510 parts = frozenset(str(x).upper() for x in v)
1511 return parts or DEFAULT_ALLOWED_NER_LABELS
1512 return DEFAULT_ALLOWED_NER_LABELS
1514 @model_validator(mode="before")
1515 @classmethod
1516 def _resolve_defaults(cls, data: Any) -> Any:
1517 from lilbee.core.system import (
1518 canonical_data_root,
1519 canonical_models_dir,
1520 default_data_dir,
1521 find_local_root,
1522 )
1524 if not isinstance(data, dict):
1525 return data
1527 # An empty LILBEE_DATA_ROOT (delivered as "") must fall through to default
1528 # resolution like an unset one, not become Path(".") = the process cwd.
1529 if isinstance(data.get("data_root"), str) and not data["data_root"].strip():
1530 data["data_root"] = None
1531 if data.get("data_root") in (None, _UNSET_PATH):
1532 data_env = os.environ.get("LILBEE_DATA", "").strip()
1533 if data_env:
1534 data["data_root"] = Path(data_env)
1535 else:
1536 local = find_local_root()
1537 data["data_root"] = local if local is not None else default_data_dir()
1538 # Every child path below derives from this, and the server lock keys on
1539 # those, so canonicalizing here is what makes one directory key one lock.
1540 # Also coerces a raw string (LILBEE_DATA_ROOT) to Path.
1541 root = canonical_data_root(data["data_root"])
1542 data["data_root"] = root
1543 for key, folder in root_folders(root).items():
1544 if data.get(key) in (None, _UNSET_PATH):
1545 data[key] = folder
1546 if data.get("models_dir") in (None, _UNSET_PATH):
1547 data["models_dir"] = canonical_models_dir()
1549 return data
1551 @classmethod
1552 def settings_customise_sources(
1553 cls,
1554 settings_cls: type[BaseSettings],
1555 init_settings: Any,
1556 env_settings: Any,
1557 dotenv_settings: Any,
1558 file_secret_settings: Any,
1559 ) -> tuple[Any, ...]:
1560 plain_env = _PlainEnvSource(settings_cls)
1561 sources: list[Any] = [init_settings, plain_env]
1562 toml_path = _config_file()
1563 if toml_path is not None:
1564 sources.append(_TomlSource(settings_cls, toml_path))
1565 sources.append(_ProfileSource(settings_cls, toml_path))
1566 return tuple(sources)
1568 def generation_options(self, **overrides: Any) -> dict[str, Any]:
1569 """Merge the user config with per-call overrides, dropping None."""
1570 result: dict[str, Any] = {}
1571 # One name for the output cap inside lilbee; the provider translators rename it.
1572 user_fields: dict[str, Any] = {
1573 "temperature": self.temperature,
1574 "top_p": self.top_p,
1575 "top_k": self.top_k_sampling,
1576 "repeat_penalty": self.repeat_penalty,
1577 "num_ctx": self.num_ctx,
1578 "seed": self.seed,
1579 "num_predict": self.max_tokens,
1580 }
1581 for k, v in user_fields.items():
1582 if v is not None:
1583 result[k] = v
1584 for k, v in overrides.items():
1585 if v is not None:
1586 result[k] = v
1587 return result
1590def _config_file() -> Path | None:
1591 """The config.toml a load reads, or None when there is none or the load skips it."""
1592 from lilbee.core.system import canonical_data_root, default_data_dir, find_local_root
1594 # .strip() to match _resolve_defaults; a padded value would otherwise
1595 # send the root and its config.toml to different directories.
1596 data_env = os.environ.get("LILBEE_DATA", "").strip()
1597 if data_env:
1598 toml_dir = Path(data_env)
1599 else:
1600 local = find_local_root()
1601 toml_dir = local if local else default_data_dir()
1602 # Same call as the root itself, so this looks where the root resolves to;
1603 # a "~/lilbee" value would otherwise search a literal ./~ and find nothing.
1604 toml_path = canonical_data_root(toml_dir) / CONFIG_FILE_NAME
1605 if toml_path.exists() and os.environ.get("LILBEE_SKIP_TOML_CONFIG") != "1":
1606 return toml_path
1607 return None
1610def _enum_of(settings_cls: type[BaseSettings], key: str) -> type[Enum] | None:
1611 """The enum the field *key* is typed as, or None for any other type."""
1612 annotation = settings_cls.model_fields[key].annotation
1613 # An annotation is a class, a union or a generic alias; only a class can be an enum.
1614 if isinstance(annotation, type) and issubclass(annotation, Enum):
1615 return annotation
1616 return None
1619def _variable(settings_cls: type[BaseSettings], field_name: str) -> str:
1620 """The environment variable that sets *field_name*."""
1621 return f"{settings_cls.model_config.get('env_prefix', '')}{field_name.upper()}"
1624def _not_a_setting(name: str, key: str) -> str:
1625 """The warning for *name*, which sets *key* although *key* is not a setting."""
1626 return f"{name} is not a setting and is ignored; {(_NOT_SETTINGS | _NOT_FROM_ENV)[key]}"
1629def _expected(error: ErrorDetails) -> str:
1630 """What one validation error says about the value, in the words of a settings file."""
1631 words = _EXPECTED_BY_ERROR.get(error["type"])
1632 if words is None:
1633 return f"is refused ({error['msg'].removeprefix(_VALUE_ERROR_PREFIX)})"
1634 return "is not " + words.format(**error.get("ctx", {}))
1637def _refusal(probe: BaseSettings, key: str, value: Any) -> str | None:
1638 """Why the field's own validators refuse *value* on the throwaway *probe*, or None."""
1639 settings_cls = type(probe)
1640 try:
1641 settings_cls.__pydantic_validator__.validate_assignment(probe, key, value)
1642 except ValidationError as exc:
1643 enum = _enum_of(settings_cls, key)
1644 if enum is not None:
1645 return "is not one of " + ", ".join(str(member.value) for member in enum)
1646 return _expected(next(iter(exc.errors())))
1647 except TypeError:
1648 # A validator handed a TOML type it has no branch for.
1649 return f"is not a value {key} accepts"
1650 return None
1653def _refusals(settings_cls: type[BaseSettings], values: dict[str, Any]) -> dict[str, str]:
1654 """Why its own field refuses each of *values* that one refuses, by key."""
1655 known = [key for key in values if key in settings_cls.model_fields]
1656 if not known:
1657 return {}
1658 probe = settings_cls.model_construct()
1659 reasons = {key: _refusal(probe, key, values[key]) for key in known}
1660 return {key: reason for key, reason in reasons.items() if reason is not None}
1663class _PlainEnvSource:
1664 """Reads LILBEE_* env vars as plain strings; a refused one stops the load or falls back."""
1666 def __init__(self, settings_cls: type[BaseSettings]) -> None:
1667 self._settings_cls = settings_cls
1669 def _read(self) -> tuple[dict[str, Any], dict[str, str]]:
1670 """Each set variable's string by field, and why its setting refuses the ones it does."""
1671 values: dict[str, Any] = {}
1672 for field_name in self._settings_cls.model_fields:
1673 if field_name in _NOT_FROM_ENV:
1674 continue
1675 raw = os.environ.get(_variable(self._settings_cls, field_name))
1676 if value_is_set(field_name, raw):
1677 values[field_name] = raw
1678 return values, _refusals(self._settings_cls, values)
1680 def _stop(self, values: dict[str, Any], refused: dict[str, str]) -> None:
1681 """Refuse the variables in *refused*, less the ones that take their default."""
1682 lines = [
1683 f"{_variable(self._settings_cls, key)} = {values[key]!r} {reason}"
1684 for key, reason in refused.items()
1685 if key not in _TAKES_DEFAULT_WHEN_REFUSED
1686 ]
1687 if lines:
1688 refuse_variable("; ".join(lines))
1690 def refuse(self) -> None:
1691 """Raise RefusedVariableError for the variables that stop a command."""
1692 self._stop(*self._read())
1694 def __call__(self) -> dict[str, Any]:
1695 values, refused = self._read()
1696 self._stop(values, refused)
1697 for key in [key for key in refused if key in _TAKES_DEFAULT_WHEN_REFUSED]:
1698 variable = _variable(self._settings_cls, key)
1699 warn_on_load(f"{variable} = {values[key]!r} {refused[key]}; {key} uses its default")
1700 values[key] = self._settings_cls.model_fields[key].default
1701 del refused[key]
1702 for key in (*_NOT_SETTINGS, *_NOT_FROM_ENV):
1703 variable = _variable(self._settings_cls, key)
1704 if os.environ.get(variable, "").strip():
1705 warn_on_load(_not_a_setting(variable, key))
1706 return {key: value for key, value in values.items() if key not in refused}
1709class _TomlSource:
1710 """Reads config.toml as a settings source; *label* names the file in a warning.
1712 With *only*, it reads that one key and no other. It reports nothing in
1713 *reported*, the warnings of the load that built cfg from this file, and
1714 with *per_change* it reports the rest once per change of the file.
1715 """
1717 def __init__(
1718 self,
1719 settings_cls: type[BaseSettings],
1720 path: Path,
1721 label: str = CONFIG_FILE_NAME,
1722 otherwise: str = _USES_ITS_DEFAULT,
1723 reported: tuple[str, ...] = (),
1724 only: str | None = None,
1725 per_change: bool = False,
1726 ) -> None:
1727 self._settings_cls = settings_cls
1728 self._path = path
1729 self._label = label
1730 self._otherwise = otherwise
1731 self._reported = reported
1732 self._only = only
1733 self._per_change = per_change
1735 def _file_version(self) -> str:
1736 """What changes when the file does: its size and the time it was last written."""
1737 try:
1738 stat = self._path.stat()
1739 except OSError:
1740 return ""
1741 return f"{stat.st_mtime_ns}:{stat.st_size}"
1743 def _warn(self, message: str) -> None:
1744 """Report *message* once, and with *per_change* once more for each change of the file."""
1745 warn_on_load(message, self._file_version() if self._per_change else "")
1747 def _line(
1748 self, label: str, otherwise: str, refused: tuple[str, str], values: dict[str, Any]
1749 ) -> str:
1750 """The warning for one *refused* key and reason, as a read under *label* words it."""
1751 key, reason = refused
1752 fallback = f"{key} {otherwise}"
1753 if env_value(key) is not None:
1754 fallback = f"{_variable(self._settings_cls, key)} sets {key}"
1755 return f"{label}: {key} = {values[key]!r} {reason}; {fallback}"
1757 def _refuse_not_settings(self, data: dict[str, Any]) -> None:
1758 """Report each key in *data* that is not a setting, as a refused value is reported."""
1759 for key in _NOT_SETTINGS.keys() & data.keys():
1760 if self._only not in (None, key):
1761 continue
1762 if f"{CONFIG_FILE_NAME}: {_not_a_setting(key, key)}" not in self._reported:
1763 self._warn(f"{self._label}: {_not_a_setting(key, key)}")
1765 def __call__(self) -> dict[str, Any]:
1766 import tomllib
1768 try:
1769 with self._path.open("rb") as f:
1770 data = tomllib.load(f)
1771 except (ValueError, OSError):
1772 unreadable = f"Failed to read {self._path}, ignoring"
1773 if unreadable not in self._reported:
1774 self._warn(unreadable)
1775 return {}
1776 self._refuse_not_settings(data)
1777 # A blank string is unset (the field default applies, since pydantic
1778 # cannot coerce "" to int|None), except on a clearable model role,
1779 # where it clears the model. TOML's native types pass through as-is.
1780 values = {k: v for k, v in data.items() if value_is_set(k, v) and self._only in (None, k)}
1781 refused = _refusals(self._settings_cls, values)
1782 for item in refused.items():
1783 # The load that built cfg words its report of this file its own way.
1784 if self._line(CONFIG_FILE_NAME, _USES_ITS_DEFAULT, item, values) not in self._reported:
1785 self._warn(self._line(self._label, self._otherwise, item, values))
1786 return {key: value for key, value in values.items() if key not in refused}
1789class _ProfileSource:
1790 """Reads the ``[profile]`` values of config.toml, the layer under the file's own keys."""
1792 def __init__(self, settings_cls: type[BaseSettings], path: Path) -> None:
1793 self._settings_cls = settings_cls
1794 self._path = path
1796 def __call__(self) -> dict[str, Any]:
1797 # circular: resolve -> model via Config
1798 from .resolve import read_layers, read_profile_table
1800 root = self._path.parent
1801 values = dict(read_layers(root).profile)
1802 refused = _refusals(self._settings_cls, values)
1803 if refused:
1804 name = read_profile_table(root).name
1805 label = f"the {name!r} profile" if name else "the applied profile"
1806 for key, reason in refused.items():
1807 warn_on_load(
1808 f"{label}: {key} = {values[key]!r} {reason}; {key} {_USES_ITS_DEFAULT}"
1809 )
1810 return {key: value for key, value in values.items() if key not in refused}
1813def env_value(field_name: str) -> str | None:
1814 """What LILBEE_<FIELD_NAME> holds, or None when it is unset or blank."""
1815 raw = os.environ.get(_variable(Config, field_name))
1816 return raw if value_is_set(field_name, raw) else None
1819def default_refusal(key: str, value: Any) -> str | None:
1820 """The report of a *value* that sends *key* to its default, or None when it does not."""
1821 # These settings refuse only text they cannot read; any other type goes to the validator.
1822 if key not in _TAKES_DEFAULT_WHEN_REFUSED or not isinstance(value, str):
1823 return None
1824 reason = _refusals(Config, {key: value}).get(key)
1825 return None if reason is None else f"{value!r} {reason}; {key} {_USES_ITS_DEFAULT}"
1828def hard_refusals(values: Mapping[str, Any]) -> dict[str, str]:
1829 """Why its own field refuses each of *values* that one refuses, by key.
1831 A setting that takes its default when refused is left out: its value is kept and replaced
1832 by :func:`default_refusal`.
1833 """
1834 checked = {key: v for key, v in values.items() if key not in _TAKES_DEFAULT_WHEN_REFUSED}
1835 return _refusals(Config, checked)
1838def refusal_sentence(key: str, value: Any) -> str:
1839 """One plain sentence for a *value* that *key* refuses, with no validator dump."""
1840 reason = _refusals(Config, {key: value}).get(key, "is not a value it accepts")
1841 return f"{key} = {value!r} {reason}"
1844def canonical_model_ref(ref: str) -> str:
1845 """The form a model ref takes in cfg; a blank ref stays blank."""
1846 if not ref or not ref.strip():
1847 return ""
1848 from lilbee.providers.model_ref import parse_model_ref
1850 return parse_model_ref(ref).for_openai_prefix()
1853def checked_write(key: str, value: Any) -> Any:
1854 """*value* for a write to *key*; raises ValueError when the setting refuses its text.
1856 A load takes the default for these settings. A write names the refusal instead.
1857 """
1858 if key in _TAKES_DEFAULT_WHEN_REFUSED and isinstance(value, str):
1859 reason = _refusals(Config, {key: value}).get(key)
1860 if reason is not None:
1861 raise ValueError(f"{key} = {value!r} {reason}")
1862 return value
1865def root_folders(root: Path) -> dict[str, Path]:
1866 """The folder each setting that derives from the data root takes when nothing sets it."""
1867 return {
1868 "documents_dir": root / "documents",
1869 "data_dir": root / "data",
1870 "lancedb_dir": root / "data" / "lancedb",
1871 }
1874def toml_values(path: Path, only: str | None = None) -> dict[str, Any]:
1875 """What the config.toml at *path* sets over a loaded cfg, less each value Config refuses.
1877 With *only*, it reads that one key and no other. A file that does not parse
1878 and a refused value are each reported once per change of the file, less
1879 what the load reported when the file is the one cfg is built from.
1880 """
1881 return _TomlSource(
1882 Config,
1883 path,
1884 label=str(path),
1885 otherwise="keeps its value",
1886 reported=load_warnings if path == loaded_config_file else (),
1887 only=only,
1888 per_change=True,
1889 )()
1892def toml_value(path: Path, key: str) -> Any:
1893 """What the config.toml at *path* sets for *key*, or None when it sets none Config accepts."""
1894 return toml_values(path, only=key).get(key)
1897def refuse_environment() -> None:
1898 """Raise RefusedVariableError for a variable that holds a value its setting refuses."""
1899 _PlainEnvSource(Config).refuse()
1902def _build_cfg() -> tuple[Config, tuple[str, ...]]:
1903 """Build cfg, with the warnings its sources reported about refused values."""
1904 with collecting() as found:
1905 built = Config()
1906 return built, tuple(found)
1909# The config.toml that cfg is built from; a later read of it reports only what is new.
1910loaded_config_file = _config_file()
1911cfg, load_warnings = _build_cfg()
1912# Once, at import on the main thread: a later warning stays in this process, because
1913# a write from a loading thread would race native readers of the environment.
1914if shown := shown_marks():
1915 os.environ[SHOWN_ENV_VAR] = shown
1918def export_data_root() -> None:
1919 """Name cfg's data root in ``LILBEE_DATA`` when the variable names none.
1921 Spawn-context worker subprocesses then inherit the same root, and the CLI
1922 loads every root through the one overlay that records the reading a config
1923 follower starts from. A user-set value is kept; a blank one names no root.
1924 """
1925 if not os.environ.get("LILBEE_DATA", "").strip():
1926 os.environ["LILBEE_DATA"] = str(cfg.data_root)
1929export_data_root()
1932def export_download_environment() -> None:
1933 """Publish ``fast_model_downloads`` to hf_xet, which reads it from the environment.
1935 Call once at startup on the main thread: hf_xet caches the value when its
1936 session is built, so a later change lands on restart, and a write from a
1937 download thread would race native readers of the environment.
1938 """
1939 if cfg.fast_model_downloads:
1940 os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
1941 else:
1942 os.environ.pop("HF_XET_HIGH_PERFORMANCE", None)
1945export_download_environment()