Coverage for src/lilbee/core/config/model.py: 100%
509 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""The :class:`Config` dataclass and the ``cfg`` singleton.
3The settings sources, TOML parser, and the resilient builder that falls
4back to defaults on stale-config validation failures live here too. Every
5``from lilbee.core.config import cfg`` resolves through ``lilbee.core.config.__init__``
6to the same instance defined at module bottom.
7"""
9import logging
10import os
11from pathlib import Path
12from typing import Any, ClassVar
14from pydantic import Field, ValidationInfo, field_validator, model_validator
15from pydantic_settings import BaseSettings, SettingsConfigDict
17from lilbee.core.system import scaled_chat_ctx_target_default
19from .defaults import (
20 DEFAULT_ALLOWED_NER_LABELS,
21 DEFAULT_CORS_ORIGIN_REGEX,
22 DEFAULT_CRAWL_EXCLUDE_PATTERNS,
23 DEFAULT_GENERAL_SYSTEM_PROMPT,
24 DEFAULT_IGNORE_DIRS,
25 DEFAULT_RAG_SYSTEM_PROMPT,
26)
27from .enums import (
28 ChatMode,
29 ClustererBackend,
30 CrawlRenderMode,
31 KvCacheType,
32 LlmProvider,
33 RerankerType,
34 TableModel,
35 WikiEntityMode,
36)
37from .parsing import parse_bool
38from .validators import ConfigField
40log = logging.getLogger(__name__)
42# Sentinel for unset Path-typed fields. ``Field(default=Path())`` produces an
43# instance equal to this, so the model_validator can distinguish "user passed
44# the default" from "user explicitly set a value".
45_UNSET_PATH = Path()
47# Snowball stemmer languages LanceDB's FTS accepts (lancedb.index.lang_mapping);
48# hardcoded so config validation does not import lancedb.
49FTS_LANGUAGES = frozenset(
50 {
51 "Arabic",
52 "Danish",
53 "Dutch",
54 "English",
55 "Finnish",
56 "French",
57 "German",
58 "Greek",
59 "Hungarian",
60 "Italian",
61 "Norwegian",
62 "Portuguese",
63 "Romanian",
64 "Russian",
65 "Spanish",
66 "Swedish",
67 "Tamil",
68 "Turkish",
69 }
70)
73class Config(BaseSettings):
74 """Runtime configuration: one singleton instance, mutated by CLI overrides."""
76 model_config = SettingsConfigDict(
77 env_prefix="LILBEE_",
78 validate_assignment=True,
79 arbitrary_types_allowed=True,
80 extra="ignore",
81 )
83 # Paths: resolved from env/defaults in model_validator(mode='before')
84 data_root: Path = Field(default=Path())
85 # Writable so plugin-managed servers can pivot storage to a vault path on
86 # first boot; rebuild the index after migrating.
87 documents_dir: Path = ConfigField(default=Path(), writable=True)
88 # External source roots ``add`` registered, mapping label -> absolute path.
89 # lilbee indexes the files where they live (no copy, no symlink); the label
90 # prefixes their source keys so a root at /data/corpus keys as ``corpus/…``.
91 # Managed by ``add`` / ``remove``, so it is writable (persisted to
92 # config.toml) but not surfaced in the settings UI.
93 linked_roots: dict[str, str] = ConfigField(default_factory=dict, writable=True, public=False)
94 data_dir: Path = Field(default=Path())
95 lancedb_dir: Path = Field(default=Path())
96 models_dir: Path = Field(default=Path())
97 # Markdown vault root; when set, search results carry a vault-relative
98 # ``vault_path`` so a host UI can deep-link into the vault.
99 vault_base: Path | None = ConfigField(default=None, writable=True)
101 # Human-readable label for the active lilbee. Empty falls back to
102 # "global" for the platform default dir, otherwise the project path
103 # (~-substituted and left-truncated to a hard cap).
104 lilbee_name: str = ConfigField(default="", writable=True)
105 # If True, the status bar pill shows the full absolute path: expands
106 # "global" to the on-disk platform-default path and skips the
107 # ~-substitution / left-truncation for project paths. Toggled by F4.
108 show_lilbee_path: bool = ConfigField(default=False, writable=True)
110 # Whether an agent launcher (opencode, hermes) registers lilbee's MCP search
111 # tool into the agent's config. Per-launch --mcp/--no-mcp overrides it.
112 agent_mcp_enabled: bool = ConfigField(default=True, writable=True)
114 # Empty = not configured, same convention as vision_model. A fresh install
115 # has no models; the catalog assigns these on the first download.
116 chat_model: str = Field(default="")
117 embedding_model: str = Field(default="")
118 # Vision OCR model for scanned PDFs and image-only pages. Empty = disabled;
119 # there is no cross-role fallback onto the chat model even if multimodal.
120 vision_model: str = ConfigField(default="", public=True)
121 embedding_dim: int = Field(default=768, ge=1)
122 chunk_size: int = ConfigField(default=512, ge=64, writable=True, reindex=True)
123 chunk_overlap: int = ConfigField(default=100, ge=0, writable=True, reindex=True)
124 # Workers for the parallel discovery/hash planning pass. 0 = auto, sized to
125 # the container-aware CPU budget (see runtime.cpu.available_cpu_count).
126 # `add --max-cpus N` sets this per invocation. Sizes only the planning pass,
127 # not the GPU-fed extract/embed batch.
128 ingest_workers: int = ConfigField(default=0, ge=0, writable=True)
129 # Worker PROCESSES for a bulk ingest (distinct from ingest_workers, which sizes
130 # the planning pass's threads). Each owns a GPU, a private store and its own
131 # slice of the corpus, and the shards are folded into one index at the end.
132 # 0 = auto: one worker per visible card, used once the corpus is big enough to
133 # pay for them. N pins the count; worker i takes card i % card_count, so more
134 # workers than cards share a card's engine rather than double-booking it.
135 ingest_processes: int = ConfigField(default=0, ge=0, writable=True)
136 # Passages packed into one embed request. Larger batches keep a GPU's
137 # continuous-batching slots full: small per-passage requests leave the card
138 # batch-starved (~96% util, low throughput). The engine still re-splits to
139 # its physical batch, so raising this only helps up to the server's --batch.
140 embed_batch_sequences: int = ConfigField(default=64, ge=1, writable=True)
141 # Files allowed in their compute phase at once during ingest. 0 = auto: the
142 # ceiling scales with the detected embed fleet (replicas x per-replica
143 # in-flight) so a multi-GPU box is kept fed without a manual cap, falling back
144 # to the CPU quota on a single card. Set a positive value only to override the
145 # auto sizing. Sizes the extract+embed fan-out, not the plan pass.
146 ingest_max_inflight: int = ConfigField(default=0, ge=0, writable=True)
147 # Gate for the pre-ask sync; --no-sync overrides per invocation.
148 auto_sync: bool = ConfigField(default=True, writable=True)
149 max_embed_chars: int = Field(default=2000, ge=1)
150 top_k: int = ConfigField(default=12, ge=1, writable=True)
151 max_distance: float = ConfigField(default=0.75, ge=0.0, writable=True)
152 # Abstention floor against the [0, 1] fused relevance score (0.0 = no
153 # filtering). When every retrieved chunk falls below it, ask refuses instead
154 # of feeding noise as context. The fused score normalizes against the
155 # configured weight budget (a constant), so an arm's top hit scores a stable
156 # share of it; useful floors start around 0.4. Tune against your own corpus.
157 min_relevance_score: float = ConfigField(default=0.0, ge=0.0, writable=True)
158 adaptive_threshold: bool = ConfigField(default=False, writable=True)
159 rag_system_prompt: str = ConfigField(
160 default=DEFAULT_RAG_SYSTEM_PROMPT, min_length=1, writable=True
161 )
162 general_system_prompt: str = ConfigField(
163 default=DEFAULT_GENERAL_SYSTEM_PROMPT, min_length=1, writable=True
164 )
165 chat_mode: str = ConfigField(default=ChatMode.SEARCH.value, writable=True)
166 ignore_dirs: frozenset[str] = Field(default=DEFAULT_IGNORE_DIRS)
167 # OCR for scanned PDFs via vision-capable chat model.
168 # None = auto-detect (use OCR if chat model is vision-capable).
169 # True = force OCR regardless of detection.
170 # False = disable OCR entirely.
171 enable_ocr: bool | None = ConfigField(default=None, writable=True)
172 # Per-page timeout in seconds for vision OCR (0 = no limit). Sized so a dense
173 # full-page scan finishes on modest hardware; a raised vision_ocr_max_tokens
174 # needs matching headroom here.
175 ocr_timeout: float = ConfigField(default=300.0, ge=0.0, writable=True)
176 # Outer wall-clock budget for the streamed pool drain: load grace plus
177 # per_page * pages. Tune up for slow hardware (M1 Pro vision is
178 # ~5min/page) or down for fast hardware. ocr_timeout still governs the
179 # per-page expectation that drives the total budget.
180 vision_load_budget_s: float = ConfigField(default=300.0, ge=0.0, writable=True)
181 # Hard cap on tokens generated per OCR page. A real page is well under this;
182 # the cap bounds the occasional runaway repetition loop (a page that loops to
183 # tens of thousands of chars) which otherwise dominates a scan's OCR time.
184 # Raising it lengthens per-page generation on dense scans, so give ocr_timeout
185 # matching headroom.
186 vision_ocr_max_tokens: int = ConfigField(default=4096, ge=256, writable=True)
187 # Pages OCR'd concurrently, and the vision server's continuous-batching slots.
188 # A single-page decode underutilizes a modern GPU (~half SM); batching several
189 # pages raises throughput. Each slot adds KV cache, so lower it on small GPUs.
190 vision_ocr_concurrency: int = ConfigField(default=4, ge=1, writable=True)
192 # Tesseract fallback wall-clock timeout per file, seconds. 0 = no cap.
193 tesseract_timeout: float = ConfigField(default=60.0, ge=0.0, writable=True)
194 # Tesseract OCR language codes for the scanned-document fallback (used when no
195 # vision model is set), e.g. ["eng"] or ["eng", "deu"]. Set via env as
196 # LILBEE_OCR_LANGUAGE="eng+deu". xberg requires a non-empty list.
197 ocr_language: list[str] = ConfigField(default_factory=lambda: ["eng"], writable=True)
198 # Typed entity table for exact counting/cross-referencing; corpus-scale pass, off by default.
199 entity_extraction: bool = ConfigField(default=False, writable=True)
200 semantic_chunking: bool = ConfigField(default=False, writable=True)
201 topic_threshold: float = ConfigField(default=0.75, ge=0.0, le=1.0, writable=True)
202 # Size chunks in real tokens via the embedder's tokenizer backend, not the
203 # chars-per-token heuristic. Plain/heading chunkers only; semantic sizes by chars.
204 token_sizing: bool = ConfigField(default=False, writable=True, reindex=True)
205 # Index each recognized table as its own markdown-serialized chunk.
206 table_extraction: bool = ConfigField(default=False, writable=True, reindex=True)
207 # Layout-aware PDF extraction (reading-order sort, header/footer stripping),
208 # run in xberg's AUTO strategy so detection only fires when it helps. Off by
209 # default: enabling it downloads the ONNX layout and table-structure models
210 # and adds per-page inference, which a CPU-only ingest pays for.
211 layout_detection: bool = ConfigField(default=False, writable=True, reindex=True)
212 # Table structure model; only applied when layout_detection is on.
213 table_model: TableModel = ConfigField(
214 default=TableModel.SLANET_AUTO, writable=True, reindex=True
215 )
216 # Coalesce concurrent extractions into one xberg extract_batch call.
217 batch_extraction: bool = ConfigField(default=False, writable=True)
218 batch_extraction_size: int = ConfigField(default=8, ge=1, writable=True)
219 # Size of anyio's thread pool: synchronous handlers (MCP tools, sync routes)
220 # that may run off the event loop at once. The ceiling on agents one daemon
221 # serves before their calls queue.
222 mcp_tool_threads: int = ConfigField(default=40, ge=1, writable=True)
223 # Crawled pages converted to markdown on anyio's thread pool at once. The
224 # conversion is synchronous, so this keeps it off the event loop that serves
225 # requests. 0 converts inline on the loop.
226 crawl_convert_workers: int = ConfigField(default=2, ge=0, writable=True)
227 server_host: str = "127.0.0.1"
228 server_port: int = Field(default=0, ge=0, le=65535)
229 cors_origins: list[str] = Field(default_factory=list)
230 cors_origin_regex: str = Field(default=DEFAULT_CORS_ORIGIN_REGEX)
231 # Seconds between SSE heartbeat events when the producer queue is idle.
232 # Must stay well below the plugin's STREAM_IDLE_TIMEOUT_MS (120s) so a
233 # single long-running vision OCR page can't starve the client into aborting.
234 sse_heartbeat_interval: float = ConfigField(default=30.0, ge=0.0, writable=True)
235 json_mode: bool = False
236 temperature: float | None = ConfigField(default=0.1, ge=0.0, writable=True)
237 top_p: float | None = ConfigField(default=0.9, ge=0.0, le=1.0, writable=True)
238 top_k_sampling: int | None = ConfigField(default=40, ge=1, writable=True)
239 # 1.1 is llama.cpp's default. Leaving this at None caused n-gram loops
240 # ("tire tire tire...") on some open-weights models.
241 repeat_penalty: float | None = ConfigField(default=1.1, ge=0.0, writable=True)
242 num_ctx: int | None = ConfigField(default=None, ge=1, writable=True)
243 max_tokens: int | None = ConfigField(default=4096, ge=1, writable=True)
244 seed: int | None = ConfigField(default=None, writable=True)
245 llm_provider: LlmProvider = ConfigField(default=LlmProvider.AUTO, writable=True)
246 # Path to a llama-server binary. Empty = use the bundled lilbee-engine
247 # wheel binary, else a llama-server on PATH.
248 llama_server_path: str = ConfigField(default="", writable=True)
249 # Per-server local model-manager URLs. Blank means "use the server's spec
250 # default" (resolved in providers.local_servers.config_urls); the default
251 # URL literal lives only in the spec, which core must not import.
252 ollama_base_url: str = ConfigField(default="", writable=True)
253 lm_studio_base_url: str = ConfigField(default="", writable=True)
254 llm_api_key: str = ConfigField(default="", writable=True, write_only=True)
255 openrouter_api_key: str = ConfigField(default="", writable=True, write_only=True)
256 gemini_api_key: str = ConfigField(default="", writable=True, write_only=True)
257 anthropic_api_key: str = ConfigField(default="", writable=True, write_only=True)
258 openai_api_key: str = ConfigField(default="", writable=True, write_only=True)
259 mistral_api_key: str = ConfigField(default="", writable=True, write_only=True)
260 deepseek_api_key: str = ConfigField(default="", writable=True, write_only=True)
261 hf_token: str = ConfigField(default="", writable=True, write_only=True)
263 # Retrieval quality knobs.
265 # Max chunks per source in top-k; prevents one large file monopolizing results.
266 diversity_max_per_source: int = ConfigField(default=5, ge=1, writable=True)
268 # MMR relevance/diversity tradeoff; 0 = max diversity, 1 = pure relevance
269 # (Carbonell & Goldstein 1998).
270 mmr_lambda: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
272 # Vector-only search retrieves this many candidates per final result so
273 # MMR reranking has a pool to diversify from. Hybrid search ignores it:
274 # fusion arms stay exactly top_k deep.
275 candidate_multiplier: int = ConfigField(default=3, ge=1, writable=True)
277 # Third lexical arm in hybrid search: BM25 over document titles, fused with
278 # the vector and chunk arms so a query naming a document by title surfaces
279 # its chunks. Off by default until the eval harness measures it.
280 title_search: bool = ConfigField(default=False, writable=True)
282 # Title arm weight relative to a full arm in rank fusion (1.0 = equal voice
283 # with the vector and chunk arms).
284 title_search_weight: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
286 # Lexical (BM25) arm weight relative to the vector arm in rank fusion.
287 # 1.0 gives the two arms equal voice; lowering it lets
288 # a strong dense embedder dominate on corpora where the lexical arm adds
289 # noise rather than signal. The right value is corpus-dependent and set by
290 # the retrieval benchmark, not guessed here.
291 lexical_fusion_weight: float = ConfigField(default=1.0, ge=0.0, le=1.0, writable=True)
293 # Adaptive fusion: scale the BM25 arm per query by vector-arm confidence
294 # instead of a fixed lexical_fusion_weight (a peaked dense ranking downweights
295 # lexical, a flat one keeps it). OFF by default, pending a benchmark run to
296 # confirm it beats the fixed weight. lexical_fusion_weight is the ceiling the
297 # rule scales down from. Set adaptive_fusion=true to enable it.
298 adaptive_fusion: bool = ConfigField(default=False, writable=True)
300 # Vector-similarity margin at which the lexical arm is fully silenced; smaller
301 # = more aggressive downweighting. 0 disables adaptation entirely (the lexical
302 # arm keeps its full fixed weight).
303 adaptive_fusion_margin: float = ConfigField(default=0.15, ge=0.0, le=2.0, writable=True)
305 # Stemmer/stop-word language for the BM25 (FTS) indexes, a tantivy language
306 # name ("English", "German", "French", ...). Applied when an index is
307 # (re)built, so changing it needs `lilbee rebuild` on an existing store.
308 # Validated against FTS_LANGUAGES: a bad name would otherwise fail index
309 # creation quietly and hybrid search would degrade to vector-only.
310 fts_language: str = ConfigField(default="English", min_length=1, writable=True, reindex=True)
312 @field_validator("fts_language", mode="after")
313 @classmethod
314 def _validate_fts_language(cls, value: str) -> str:
315 normalized = value.strip().title()
316 if normalized not in FTS_LANGUAGES:
317 raise ValueError(f"fts_language must be one of: {', '.join(sorted(FTS_LANGUAGES))}")
318 return normalized
320 # Prefix each chunk's document title to its embedding input (the stored
321 # chunk text is unchanged). Changes the embedding space: toggling it needs
322 # `lilbee rebuild`, so it ships off.
323 embed_titles: bool = ConfigField(default=False, writable=True, reindex=True)
325 # Contextual retrieval: prepend one LLM-written sentence situating each
326 # chunk in its document to the embedding input. One generation per chunk,
327 # so ingest slows substantially; stored text and citations stay verbatim.
328 # Toggling needs `lilbee rebuild`.
329 contextual_enrichment: bool = ConfigField(default=False, writable=True, reindex=True)
331 # Drop tables-of-contents and classification-banner cover/title pages from
332 # search results. OFF by default; validate per corpus, since the cover-page
333 # heuristic can also fire on short banner-carrying body pages. A query-matched
334 # or top-ranked page is never dropped, so removal is limited to structural
335 # chunks the query did not hit.
336 filter_structural_chunks: bool = ConfigField(default=False, writable=True)
338 # Chunk count at/above which sync builds an approximate (ANN) vector index
339 # so search stays fast at millions of vectors. Below this, search uses exact
340 # flat scan (faster and exact for small vaults). 0 disables the ANN index.
341 ann_index_threshold: int = ConfigField(default=50_000, ge=0, writable=True)
343 # Condense a follow-up question into a standalone retrieval query using
344 # the chat history (one LLM call; skipped when there is no history).
345 # Without it, "what about his brother?" is embedded and BM25-matched
346 # with its pronouns.
347 history_rewrite: bool = ConfigField(default=True, writable=True)
349 # Route questions by shape before top-k retrieval: a question naming a
350 # document resolves to that document's chunks; a count-shaped question
351 # runs a full-corpus scan (a count is a corpus property top-k cannot
352 # answer). Unrecognized shapes take the topical path unchanged.
353 intent_routing: bool = ConfigField(default=True, writable=True)
355 # Ask the chat model to classify count questions the deterministic
356 # patterns miss (phrasing variants, other languages). Adds one short LLM
357 # call to every turn the patterns don't already route, so it's opt-in.
358 intent_llm: bool = ConfigField(default=False, writable=True)
360 # LLM-generated alternative queries for expansion. 0 disables.
361 query_expansion_count: int = ConfigField(default=3, ge=0, writable=True)
363 # Skip LLM expansion when tokenized query length ≤ this. The LLM round-trip
364 # dominates latency on small local models; short queries already have strong
365 # BM25/vector signal. Concept-graph expansion still runs. 0 disables the skip.
366 expansion_short_query_tokens: int = ConfigField(default=2, ge=0, writable=True)
368 # Cosine-distance step when adaptive-widening retry kicks in.
369 adaptive_threshold_step: float = ConfigField(default=0.2, gt=0.0, writable=True)
371 # Reject expansion variants below expansion_similarity_threshold.
372 expansion_guardrails: bool = ConfigField(default=True, writable=True)
374 # Min cosine similarity between question and variant embeddings.
375 expansion_similarity_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
377 # Saturating BM25 confidence (s / (s + 5)) above which query expansion is
378 # skipped; 0.8 corresponds to a raw BM25 score of 20.
379 expansion_skip_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
381 # Min relative BM25 top-1 vs top-2 gap ((top - second) / top) to skip expansion.
382 expansion_skip_gap: float = Field(default=0.15, ge=0.0, le=1.0)
384 # Chunks included in LLM context after adaptive selection.
385 max_context_sources: int = ConfigField(default=8, ge=1, writable=True)
387 # Adjacent chunks pulled from the same source on each side of every
388 # selected chunk and merged into one contiguous passage, so a hit that
389 # lands mid-argument regains the text before and after it. 0 disables.
390 # Capped: it is a small chunk radius (useful values are single digits), and
391 # the merged text is token-budget-bounded anyway, so a large value only
392 # inflates per-query fetch cost -- and a misread as a token count (e.g.
393 # 50000) would build a megabyte-long IN-predicate per source.
394 neighbor_expansion: int = ConfigField(default=0, ge=0, le=100, writable=True)
396 # HyDE (Gao et al. 2022): hypothetical-answer embedding search. +~500ms.
397 hyde: bool = ConfigField(default=False, writable=True)
399 # HyDE result weight relative to real-doc search (0.0-1.0).
400 hyde_weight: float = ConfigField(default=0.7, ge=0.0, le=1.0, writable=True)
402 # HyDE prompt template. Must contain {question} placeholder.
403 hyde_prompt: str = (
404 "Write a 50-100 word passage that directly answers this question as if "
405 "it were an excerpt from a real document. Do not include any preamble, "
406 "just write the passage.\n\nQuestion: {question}"
407 )
409 # Reranker model ref. Empty disables reranking. Native GGUFs run on
410 # llama-server (rank pooling or LLM logprob scoring); hosted refs
411 # (cohere/voyage/jina/together/hf-tei) need the backend extra.
412 reranker_model: str = ConfigField(default="", public=True)
414 # auto detects cross-encoder vs LLM reranker by GGUF arch; override forces one.
415 reranker_type: RerankerType = ConfigField(default=RerankerType.AUTO, writable=True, public=True)
416 # Relevance prompt for LLM rerankers; empty uses the built-in generic template.
417 # A format string with {query} and {document} placeholders.
418 reranker_prompt: str = ConfigField(default="", writable=True, public=True)
420 # Long-term chat memory. Off by default (opt-in): when disabled the whole
421 # subsystem is dormant and the write surfaces respond with an enable hint.
422 memory_enabled: bool = ConfigField(default=False, writable=True)
424 # Facts recalled by similarity per turn (preferences are always injected).
425 memory_top_k: int = ConfigField(default=5, ge=0, writable=True)
427 # Cosine-distance ceiling for fact recall; stricter than the document default
428 # because a tiny memory corpus floods at the wider document threshold.
429 memory_max_distance: float = ConfigField(default=0.6, ge=0.0, le=1.0, writable=True)
431 # Char/4 token budget for the injected memory block.
432 memory_token_budget: int = ConfigField(default=512, ge=0, writable=True)
434 # Per-owner soft cap; oldest memories evicted past it (runaway-write guard).
435 memory_max_per_owner: int = ConfigField(default=200, ge=1, writable=True)
437 # Cosine distance below which a new memory is treated as a duplicate of an
438 # existing same-owner memory and updates it in place instead of inserting.
439 memory_dedup_distance: float = ConfigField(default=0.05, ge=0.0, le=1.0, writable=True)
441 # LLM pass that extracts memories from the chat loop. Off by default; extracted
442 # memories are saved directly and recalled like any other memory.
443 memory_auto_extract: bool = ConfigField(default=False, writable=True)
445 # Candidate count sent to the reranker.
446 rerank_candidates: int = ConfigField(default=60, ge=1, writable=True, public=True)
448 # Blend reranker scores with the retrieval fusion signal (position-aware).
449 # Off = the cross-encoder's own ordering stands unblended, which isolates
450 # the reranker's effect when measuring it.
451 rerank_blend: bool = ConfigField(default=True, writable=True, public=True)
453 # Drop candidates whose RAW reranker score falls below this; unset = off.
454 # The scale is provider/model specific (bge logits can be negative, hosted
455 # rerankers use 0..1), so set it against observed scores.
456 rerank_min_score: float | None = ConfigField(default=None, writable=True, public=True)
458 # Date-range filter; only fires when a temporal keyword is detected.
459 temporal_filtering: bool = ConfigField(default=True, writable=True)
461 # If True, emit <think>…</think> content as separate SSE reasoning events;
462 # if False, strip it silently.
463 show_reasoning: bool = ConfigField(default=False, writable=True)
465 # Maximum reasoning characters before lilbee forces the model to answer.
466 # Per-model overrides apply on top of this default. Approx N/4 tokens.
467 # 0 disables the cap (unlimited reasoning; accept the runaway-loop risk).
468 max_reasoning_chars: int = ConfigField(default=64_000, ge=0, writable=True)
470 # Web crawling.
472 # How crawls fetch pages. ``http`` (default) uses a plain HTTP client with
473 # no browser, the lightweight path for static / server-rendered sites.
474 # ``browser`` launches a tuned Chromium with JavaScript enabled for sites
475 # that render content client-side, at a much higher memory cost.
476 crawl_render_mode: CrawlRenderMode = ConfigField(default=CrawlRenderMode.HTTP, writable=True)
478 # Browser-mode memory levers (only used when crawl_render_mode is browser).
479 # Recycle the Chromium process every N fetched pages to cap RSS growth on a
480 # long recursive crawl; 0 disables recycling. Raise on a roomy machine for
481 # fewer restarts, lower it if memory is tight.
482 crawl_browser_recycle_pages: int = ConfigField(default=50, ge=0, writable=True)
484 # Extra Chromium launch flags for browser-mode crawls. Defaults trim shared
485 # memory and GPU use; override to pass site- or environment-specific flags.
486 crawl_browser_extra_args: list[str] = ConfigField(
487 default_factory=lambda: ["--disable-dev-shm-usage", "--disable-gpu"],
488 writable=True,
489 )
491 # Optional global ceilings. None = no ceiling.
492 crawl_max_depth: int | None = ConfigField(default=None, ge=0, writable=True)
493 crawl_max_pages: int | None = ConfigField(default=None, ge=1, writable=True)
495 # Default page bound for an unbounded crawl (no explicit max_pages /
496 # crawl_max_pages), so a hostile site can't exhaust the disk by default.
497 # An explicit limit overrides it; raise this to crawl larger sites unbounded.
498 crawl_safety_max_pages: int = ConfigField(default=5_000, ge=1, writable=True)
500 # Per-URL fetch timeout, seconds.
501 crawl_timeout: int = ConfigField(default=30, ge=1, writable=True)
503 # 0 = unlimited, default = CPU count.
504 crawl_max_concurrent: int = Field(default=0, ge=0)
506 # Seconds between periodic syncs during crawl. 0 = sync only at end.
507 crawl_sync_interval: int = ConfigField(default=30, ge=0, writable=True)
509 # Per-request delay + jitter (defaults chosen to be gentler than crawl4ai's).
510 crawl_mean_delay: float = ConfigField(default=0.5, ge=0.0, writable=True)
511 crawl_max_delay_range: float = ConfigField(default=0.5, ge=0.0, writable=True)
513 # In-flight requests per crawl.
514 crawl_concurrent_requests: int = ConfigField(default=3, ge=1, writable=True)
516 # Per-domain rate-limiter that backs off on HTTP 429/503 and retries.
517 crawl_retry_on_rate_limit: bool = ConfigField(default=True, writable=True)
518 crawl_retry_base_delay_min: float = ConfigField(default=1.0, ge=0.0, writable=True)
519 crawl_retry_base_delay_max: float = ConfigField(default=3.0, ge=0.0, writable=True)
520 crawl_retry_max_backoff: float = ConfigField(default=30.0, ge=0.0, writable=True)
521 crawl_retry_max_attempts: int = ConfigField(default=3, ge=0, writable=True)
523 # Regex patterns dropped at link-discovery time. Defaults block CMS
524 # scaffolding (WordPress admin, archives, tracking params, etc.).
525 crawl_exclude_patterns: list[str] = ConfigField(
526 default_factory=lambda: list(DEFAULT_CRAWL_EXCLUDE_PATTERNS),
527 writable=True,
528 )
530 # Fraction of GPU/unified memory reserved for loaded models.
531 gpu_memory_fraction: float = ConfigField(default=0.75, ge=0.1, le=1.0, writable=True)
533 # Share of a card placement may charge, leaving room for allocator
534 # fragmentation and driver overhead. Tunable because it decides admission: at
535 # the default, a 16 GB machine whose chat model needs 12-13 GB can be refused
536 # chat entirely, and the owner is the one who knows whether that card has the
537 # room. Raising it trades safety margin for the ability to serve at all.
538 usable_vram_fraction: float = ConfigField(default=0.9, ge=0.5, le=1.0, writable=True)
540 # RAM held back for the OS when placing against system memory, in GiB. Capped
541 # at a quarter of total RAM either way, so a small host keeps its proportional
542 # reserve however this is set.
543 system_memory_reserve_gb: float = ConfigField(default=4.0, ge=0.0, le=64.0, writable=True)
545 # Data-parallel replicas of the embed / vision role across GPUs: N independent
546 # servers, round-robined, so large-scale ingest fans the embedding / OCR work
547 # across the whole box. 0 means "auto": one replica per detected GPU, capped by
548 # the VRAM left after the persistent query fleet (chat, one embed, rerank, one
549 # vision) is reserved. A positive value pins the count. The extra replicas are
550 # ingest-only and reclaimed when ingest ends; the persistent query embedder /
551 # vision (replica 0) always exists if its model fits.
552 embed_replicas: int = ConfigField(default=0, ge=0, writable=True)
553 vision_replicas: int = ConfigField(default=0, ge=0, writable=True)
555 # Seconds a model stays loaded after last use. 0 = unload immediately.
556 model_keep_alive: int = ConfigField(default=300, ge=0, writable=True)
558 # Spawn every configured role server at startup instead of on first use.
559 # Trades a slower TUI mount (the role servers cold-start in parallel) for a
560 # responsive first interaction. Roles whose model is unset are skipped, so a
561 # setup with only chat + embed never spawns rerank or vision. Set to false
562 # for headless / scripted use where the first call doesn't need to be fast.
563 worker_pool_eager_start: bool = ConfigField(default=True, writable=True)
565 # Leave the engine fleet running on quit so the next launch adopts it warm.
566 # On keeps the engine resident: it never idle-unloads and survives app close
567 # so the next launch binds instantly. Off (default): the engine stops when
568 # the last lilbee process exits, leaving the machine clean.
569 keep_engine_warm: bool = ConfigField(default=False, writable=True)
571 # Hugging Face's high-performance transfer mode: more connections and much
572 # larger in-flight buffers. Off by default, those ceilings suit a server
573 # rather than a laptop also holding a model in memory.
574 fast_model_downloads: bool = ConfigField(default=False, writable=True)
576 # Idle minutes before the engine unloads its weights (llama-swap ttl), in
577 # every mode: even a persistent engine naps when unused. 0 keeps weights
578 # loaded until the engine stops.
579 engine_idle_ttl_minutes: int = ConfigField(default=5, writable=True)
581 # Working n_ctx the dynamic picker aims for. Default scales with
582 # total host RAM (see core.system.chat_ctx_target_for_total_bytes):
583 # <16 GiB -> 8192, 16-32 -> 12288, 32-64 -> 16384, 64-128 -> 24576,
584 # >=128 -> 65536 (an agent-capable window on server-class hosts).
585 # 8192 is the floor; the picker still clamps to training_ctx and
586 # host headroom.
587 chat_n_ctx_target: int = ConfigField(
588 default_factory=scaled_chat_ctx_target_default,
589 ge=512,
590 writable=True,
591 )
593 # Condense turns that outgrow chat_n_ctx_target into carried notes instead
594 # of dropping them. Off: zero model calls; the oldest turns drop and the
595 # context chip shows it. On: each firing blocks on a summarize call
596 # (measured: 1.3-2.5s per 60-turn fold on a datacenter GPU, 0.7-2s on an
597 # 8-core CPU with a 0.6B-4B model).
598 chat_compaction: bool = ConfigField(default=False, writable=True)
600 # Persist conversations and expose the Sessions drawer, tab, and commands.
601 # On by default; turning it off stops chats being written to disk, hides the
602 # ctrl+o binding from the footer, and gates the Sessions view behind a notice.
603 # Governs the human surfaces (TUI, HTTP, CLI); agent sessions have their own
604 # flag below, so the two domains the store already separates stay separate.
605 sessions_enabled: bool = ConfigField(default=True, writable=True)
607 # The agent (MCP) half of the same feature, off by default: agent hosts
608 # generally track their own conversation history, and the seven session
609 # tools cost schema on every request whether or not anything uses them.
610 mcp_sessions_enabled: bool = ConfigField(default=False, writable=True)
612 # Explicit ceiling for the dynamic n_ctx picker. ``None`` (default)
613 # lets the model's training_ctx from GGUF metadata be the ceiling,
614 # so a 128K-context model can reach for it on a host with the RAM
615 # to back it. Set explicitly to cap below the model's training_ctx.
616 num_ctx_max: int | None = ConfigField(default=None, ge=512, writable=True)
618 # Flash attention. None (default) = on, True = force on, False = off
619 # for backends or models where it misbehaves.
620 # Resolves the 'padding V cache to 1024' warning on models with
621 # uneven per-layer V dims (e.g. Gemma3) and saves ~25% KV memory.
622 flash_attention: bool | None = ConfigField(default=None, writable=True)
624 # KV cache element type. q8_0 (default) halves cache memory vs f16
625 # with no measurable quality loss for chat; q4_0 quarters it with a
626 # small quality cost. Both require flash attention to be enabled.
627 kv_cache_type: KvCacheType = ConfigField(default=KvCacheType.Q8_0, writable=True)
629 # Number of model layers to offload to GPU. None (default) = all
630 # layers, 0 = CPU only, positive int = partial offload. Useful when a
631 # discrete GPU has less VRAM than the model needs.
632 n_gpu_layers: int | None = ConfigField(default=None, writable=True)
634 # Keep a MoE model's expert weights in system memory, attention and shared
635 # layers on the GPU. Lets a sparse model run on a card too small to hold it.
636 # No effect on dense models, which have no expert tensors.
637 cpu_moe: bool = ConfigField(default=False, writable=True)
639 # Offload only the first N layers' experts. Takes precedence over cpu_moe;
640 # a smaller N keeps more of the model resident.
641 n_cpu_moe: int | None = ConfigField(default=None, writable=True)
643 # GPU device picker for dual-GPU machines (typical laptop case:
644 # discrete NVIDIA + integrated Intel/AMD). The Vulkan backend
645 # enumerates every adapter the system exposes and may pick the
646 # integrated one first, producing stalls or OOMs that look like
647 # llama.cpp bugs. Setting ``gpu_devices`` constrains visibility
648 # before the servers spawn, pinning inference to the chosen device(s).
649 #
650 # Accepts a comma-separated list of device indexes ("0", "1",
651 # "0,1") and applies it to every backend simultaneously:
652 # ``GGML_VK_VISIBLE_DEVICES`` for Vulkan, ``CUDA_VISIBLE_DEVICES``
653 # for CUDA, ``HIP_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` for
654 # ROCm. Setting one variable that the active backend ignores is
655 # harmless, so we set all four rather than detecting the build.
656 #
657 # Must be set before the first llama.cpp call; in practice that
658 # means via ``LILBEE_GPU_DEVICES`` or ``config.toml`` (TUI edits
659 # only take effect after a restart). ``None`` (default) hands off
660 # to the autodetect in ``providers/fleet/gpu_select.py``,
661 # which parses ``vulkaninfo --summary`` and pins the discrete
662 # adapter when one is present. The autodetect is silent on failure
663 # (no vulkaninfo, single device, parse error), leaving the
664 # Vulkan-loader's default ordering in place.
665 gpu_devices: str | None = ConfigField(default=None, writable=True)
667 # Primary GPU index passed to ``Llama(main_gpu=...)``. Only matters
668 # when multiple devices remain visible after ``gpu_devices``; with
669 # a single visible device, llama.cpp ignores this. ``None``
670 # (default) lets llama.cpp pick (index 0).
671 main_gpu: int | None = ConfigField(default=None, writable=True)
673 # Manual GPU placement override stored as a JSON scalar (the config.toml store
674 # is flat, and core must not depend on the provider PlacementSpec type). When
675 # set, it fully replaces the automatic placement planner: each active role pins
676 # to the listed device indices, with an optional tensor_split and replica count.
677 # Edited via the placement CLI/MCP/HTTP/TUI surfaces rather than the generic
678 # settings list, so public=False. None hands off to the VRAM-aware auto planner.
679 placement: str | None = ConfigField(default=None, writable=True, public=False)
681 # Allow PUT/DELETE /api/placement to apply or clear placement over HTTP.
682 # Off by default because applying placement restarts the shared fleet's moved roles, which
683 # is unsafe across concurrent HTTP clients. Turn it on (LILBEE_ALLOW_HTTP_PLACEMENT=1)
684 # only for a single-client / owned deployment: the plugin's managed local
685 # server, or a personally-owned pod where one operator runs `lilbee serve`.
686 allow_http_placement: bool = Field(default=False)
688 # True = Markdown widget for chat; False = plain Static (faster).
689 markdown_rendering: bool = True
691 # TUI theme name; persists the last Ctrl+T pick across sessions.
692 theme: str = ConfigField(default="rose-pine", writable=True)
694 # Per-model generation defaults set via apply_model_defaults().
695 _model_defaults: Any = None
697 # Wiki layer. LLM-maintained synthesis pages with citation provenance.
698 # Off by default; flip to True (or set LILBEE_WIKI=1) to enable. When off,
699 # the Wiki view tab and the chat ModelBar's scope picker are both hidden.
700 wiki: bool = ConfigField(default=False, writable=True)
701 # Whether a sync regenerates touched wiki pages on its own. Off by
702 # default: enabling the wiki never starts generating by itself, the
703 # user wikifies explicitly via `lilbee wiki build` / `wiki update`.
704 wiki_auto_update: bool = ConfigField(default=False, writable=True)
705 # Read-only: changing the directory at runtime strands prior wiki pages
706 # under the old path. Users who want a different location set it via
707 # LILBEE_WIKI_DIR / config.toml before the first wiki_build.
708 wiki_dir: str = "wiki"
709 wiki_prune_raw: bool = ConfigField(default=False, writable=True)
711 # Minimum cosine similarity between a page body and the mean of its
712 # source chunk vectors before a page is published (below → drafts).
713 # Replaces the old LLM-based faithfulness score: mean-of-chunks is a
714 # deterministic, zero-LLM-call signal that routes topic-drifted
715 # pages to drafts without the 0.0 to 1.0 ambiguity of a model-emitted
716 # number. Tuning knob: swap to per-chunk max or top-K-mean if the
717 # default 0.5 produces false drafts.
718 wiki_embedding_faithfulness_threshold: float = ConfigField(
719 default=0.5, ge=0.0, le=1.0, writable=True
720 )
722 # Per-call output token cap for wiki generation. Without this a
723 # reasoning model (Qwen3, DeepSeek-R1) can burn the full context
724 # window emitting <think> tokens before the actual answer, taking
725 # minutes per page. Default leaves headroom for a typical reasoning
726 # budget plus a real response (~1000 output + ~1000 slack).
727 wiki_summary_max_tokens: int = ConfigField(default=2048, ge=256, writable=True)
729 # Wiki generation is a structured-output task: the model must emit the
730 # block separators, the citation footnotes, and verbatim quotes. The
731 # usual chat default (~0.8) is too creative for that. Lowering the
732 # sampling temperature makes the model stick to the template and quote
733 # more faithfully. 0.1 leaves just enough slack to avoid hard loops.
734 wiki_temperature: float = ConfigField(default=0.1, ge=0.0, le=2.0, writable=True)
736 # Fraction of citations that must be stale before a wiki page is flagged.
737 wiki_stale_citation_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
739 # Fraction of content changed that triggers human-review drift guard.
740 wiki_drift_threshold: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True)
742 # LLM prompt templates for wiki page generation: wiki_synthesis_prompt
743 # for cross-source synthesis pages, wiki_entity_batch_prompt (below)
744 # for the per-source batched call. Writable so advanced users can
745 # override them from /settings, config.toml, or ``LILBEE_WIKI_*_PROMPT``
746 # env vars. Templates must keep the expected ``{placeholders}``. If you
747 # remove one the generator will crash on first use.
748 wiki_synthesis_prompt: str = ConfigField(
749 writable=True,
750 default=(
751 "You are a knowledge compiler. Given source chunks from MULTIPLE documents "
752 "about related concepts, write a synthesis wiki page in markdown that connects "
753 "ideas across sources.\n\n"
754 "Rules:\n"
755 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
756 "Never cite by chunk label: [Chunk N] labels only organize the "
757 "chunks below and must not appear in the page.\n"
758 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
759 "3. For connections, interpretations, or patterns you identify across sources, "
760 "mark with [*inference*].\n"
761 "4. Use blockquotes (>) for directly cited facts.\n"
762 "5. Reference each source by its filename when drawing connections.\n"
763 "6. End with a citation block in this format:\n\n"
764 "---\n"
765 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
766 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
767 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
768 "Topic: {topic}\n\n"
769 "Sources:\n{source_list}\n\n"
770 "Chunks:\n{chunks_text}\n\n"
771 "Write the synthesis page now. Start with a heading."
772 ),
773 )
775 # Wiki synthesis clusterer backend. CONCEPTS requires the [graph] extra
776 # and falls back to EMBEDDING when unavailable.
777 wiki_clusterer: ClustererBackend = ConfigField(
778 default=ClustererBackend.EMBEDDING, writable=True
779 )
781 # Neighborhood size for the mutual-kNN graph. 0 = auto-scale from corpus size.
782 wiki_clusterer_k: int = ConfigField(default=0, ge=0, writable=True)
784 # LazyGraphRAG-style concept graph. Requires the [graph] extra.
785 concept_graph: bool = ConfigField(default=True, writable=True)
787 # Weight of concept overlap boost relative to vector similarity.
788 concept_boost_weight: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True)
790 # Max noun-phrase concepts extracted per chunk.
791 concept_max_per_chunk: int = ConfigField(default=5, ge=1, writable=True)
793 # spaCy NER labels kept by the wiki entity extractor. Anything not
794 # in this set (QUANTITY, CARDINAL, DATE, TIME, MONEY, PERCENT,
795 # ORDINAL, ...) is dropped before aggregation. Override via
796 # LILBEE_CONCEPT_ALLOWED_ENT_TYPES as a comma-separated list.
797 concept_allowed_ent_types: frozenset[str] = Field(default=DEFAULT_ALLOWED_NER_LABELS)
799 # Strategy used to extract entities for the concept/entity wiki.
800 # NER_ENTITIES (default) pulls typed NER entities with spaCy; concept
801 # pages are proposed by the LLM inside the per-source batched call,
802 # not by the extractor. NER_CONCEPTS_PLUS_LLM_TYPES layers an
803 # LLM-proposed domain schema on top. LLM_TAGGED asks the LLM to tag
804 # every chunk (most expensive). Unimplemented modes fall back to
805 # NER_ENTITIES.
806 wiki_entity_mode: WikiEntityMode = ConfigField(
807 default=WikiEntityMode.NER_ENTITIES, writable=True
808 )
810 # Minimum distinct chunk mentions before an entity or concept earns
811 # its own wiki page. Filters one-off noise.
812 wiki_entity_min_mentions: int = ConfigField(default=3, ge=1, writable=True)
813 wiki_stub_max_chunk_refs: int = ConfigField(default=50, ge=1, writable=True)
815 # Auto-update cap: if a single sync touches more than this many
816 # concept or entity pages, skip the per-slug regeneration and tell
817 # the user to run `lilbee wiki update` explicitly. Keeps a surprise
818 # bulk import from firing hundreds of LLM calls.
819 wiki_ingest_update_cap: int = ConfigField(default=20, ge=1, writable=True)
821 # Whether the per-source batched call asks the LLM to curate
822 # concept pages alongside the pre-extracted entity list. False →
823 # entity sections only, no concept curation (incremental ingest
824 # path uses this to avoid churning concept slugs per source-touch).
825 wiki_extract_concepts: bool = ConfigField(default=True, writable=True)
827 # Minimum chunk count a source must contribute before it is eligible
828 # for concept curation. Sources below the floor still get a batched
829 # call when they have entities (the prompt writes entity-only
830 # sections); sources below the floor with zero entities are skipped
831 # entirely. Prevents boilerplate / TOC / appendix documents from
832 # burning an LLM call to invent "concepts".
833 wiki_batch_min_chunks: int = ConfigField(default=3, ge=1, writable=True)
835 # Prompt template for the per-source batched call. Placeholders:
836 # {source}, {entity_list}, {chunks_text}, {concept_instruction}.
837 # {concept_instruction} is filled with a concept-curation paragraph
838 # when concepts are requested, or the empty string otherwise.
839 # Single-entity page written on demand from that entity's chunks across
840 # every source naming it. The batched prompt above cannot serve this: it
841 # writes every section for one source in one call.
842 wiki_entity_page_prompt: str = ConfigField(
843 writable=True,
844 default=(
845 "You are a knowledge compiler. Given source chunks that mention "
846 "ONE subject, write a wiki page about that subject in markdown.\n\n"
847 "Rules:\n"
848 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
849 "Never cite by chunk label: [Chunk N] labels only organize the "
850 "chunks below and must not appear in the page.\n"
851 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
852 "3. Write only what the chunks support. Mark anything you infer with "
853 "[*inference*].\n"
854 "4. Use blockquotes (>) for directly cited facts.\n"
855 "5. When sources disagree, say so and cite both.\n"
856 "6. End with a citation block in this format:\n\n"
857 "---\n"
858 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
859 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
860 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
861 "Subject: {topic}\n\n"
862 "Sources:\n{source_list}\n\n"
863 "Chunks:\n{chunks_text}\n\n"
864 "Write the page now. Start with a heading naming the subject."
865 ),
866 )
867 wiki_entity_batch_prompt: str = ConfigField(
868 writable=True,
869 default=(
870 "You are writing wiki sections based on these chunks from {source}.\n\n"
871 "{concept_instruction}"
872 "Write a wiki section for each of these NER ENTITIES: {entity_list}\n\n"
873 "Format each section exactly as:\n"
874 "## Name\n"
875 "{{content with [^src1]-style citations}}\n\n"
876 "Rules:\n"
877 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
878 "Never cite by chunk label: [Chunk N] labels only organize the "
879 "chunks below and must not appear in the page.\n"
880 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
881 "3. For interpretations or connections not directly stated, mark with [*inference*].\n"
882 "4. Use blockquotes (>) for directly cited facts.\n"
883 "5. End the response with a citation block in this format:\n\n"
884 "---\n"
885 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
886 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
887 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
888 "Source chunks:\n{chunks_text}\n"
889 ),
890 )
892 # Class variable: not a settings field
893 _toml_cache: ClassVar[dict[str, Any]] = {}
895 @field_validator("lilbee_name", mode="after")
896 @classmethod
897 def _strip_lilbee_name(cls, value: str) -> str:
898 """Strip whitespace; an empty string signals 'use the path-derived label'."""
899 return value.strip()
901 @field_validator(
902 "temperature",
903 "top_p",
904 "repeat_penalty",
905 "top_k_sampling",
906 "num_ctx",
907 "seed",
908 mode="before",
909 )
910 @classmethod
911 def _empty_string_to_none(cls, v: Any) -> Any:
912 if isinstance(v, str) and v.strip() == "":
913 return None
914 return v
916 @field_validator("chat_mode", mode="before")
917 @classmethod
918 def _normalize_chat_mode(cls, v: Any) -> str:
919 """Coerce chat_mode to a ChatMode value; default ChatMode.SEARCH."""
920 if v is None or v == "":
921 return ChatMode.SEARCH.value
922 candidate = str(v).strip().lower()
923 try:
924 return ChatMode(candidate).value
925 except ValueError as exc:
926 valid = ", ".join(repr(m.value) for m in ChatMode)
927 raise ValueError(f"chat_mode must be one of {{{valid}}}, got {v!r}") from exc
929 @field_validator("enable_ocr", mode="before")
930 @classmethod
931 def _parse_enable_ocr(cls, v: Any) -> bool | None:
932 """Parse enable_ocr from env var string or direct value.
934 Accepts: true/false/1/0/yes/no (case-insensitive), empty string
935 or None for auto-detect.
936 """
937 if v is None:
938 return None
939 if isinstance(v, bool):
940 return v
941 if isinstance(v, str):
942 if v.strip().lower() in ("", "auto", "none"):
943 return None
944 try:
945 return parse_bool(v)
946 except ValueError:
947 # bool() on a non-empty string is True, so falling through here
948 # turned an unparseable value into "on". Warn and auto-detect,
949 # matching the sibling validators.
950 log.warning("Invalid LILBEE_ENABLE_OCR=%r, using auto", v)
951 return None
952 return bool(v)
954 @field_validator("ocr_language", mode="before")
955 @classmethod
956 def _parse_ocr_language(cls, v: Any) -> list[str]:
957 """Accept a list or a ``+``/comma/newline-separated string; never empty.
959 Tesseract joins languages with ``+`` (e.g. ``eng+deu``), so that is the
960 canonical user-facing form. Commas are also accepted. Newlines are
961 accepted because ``app.settings`` joins list values with ``\\n`` when it
962 persists them to config.toml; without splitting on it a multi-language
963 value would reload as one malformed token. Blank input falls back to
964 English, since xberg errors on an empty list.
965 """
966 if isinstance(v, str):
967 v = v.replace("+", ",").replace("\n", ",").split(",")
968 items = v or []
969 langs = [s.strip() for s in items if isinstance(s, str) and s.strip()]
970 return langs or ["eng"]
972 @field_validator("flash_attention", mode="before")
973 @classmethod
974 def _parse_flash_attention(cls, v: Any) -> bool | None:
975 """Auto/on/off tri-state: empty/auto/none -> None, else parse bool."""
976 if v is None:
977 return None
978 if isinstance(v, bool):
979 return v
980 if isinstance(v, str):
981 if v.strip().lower() in ("", "auto", "none"):
982 return None
983 try:
984 return parse_bool(v)
985 except ValueError:
986 log.warning("Invalid flash_attention=%r, using auto", v)
987 return None
988 return bool(v)
990 @field_validator("n_gpu_layers", mode="before")
991 @classmethod
992 def _parse_n_gpu_layers(cls, v: Any) -> int | None:
993 """Auto -> None, ``cpu`` alias -> 0, integers parsed verbatim."""
994 if v is None:
995 return None
996 if isinstance(v, str):
997 label = v.strip().lower()
998 if label in ("", "auto", "none"):
999 return None
1000 if label == "cpu":
1001 return 0
1002 try:
1003 return int(label)
1004 except ValueError:
1005 log.warning("Invalid LILBEE_N_GPU_LAYERS=%r, using auto", v)
1006 return None
1007 return int(v)
1009 @field_validator("main_gpu", mode="before")
1010 @classmethod
1011 def _parse_main_gpu(cls, v: Any) -> int | None:
1012 """Empty/auto strings -> None, integers parsed verbatim."""
1013 if v is None:
1014 return None
1015 if isinstance(v, str):
1016 label = v.strip().lower()
1017 if label in ("", "auto", "none"):
1018 return None
1019 try:
1020 return int(label)
1021 except ValueError:
1022 log.warning("Invalid LILBEE_MAIN_GPU=%r, using auto", v)
1023 return None
1024 return int(v)
1026 @field_validator("gpu_devices", mode="before")
1027 @classmethod
1028 def _parse_gpu_devices(cls, v: Any) -> str | None:
1029 """Normalize device list: strip whitespace, drop empties, keep order."""
1030 if v is None:
1031 return None
1032 if isinstance(v, str):
1033 label = v.strip().lower()
1034 if label in ("", "auto", "all", "none"):
1035 return None
1036 parts = [p.strip() for p in v.split(",") if p.strip()]
1037 if not parts:
1038 return None
1039 for part in parts:
1040 if not part.lstrip("-").isdigit():
1041 log.warning("Invalid LILBEE_GPU_DEVICES=%r, ignoring", v)
1042 return None
1043 return ",".join(parts)
1044 return str(v)
1046 @field_validator("placement", mode="before")
1047 @classmethod
1048 def _parse_placement(cls, v: Any) -> str | None:
1049 """Blank/None -> None; validate a JSON string or PlacementSpec; store JSON."""
1050 from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec
1052 if v is None:
1053 return None
1054 if isinstance(v, PlacementSpec):
1055 json_str = v.to_json()
1056 PlacementSpec.from_json(json_str) # re-validate a directly-built spec
1057 return json_str
1058 if isinstance(v, str):
1059 if v.strip() == "":
1060 return None
1061 PlacementSpec.from_json(v)
1062 return v
1063 raise PlacementError("placement must be a JSON string or PlacementSpec")
1065 @field_validator("semantic_chunking", mode="before")
1066 @classmethod
1067 def _parse_semantic_chunking(cls, v: Any) -> bool:
1068 """Parse from env string; invalid values warn and fall back to False."""
1069 if isinstance(v, bool):
1070 return v
1071 if isinstance(v, str):
1072 try:
1073 return parse_bool(v)
1074 except ValueError:
1075 log.warning("Invalid LILBEE_SEMANTIC_CHUNKING=%r, using default False", v)
1076 return False
1077 return bool(v)
1079 @field_validator(
1080 "chat_model", "embedding_model", "vision_model", "reranker_model", mode="after"
1081 )
1082 @classmethod
1083 def _normalize_model_tag(cls, v: str, info: ValidationInfo) -> str:
1084 """Validate and canonicalize a model ref; blank means the role is unconfigured."""
1085 if not v or not v.strip():
1086 return ""
1087 from lilbee.providers.model_ref import parse_model_ref
1089 return parse_model_ref(v).for_openai_prefix()
1091 @field_validator("ollama_base_url", "lm_studio_base_url", mode="after")
1092 @classmethod
1093 def _strip_trailing_slash(cls, v: str) -> str:
1094 """Canonicalize a local-server URL once at the write boundary."""
1095 return v.rstrip("/")
1097 @field_validator("cors_origins", mode="before")
1098 @classmethod
1099 def _split_cors_origins(cls, v: Any) -> Any:
1100 if isinstance(v, str):
1101 return [o.strip() for o in v.split(",") if o.strip()]
1102 return v
1104 @field_validator("crawl_browser_extra_args", mode="before")
1105 @classmethod
1106 def _split_crawl_browser_extra_args(cls, v: Any) -> Any:
1107 """Accept a newline-separated string, matching how the field is persisted.
1109 ``app.settings`` joins list values with newlines before writing them to
1110 ``config.toml`` as a scalar string. Without this inverse, reload cannot
1111 coerce that string to ``list[str]`` and the whole config.toml is dropped.
1112 TOML lists and JSON arrays pass through unchanged.
1113 """
1114 if isinstance(v, str):
1115 return [a.strip() for a in v.splitlines() if a.strip()]
1116 return v
1118 @field_validator("crawl_exclude_patterns", mode="before")
1119 @classmethod
1120 def _split_crawl_exclude_patterns(cls, v: Any) -> Any:
1121 """Accept newline-separated strings from env vars / plain-text config.
1123 Regex commonly uses commas (e.g. `{2,4}`) and pipes (alternation), so
1124 newline is the only separator safe to use for this field. TOML lists
1125 and JSON arrays pass through unchanged.
1126 """
1127 if isinstance(v, str):
1128 return [p.strip() for p in v.splitlines() if p.strip()]
1129 return v
1131 @field_validator("crawl_exclude_patterns", mode="after")
1132 @classmethod
1133 def _validate_crawl_exclude_patterns(cls, v: list[str]) -> list[str]:
1134 """Reject any entry that isn't a valid Python regex.
1136 These patterns are compiled at crawl time. An invalid pattern there
1137 surfaces as an opaque mid-crawl error; catching it at PATCH time gives
1138 the user a 400 with a pointer to the bad entry.
1139 """
1140 import re
1142 bad: list[str] = []
1143 for i, pattern in enumerate(v):
1144 try:
1145 re.compile(pattern)
1146 except re.error as exc:
1147 bad.append(f"[{i}] {pattern!r}: {exc}")
1148 if bad:
1149 raise ValueError("invalid regex in crawl_exclude_patterns:\n " + "\n ".join(bad))
1150 return v
1152 @field_validator("ignore_dirs", mode="before")
1153 @classmethod
1154 def _merge_ignore_dirs(cls, v: Any) -> frozenset[str]:
1155 if isinstance(v, str):
1156 extra = frozenset(name.strip() for name in v.split(",") if name.strip())
1157 return DEFAULT_IGNORE_DIRS | extra
1158 if isinstance(v, (set, frozenset, list)):
1159 return DEFAULT_IGNORE_DIRS | frozenset(v)
1160 return DEFAULT_IGNORE_DIRS
1162 @field_validator("concept_allowed_ent_types", mode="before")
1163 @classmethod
1164 def _parse_ent_types(cls, v: Any) -> frozenset[str]:
1165 """Replace-semantics override: a narrowed set is used as-is,
1166 not unioned with defaults. A user asking for ``PERSON,ORG``
1167 wants exactly those kinds. Accepts comma-separated strings
1168 from env and list / set / frozenset from code. Empty input
1169 falls back to :data:`DEFAULT_ALLOWED_NER_LABELS` so an empty
1170 env var does not silently disable the gate.
1171 """
1172 if isinstance(v, str):
1173 parts = frozenset(name.strip().upper() for name in v.split(",") if name.strip())
1174 return parts or DEFAULT_ALLOWED_NER_LABELS
1175 if isinstance(v, (set, frozenset, list)):
1176 parts = frozenset(str(x).upper() for x in v)
1177 return parts or DEFAULT_ALLOWED_NER_LABELS
1178 return DEFAULT_ALLOWED_NER_LABELS
1180 @model_validator(mode="before")
1181 @classmethod
1182 def _resolve_defaults(cls, data: Any) -> Any:
1183 from lilbee.core.system import (
1184 canonical_data_root,
1185 canonical_models_dir,
1186 default_data_dir,
1187 find_local_root,
1188 )
1190 if not isinstance(data, dict):
1191 return data
1193 # An empty LILBEE_DATA_ROOT (delivered as "") must fall through to default
1194 # resolution like an unset one, not become Path(".") = the process cwd.
1195 if isinstance(data.get("data_root"), str) and not data["data_root"].strip():
1196 data["data_root"] = None
1197 if data.get("data_root") in (None, _UNSET_PATH):
1198 data_env = os.environ.get("LILBEE_DATA", "").strip()
1199 if data_env:
1200 data["data_root"] = Path(data_env)
1201 else:
1202 local = find_local_root()
1203 data["data_root"] = local if local is not None else default_data_dir()
1204 # Every child path below derives from this, and the server lock keys on
1205 # those, so canonicalizing here is what makes one directory key one lock.
1206 # Also coerces a raw string (LILBEE_DATA_ROOT) to Path.
1207 root = canonical_data_root(data["data_root"])
1208 data["data_root"] = root
1209 if data.get("documents_dir") in (None, _UNSET_PATH):
1210 data["documents_dir"] = root / "documents"
1211 if data.get("data_dir") in (None, _UNSET_PATH):
1212 data["data_dir"] = root / "data"
1213 if data.get("lancedb_dir") in (None, _UNSET_PATH):
1214 data["lancedb_dir"] = root / "data" / "lancedb"
1215 if data.get("models_dir") in (None, _UNSET_PATH):
1216 data["models_dir"] = canonical_models_dir()
1218 return data
1220 @classmethod
1221 def settings_customise_sources(
1222 cls,
1223 settings_cls: type[BaseSettings],
1224 init_settings: Any,
1225 env_settings: Any,
1226 dotenv_settings: Any,
1227 file_secret_settings: Any,
1228 ) -> tuple[Any, ...]:
1229 from lilbee.core.system import canonical_data_root, default_data_dir, find_local_root
1231 # .strip() to match _resolve_defaults; a padded value would otherwise
1232 # send the root and its config.toml to different directories.
1233 data_env = os.environ.get("LILBEE_DATA", "").strip()
1234 if data_env:
1235 toml_dir = Path(data_env)
1236 else:
1237 local = find_local_root()
1238 toml_dir = local if local else default_data_dir()
1239 # Same call as the root itself, so this looks where the root resolves to;
1240 # a "~/lilbee" value would otherwise search a literal ./~ and find nothing.
1241 toml_path = canonical_data_root(toml_dir) / "config.toml"
1243 plain_env = _PlainEnvSource(settings_cls, env_prefix="LILBEE_", env_ignore_empty=True)
1244 sources: list[Any] = [init_settings, plain_env]
1245 if toml_path.exists() and os.environ.get("LILBEE_SKIP_TOML_CONFIG") != "1":
1246 sources.append(_TomlSource(settings_cls, toml_path))
1247 return tuple(sources)
1249 @property
1250 def model_defaults(self) -> Any:
1251 """Per-model generation defaults (read-only). Set via apply_model_defaults()."""
1252 return self._model_defaults
1254 def apply_model_defaults(self, defaults: Any) -> None:
1255 """Store per-model generation defaults for 3-layer merge."""
1256 object.__setattr__(self, "_model_defaults", defaults)
1258 def clear_model_defaults(self) -> None:
1259 """Reset per-model defaults to None."""
1260 object.__setattr__(self, "_model_defaults", None)
1262 def generation_options(self, **overrides: Any) -> dict[str, Any]:
1263 """Merge model defaults, user config, and per-call overrides, dropping None."""
1264 result = _model_defaults_dict(self._model_defaults)
1265 user_fields: dict[str, Any] = {
1266 "temperature": self.temperature,
1267 "top_p": self.top_p,
1268 "top_k": self.top_k_sampling,
1269 "repeat_penalty": self.repeat_penalty,
1270 "num_ctx": self.num_ctx,
1271 "seed": self.seed,
1272 "max_tokens": self.max_tokens,
1273 }
1274 for k, v in user_fields.items():
1275 if v is not None:
1276 result[k] = v
1277 for k, v in overrides.items():
1278 if v is not None:
1279 result[k] = v
1280 return result
1283def _model_defaults_dict(defaults: Any) -> dict[str, Any]:
1284 """Non-None fields of a ModelDefaults instance as a dict."""
1285 if defaults is None:
1286 return {}
1287 from dataclasses import fields as dc_fields
1289 return {
1290 f.name: getattr(defaults, f.name)
1291 for f in dc_fields(defaults)
1292 if getattr(defaults, f.name) is not None
1293 }
1296class _PlainEnvSource:
1297 """Reads LILBEE_* env vars as plain strings so field validators handle parsing."""
1299 def __init__(
1300 self,
1301 settings_cls: type[BaseSettings],
1302 env_prefix: str,
1303 env_ignore_empty: bool = True,
1304 ) -> None:
1305 self._prefix = env_prefix
1306 self._ignore_empty = env_ignore_empty
1307 self._fields = set(settings_cls.model_fields)
1309 def __call__(self) -> dict[str, Any]:
1310 result: dict[str, Any] = {}
1311 for field_name in self._fields:
1312 env_key = f"{self._prefix}{field_name.upper()}"
1313 raw = os.environ.get(env_key)
1314 if raw is None:
1315 continue
1316 if self._ignore_empty and raw == "":
1317 continue
1318 result[field_name] = raw
1319 return result
1322class _TomlSource:
1323 """Custom pydantic-settings source that reads config.toml."""
1325 def __init__(self, settings_cls: type[BaseSettings], path: Path) -> None:
1326 self._path = path
1328 def __call__(self) -> dict[str, Any]:
1329 import tomllib
1331 try:
1332 with self._path.open("rb") as f:
1333 data = tomllib.load(f)
1334 except (ValueError, OSError):
1335 log.warning("Failed to read %s, ignoring", self._path)
1336 return {}
1337 # Empty strings represent "no persisted value" for nullable scalar
1338 # fields (legacy from set_setting writing "" for None). Pydantic
1339 # can't coerce "" to int|None, so dropping them here lets the field
1340 # default apply rather than crashing the whole Config load. TOML's
1341 # native types (lists, ints, bools) pass through untouched: stringifying
1342 # turned a list field's ["a", "b"] into the literal "['a', 'b']".
1343 return {k: v for k, v in data.items() if v != ""}
1346def _build_cfg() -> tuple[Config, Exception | None]:
1347 """Build cfg; on stale-config validation failure, fall back to defaults.
1349 A persisted ``config.toml`` from before a breaking schema change can
1350 contain values the new validators reject. Crashing at module import
1351 means every command (``lilbee --help`` included) emits a Python
1352 traceback. Falling back to env+defaults lets the package load; the
1353 CLI / TUI surfaces the original error before doing real work.
1354 """
1355 try:
1356 return Config(), None
1357 except Exception as exc:
1358 os.environ["LILBEE_SKIP_TOML_CONFIG"] = "1"
1359 try:
1360 return Config(), exc
1361 finally:
1362 os.environ.pop("LILBEE_SKIP_TOML_CONFIG", None)
1365cfg, config_load_error = _build_cfg()
1367# Canonicalize LILBEE_DATA at the cfg.data_root resolution boundary so
1368# spawn-context worker subprocesses inherit the same data root.
1369# ``setdefault`` preserves a user-set value.
1370os.environ.setdefault("LILBEE_DATA", str(cfg.data_root))