Coverage for src/lilbee/core/config/model.py: 100%

514 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-04 17:08 +0000

1"""The :class:`Config` dataclass and the ``cfg`` singleton. 

2 

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""" 

8 

9import logging 

10import os 

11from pathlib import Path 

12from typing import Any, ClassVar 

13 

14from pydantic import Field, ValidationInfo, field_validator, model_validator 

15from pydantic_settings import BaseSettings, SettingsConfigDict 

16 

17from lilbee.core.system import scaled_chat_ctx_target_default 

18 

19from .defaults import ( 

20 CONFIG_FILE_NAME, 

21 DEFAULT_ALLOWED_NER_LABELS, 

22 DEFAULT_CORS_ORIGIN_REGEX, 

23 DEFAULT_CRAWL_EXCLUDE_PATTERNS, 

24 DEFAULT_GENERAL_SYSTEM_PROMPT, 

25 DEFAULT_IGNORE_DIRS, 

26 DEFAULT_RAG_SYSTEM_PROMPT, 

27) 

28from .enums import ( 

29 ChatMode, 

30 ClustererBackend, 

31 CrawlRenderMode, 

32 KvCacheType, 

33 LlmProvider, 

34 ReasoningMode, 

35 RerankerType, 

36 TableModel, 

37 WikiEntityMode, 

38) 

39from .parsing import parse_bool 

40from .validators import ConfigField 

41 

42log = logging.getLogger(__name__) 

43 

44# Sentinel for unset Path-typed fields. ``Field(default=Path())`` produces an 

45# instance equal to this, so the model_validator can distinguish "user passed 

46# the default" from "user explicitly set a value". 

47_UNSET_PATH = Path() 

48 

49# Snowball stemmer languages LanceDB's FTS accepts (lancedb.index.lang_mapping); 

50# hardcoded so config validation does not import lancedb. 

51FTS_LANGUAGES = frozenset( 

52 { 

53 "Arabic", 

54 "Danish", 

55 "Dutch", 

56 "English", 

57 "Finnish", 

58 "French", 

59 "German", 

60 "Greek", 

61 "Hungarian", 

62 "Italian", 

63 "Norwegian", 

64 "Portuguese", 

65 "Romanian", 

66 "Russian", 

67 "Spanish", 

68 "Swedish", 

69 "Tamil", 

70 "Turkish", 

71 } 

72) 

73 

74 

75class Config(BaseSettings): 

76 """Runtime configuration: one singleton instance, mutated by CLI overrides.""" 

77 

78 model_config = SettingsConfigDict( 

79 env_prefix="LILBEE_", 

80 validate_assignment=True, 

81 arbitrary_types_allowed=True, 

82 extra="ignore", 

83 ) 

84 

85 # Paths: resolved from env/defaults in model_validator(mode='before') 

86 data_root: Path = Field( 

87 default=Path(), 

88 description=( 

89 "Root directory for this library. Resolved at start from LILBEE_DATA, " 

90 "then a .lilbee/ directory walked up from the working directory, then " 

91 "the platform default. Every other path below hangs off it" 

92 ), 

93 ) 

94 # Writable so plugin-managed servers can pivot storage to a vault path on 

95 # first boot; rebuild the index after migrating. 

96 documents_dir: Path = ConfigField(default=Path(), writable=True) 

97 # External source roots ``add`` registered, mapping label -> absolute path. 

98 # lilbee indexes the files where they live (no copy, no symlink); the label 

99 # prefixes their source keys so a root at /data/corpus keys as ``corpus/…``. 

100 # Managed by ``add`` / ``remove``, so it is writable (persisted to 

101 # config.toml) but not surfaced in the settings UI. 

102 linked_roots: dict[str, str] = ConfigField( 

103 default_factory=dict, 

104 writable=True, 

105 public=False, 

106 description=( 

107 "External source roots that `add` registered, as label -> absolute path. " 

108 "`add` and `remove` maintain it; do not edit it by hand" 

109 ), 

110 ) 

111 data_dir: Path = Field( 

112 default=Path(), 

113 description="Directory holding the database. Defaults to data_root/data", 

114 ) 

115 lancedb_dir: Path = Field( 

116 default=Path(), 

117 description=( 

118 "Directory holding the LanceDB vector tables. Defaults to data_root/data/lancedb" 

119 ), 

120 ) 

121 models_dir: Path = Field( 

122 default=Path(), 

123 description=( 

124 "Directory holding downloaded model files. Shared across libraries, so a " 

125 "model pulled for one is available to all" 

126 ), 

127 ) 

128 # Markdown vault root; when set, search results carry a vault-relative 

129 # ``vault_path`` so a host UI can deep-link into the vault. 

130 vault_base: Path | None = ConfigField(default=None, writable=True) 

131 

132 # Human-readable label for the active lilbee. Empty falls back to 

133 # "global" for the platform default dir, otherwise the project path 

134 # (~-substituted and left-truncated to a hard cap). 

135 lilbee_name: str = ConfigField(default="", writable=True) 

136 # If True, the status bar pill shows the full absolute path: expands 

137 # "global" to the on-disk platform-default path and skips the 

138 # ~-substitution / left-truncation for project paths. Toggled by F4. 

139 show_lilbee_path: bool = ConfigField(default=False, writable=True) 

140 

141 # Whether an agent launcher (opencode, hermes) registers lilbee's MCP search 

142 # tool into the agent's config. Per-launch --mcp/--no-mcp overrides it. 

143 agent_mcp_enabled: bool = ConfigField(default=True, writable=True) 

144 

145 # Empty = not configured, same convention as vision_model. A fresh install 

146 # has no models; the catalog assigns these on the first download. 

147 chat_model: str = Field(default="") 

148 embedding_model: str = Field(default="") 

149 # Vision OCR model for scanned PDFs and image-only pages. Empty = disabled; 

150 # there is no cross-role fallback onto the chat model even if multimodal. 

151 vision_model: str = ConfigField(default="", public=True) 

152 embedding_dim: int = Field( 

153 default=768, 

154 ge=1, 

155 description=( 

156 "Vector width the index is built with. The embedding model sets it; " 

157 "change it only to match a model lilbee cannot introspect" 

158 ), 

159 ) 

160 chunk_size: int = ConfigField(default=512, ge=64, writable=True, reindex=True) 

161 chunk_overlap: int = ConfigField(default=100, ge=0, writable=True, reindex=True) 

162 # A file over this many chunks is skipped before embedding; 0 lifts the ceiling. 

163 max_chunks_per_file: int = ConfigField(default=3_000, ge=0, writable=True) 

164 # Workers for the parallel discovery/hash planning pass. 0 = auto, sized to 

165 # the container-aware CPU budget (see runtime.cpu.available_cpu_count). 

166 # `add --max-cpus N` sets this per invocation. Sizes only the planning pass, 

167 # not the GPU-fed extract/embed batch. 

168 ingest_workers: int = ConfigField(default=0, ge=0, writable=True) 

169 # Worker PROCESSES for a bulk ingest (distinct from ingest_workers, which sizes 

170 # the planning pass's threads). Each owns a GPU, a private store and its own 

171 # slice of the corpus, and the shards are folded into one index at the end. 

172 # 0 = auto: one worker per visible card, used once the corpus is big enough to 

173 # pay for them. N pins the count; worker i takes card i % card_count, so more 

174 # workers than cards share a card's engine rather than double-booking it. 

175 ingest_processes: int = ConfigField(default=0, ge=0, writable=True) 

176 # Passages packed into one embed request. Larger batches keep a GPU's 

177 # continuous-batching slots full: small per-passage requests leave the card 

178 # batch-starved (~96% util, low throughput). The engine still re-splits to 

179 # its physical batch, so raising this only helps up to the server's --batch. 

180 embed_batch_sequences: int = ConfigField( 

181 default=64, 

182 ge=1, 

183 writable=True, 

184 description=( 

185 "Passages packed into one embed request. Larger batches keep a GPU's " 

186 "continuous-batching slots full. The engine re-splits to its physical " 

187 "batch, so raising this helps only up to the server's --batch" 

188 ), 

189 ) 

190 # Files allowed in their compute phase at once during ingest. 0 = auto: the 

191 # ceiling scales with the detected embed fleet (replicas x per-replica 

192 # in-flight) so a multi-GPU box is kept fed without a manual cap, falling back 

193 # to the CPU quota on a single card. Set a positive value only to override the 

194 # auto sizing. Sizes the extract+embed fan-out, not the plan pass. 

195 ingest_max_inflight: int = ConfigField( 

196 default=0, 

197 ge=0, 

198 writable=True, 

199 description=( 

200 "Files allowed in their compute phase at once during ingest. 0 = auto, " 

201 "scaled to the detected embed fleet. Sizes the extract and embed fan-out, " 

202 "not the planning pass" 

203 ), 

204 ) 

205 # Gate for the pre-ask sync; --no-sync overrides per invocation. 

206 auto_sync: bool = ConfigField(default=True, writable=True) 

207 max_embed_chars: int = Field( 

208 default=2000, 

209 ge=1, 

210 description="Maximum characters sent to the embedding model per chunk. Longer text is cut", 

211 ) 

212 top_k: int = ConfigField(default=12, ge=1, writable=True) 

213 max_distance: float = ConfigField(default=0.75, ge=0.0, writable=True) 

214 # Abstention floor against the [0, 1] fused relevance score (0.0 = no 

215 # filtering). When every retrieved chunk falls below it, ask refuses instead 

216 # of feeding noise as context. The fused score normalizes against the 

217 # configured weight budget (a constant), so an arm's top hit scores a stable 

218 # share of it; useful floors start around 0.4. Tune against your own corpus. 

219 min_relevance_score: float = ConfigField(default=0.0, ge=0.0, writable=True) 

220 adaptive_threshold: bool = ConfigField(default=False, writable=True) 

221 rag_system_prompt: str = ConfigField( 

222 default=DEFAULT_RAG_SYSTEM_PROMPT, min_length=1, writable=True 

223 ) 

224 general_system_prompt: str = ConfigField( 

225 default=DEFAULT_GENERAL_SYSTEM_PROMPT, min_length=1, writable=True 

226 ) 

227 chat_mode: str = ConfigField(default=ChatMode.SEARCH.value, writable=True) 

228 ignore_dirs: frozenset[str] = Field( 

229 default=DEFAULT_IGNORE_DIRS, 

230 description=( 

231 "Directory names ingest never walks (.git, node_modules, and similar). " 

232 "Use a .lilbeeignore file for per-library patterns" 

233 ), 

234 ) 

235 # OCR for scanned PDFs via vision-capable chat model. 

236 # None = auto-detect (use OCR if chat model is vision-capable). 

237 # True = force OCR regardless of detection. 

238 # False = disable OCR entirely. 

239 enable_ocr: bool | None = ConfigField(default=None, writable=True) 

240 # Per-page timeout in seconds for vision OCR (0 = no limit). Sized so a dense 

241 # full-page scan finishes on modest hardware; a raised vision_ocr_max_tokens 

242 # needs matching headroom here. 

243 ocr_timeout: float = ConfigField(default=300.0, ge=0.0, writable=True) 

244 # Outer wall-clock budget for the streamed pool drain: load grace plus 

245 # per_page * pages. Tune up for slow hardware (M1 Pro vision is 

246 # ~5min/page) or down for fast hardware. ocr_timeout still governs the 

247 # per-page expectation that drives the total budget. 

248 vision_load_budget_s: float = ConfigField(default=300.0, ge=0.0, writable=True) 

249 # Hard cap on tokens generated per OCR page. A real page is well under this; 

250 # the cap bounds the occasional runaway repetition loop (a page that loops to 

251 # tens of thousands of chars) which otherwise dominates a scan's OCR time. 

252 # Raising it lengthens per-page generation on dense scans, so give ocr_timeout 

253 # matching headroom. 

254 vision_ocr_max_tokens: int = ConfigField(default=4096, ge=256, writable=True) 

255 # Pages OCR'd concurrently, and the vision server's continuous-batching slots. 

256 # A single-page decode underutilizes a modern GPU (~half SM); batching several 

257 # pages raises throughput. Each slot adds KV cache, so lower it on small GPUs. 

258 vision_ocr_concurrency: int = ConfigField(default=4, ge=1, writable=True) 

259 

260 # Tesseract fallback wall-clock timeout per file, seconds. 0 = no cap. 

261 tesseract_timeout: float = ConfigField(default=60.0, ge=0.0, writable=True) 

262 # Tesseract OCR language codes for the scanned-document fallback (used when no 

263 # vision model is set), e.g. ["eng"] or ["eng", "deu"]. Set via env as 

264 # LILBEE_OCR_LANGUAGE="eng+deu". xberg requires a non-empty list. 

265 ocr_language: list[str] = ConfigField(default_factory=lambda: ["eng"], writable=True) 

266 # Typed entity table for exact counting/cross-referencing; corpus-scale pass, off by default. 

267 entity_extraction: bool = ConfigField(default=False, writable=True) 

268 semantic_chunking: bool = ConfigField(default=False, writable=True) 

269 topic_threshold: float = ConfigField(default=0.75, ge=0.0, le=1.0, writable=True) 

270 # Size chunks in real tokens via the embedder's tokenizer backend, not the 

271 # chars-per-token heuristic. Plain/heading chunkers only; semantic sizes by chars. 

272 token_sizing: bool = ConfigField(default=False, writable=True, reindex=True) 

273 # Index each recognized table as its own markdown-serialized chunk. 

274 table_extraction: bool = ConfigField(default=False, writable=True, reindex=True) 

275 # Layout-aware PDF extraction (reading-order sort, header/footer stripping), 

276 # run in xberg's AUTO strategy so detection only fires when it helps. Off by 

277 # default: enabling it downloads the ONNX layout and table-structure models 

278 # and adds per-page inference, which a CPU-only ingest pays for. 

279 layout_detection: bool = ConfigField(default=False, writable=True, reindex=True) 

280 # Table structure model; only applied when layout_detection is on. 

281 table_model: TableModel = ConfigField( 

282 default=TableModel.SLANET_AUTO, writable=True, reindex=True 

283 ) 

284 # Coalesce concurrent extractions into one xberg extract_batch call. 

285 batch_extraction: bool = ConfigField(default=False, writable=True) 

286 batch_extraction_size: int = ConfigField(default=8, ge=1, writable=True) 

287 # Size of anyio's thread pool: synchronous handlers (MCP tools, sync routes) 

288 # that may run off the event loop at once. The ceiling on agents one daemon 

289 # serves before their calls queue. 

290 mcp_tool_threads: int = ConfigField(default=40, ge=1, writable=True) 

291 # Crawled pages converted to markdown on anyio's thread pool at once. The 

292 # conversion is synchronous, so this keeps it off the event loop that serves 

293 # requests. 0 converts inline on the loop. 

294 crawl_convert_workers: int = ConfigField(default=2, ge=0, writable=True) 

295 server_host: str = Field( 

296 default="127.0.0.1", 

297 description=( 

298 "Address `lilbee serve` binds. Loopback by default; set 0.0.0.0 to accept " 

299 "connections from the network" 

300 ), 

301 ) 

302 server_port: int = Field( 

303 default=0, 

304 ge=0, 

305 le=65535, 

306 description="Port `lilbee serve` binds. 0 picks a free port and prints it", 

307 ) 

308 cors_origins: list[str] = Field( 

309 default_factory=list, 

310 description=( 

311 "Extra browser origins the HTTP server accepts, in addition to cors_origin_regex" 

312 ), 

313 ) 

314 cors_origin_regex: str = Field( 

315 default=DEFAULT_CORS_ORIGIN_REGEX, 

316 description="Regular expression matching browser origins the HTTP server accepts", 

317 ) 

318 # Seconds between SSE heartbeat events when the producer queue is idle. 

319 # Must stay well below the plugin's STREAM_IDLE_TIMEOUT_MS (120s) so a 

320 # single long-running vision OCR page can't starve the client into aborting. 

321 sse_heartbeat_interval: float = ConfigField(default=30.0, ge=0.0, writable=True) 

322 json_mode: bool = Field( 

323 default=False, 

324 description=( 

325 "Emit structured JSON from CLI commands. The --json flag sets it for one invocation" 

326 ), 

327 ) 

328 temperature: float | None = ConfigField(default=0.1, ge=0.0, writable=True) 

329 top_p: float | None = ConfigField(default=0.9, ge=0.0, le=1.0, writable=True) 

330 top_k_sampling: int | None = ConfigField(default=40, ge=1, writable=True) 

331 # 1.1 is llama.cpp's default. Leaving this at None caused n-gram loops 

332 # ("tire tire tire...") on some open-weights models. 

333 repeat_penalty: float | None = ConfigField(default=1.1, ge=0.0, writable=True) 

334 num_ctx: int | None = ConfigField(default=None, ge=1, writable=True) 

335 max_tokens: int | None = ConfigField(default=4096, ge=1, writable=True) 

336 seed: int | None = ConfigField(default=None, writable=True) 

337 llm_provider: LlmProvider = ConfigField(default=LlmProvider.AUTO, writable=True) 

338 # Path to a llama-server binary. Empty = use the bundled lilbee-engine 

339 # wheel binary, else a llama-server on PATH. 

340 llama_server_path: str = ConfigField(default="", writable=True) 

341 # Per-server local model-manager URLs. Blank means "use the server's spec 

342 # default" (resolved in providers.local_servers.config_urls); the default 

343 # URL literal lives only in the spec, which core must not import. 

344 ollama_base_url: str = ConfigField(default="", writable=True) 

345 lm_studio_base_url: str = ConfigField(default="", writable=True) 

346 llm_api_key: str = ConfigField(default="", writable=True, write_only=True) 

347 openrouter_api_key: str = ConfigField(default="", writable=True, write_only=True) 

348 gemini_api_key: str = ConfigField(default="", writable=True, write_only=True) 

349 anthropic_api_key: str = ConfigField(default="", writable=True, write_only=True) 

350 openai_api_key: str = ConfigField(default="", writable=True, write_only=True) 

351 mistral_api_key: str = ConfigField(default="", writable=True, write_only=True) 

352 deepseek_api_key: str = ConfigField(default="", writable=True, write_only=True) 

353 hf_token: str = ConfigField(default="", writable=True, write_only=True) 

354 

355 # Retrieval quality knobs. 

356 

357 # Max chunks per source in top-k; prevents one large file monopolizing results. 

358 diversity_max_per_source: int = ConfigField(default=5, ge=1, writable=True) 

359 

360 # MMR relevance/diversity tradeoff; 0 = max diversity, 1 = pure relevance 

361 # (Carbonell & Goldstein 1998). 

362 mmr_lambda: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

363 

364 # Vector-only search retrieves this many candidates per final result so 

365 # MMR reranking has a pool to diversify from. Hybrid search ignores it: 

366 # fusion arms stay exactly top_k deep. 

367 candidate_multiplier: int = ConfigField(default=3, ge=1, writable=True) 

368 

369 # Third lexical arm in hybrid search: BM25 over document titles, fused with 

370 # the vector and chunk arms so a query naming a document by title surfaces 

371 # its chunks. Off by default until the eval harness measures it. 

372 title_search: bool = ConfigField(default=False, writable=True) 

373 

374 # Title arm weight relative to a full arm in rank fusion (1.0 = equal voice 

375 # with the vector and chunk arms). 

376 title_search_weight: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

377 

378 # Lexical (BM25) arm weight relative to the vector arm in rank fusion. 

379 # 1.0 gives the two arms equal voice; lowering it lets 

380 # a strong dense embedder dominate on corpora where the lexical arm adds 

381 # noise rather than signal. The right value is corpus-dependent and set by 

382 # the retrieval benchmark, not guessed here. 

383 lexical_fusion_weight: float = ConfigField(default=1.0, ge=0.0, le=1.0, writable=True) 

384 

385 # Adaptive fusion: scale the BM25 arm per query by vector-arm confidence 

386 # instead of a fixed lexical_fusion_weight (a peaked dense ranking downweights 

387 # lexical, a flat one keeps it). OFF by default, pending a benchmark run to 

388 # confirm it beats the fixed weight. lexical_fusion_weight is the ceiling the 

389 # rule scales down from. Set adaptive_fusion=true to enable it. 

390 adaptive_fusion: bool = ConfigField(default=False, writable=True) 

391 

392 # Vector-similarity margin at which the lexical arm is fully silenced; smaller 

393 # = more aggressive downweighting. 0 disables adaptation entirely (the lexical 

394 # arm keeps its full fixed weight). 

395 adaptive_fusion_margin: float = ConfigField(default=0.15, ge=0.0, le=2.0, writable=True) 

396 

397 # Stemmer/stop-word language for the BM25 (FTS) indexes, a tantivy language 

398 # name ("English", "German", "French", ...). Applied when an index is 

399 # (re)built, so changing it needs `lilbee rebuild` on an existing store. 

400 # Validated against FTS_LANGUAGES: a bad name would otherwise fail index 

401 # creation quietly and hybrid search would degrade to vector-only. 

402 fts_language: str = ConfigField(default="English", min_length=1, writable=True, reindex=True) 

403 

404 @field_validator("fts_language", mode="after") 

405 @classmethod 

406 def _validate_fts_language(cls, value: str) -> str: 

407 normalized = value.strip().title() 

408 if normalized not in FTS_LANGUAGES: 

409 raise ValueError(f"fts_language must be one of: {', '.join(sorted(FTS_LANGUAGES))}") 

410 return normalized 

411 

412 # Prefix each chunk's document title to its embedding input (the stored 

413 # chunk text is unchanged). Changes the embedding space: toggling it needs 

414 # `lilbee rebuild`, so it ships off. 

415 embed_titles: bool = ConfigField(default=False, writable=True, reindex=True) 

416 

417 # Contextual retrieval: prepend one LLM-written sentence situating each 

418 # chunk in its document to the embedding input. One generation per chunk, 

419 # so ingest slows substantially; stored text and citations stay verbatim. 

420 # Toggling needs `lilbee rebuild`. 

421 contextual_enrichment: bool = ConfigField(default=False, writable=True, reindex=True) 

422 

423 # Drop tables-of-contents and classification-banner cover/title pages from 

424 # search results. OFF by default; validate per corpus, since the cover-page 

425 # heuristic can also fire on short banner-carrying body pages. A query-matched 

426 # or top-ranked page is never dropped, so removal is limited to structural 

427 # chunks the query did not hit. 

428 filter_structural_chunks: bool = ConfigField(default=False, writable=True) 

429 

430 # Chunk count at/above which sync builds an approximate (ANN) vector index 

431 # so search stays fast at millions of vectors. Below this, search uses exact 

432 # flat scan (faster and exact for small vaults). 0 disables the ANN index. 

433 ann_index_threshold: int = ConfigField(default=50_000, ge=0, writable=True) 

434 

435 # Condense a follow-up question into a standalone retrieval query using 

436 # the chat history (one LLM call; skipped when there is no history). 

437 # Without it, "what about his brother?" is embedded and BM25-matched 

438 # with its pronouns. 

439 history_rewrite: bool = ConfigField(default=False, writable=True) 

440 

441 # Route questions by shape before top-k retrieval: a question naming a 

442 # document resolves to that document's chunks; a count-shaped question 

443 # runs a full-corpus scan (a count is a corpus property top-k cannot 

444 # answer). Unrecognized shapes take the topical path unchanged. 

445 intent_routing: bool = ConfigField(default=True, writable=True) 

446 

447 # Ask the chat model to classify count questions the deterministic 

448 # patterns miss (phrasing variants, other languages). Adds one short LLM 

449 # call to every turn the patterns don't already route, so it's opt-in. 

450 intent_llm: bool = ConfigField(default=False, writable=True) 

451 

452 # LLM-generated alternative queries for expansion. 0 disables. 

453 query_expansion_count: int = ConfigField(default=3, ge=0, writable=True) 

454 

455 # Skip LLM expansion when tokenized query length ≤ this. The LLM round-trip 

456 # dominates latency on small local models; short queries already have strong 

457 # BM25/vector signal. Concept-graph expansion still runs. 0 disables the skip. 

458 expansion_short_query_tokens: int = ConfigField(default=2, ge=0, writable=True) 

459 

460 # Cosine-distance step when adaptive-widening retry kicks in. 

461 adaptive_threshold_step: float = ConfigField(default=0.2, gt=0.0, writable=True) 

462 

463 # Reject expansion variants below expansion_similarity_threshold. 

464 expansion_guardrails: bool = ConfigField(default=True, writable=True) 

465 

466 # Min cosine similarity between question and variant embeddings. 

467 expansion_similarity_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

468 

469 # Saturating BM25 confidence (s / (s + 5)) above which query expansion is 

470 # skipped; 0.8 corresponds to a raw BM25 score of 20. 

471 expansion_skip_threshold: float = Field( 

472 default=0.8, 

473 ge=0.0, 

474 le=1.0, 

475 description=( 

476 "Saturating BM25 confidence above which query expansion is skipped. " 

477 "0.8 corresponds to a raw BM25 score of 20" 

478 ), 

479 ) 

480 

481 # Min relative BM25 top-1 vs top-2 gap ((top - second) / top) to skip expansion. 

482 expansion_skip_gap: float = Field( 

483 default=0.15, 

484 ge=0.0, 

485 le=1.0, 

486 description=( 

487 "Minimum relative BM25 gap between the top two hits that skips query expansion" 

488 ), 

489 ) 

490 

491 # Chunks included in LLM context after adaptive selection. 

492 max_context_sources: int = ConfigField(default=8, ge=1, writable=True) 

493 

494 # Adjacent chunks pulled from the same source on each side of every 

495 # selected chunk and merged into one contiguous passage, so a hit that 

496 # lands mid-argument regains the text before and after it. 0 disables. 

497 # Capped: it is a small chunk radius (useful values are single digits), and 

498 # the merged text is token-budget-bounded anyway, so a large value only 

499 # inflates per-query fetch cost -- and a misread as a token count (e.g. 

500 # 50000) would build a megabyte-long IN-predicate per source. 

501 neighbor_expansion: int = ConfigField(default=0, ge=0, le=100, writable=True) 

502 

503 # HyDE (Gao et al. 2022): hypothetical-answer embedding search. +~500ms. 

504 hyde: bool = ConfigField(default=False, writable=True) 

505 

506 # HyDE result weight relative to real-doc search (0.0-1.0). 

507 hyde_weight: float = ConfigField(default=0.7, ge=0.0, le=1.0, writable=True) 

508 

509 # HyDE prompt template. Must contain {question} placeholder. 

510 hyde_prompt: str = Field( 

511 default=( 

512 "Write a 50-100 word passage that directly answers this question as if " 

513 "it were an excerpt from a real document. Do not include any preamble, " 

514 "just write the passage.\n\nQuestion: {question}" 

515 ), 

516 description=( 

517 "Prompt template HyDE uses to write the hypothetical answer. Must contain {question}" 

518 ), 

519 ) 

520 

521 # Reranker model ref. Empty disables reranking. Native GGUFs run on 

522 # llama-server (rank pooling or LLM logprob scoring); hosted refs 

523 # (cohere/voyage/jina/together/hf-tei) need the backend extra. 

524 reranker_model: str = ConfigField(default="", public=True) 

525 

526 # auto detects cross-encoder vs LLM reranker by GGUF arch; override forces one. 

527 reranker_type: RerankerType = ConfigField(default=RerankerType.AUTO, writable=True, public=True) 

528 # Relevance prompt for LLM rerankers; empty uses the built-in generic template. 

529 # A format string with {query} and {document} placeholders. 

530 reranker_prompt: str = ConfigField(default="", writable=True, public=True) 

531 

532 # Long-term chat memory. Off by default (opt-in): when disabled the whole 

533 # subsystem is dormant and the write surfaces respond with an enable hint. 

534 memory_enabled: bool = ConfigField(default=False, writable=True) 

535 

536 # Facts recalled by similarity per turn (preferences are always injected). 

537 memory_top_k: int = ConfigField(default=5, ge=0, writable=True) 

538 

539 # Cosine-distance ceiling for fact recall; stricter than the document default 

540 # because a tiny memory corpus floods at the wider document threshold. 

541 memory_max_distance: float = ConfigField(default=0.6, ge=0.0, le=1.0, writable=True) 

542 

543 # Char/4 token budget for the injected memory block. 

544 memory_token_budget: int = ConfigField(default=512, ge=0, writable=True) 

545 

546 # Per-owner soft cap; oldest memories evicted past it (runaway-write guard). 

547 memory_max_per_owner: int = ConfigField(default=200, ge=1, writable=True) 

548 

549 # Cosine distance below which a new memory is treated as a duplicate of an 

550 # existing same-owner memory and updates it in place instead of inserting. 

551 memory_dedup_distance: float = ConfigField(default=0.05, ge=0.0, le=1.0, writable=True) 

552 

553 # LLM pass that extracts memories from the chat loop. Off by default; extracted 

554 # memories are saved directly and recalled like any other memory. 

555 memory_auto_extract: bool = ConfigField(default=False, writable=True) 

556 

557 # Candidate count sent to the reranker. 

558 rerank_candidates: int = ConfigField(default=60, ge=1, writable=True, public=True) 

559 

560 # Blend reranker scores with the retrieval fusion signal (position-aware). 

561 # Off = the cross-encoder's own ordering stands unblended, which isolates 

562 # the reranker's effect when measuring it. 

563 rerank_blend: bool = ConfigField(default=True, writable=True, public=True) 

564 

565 # Drop candidates whose RAW reranker score falls below this; unset = off. 

566 # The scale is provider/model specific (bge logits can be negative, hosted 

567 # rerankers use 0..1), so set it against observed scores. 

568 rerank_min_score: float | None = ConfigField(default=None, writable=True, public=True) 

569 

570 # Date-range filter; only fires when a temporal keyword is detected. 

571 temporal_filtering: bool = ConfigField(default=True, writable=True) 

572 

573 # If True, emit <think>…</think> content as separate SSE reasoning events; 

574 # if False, strip it silently. 

575 show_reasoning: bool = ConfigField(default=False, writable=True) 

576 

577 # How /v1/chat/completions presents a reasoning model's thinking. ``separate`` 

578 # reports it in ``reasoning_content`` (OpenAI-compatible); ``inline`` keeps it 

579 # in ``content`` as <think> text for clients that never render 

580 # ``reasoning_content``; ``off`` asks the model not to think. A request's 

581 # ``reasoning`` field overrides this per call. 

582 completions_reasoning: ReasoningMode = ConfigField( 

583 default=ReasoningMode.SEPARATE, writable=True 

584 ) 

585 

586 # How /v1/messages presents a reasoning model's thinking. ``separate`` reports 

587 # it as a ``thinking`` block (Anthropic-compatible); ``inline`` folds it into 

588 # the answer text for clients that never render thinking blocks; ``off`` asks 

589 # the model not to think and drops any thinking it produces anyway. A 

590 # request's ``thinking`` parameter overrides this per call. 

591 messages_reasoning: ReasoningMode = ConfigField(default=ReasoningMode.SEPARATE, writable=True) 

592 

593 # Maximum reasoning characters before lilbee forces the model to answer. 

594 # Per-model overrides apply on top of this default. Approx N/4 tokens. 

595 # 0 disables the cap (unlimited reasoning; accept the runaway-loop risk). 

596 max_reasoning_chars: int = ConfigField(default=64_000, ge=0, writable=True) 

597 

598 # Web crawling. 

599 

600 # How crawls fetch pages. ``http`` (default) uses a plain HTTP client with 

601 # no browser, the lightweight path for static / server-rendered sites. 

602 # ``browser`` launches a tuned Chromium with JavaScript enabled for sites 

603 # that render content client-side, at a much higher memory cost. 

604 crawl_render_mode: CrawlRenderMode = ConfigField(default=CrawlRenderMode.HTTP, writable=True) 

605 

606 # Browser-mode memory levers (only used when crawl_render_mode is browser). 

607 # Recycle the Chromium process every N fetched pages to cap RSS growth on a 

608 # long recursive crawl; 0 disables recycling. Raise on a roomy machine for 

609 # fewer restarts, lower it if memory is tight. 

610 crawl_browser_recycle_pages: int = ConfigField(default=50, ge=0, writable=True) 

611 

612 # Extra Chromium launch flags for browser-mode crawls. Defaults trim shared 

613 # memory and GPU use; override to pass site- or environment-specific flags. 

614 crawl_browser_extra_args: list[str] = ConfigField( 

615 default_factory=lambda: ["--disable-dev-shm-usage", "--disable-gpu"], 

616 writable=True, 

617 ) 

618 

619 # Optional global ceilings. None = no ceiling. 

620 crawl_max_depth: int | None = ConfigField(default=None, ge=0, writable=True) 

621 crawl_max_pages: int | None = ConfigField(default=None, ge=1, writable=True) 

622 

623 # Default page bound for an unbounded crawl (no explicit max_pages / 

624 # crawl_max_pages), so a hostile site can't exhaust the disk by default. 

625 # An explicit limit overrides it; raise this to crawl larger sites unbounded. 

626 crawl_safety_max_pages: int = ConfigField(default=5_000, ge=1, writable=True) 

627 

628 # Per-URL fetch timeout, seconds. 

629 crawl_timeout: int = ConfigField(default=30, ge=1, writable=True) 

630 

631 # 0 = unlimited, default = CPU count. 

632 crawl_max_concurrent: int = Field( 

633 default=0, 

634 ge=0, 

635 description="Pages fetched in parallel during a crawl. 0 = unlimited; default = CPU count", 

636 ) 

637 

638 # Seconds between periodic syncs during crawl. 0 = sync only at end. 

639 crawl_sync_interval: int = ConfigField(default=30, ge=0, writable=True) 

640 

641 # Per-request delay + jitter (defaults chosen to be gentler than crawl4ai's). 

642 crawl_mean_delay: float = ConfigField(default=0.5, ge=0.0, writable=True) 

643 crawl_max_delay_range: float = ConfigField(default=0.5, ge=0.0, writable=True) 

644 

645 # In-flight requests per crawl. 

646 crawl_concurrent_requests: int = ConfigField(default=3, ge=1, writable=True) 

647 

648 # Per-domain rate-limiter that backs off on HTTP 429/503 and retries. 

649 crawl_retry_on_rate_limit: bool = ConfigField(default=True, writable=True) 

650 crawl_retry_base_delay_min: float = ConfigField(default=1.0, ge=0.0, writable=True) 

651 crawl_retry_base_delay_max: float = ConfigField(default=3.0, ge=0.0, writable=True) 

652 crawl_retry_max_backoff: float = ConfigField(default=30.0, ge=0.0, writable=True) 

653 crawl_retry_max_attempts: int = ConfigField(default=3, ge=0, writable=True) 

654 

655 # Regex patterns dropped at link-discovery time. Defaults block CMS 

656 # scaffolding (WordPress admin, archives, tracking params, etc.). 

657 crawl_exclude_patterns: list[str] = ConfigField( 

658 default_factory=lambda: list(DEFAULT_CRAWL_EXCLUDE_PATTERNS), 

659 writable=True, 

660 ) 

661 

662 # Fraction of GPU/unified memory reserved for loaded models. 

663 gpu_memory_fraction: float = ConfigField(default=0.75, ge=0.1, le=1.0, writable=True) 

664 

665 # Share of a card placement may charge, leaving room for allocator 

666 # fragmentation and driver overhead. Tunable because it decides admission: at 

667 # the default, a 16 GB machine whose chat model needs 12-13 GB can be refused 

668 # chat entirely, and the owner is the one who knows whether that card has the 

669 # room. Raising it trades safety margin for the ability to serve at all. 

670 usable_vram_fraction: float = ConfigField(default=0.9, ge=0.5, le=1.0, writable=True) 

671 

672 # RAM held back for the OS when placing against system memory, in GiB. Capped 

673 # at a quarter of total RAM either way, so a small host keeps its proportional 

674 # reserve however this is set. 

675 system_memory_reserve_gb: float = ConfigField(default=4.0, ge=0.0, le=64.0, writable=True) 

676 

677 # Data-parallel replicas of the embed / vision role across GPUs: N independent 

678 # servers, round-robined, so large-scale ingest fans the embedding / OCR work 

679 # across the whole box. 0 means "auto": one replica per detected GPU, capped by 

680 # the VRAM left after the persistent query fleet (chat, one embed, rerank, one 

681 # vision) is reserved. A positive value pins the count. The extra replicas are 

682 # ingest-only and reclaimed when ingest ends; the persistent query embedder / 

683 # vision (replica 0) always exists if its model fits. 

684 embed_replicas: int = ConfigField(default=0, ge=0, writable=True) 

685 vision_replicas: int = ConfigField(default=0, ge=0, writable=True) 

686 

687 # Seconds a model stays loaded after last use. 0 = unload immediately. 

688 model_keep_alive: int = ConfigField(default=300, ge=0, writable=True) 

689 

690 # Spawn every configured role server at startup instead of on first use. 

691 # Trades a slower TUI mount (the role servers cold-start in parallel) for a 

692 # responsive first interaction. Roles whose model is unset are skipped, so a 

693 # setup with only chat + embed never spawns rerank or vision. Set to false 

694 # for headless / scripted use where the first call doesn't need to be fast. 

695 worker_pool_eager_start: bool = ConfigField(default=True, writable=True) 

696 

697 # Leave the engine fleet running on quit so the next launch adopts it warm. 

698 # On keeps the engine process alive across app close so the next launch 

699 # binds instantly; its weights still follow engine_idle_ttl_minutes. Off 

700 # (default): the engine stops when the last lilbee process exits, leaving 

701 # the machine clean. 

702 keep_engine_warm: bool = ConfigField(default=False, writable=True) 

703 

704 # Hugging Face's high-performance transfer mode: more connections and much 

705 # larger in-flight buffers. Off by default, those ceilings suit a server 

706 # rather than a laptop also holding a model in memory. 

707 fast_model_downloads: bool = ConfigField(default=False, writable=True) 

708 

709 # Idle minutes before the engine unloads its weights (llama-swap ttl), in 

710 # every mode: even a persistent engine naps when unused. 0 keeps weights 

711 # loaded until the engine stops. 

712 engine_idle_ttl_minutes: int = ConfigField(default=5, writable=True) 

713 

714 # Working n_ctx the dynamic picker aims for. Default scales with 

715 # total host RAM (see core.system.chat_ctx_target_for_total_bytes): 

716 # <16 GiB -> 8192, 16-32 -> 12288, 32-64 -> 16384, 64-128 -> 24576, 

717 # >=128 -> 65536 (an agent-capable window on server-class hosts). 

718 # 8192 is the floor; the picker still clamps to training_ctx and 

719 # host headroom. 

720 chat_n_ctx_target: int = ConfigField( 

721 default_factory=scaled_chat_ctx_target_default, 

722 ge=512, 

723 writable=True, 

724 ) 

725 

726 # Condense turns that outgrow chat_n_ctx_target into carried notes instead 

727 # of dropping them. Off: zero model calls; the oldest turns drop and the 

728 # context chip shows it. On: each firing blocks on a summarize call 

729 # (measured: 1.3-2.5s per 60-turn fold on a datacenter GPU, 0.7-2s on an 

730 # 8-core CPU with a 0.6B-4B model). 

731 chat_compaction: bool = ConfigField(default=False, writable=True) 

732 

733 # Persist conversations and expose the Sessions drawer, tab, and commands. 

734 # On by default; turning it off stops chats being written to disk, hides the 

735 # ctrl+o binding from the footer, and gates the Sessions view behind a notice. 

736 # Governs the human surfaces (TUI, HTTP, CLI); agent sessions have their own 

737 # flag below, so the two domains the store already separates stay separate. 

738 sessions_enabled: bool = ConfigField(default=True, writable=True) 

739 

740 # The agent (MCP) half of the same feature, off by default: agent hosts 

741 # generally track their own conversation history, and the seven session 

742 # tools cost schema on every request whether or not anything uses them. 

743 mcp_sessions_enabled: bool = ConfigField(default=False, writable=True) 

744 

745 # Explicit ceiling for the dynamic n_ctx picker. ``None`` (default) 

746 # lets the model's training_ctx from GGUF metadata be the ceiling, 

747 # so a 128K-context model can reach for it on a host with the RAM 

748 # to back it. Set explicitly to cap below the model's training_ctx. 

749 num_ctx_max: int | None = ConfigField(default=None, ge=512, writable=True) 

750 

751 # Flash attention. None (default) = on, True = force on, False = off 

752 # for backends or models where it misbehaves. 

753 # Resolves the 'padding V cache to 1024' warning on models with 

754 # uneven per-layer V dims (e.g. Gemma3) and saves ~25% KV memory. 

755 flash_attention: bool | None = ConfigField(default=None, writable=True) 

756 

757 # KV cache element type. q8_0 (default) halves cache memory vs f16 

758 # with no measurable quality loss for chat; q4_0 quarters it with a 

759 # small quality cost. Both require flash attention to be enabled. 

760 kv_cache_type: KvCacheType = ConfigField(default=KvCacheType.Q8_0, writable=True) 

761 

762 # Number of model layers to offload to GPU. None (default) = all 

763 # layers, 0 = CPU only, positive int = partial offload. Useful when a 

764 # discrete GPU has less VRAM than the model needs. 

765 n_gpu_layers: int | None = ConfigField(default=None, writable=True) 

766 

767 # Keep a MoE model's expert weights in system memory, attention and shared 

768 # layers on the GPU. Lets a sparse model run on a card too small to hold it. 

769 # No effect on dense models, which have no expert tensors. 

770 cpu_moe: bool = ConfigField(default=False, writable=True) 

771 

772 # Offload only the first N layers' experts. Takes precedence over cpu_moe; 

773 # a smaller N keeps more of the model resident. 

774 n_cpu_moe: int | None = ConfigField(default=None, writable=True) 

775 

776 # GPU device picker for dual-GPU machines (typical laptop case: 

777 # discrete NVIDIA + integrated Intel/AMD). The Vulkan backend 

778 # enumerates every adapter the system exposes and may pick the 

779 # integrated one first, producing stalls or OOMs that look like 

780 # llama.cpp bugs. Setting ``gpu_devices`` constrains visibility 

781 # before the servers spawn, pinning inference to the chosen device(s). 

782 # 

783 # Accepts a comma-separated list of device indexes ("0", "1", 

784 # "0,1") and applies it to every backend simultaneously: 

785 # ``GGML_VK_VISIBLE_DEVICES`` for Vulkan, ``CUDA_VISIBLE_DEVICES`` 

786 # for CUDA, ``HIP_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` for 

787 # ROCm. Setting one variable that the active backend ignores is 

788 # harmless, so we set all four rather than detecting the build. 

789 # 

790 # Must be set before the first llama.cpp call; in practice that 

791 # means via ``LILBEE_GPU_DEVICES`` or ``config.toml`` (TUI edits 

792 # only take effect after a restart). ``None`` (default) hands off 

793 # to the autodetect in ``providers/fleet/gpu_select.py``, 

794 # which parses ``vulkaninfo --summary`` and pins the discrete 

795 # adapter when one is present. The autodetect is silent on failure 

796 # (no vulkaninfo, single device, parse error), leaving the 

797 # Vulkan-loader's default ordering in place. 

798 gpu_devices: str | None = ConfigField(default=None, writable=True) 

799 

800 # Primary GPU index passed to ``Llama(main_gpu=...)``. Only matters 

801 # when multiple devices remain visible after ``gpu_devices``; with 

802 # a single visible device, llama.cpp ignores this. ``None`` 

803 # (default) lets llama.cpp pick (index 0). 

804 main_gpu: int | None = ConfigField(default=None, writable=True) 

805 

806 # Manual GPU placement override stored as a JSON scalar (the config.toml store 

807 # is flat, and core must not depend on the provider PlacementSpec type). When 

808 # set, it fully replaces the automatic placement planner: each active role pins 

809 # to the listed device indices, with an optional tensor_split and replica count. 

810 # Edited via the placement CLI/MCP/HTTP/TUI surfaces rather than the generic 

811 # settings list, so public=False. None hands off to the VRAM-aware auto planner. 

812 placement: str | None = ConfigField( 

813 default=None, 

814 writable=True, 

815 public=False, 

816 description=( 

817 "Manual multi-GPU placement spec. It fully replaces the automatic planner: " 

818 "each active role pins to the listed device indices. Edit it with the " 

819 "placement commands, not the settings list. Empty uses the VRAM-aware planner" 

820 ), 

821 ) 

822 

823 # Allow PUT/DELETE /api/placement to apply or clear placement over HTTP. 

824 # Off by default because applying placement restarts the shared fleet's moved roles, which 

825 # is unsafe across concurrent HTTP clients. Turn it on (LILBEE_ALLOW_HTTP_PLACEMENT=1) 

826 # only for a single-client / owned deployment: the plugin's managed local 

827 # server, or a personally-owned pod where one operator runs `lilbee serve`. 

828 allow_http_placement: bool = Field( 

829 default=False, 

830 description=( 

831 "Let PUT and DELETE /api/placement change model placement over HTTP. Off by " 

832 "default because applying placement restarts the moved roles, which is unsafe " 

833 "with concurrent clients. Turn it on only for a deployment you alone use" 

834 ), 

835 ) 

836 

837 # True = Markdown widget for chat; False = plain Static (faster). 

838 markdown_rendering: bool = Field( 

839 default=True, 

840 description=( 

841 "Render chat replies as Markdown in the TUI. Off draws plain text, which is faster" 

842 ), 

843 ) 

844 

845 # TUI theme name; persists the last Ctrl+T pick across sessions. 

846 theme: str = ConfigField(default="rose-pine", writable=True) 

847 

848 # Per-model generation defaults set via apply_model_defaults(). 

849 _model_defaults: Any = None 

850 

851 # Wiki layer. LLM-maintained synthesis pages with citation provenance. 

852 # Off by default; flip to True (or set LILBEE_WIKI=1) to enable. When off, 

853 # the Wiki view tab and the chat ModelBar's scope picker are both hidden. 

854 wiki: bool = ConfigField(default=False, writable=True) 

855 # Whether a sync regenerates touched wiki pages on its own. Off by 

856 # default: enabling the wiki never starts generating by itself, the 

857 # user wikifies explicitly via `lilbee wiki build` / `wiki update`. 

858 wiki_auto_update: bool = ConfigField(default=False, writable=True) 

859 # Read-only: changing the directory at runtime strands prior wiki pages 

860 # under the old path. Users who want a different location set it via 

861 # LILBEE_WIKI_DIR / config.toml before the first wiki_build. 

862 wiki_dir: str = "wiki" 

863 wiki_prune_raw: bool = ConfigField(default=False, writable=True) 

864 

865 # Minimum cosine similarity between a page body and the mean of its 

866 # source chunk vectors before a page is published (below → drafts). 

867 # Replaces the old LLM-based faithfulness score: mean-of-chunks is a 

868 # deterministic, zero-LLM-call signal that routes topic-drifted 

869 # pages to drafts without the 0.0 to 1.0 ambiguity of a model-emitted 

870 # number. Tuning knob: swap to per-chunk max or top-K-mean if the 

871 # default 0.5 produces false drafts. 

872 wiki_embedding_faithfulness_threshold: float = ConfigField( 

873 default=0.5, ge=0.0, le=1.0, writable=True 

874 ) 

875 

876 # Per-call output token cap for wiki generation. Without this a 

877 # reasoning model (Qwen3, DeepSeek-R1) can burn the full context 

878 # window emitting <think> tokens before the actual answer, taking 

879 # minutes per page. Default leaves headroom for a typical reasoning 

880 # budget plus a real response (~1000 output + ~1000 slack). 

881 wiki_summary_max_tokens: int = ConfigField(default=2048, ge=256, writable=True) 

882 

883 # Wiki generation is a structured-output task: the model must emit the 

884 # block separators, the citation footnotes, and verbatim quotes. The 

885 # usual chat default (~0.8) is too creative for that. Lowering the 

886 # sampling temperature makes the model stick to the template and quote 

887 # more faithfully. 0.1 leaves just enough slack to avoid hard loops. 

888 wiki_temperature: float = ConfigField(default=0.1, ge=0.0, le=2.0, writable=True) 

889 

890 # Fraction of citations that must be stale before a wiki page is flagged. 

891 wiki_stale_citation_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

892 

893 # Fraction of content changed that triggers human-review drift guard. 

894 wiki_drift_threshold: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True) 

895 

896 # LLM prompt templates for wiki page generation: wiki_synthesis_prompt 

897 # for cross-source synthesis pages, wiki_entity_batch_prompt (below) 

898 # for the per-source batched call. Writable so advanced users can 

899 # override them from /settings, config.toml, or ``LILBEE_WIKI_*_PROMPT`` 

900 # env vars. Templates must keep the expected ``{placeholders}``. If you 

901 # remove one the generator will crash on first use. 

902 wiki_synthesis_prompt: str = ConfigField( 

903 writable=True, 

904 default=( 

905 "You are a knowledge compiler. Given source chunks from MULTIPLE documents " 

906 "about related concepts, write a synthesis wiki page in markdown that connects " 

907 "ideas across sources.\n\n" 

908 "Rules:\n" 

909 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. " 

910 "Never cite by chunk label: [Chunk N] labels only organize the " 

911 "chunks below and must not appear in the page.\n" 

912 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n" 

913 "3. For connections, interpretations, or patterns you identify across sources, " 

914 "mark with [*inference*].\n" 

915 "4. Use blockquotes (>) for directly cited facts.\n" 

916 "5. Reference each source by its filename when drawing connections.\n" 

917 "6. End with a citation block in this format:\n\n" 

918 "---\n" 

919 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n" 

920 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n' 

921 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n' 

922 "Topic: {topic}\n\n" 

923 "Sources:\n{source_list}\n\n" 

924 "Chunks:\n{chunks_text}\n\n" 

925 "Write the synthesis page now. Start with a heading." 

926 ), 

927 ) 

928 

929 # Wiki synthesis clusterer backend. CONCEPTS requires the [graph] extra 

930 # and falls back to EMBEDDING when unavailable. 

931 wiki_clusterer: ClustererBackend = ConfigField( 

932 default=ClustererBackend.EMBEDDING, writable=True 

933 ) 

934 

935 # Neighborhood size for the mutual-kNN graph. 0 = auto-scale from corpus size. 

936 wiki_clusterer_k: int = ConfigField(default=0, ge=0, writable=True) 

937 

938 # LazyGraphRAG-style concept graph. Requires the [graph] extra. 

939 concept_graph: bool = ConfigField(default=True, writable=True) 

940 

941 # Weight of concept overlap boost relative to vector similarity. 

942 concept_boost_weight: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True) 

943 

944 # Max noun-phrase concepts extracted per chunk. 

945 concept_max_per_chunk: int = ConfigField(default=5, ge=1, writable=True) 

946 

947 # spaCy NER labels kept by the wiki entity extractor. Anything not 

948 # in this set (QUANTITY, CARDINAL, DATE, TIME, MONEY, PERCENT, 

949 # ORDINAL, ...) is dropped before aggregation. Override via 

950 # LILBEE_CONCEPT_ALLOWED_ENT_TYPES as a comma-separated list. 

951 concept_allowed_ent_types: frozenset[str] = Field( 

952 default=DEFAULT_ALLOWED_NER_LABELS, 

953 description=( 

954 "spaCy NER labels the wiki entity extractor keeps. It drops everything else " 

955 "(QUANTITY, CARDINAL, DATE, and so on) before aggregation" 

956 ), 

957 ) 

958 

959 # Strategy used to extract entities for the concept/entity wiki. 

960 # NER_ENTITIES (default) pulls typed NER entities with spaCy; concept 

961 # pages are proposed by the LLM inside the per-source batched call, 

962 # not by the extractor. NER_CONCEPTS_PLUS_LLM_TYPES layers an 

963 # LLM-proposed domain schema on top. LLM_TAGGED asks the LLM to tag 

964 # every chunk (most expensive). Unimplemented modes fall back to 

965 # NER_ENTITIES. 

966 wiki_entity_mode: WikiEntityMode = ConfigField( 

967 default=WikiEntityMode.NER_ENTITIES, writable=True 

968 ) 

969 

970 # Minimum distinct chunk mentions before an entity or concept earns 

971 # its own wiki page. Filters one-off noise. 

972 wiki_entity_min_mentions: int = ConfigField(default=3, ge=1, writable=True) 

973 wiki_stub_max_chunk_refs: int = ConfigField(default=50, ge=1, writable=True) 

974 

975 # Auto-update cap: if a single sync touches more than this many 

976 # concept or entity pages, skip the per-slug regeneration and tell 

977 # the user to run `lilbee wiki update` explicitly. Keeps a surprise 

978 # bulk import from firing hundreds of LLM calls. 

979 wiki_ingest_update_cap: int = ConfigField(default=20, ge=1, writable=True) 

980 

981 # Whether the per-source batched call asks the LLM to curate 

982 # concept pages alongside the pre-extracted entity list. False → 

983 # entity sections only, no concept curation (incremental ingest 

984 # path uses this to avoid churning concept slugs per source-touch). 

985 wiki_extract_concepts: bool = ConfigField(default=True, writable=True) 

986 

987 # Minimum chunk count a source must contribute before it is eligible 

988 # for concept curation. Sources below the floor still get a batched 

989 # call when they have entities (the prompt writes entity-only 

990 # sections); sources below the floor with zero entities are skipped 

991 # entirely. Prevents boilerplate / TOC / appendix documents from 

992 # burning an LLM call to invent "concepts". 

993 wiki_batch_min_chunks: int = ConfigField(default=3, ge=1, writable=True) 

994 

995 # Prompt template for the per-source batched call. Placeholders: 

996 # {source}, {entity_list}, {chunks_text}, {concept_instruction}. 

997 # {concept_instruction} is filled with a concept-curation paragraph 

998 # when concepts are requested, or the empty string otherwise. 

999 # Single-entity page written on demand from that entity's chunks across 

1000 # every source naming it. The batched prompt above cannot serve this: it 

1001 # writes every section for one source in one call. 

1002 wiki_entity_page_prompt: str = ConfigField( 

1003 writable=True, 

1004 default=( 

1005 "You are a knowledge compiler. Given source chunks that mention " 

1006 "ONE subject, write a wiki page about that subject in markdown.\n\n" 

1007 "Rules:\n" 

1008 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. " 

1009 "Never cite by chunk label: [Chunk N] labels only organize the " 

1010 "chunks below and must not appear in the page.\n" 

1011 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n" 

1012 "3. Write only what the chunks support. Mark anything you infer with " 

1013 "[*inference*].\n" 

1014 "4. Use blockquotes (>) for directly cited facts.\n" 

1015 "5. When sources disagree, say so and cite both.\n" 

1016 "6. End with a citation block in this format:\n\n" 

1017 "---\n" 

1018 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n" 

1019 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n' 

1020 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n' 

1021 "Subject: {topic}\n\n" 

1022 "Sources:\n{source_list}\n\n" 

1023 "Chunks:\n{chunks_text}\n\n" 

1024 "Write the page now. Start with a heading naming the subject." 

1025 ), 

1026 ) 

1027 wiki_entity_batch_prompt: str = ConfigField( 

1028 writable=True, 

1029 default=( 

1030 "You are writing wiki sections based on these chunks from {source}.\n\n" 

1031 "{concept_instruction}" 

1032 "Write a wiki section for each of these NER ENTITIES: {entity_list}\n\n" 

1033 "Format each section exactly as:\n" 

1034 "## Name\n" 

1035 "{{content with [^src1]-style citations}}\n\n" 

1036 "Rules:\n" 

1037 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. " 

1038 "Never cite by chunk label: [Chunk N] labels only organize the " 

1039 "chunks below and must not appear in the page.\n" 

1040 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n" 

1041 "3. For interpretations or connections not directly stated, mark with [*inference*].\n" 

1042 "4. Use blockquotes (>) for directly cited facts.\n" 

1043 "5. End the response with a citation block in this format:\n\n" 

1044 "---\n" 

1045 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n" 

1046 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n' 

1047 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n' 

1048 "Source chunks:\n{chunks_text}\n" 

1049 ), 

1050 ) 

1051 

1052 # Class variable: not a settings field 

1053 _toml_cache: ClassVar[dict[str, Any]] = {} 

1054 

1055 @field_validator("lilbee_name", mode="after") 

1056 @classmethod 

1057 def _strip_lilbee_name(cls, value: str) -> str: 

1058 """Strip whitespace; an empty string signals 'use the path-derived label'.""" 

1059 return value.strip() 

1060 

1061 @field_validator( 

1062 "temperature", 

1063 "top_p", 

1064 "repeat_penalty", 

1065 "top_k_sampling", 

1066 "num_ctx", 

1067 "seed", 

1068 mode="before", 

1069 ) 

1070 @classmethod 

1071 def _empty_string_to_none(cls, v: Any) -> Any: 

1072 if isinstance(v, str) and v.strip() == "": 

1073 return None 

1074 return v 

1075 

1076 @field_validator("chat_mode", mode="before") 

1077 @classmethod 

1078 def _normalize_chat_mode(cls, v: Any) -> str: 

1079 """Coerce chat_mode to a ChatMode value; default ChatMode.SEARCH.""" 

1080 if v is None or v == "": 

1081 return ChatMode.SEARCH.value 

1082 candidate = str(v).strip().lower() 

1083 try: 

1084 return ChatMode(candidate).value 

1085 except ValueError as exc: 

1086 valid = ", ".join(repr(m.value) for m in ChatMode) 

1087 raise ValueError(f"chat_mode must be one of {{{valid}}}, got {v!r}") from exc 

1088 

1089 @field_validator("enable_ocr", mode="before") 

1090 @classmethod 

1091 def _parse_enable_ocr(cls, v: Any) -> bool | None: 

1092 """Parse enable_ocr from env var string or direct value. 

1093 

1094 Accepts: true/false/1/0/yes/no (case-insensitive), empty string 

1095 or None for auto-detect. 

1096 """ 

1097 if v is None: 

1098 return None 

1099 if isinstance(v, bool): 

1100 return v 

1101 if isinstance(v, str): 

1102 if v.strip().lower() in ("", "auto", "none"): 

1103 return None 

1104 try: 

1105 return parse_bool(v) 

1106 except ValueError: 

1107 # bool() on a non-empty string is True, so falling through here 

1108 # turned an unparseable value into "on". Warn and auto-detect, 

1109 # matching the sibling validators. 

1110 log.warning("Invalid LILBEE_ENABLE_OCR=%r, using auto", v) 

1111 return None 

1112 return bool(v) 

1113 

1114 @field_validator("ocr_language", mode="before") 

1115 @classmethod 

1116 def _parse_ocr_language(cls, v: Any) -> list[str]: 

1117 """Accept a list or a ``+``/comma/newline-separated string; never empty. 

1118 

1119 Tesseract joins languages with ``+`` (e.g. ``eng+deu``), so that is the 

1120 canonical user-facing form. Commas are also accepted. Newlines are 

1121 accepted because ``app.settings`` joins list values with ``\\n`` when it 

1122 persists them to config.toml; without splitting on it a multi-language 

1123 value would reload as one malformed token. Blank input falls back to 

1124 English, since xberg errors on an empty list. 

1125 """ 

1126 if isinstance(v, str): 

1127 v = v.replace("+", ",").replace("\n", ",").split(",") 

1128 items = v or [] 

1129 langs = [s.strip() for s in items if isinstance(s, str) and s.strip()] 

1130 return langs or ["eng"] 

1131 

1132 @field_validator("flash_attention", mode="before") 

1133 @classmethod 

1134 def _parse_flash_attention(cls, v: Any) -> bool | None: 

1135 """Auto/on/off tri-state: empty/auto/none -> None, else parse bool.""" 

1136 if v is None: 

1137 return None 

1138 if isinstance(v, bool): 

1139 return v 

1140 if isinstance(v, str): 

1141 if v.strip().lower() in ("", "auto", "none"): 

1142 return None 

1143 try: 

1144 return parse_bool(v) 

1145 except ValueError: 

1146 log.warning("Invalid flash_attention=%r, using auto", v) 

1147 return None 

1148 return bool(v) 

1149 

1150 @field_validator("n_gpu_layers", mode="before") 

1151 @classmethod 

1152 def _parse_n_gpu_layers(cls, v: Any) -> int | None: 

1153 """Auto -> None, ``cpu`` alias -> 0, integers parsed verbatim.""" 

1154 if v is None: 

1155 return None 

1156 if isinstance(v, str): 

1157 label = v.strip().lower() 

1158 if label in ("", "auto", "none"): 

1159 return None 

1160 if label == "cpu": 

1161 return 0 

1162 try: 

1163 return int(label) 

1164 except ValueError: 

1165 log.warning("Invalid LILBEE_N_GPU_LAYERS=%r, using auto", v) 

1166 return None 

1167 return int(v) 

1168 

1169 @field_validator("main_gpu", mode="before") 

1170 @classmethod 

1171 def _parse_main_gpu(cls, v: Any) -> int | None: 

1172 """Empty/auto strings -> None, integers parsed verbatim.""" 

1173 if v is None: 

1174 return None 

1175 if isinstance(v, str): 

1176 label = v.strip().lower() 

1177 if label in ("", "auto", "none"): 

1178 return None 

1179 try: 

1180 return int(label) 

1181 except ValueError: 

1182 log.warning("Invalid LILBEE_MAIN_GPU=%r, using auto", v) 

1183 return None 

1184 return int(v) 

1185 

1186 @field_validator("gpu_devices", mode="before") 

1187 @classmethod 

1188 def _parse_gpu_devices(cls, v: Any) -> str | None: 

1189 """Normalize device list: strip whitespace, drop empties, keep order.""" 

1190 if v is None: 

1191 return None 

1192 if isinstance(v, str): 

1193 label = v.strip().lower() 

1194 if label in ("", "auto", "all", "none"): 

1195 return None 

1196 parts = [p.strip() for p in v.split(",") if p.strip()] 

1197 if not parts: 

1198 return None 

1199 for part in parts: 

1200 if not part.lstrip("-").isdigit(): 

1201 log.warning("Invalid LILBEE_GPU_DEVICES=%r, ignoring", v) 

1202 return None 

1203 return ",".join(parts) 

1204 return str(v) 

1205 

1206 @field_validator("placement", mode="before") 

1207 @classmethod 

1208 def _parse_placement(cls, v: Any) -> str | None: 

1209 """Blank/None -> None; validate a JSON string or PlacementSpec; store JSON.""" 

1210 from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec 

1211 

1212 if v is None: 

1213 return None 

1214 if isinstance(v, PlacementSpec): 

1215 json_str = v.to_json() 

1216 PlacementSpec.from_json(json_str) # re-validate a directly-built spec 

1217 return json_str 

1218 if isinstance(v, str): 

1219 if v.strip() == "": 

1220 return None 

1221 PlacementSpec.from_json(v) 

1222 return v 

1223 raise PlacementError("placement must be a JSON string or PlacementSpec") 

1224 

1225 @field_validator("semantic_chunking", mode="before") 

1226 @classmethod 

1227 def _parse_semantic_chunking(cls, v: Any) -> bool: 

1228 """Parse from env string; invalid values warn and fall back to False.""" 

1229 if isinstance(v, bool): 

1230 return v 

1231 if isinstance(v, str): 

1232 try: 

1233 return parse_bool(v) 

1234 except ValueError: 

1235 log.warning("Invalid LILBEE_SEMANTIC_CHUNKING=%r, using default False", v) 

1236 return False 

1237 return bool(v) 

1238 

1239 @field_validator( 

1240 "chat_model", "embedding_model", "vision_model", "reranker_model", mode="after" 

1241 ) 

1242 @classmethod 

1243 def _normalize_model_tag(cls, v: str, info: ValidationInfo) -> str: 

1244 """Validate and canonicalize a model ref; blank means the role is unconfigured.""" 

1245 if not v or not v.strip(): 

1246 return "" 

1247 from lilbee.providers.model_ref import parse_model_ref 

1248 

1249 return parse_model_ref(v).for_openai_prefix() 

1250 

1251 @field_validator("ollama_base_url", "lm_studio_base_url", mode="after") 

1252 @classmethod 

1253 def _strip_trailing_slash(cls, v: str) -> str: 

1254 """Canonicalize a local-server URL once at the write boundary.""" 

1255 return v.rstrip("/") 

1256 

1257 @field_validator("cors_origins", mode="before") 

1258 @classmethod 

1259 def _split_cors_origins(cls, v: Any) -> Any: 

1260 if isinstance(v, str): 

1261 return [o.strip() for o in v.split(",") if o.strip()] 

1262 return v 

1263 

1264 @field_validator("crawl_browser_extra_args", mode="before") 

1265 @classmethod 

1266 def _split_crawl_browser_extra_args(cls, v: Any) -> Any: 

1267 """Accept a newline-separated string, matching how the field is persisted. 

1268 

1269 ``app.settings`` joins list values with newlines before writing them to 

1270 ``config.toml`` as a scalar string. Without this inverse, reload cannot 

1271 coerce that string to ``list[str]`` and the whole config.toml is dropped. 

1272 TOML lists and JSON arrays pass through unchanged. 

1273 """ 

1274 if isinstance(v, str): 

1275 return [a.strip() for a in v.splitlines() if a.strip()] 

1276 return v 

1277 

1278 @field_validator("crawl_exclude_patterns", mode="before") 

1279 @classmethod 

1280 def _split_crawl_exclude_patterns(cls, v: Any) -> Any: 

1281 """Accept newline-separated strings from env vars / plain-text config. 

1282 

1283 Regex commonly uses commas (e.g. `{2,4}`) and pipes (alternation), so 

1284 newline is the only separator safe to use for this field. TOML lists 

1285 and JSON arrays pass through unchanged. 

1286 """ 

1287 if isinstance(v, str): 

1288 return [p.strip() for p in v.splitlines() if p.strip()] 

1289 return v 

1290 

1291 @field_validator("crawl_exclude_patterns", mode="after") 

1292 @classmethod 

1293 def _validate_crawl_exclude_patterns(cls, v: list[str]) -> list[str]: 

1294 """Reject any entry that isn't a valid Python regex. 

1295 

1296 These patterns are compiled at crawl time. An invalid pattern there 

1297 surfaces as an opaque mid-crawl error; catching it at PATCH time gives 

1298 the user a 400 with a pointer to the bad entry. 

1299 """ 

1300 import re 

1301 

1302 bad: list[str] = [] 

1303 for i, pattern in enumerate(v): 

1304 try: 

1305 re.compile(pattern) 

1306 except re.error as exc: 

1307 bad.append(f"[{i}] {pattern!r}: {exc}") 

1308 if bad: 

1309 raise ValueError("invalid regex in crawl_exclude_patterns:\n " + "\n ".join(bad)) 

1310 return v 

1311 

1312 @field_validator("ignore_dirs", mode="before") 

1313 @classmethod 

1314 def _merge_ignore_dirs(cls, v: Any) -> frozenset[str]: 

1315 if isinstance(v, str): 

1316 extra = frozenset(name.strip() for name in v.split(",") if name.strip()) 

1317 return DEFAULT_IGNORE_DIRS | extra 

1318 if isinstance(v, (set, frozenset, list)): 

1319 return DEFAULT_IGNORE_DIRS | frozenset(v) 

1320 return DEFAULT_IGNORE_DIRS 

1321 

1322 @field_validator("concept_allowed_ent_types", mode="before") 

1323 @classmethod 

1324 def _parse_ent_types(cls, v: Any) -> frozenset[str]: 

1325 """Replace-semantics override: a narrowed set is used as-is, 

1326 not unioned with defaults. A user asking for ``PERSON,ORG`` 

1327 wants exactly those kinds. Accepts comma-separated strings 

1328 from env and list / set / frozenset from code. Empty input 

1329 falls back to :data:`DEFAULT_ALLOWED_NER_LABELS` so an empty 

1330 env var does not silently disable the gate. 

1331 """ 

1332 if isinstance(v, str): 

1333 parts = frozenset(name.strip().upper() for name in v.split(",") if name.strip()) 

1334 return parts or DEFAULT_ALLOWED_NER_LABELS 

1335 if isinstance(v, (set, frozenset, list)): 

1336 parts = frozenset(str(x).upper() for x in v) 

1337 return parts or DEFAULT_ALLOWED_NER_LABELS 

1338 return DEFAULT_ALLOWED_NER_LABELS 

1339 

1340 @model_validator(mode="before") 

1341 @classmethod 

1342 def _resolve_defaults(cls, data: Any) -> Any: 

1343 from lilbee.core.system import ( 

1344 canonical_data_root, 

1345 canonical_models_dir, 

1346 default_data_dir, 

1347 find_local_root, 

1348 ) 

1349 

1350 if not isinstance(data, dict): 

1351 return data 

1352 

1353 # An empty LILBEE_DATA_ROOT (delivered as "") must fall through to default 

1354 # resolution like an unset one, not become Path(".") = the process cwd. 

1355 if isinstance(data.get("data_root"), str) and not data["data_root"].strip(): 

1356 data["data_root"] = None 

1357 if data.get("data_root") in (None, _UNSET_PATH): 

1358 data_env = os.environ.get("LILBEE_DATA", "").strip() 

1359 if data_env: 

1360 data["data_root"] = Path(data_env) 

1361 else: 

1362 local = find_local_root() 

1363 data["data_root"] = local if local is not None else default_data_dir() 

1364 # Every child path below derives from this, and the server lock keys on 

1365 # those, so canonicalizing here is what makes one directory key one lock. 

1366 # Also coerces a raw string (LILBEE_DATA_ROOT) to Path. 

1367 root = canonical_data_root(data["data_root"]) 

1368 data["data_root"] = root 

1369 if data.get("documents_dir") in (None, _UNSET_PATH): 

1370 data["documents_dir"] = root / "documents" 

1371 if data.get("data_dir") in (None, _UNSET_PATH): 

1372 data["data_dir"] = root / "data" 

1373 if data.get("lancedb_dir") in (None, _UNSET_PATH): 

1374 data["lancedb_dir"] = root / "data" / "lancedb" 

1375 if data.get("models_dir") in (None, _UNSET_PATH): 

1376 data["models_dir"] = canonical_models_dir() 

1377 

1378 return data 

1379 

1380 @classmethod 

1381 def settings_customise_sources( 

1382 cls, 

1383 settings_cls: type[BaseSettings], 

1384 init_settings: Any, 

1385 env_settings: Any, 

1386 dotenv_settings: Any, 

1387 file_secret_settings: Any, 

1388 ) -> tuple[Any, ...]: 

1389 from lilbee.core.system import canonical_data_root, default_data_dir, find_local_root 

1390 

1391 # .strip() to match _resolve_defaults; a padded value would otherwise 

1392 # send the root and its config.toml to different directories. 

1393 data_env = os.environ.get("LILBEE_DATA", "").strip() 

1394 if data_env: 

1395 toml_dir = Path(data_env) 

1396 else: 

1397 local = find_local_root() 

1398 toml_dir = local if local else default_data_dir() 

1399 # Same call as the root itself, so this looks where the root resolves to; 

1400 # a "~/lilbee" value would otherwise search a literal ./~ and find nothing. 

1401 toml_path = canonical_data_root(toml_dir) / CONFIG_FILE_NAME 

1402 

1403 plain_env = _PlainEnvSource(settings_cls, env_prefix="LILBEE_", env_ignore_empty=True) 

1404 sources: list[Any] = [init_settings, plain_env] 

1405 if toml_path.exists() and os.environ.get("LILBEE_SKIP_TOML_CONFIG") != "1": 

1406 sources.append(_TomlSource(settings_cls, toml_path)) 

1407 return tuple(sources) 

1408 

1409 @property 

1410 def model_defaults(self) -> Any: 

1411 """Per-model generation defaults (read-only). Set via apply_model_defaults().""" 

1412 return self._model_defaults 

1413 

1414 def apply_model_defaults(self, defaults: Any) -> None: 

1415 """Store per-model generation defaults for 3-layer merge.""" 

1416 object.__setattr__(self, "_model_defaults", defaults) 

1417 

1418 def clear_model_defaults(self) -> None: 

1419 """Reset per-model defaults to None.""" 

1420 object.__setattr__(self, "_model_defaults", None) 

1421 

1422 def generation_options(self, **overrides: Any) -> dict[str, Any]: 

1423 """Merge model defaults, user config, and per-call overrides, dropping None.""" 

1424 result = _model_defaults_dict(self._model_defaults) 

1425 # One name for the output cap inside lilbee; the provider translators rename it. 

1426 if "max_tokens" in result: 

1427 result["num_predict"] = result.pop("max_tokens") 

1428 user_fields: dict[str, Any] = { 

1429 "temperature": self.temperature, 

1430 "top_p": self.top_p, 

1431 "top_k": self.top_k_sampling, 

1432 "repeat_penalty": self.repeat_penalty, 

1433 "num_ctx": self.num_ctx, 

1434 "seed": self.seed, 

1435 "num_predict": self.max_tokens, 

1436 } 

1437 for k, v in user_fields.items(): 

1438 if v is not None: 

1439 result[k] = v 

1440 for k, v in overrides.items(): 

1441 if v is not None: 

1442 result[k] = v 

1443 return result 

1444 

1445 

1446def _model_defaults_dict(defaults: Any) -> dict[str, Any]: 

1447 """Non-None fields of a ModelDefaults instance as a dict.""" 

1448 if defaults is None: 

1449 return {} 

1450 from dataclasses import fields as dc_fields 

1451 

1452 return { 

1453 f.name: getattr(defaults, f.name) 

1454 for f in dc_fields(defaults) 

1455 if getattr(defaults, f.name) is not None 

1456 } 

1457 

1458 

1459class _PlainEnvSource: 

1460 """Reads LILBEE_* env vars as plain strings so field validators handle parsing.""" 

1461 

1462 def __init__( 

1463 self, 

1464 settings_cls: type[BaseSettings], 

1465 env_prefix: str, 

1466 env_ignore_empty: bool = True, 

1467 ) -> None: 

1468 self._prefix = env_prefix 

1469 self._ignore_empty = env_ignore_empty 

1470 self._fields = set(settings_cls.model_fields) 

1471 

1472 def __call__(self) -> dict[str, Any]: 

1473 result: dict[str, Any] = {} 

1474 for field_name in self._fields: 

1475 env_key = f"{self._prefix}{field_name.upper()}" 

1476 raw = os.environ.get(env_key) 

1477 if raw is None: 

1478 continue 

1479 if self._ignore_empty and raw == "": 

1480 continue 

1481 result[field_name] = raw 

1482 return result 

1483 

1484 

1485class _TomlSource: 

1486 """Custom pydantic-settings source that reads config.toml.""" 

1487 

1488 def __init__(self, settings_cls: type[BaseSettings], path: Path) -> None: 

1489 self._path = path 

1490 

1491 def __call__(self) -> dict[str, Any]: 

1492 import tomllib 

1493 

1494 try: 

1495 with self._path.open("rb") as f: 

1496 data = tomllib.load(f) 

1497 except (ValueError, OSError): 

1498 log.warning("Failed to read %s, ignoring", self._path) 

1499 return {} 

1500 # Empty strings represent "no persisted value" for nullable scalar 

1501 # fields (legacy from set_setting writing "" for None). Pydantic 

1502 # can't coerce "" to int|None, so dropping them here lets the field 

1503 # default apply rather than crashing the whole Config load. TOML's 

1504 # native types (lists, ints, bools) pass through untouched: stringifying 

1505 # turned a list field's ["a", "b"] into the literal "['a', 'b']". 

1506 return {k: v for k, v in data.items() if v != ""} 

1507 

1508 

1509def _build_cfg() -> tuple[Config, Exception | None]: 

1510 """Build cfg; on stale-config validation failure, fall back to defaults. 

1511 

1512 A persisted ``config.toml`` from before a breaking schema change can 

1513 contain values the new validators reject. Crashing at module import 

1514 means every command (``lilbee --help`` included) emits a Python 

1515 traceback. Falling back to env+defaults lets the package load; the 

1516 CLI / TUI surfaces the original error before doing real work. 

1517 """ 

1518 try: 

1519 return Config(), None 

1520 except Exception as exc: 

1521 os.environ["LILBEE_SKIP_TOML_CONFIG"] = "1" 

1522 try: 

1523 return Config(), exc 

1524 finally: 

1525 os.environ.pop("LILBEE_SKIP_TOML_CONFIG", None) 

1526 

1527 

1528cfg, config_load_error = _build_cfg() 

1529 

1530# Canonicalize LILBEE_DATA at the cfg.data_root resolution boundary so 

1531# spawn-context worker subprocesses inherit the same data root. 

1532# ``setdefault`` preserves a user-set value. 

1533os.environ.setdefault("LILBEE_DATA", str(cfg.data_root))