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

641 statements  

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

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

2 

3The settings sources and the TOML parser live here too. Every 

4``from lilbee.core.config import cfg`` resolves through ``lilbee.core.config.__init__`` 

5to the same instance defined at module bottom. 

6""" 

7 

8import logging 

9import os 

10import re 

11from collections.abc import Mapping 

12from enum import Enum 

13from pathlib import Path 

14from typing import Any, ClassVar 

15 

16from pydantic import Field, ValidationError, ValidationInfo, field_validator, model_validator 

17from pydantic_core import ErrorDetails 

18from pydantic_settings import BaseSettings, SettingsConfigDict 

19 

20from lilbee.core.system import scaled_chat_ctx_target_default 

21 

22from .defaults import ( 

23 CONFIG_FILE_NAME, 

24 DEFAULT_ALLOWED_NER_LABELS, 

25 DEFAULT_CORS_ORIGIN_REGEX, 

26 DEFAULT_CRAWL_EXCLUDE_PATTERNS, 

27 DEFAULT_GENERAL_SYSTEM_PROMPT, 

28 DEFAULT_IGNORE_DIRS, 

29 DEFAULT_RAG_SYSTEM_PROMPT, 

30 ENGINE_IDLE_TTL_MINUTES_DEFAULT, 

31 ENV_PREFIX, 

32 MAX_CRAWL_CONCURRENCY, 

33 MAX_CRAWL_DEPTH, 

34 MAX_CRAWL_PAGES, 

35 MAX_CRAWL_WAIT_SECONDS, 

36 exclude_pattern_lines, 

37) 

38from .enums import ( 

39 ChatMode, 

40 ClustererBackend, 

41 CrawlRenderMode, 

42 FtsLanguage, 

43 KvCacheType, 

44 LlmProvider, 

45 OcrMode, 

46 OcrPageStrategy, 

47 ProfileScope, 

48 ReasoningMode, 

49 RerankerType, 

50 TableModel, 

51 WikiEntityMode, 

52) 

53from .load_warnings import ( 

54 SHOWN_ENV_VAR, 

55 collecting, 

56 refuse_variable, 

57 shown_marks, 

58 warn_on_load, 

59) 

60from .login_host import refuse_unless_host 

61from .parsing import ( 

62 parse_bool, 

63 parse_gpu_device_list, 

64 parse_optional_int, 

65 parse_tristate_bool, 

66) 

67from .validators import ConfigField 

68 

69log = logging.getLogger(__name__) 

70 

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

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

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

74_UNSET_PATH = Path() 

75 

76# A Tesseract language code: ISO 639 letters plus script or orientation suffixes 

77# (eng, en, chi_sim, jpn_vert). xberg rejects anything else before extracting. 

78_TESSERACT_LANGUAGE_CODE = re.compile(r"[a-z]{2,3}(?:_[a-z]+)*") 

79 

80# A control character, which a crawl request header value refuses. 

81CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") 

82 

83# Model roles that can be off. An empty LILBEE_<FIELD> or config.toml value 

84# clears one of these; on every other field an empty value counts as unset. 

85CLEARABLE_MODEL_FIELDS = frozenset({"vision_model", "reranker_model"}) 

86 

87# What a refused value is not, by pydantic error type. An error outside this 

88# map carries its own text. 

89_EXPECTED_BY_ERROR: dict[str, str] = { 

90 "int_parsing": "a whole number", 

91 "int_from_float": "a whole number", 

92 "int_type": "a whole number", 

93 "float_parsing": "a number", 

94 "float_type": "a number", 

95 "bool_parsing": "true or false", 

96 "bool_type": "true or false", 

97 "string_type": "text", 

98 "path_type": "a path", 

99 "list_type": "a list", 

100 "dict_type": "a table of names and values", 

101 "greater_than_equal": "{ge} or more", 

102 "greater_than": "more than {gt}", 

103 "less_than_equal": "{le} or less", 

104 "less_than": "less than {lt}", 

105} 

106_VALUE_ERROR_PREFIX = "Value error, " 

107_USES_ITS_DEFAULT = "uses its default" 

108 

109# A variable its setting refuses stops the command and a write of one is refused, 

110# except on these: each takes its default, automatic or off, with a warning. 

111_TAKES_DEFAULT_WHEN_REFUSED = frozenset( 

112 {"flash_attention", "n_gpu_layers", "gpu_devices", "semantic_chunking"} 

113) 

114 

115# A key that is not a setting, by name, and what to use instead. A stored or 

116# exported one is refused out loud rather than silently ignored. 

117_NOT_SETTINGS: dict[str, str] = { 

118 # llama-server reads it only under split mode none, which no fleet launch uses. 

119 "main_gpu": "choose the cards with gpu_devices or placement", 

120} 

121 

122# A field no environment string parses into, by name, and what to use instead. 

123# Its variable is ignored with a warning, as the resolver ignores it. 

124_NOT_FROM_ENV: dict[str, str] = { 

125 "linked_roots": "register a folder with `lilbee add`, or edit linked_roots in config.toml", 

126} 

127 

128 

129def value_is_set(field_name: str, raw: object) -> bool: 

130 """Whether an env or config.toml value is set: not blank, or blank on a clearable model role.""" 

131 if raw is None: 

132 return False 

133 # A source hands over a string or any TOML type; only a string can be blank. 

134 blank = isinstance(raw, str) and not raw.strip() 

135 return not blank or field_name in CLEARABLE_MODEL_FIELDS 

136 

137 

138def _as_int(item: Any) -> int: 

139 """A force_ocr_pages entry as an int: an int, or a string of digits.""" 

140 if isinstance(item, str) and item.strip().lstrip("-").isdigit(): 

141 return int(item) 

142 # bool is an int subclass; True is not a page number. 

143 if isinstance(item, int) and not isinstance(item, bool): 

144 return item 

145 raise ValueError(f"force_ocr_pages: {item!r} is not a page number") 

146 

147 

148def _split_page_item(item: Any) -> list[Any]: 

149 """A string force_ocr_pages item split on commas and newlines; other items as-is.""" 

150 if isinstance(item, str): 

151 return item.replace("\n", ",").split(",") 

152 return [item] 

153 

154 

155def _page_number(item: Any) -> int: 

156 """One force_ocr_pages entry as a 1-indexed page.""" 

157 page = _as_int(item) 

158 if page < 1: 

159 raise ValueError(f"force_ocr_pages: page numbers start at 1 (got {page})") 

160 return page 

161 

162 

163class Config(BaseSettings): 

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

165 

166 model_config = SettingsConfigDict( 

167 env_prefix=ENV_PREFIX, 

168 validate_assignment=True, 

169 arbitrary_types_allowed=True, 

170 extra="ignore", 

171 ) 

172 

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

174 data_root: Path = Field( 

175 default=Path(), 

176 description=( 

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

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

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

180 ), 

181 ) 

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

183 # first boot; rebuild the index after migrating. 

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

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

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

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

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

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

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

191 default_factory=dict, 

192 writable=True, 

193 public=False, 

194 from_env=False, 

195 description=( 

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

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

198 ), 

199 ) 

200 data_dir: Path = Field( 

201 default=Path(), 

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

203 ) 

204 lancedb_dir: Path = Field( 

205 default=Path(), 

206 description=( 

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

208 ), 

209 ) 

210 models_dir: Path = Field( 

211 default=Path(), 

212 description=( 

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

214 "model pulled for one is available to all" 

215 ), 

216 ) 

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

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

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

220 

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

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

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

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

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

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

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

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

229 

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

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

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

233 

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

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

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

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

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

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

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

241 embedding_dim: int = Field( 

242 default=768, 

243 ge=1, 

244 description=( 

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

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

247 ), 

248 ) 

249 chunk_size: int = ConfigField( 

250 default=512, ge=64, writable=True, profile=ProfileScope.INGEST, reindex=True 

251 ) 

252 chunk_overlap: int = ConfigField( 

253 default=100, ge=0, writable=True, profile=ProfileScope.INGEST, reindex=True 

254 ) 

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

256 max_chunks_per_file: int = ConfigField( 

257 default=3_000, ge=0, writable=True, profile=ProfileScope.INGEST 

258 ) 

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

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

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

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

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

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

265 # the planning pass's threads). Each owns a GPU and its own slice of the 

266 # corpus, and writes that slice to the one index. 

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

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

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

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

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

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

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

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

275 embed_batch_sequences: int = ConfigField( 

276 default=64, 

277 ge=1, 

278 writable=True, 

279 description=( 

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

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

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

283 ), 

284 ) 

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

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

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

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

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

290 ingest_max_inflight: int = ConfigField( 

291 default=0, 

292 ge=0, 

293 writable=True, 

294 description=( 

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

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

297 "not the planning pass" 

298 ), 

299 ) 

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

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

302 max_embed_chars: int = Field( 

303 default=2000, 

304 ge=1, 

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

306 ) 

307 top_k: int = ConfigField(default=12, ge=1, writable=True, profile=ProfileScope.RETRIEVAL) 

308 max_distance: float = ConfigField( 

309 default=0.75, ge=0.0, writable=True, profile=ProfileScope.RETRIEVAL 

310 ) 

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

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

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

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

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

316 min_relevance_score: float = ConfigField( 

317 default=0.0, ge=0.0, writable=True, profile=ProfileScope.RETRIEVAL 

318 ) 

319 adaptive_threshold: bool = ConfigField( 

320 default=False, writable=True, profile=ProfileScope.RETRIEVAL 

321 ) 

322 rag_system_prompt: str = ConfigField( 

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

324 ) 

325 general_system_prompt: str = ConfigField( 

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

327 ) 

328 chat_mode: ChatMode = ConfigField(default=ChatMode.SEARCH, writable=True) 

329 ignore_dirs: frozenset[str] = Field( 

330 default=DEFAULT_IGNORE_DIRS, 

331 description=( 

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

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

334 ), 

335 ) 

336 # Which pages OCR reads; vision_model picks the engine (set: vision, empty: Tesseract). 

337 ocr: OcrMode = ConfigField(default=OcrMode.AUTO, writable=True, profile=ProfileScope.INGEST) 

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

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

340 # needs matching headroom here. 

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

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

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

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

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

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

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

348 # the vision request's repeat penalty stops a page looping one line, and the cap 

349 # bounds any loop that still escapes it. Raising it lengthens per-page 

350 # generation on dense scans, so give ocr_timeout matching headroom. 

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

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

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

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

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

356 

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

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

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

360 ocr_language: list[str] = ConfigField( 

361 default_factory=lambda: ["eng"], writable=True, profile=ProfileScope.INGEST 

362 ) 

363 # PDF pages xberg OCRs. auto = pages whose native text fails its quality 

364 # check; scanned_pages also OCRs every page graded as a scan. 

365 ocr_strategy: OcrPageStrategy = ConfigField( 

366 default=OcrPageStrategy.AUTO, writable=True, profile=ProfileScope.INGEST 

367 ) 

368 # Scan-grade threshold for scanned_pages. A slide with a full-bleed 

369 # background image grades 0.5, so lower this to OCR such slides too. 

370 ocr_scan_confidence: float = ConfigField( 

371 default=0.7, ge=0.0, le=1.0, writable=True, profile=ProfileScope.INGEST 

372 ) 

373 # 1-indexed pages that lilbee OCRs in every PDF; while set, it replaces the 

374 # ocr_strategy page selection. Env form: LILBEE_FORCE_OCR_PAGES="1,3". 

375 force_ocr_pages: list[int] = ConfigField( 

376 default_factory=list, writable=True, profile=ProfileScope.INGEST 

377 ) 

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

379 entity_extraction: bool = ConfigField(default=False, writable=True, profile=ProfileScope.INGEST) 

380 semantic_chunking: bool = ConfigField( 

381 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True 

382 ) 

383 # Boundary-similarity threshold; only applied when semantic_chunking is on. 

384 topic_threshold: float = ConfigField( 

385 default=0.75, ge=0.0, le=1.0, writable=True, profile=ProfileScope.INGEST, reindex=True 

386 ) 

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

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

389 token_sizing: bool = ConfigField( 

390 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True 

391 ) 

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

393 table_extraction: bool = ConfigField( 

394 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True 

395 ) 

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

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

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

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

400 layout_detection: bool = ConfigField( 

401 default=False, writable=True, profile=ProfileScope.INGEST, reindex=True 

402 ) 

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

404 table_model: TableModel = ConfigField( 

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

406 ) 

407 # Wall-clock cap per file for one xberg extraction, seconds. 0 = no cap. 

408 # xberg's own default is 600s and applies on its batch path, so leaving this 

409 # unset drops a slow file at ten minutes with no lilbee knob to raise it. 

410 # Zero matches the uncapped single-file path, and ingest is background work. 

411 extraction_timeout: int = ConfigField(default=0, ge=0, writable=True) 

412 # Coalesce concurrent extractions into one xberg extract_batch call. 

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

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

415 # Files analyze extracts; a larger corpus is sampled evenly down to this many. 

416 analyze_max_files: int = ConfigField(default=500, ge=1, writable=True) 

417 # xberg's shared thread budget: PDF rendering, OCR and ONNX inference. It 

418 # also bounds concurrent Tesseract sessions, which xberg further limits to 

419 # what free memory holds. 0 = auto, runtime.cpu.cpu_quota() (half the usable 

420 # CPUs). The rayon pool is fixed at the first extraction, so a change takes 

421 # full effect after a restart. 

422 extraction_threads: int = ConfigField(default=0, ge=0, writable=True, derived=True) 

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

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

425 # serves before their calls queue. 

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

427 server_host: str = Field( 

428 default="127.0.0.1", 

429 description=( 

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

431 "connections from the network" 

432 ), 

433 ) 

434 server_port: int = Field( 

435 default=0, 

436 ge=0, 

437 le=65535, 

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

439 ) 

440 cors_origins: list[str] = Field( 

441 default_factory=list, 

442 description=( 

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

444 ), 

445 ) 

446 cors_origin_regex: str = Field( 

447 default=DEFAULT_CORS_ORIGIN_REGEX, 

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

449 ) 

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

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

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

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

454 json_mode: bool = Field( 

455 default=False, 

456 description=( 

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

458 ), 

459 ) 

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

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

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

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

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

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

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

467 # None sends no cap: the engine stops at the served context, which the fleet 

468 # already fits the prompt to, so a long reasoning answer is not cut at a fixed number. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

488 

489 # Retrieval quality knobs. 

490 

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

492 diversity_max_per_source: int = ConfigField( 

493 default=5, ge=1, writable=True, profile=ProfileScope.RETRIEVAL 

494 ) 

495 

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

497 # (Carbonell & Goldstein 1998). 

498 mmr_lambda: float = ConfigField( 

499 default=0.5, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL 

500 ) 

501 

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

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

504 # fusion arms stay exactly top_k deep. 

505 candidate_multiplier: int = ConfigField( 

506 default=3, ge=1, writable=True, profile=ProfileScope.RETRIEVAL 

507 ) 

508 

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

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

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

512 title_search: bool = ConfigField(default=False, writable=True, profile=ProfileScope.RETRIEVAL) 

513 

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

515 # with the vector and chunk arms). 

516 title_search_weight: float = ConfigField( 

517 default=0.5, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL 

518 ) 

519 

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

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

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

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

524 # the retrieval benchmark, not guessed here. 

525 lexical_fusion_weight: float = ConfigField( 

526 default=1.0, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL 

527 ) 

528 

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

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

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

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

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

534 adaptive_fusion: bool = ConfigField( 

535 default=False, writable=True, profile=ProfileScope.RETRIEVAL 

536 ) 

537 

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

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

540 # arm keeps its full fixed weight). 

541 adaptive_fusion_margin: float = ConfigField( 

542 default=0.15, ge=0.0, le=2.0, writable=True, profile=ProfileScope.RETRIEVAL 

543 ) 

544 

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

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

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

548 # A bad name would otherwise fail index creation quietly and hybrid search 

549 # would degrade to vector-only. 

550 fts_language: FtsLanguage = ConfigField( 

551 default=FtsLanguage.ENGLISH, writable=True, profile=ProfileScope.RETRIEVAL, reindex=True 

552 ) 

553 

554 @field_validator("fts_language", mode="before") 

555 @classmethod 

556 def _validate_fts_language(cls, value: Any) -> FtsLanguage: 

557 """Accept a language name in any casing, with surrounding whitespace.""" 

558 try: 

559 return FtsLanguage(str(value).strip().title()) 

560 except ValueError as exc: 

561 valid = ", ".join(member.value for member in FtsLanguage) 

562 raise ValueError(f"fts_language must be one of: {valid}") from exc 

563 

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

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

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

567 embed_titles: bool = ConfigField( 

568 default=False, writable=True, profile=ProfileScope.RETRIEVAL, reindex=True 

569 ) 

570 

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

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

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

574 # Toggling needs `lilbee rebuild`. 

575 contextual_enrichment: bool = ConfigField( 

576 default=False, writable=True, profile=ProfileScope.RETRIEVAL, reindex=True 

577 ) 

578 

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

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

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

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

583 # chunks the query did not hit. 

584 filter_structural_chunks: bool = ConfigField( 

585 default=False, writable=True, profile=ProfileScope.RETRIEVAL 

586 ) 

587 

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

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

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

591 ann_index_threshold: int = ConfigField( 

592 default=50_000, ge=0, writable=True, profile=ProfileScope.RETRIEVAL 

593 ) 

594 

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

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

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

598 # with its pronouns. 

599 history_rewrite: bool = ConfigField( 

600 default=False, writable=True, profile=ProfileScope.RETRIEVAL 

601 ) 

602 

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

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

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

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

607 intent_routing: bool = ConfigField(default=True, writable=True, profile=ProfileScope.RETRIEVAL) 

608 

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

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

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

612 intent_llm: bool = ConfigField(default=False, writable=True, profile=ProfileScope.RETRIEVAL) 

613 

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

615 query_expansion_count: int = ConfigField( 

616 default=3, ge=0, writable=True, profile=ProfileScope.RETRIEVAL 

617 ) 

618 

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

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

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

622 expansion_short_query_tokens: int = ConfigField( 

623 default=2, ge=0, writable=True, profile=ProfileScope.RETRIEVAL 

624 ) 

625 

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

627 adaptive_threshold_step: float = ConfigField( 

628 default=0.2, gt=0.0, writable=True, profile=ProfileScope.RETRIEVAL 

629 ) 

630 

631 # Reject expansion variants below expansion_similarity_threshold. 

632 expansion_guardrails: bool = ConfigField( 

633 default=True, writable=True, profile=ProfileScope.RETRIEVAL 

634 ) 

635 

636 # Min cosine similarity between question and variant embeddings. 

637 expansion_similarity_threshold: float = ConfigField( 

638 default=0.5, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL 

639 ) 

640 

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

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

643 expansion_skip_threshold: float = Field( 

644 default=0.8, 

645 ge=0.0, 

646 le=1.0, 

647 description=( 

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

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

650 ), 

651 ) 

652 

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

654 expansion_skip_gap: float = Field( 

655 default=0.15, 

656 ge=0.0, 

657 le=1.0, 

658 description=( 

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

660 ), 

661 ) 

662 

663 # Chunks included in LLM context after adaptive selection. 

664 max_context_sources: int = ConfigField( 

665 default=8, ge=1, writable=True, profile=ProfileScope.RETRIEVAL 

666 ) 

667 

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

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

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

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

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

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

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

675 neighbor_expansion: int = ConfigField( 

676 default=0, ge=0, le=100, writable=True, profile=ProfileScope.RETRIEVAL 

677 ) 

678 

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

680 hyde: bool = ConfigField(default=False, writable=True, profile=ProfileScope.RETRIEVAL) 

681 

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

683 hyde_weight: float = ConfigField( 

684 default=0.7, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL 

685 ) 

686 

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

688 hyde_prompt: str = Field( 

689 default=( 

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

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

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

693 ), 

694 description=( 

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

696 ), 

697 ) 

698 

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

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

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

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

703 

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

705 reranker_type: RerankerType = ConfigField( 

706 default=RerankerType.AUTO, writable=True, public=True, derived=True 

707 ) 

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

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

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

711 

712 # Recommend safety-stripped models in Picks and Discover. Off keeps them 

713 # in browse and search only; on restores them to the recommendations. 

714 include_uncensored: bool = ConfigField(default=False, writable=True) 

715 

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

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

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

719 

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

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

722 

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

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

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

726 

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

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

729 

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

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

732 

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

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

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

736 

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

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

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

740 

741 # Candidate count sent to the reranker. 

742 rerank_candidates: int = ConfigField( 

743 default=60, ge=1, writable=True, profile=ProfileScope.RETRIEVAL, public=True 

744 ) 

745 

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

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

748 # the reranker's effect when measuring it. 

749 rerank_blend: bool = ConfigField( 

750 default=True, writable=True, profile=ProfileScope.RETRIEVAL, public=True 

751 ) 

752 

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

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

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

756 rerank_min_score: float | None = ConfigField( 

757 default=None, writable=True, profile=ProfileScope.RETRIEVAL, public=True 

758 ) 

759 

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

761 temporal_filtering: bool = ConfigField( 

762 default=True, writable=True, profile=ProfileScope.RETRIEVAL 

763 ) 

764 

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

766 # if False, strip it silently. 

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

768 

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

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

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

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

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

774 completions_reasoning: ReasoningMode = ConfigField( 

775 default=ReasoningMode.SEPARATE, writable=True 

776 ) 

777 

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

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

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

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

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

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

784 

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

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

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

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

789 

790 # Web crawling. 

791 

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

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

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

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

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

797 

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

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

800 crawl_browser_extra_args: list[str] = ConfigField( 

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

802 writable=True, 

803 ) 

804 

805 # Milliseconds a browser-mode crawl waits after a page has loaded and stopped changing, 

806 # for text a script writes later. 0 = no extra wait, at most ten minutes. HTTP mode ignores it. 

807 crawl_browser_extra_wait_ms: int = ConfigField(default=0, ge=0, le=600_000, writable=True) 

808 

809 # The user agent of every crawl request. Blank = no override: a browser crawl 

810 # sends Chrome's own, an HTTP crawl crawlberg's, the sitemap lookup httpx's. 

811 crawl_user_agent: str = ConfigField(default="", writable=True) 

812 

813 # The login every crawl of crawl_login_host sends; a crawl of any other host gets none. 

814 # CrawlLogin (crawler.models) validates the fields together. 

815 crawl_login_host: str = ConfigField(default="", writable=True) 

816 crawl_authorization: str = ConfigField(default="", writable=True, write_only=True) 

817 crawl_username: str = ConfigField(default="", writable=True) 

818 crawl_password: str = ConfigField(default="", writable=True, write_only=True) 

819 crawl_cookie: str = ConfigField(default="", writable=True, write_only=True) 

820 

821 # Whether crawls skip the pages a site's robots.txt disallows, and every page of a 

822 # site whose robots.txt cannot be read. 

823 crawl_respect_robots_txt: bool = ConfigField(default=True, writable=True) 

824 

825 # Optional global ceilings. None = no ceiling. The bounds keep the engine from getting 

826 # a number it cannot hold. 

827 crawl_max_depth: int | None = ConfigField(default=None, ge=0, le=MAX_CRAWL_DEPTH, writable=True) 

828 crawl_max_pages: int | None = ConfigField(default=None, ge=1, le=MAX_CRAWL_PAGES, writable=True) 

829 

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

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

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

833 crawl_safety_max_pages: int = ConfigField( 

834 default=5_000, ge=1, le=MAX_CRAWL_PAGES, writable=True 

835 ) 

836 

837 # Per-URL fetch timeout, seconds. 

838 crawl_timeout: int = ConfigField(default=30, ge=1, le=3600, writable=True) 

839 

840 # 0 = unlimited, default = CPU count. 

841 crawl_max_concurrent: int = Field( 

842 default=0, 

843 ge=0, 

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

845 ) 

846 

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

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

849 

850 # Delay + jitter each concurrent request waits, seconds, at most one hour. 

851 crawl_mean_delay: float = ConfigField( 

852 default=0.5, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True 

853 ) 

854 crawl_max_delay_range: float = ConfigField( 

855 default=0.5, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True 

856 ) 

857 

858 # In-flight requests per crawl, at most 256. 

859 crawl_concurrent_requests: int = ConfigField( 

860 default=3, ge=1, le=MAX_CRAWL_CONCURRENCY, writable=True 

861 ) 

862 

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

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

865 # Retry delays in seconds, at most one hour each. 

866 crawl_retry_base_delay_min: float = ConfigField( 

867 default=1.0, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True 

868 ) 

869 crawl_retry_base_delay_max: float = ConfigField( 

870 default=3.0, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True 

871 ) 

872 crawl_retry_max_backoff: float = ConfigField( 

873 default=30.0, ge=0.0, le=MAX_CRAWL_WAIT_SECONDS, writable=True 

874 ) 

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

876 

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

878 # scaffolding (WordPress admin, archives, feeds, etc.). 

879 crawl_exclude_patterns: list[str] = ConfigField( 

880 default_factory=lambda: list(DEFAULT_CRAWL_EXCLUDE_PATTERNS), 

881 writable=True, 

882 ) 

883 

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

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

886 

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

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

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

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

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

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

893 

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

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

896 # reserve however this is set. 

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

898 

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

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

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

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

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

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

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

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

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

908 

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

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

911 

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

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

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

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

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

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

918 

919 # On: weights stay loaded while any lilbee process holds the engine, and 

920 # unload engine_idle_ttl_minutes after the last one exits; the engine process 

921 # stays up so the next launch binds instantly. Off (default): the engine 

922 # stops when the last lilbee process exits. A change applies at the next 

923 # engine start. 

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

925 

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

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

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

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

930 

931 # Idle minutes before the engine unloads its weights: llama-swap's ttl, or 

932 # with keep_engine_warm the time after the last lilbee exit. 0 keeps weights 

933 # loaded until the engine stops. 

934 engine_idle_ttl_minutes: int = ConfigField( 

935 default=ENGINE_IDLE_TTL_MINUTES_DEFAULT, writable=True 

936 ) 

937 

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

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

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

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

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

943 # host headroom. 

944 chat_n_ctx_target: int = ConfigField( 

945 default_factory=scaled_chat_ctx_target_default, 

946 ge=512, 

947 writable=True, 

948 derived=True, 

949 ) 

950 

951 # Condense turns that outgrow chat_n_ctx_target into carried notes instead 

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

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

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

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

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

957 

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

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

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

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

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

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

964 

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

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

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

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

969 

970 # The profile tools over MCP, off by default for the same schema cost. 

971 mcp_profiles_enabled: bool = ConfigField(default=False, writable=True) 

972 

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

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

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

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

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

978 

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

980 # for backends or models where it misbehaves. 

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

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

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

984 

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

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

987 # small quality cost. Both apply to K and V together, and only where 

988 # lilbee sends flash attention on. On Vulkan and SYCL it sends auto even 

989 # when flash_attention is true, so the cache is f16 there. 

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

991 

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

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

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

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

996 

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

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

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

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

1001 

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

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

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

1005 

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

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

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

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

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

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

1012 # 

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

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

1015 # ``GGML_VK_VISIBLE_DEVICES`` for Vulkan, ``CUDA_VISIBLE_DEVICES`` 

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

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

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

1019 # 

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

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

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

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

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

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

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

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

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

1029 

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

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

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

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

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

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

1036 placement: str | None = ConfigField( 

1037 default=None, 

1038 writable=True, 

1039 public=False, 

1040 derived=True, 

1041 description=( 

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

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

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

1045 ), 

1046 ) 

1047 

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

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

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

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

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

1053 allow_http_placement: bool = Field( 

1054 default=False, 

1055 description=( 

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

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

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

1059 ), 

1060 ) 

1061 

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

1063 markdown_rendering: bool = Field( 

1064 default=True, 

1065 description=( 

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

1067 ), 

1068 ) 

1069 

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

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

1072 

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

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

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

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

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

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

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

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

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

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

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

1084 wiki_dir: str = "wiki" 

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

1086 

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

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

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

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

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

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

1093 # default 0.5 produces false drafts. 

1094 wiki_embedding_faithfulness_threshold: float = ConfigField( 

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

1096 ) 

1097 

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

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

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

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

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

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

1104 

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

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

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

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

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

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

1111 

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

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

1114 

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

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

1117 

1118 # LLM prompt templates for wiki page generation: wiki_synthesis_prompt 

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

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

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

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

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

1124 wiki_synthesis_prompt: str = ConfigField( 

1125 writable=True, 

1126 default=( 

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

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

1129 "ideas across sources.\n\n" 

1130 "Rules:\n" 

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

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

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

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

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

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

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

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

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

1140 "---\n" 

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

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

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

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

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

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

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

1148 ), 

1149 ) 

1150 

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

1152 # and falls back to EMBEDDING when unavailable. 

1153 wiki_clusterer: ClustererBackend = ConfigField( 

1154 default=ClustererBackend.EMBEDDING, writable=True 

1155 ) 

1156 

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

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

1159 

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

1161 concept_graph: bool = ConfigField(default=True, writable=True, profile=ProfileScope.RETRIEVAL) 

1162 

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

1164 concept_boost_weight: float = ConfigField( 

1165 default=0.3, ge=0.0, le=1.0, writable=True, profile=ProfileScope.RETRIEVAL 

1166 ) 

1167 

1168 # Max noun-phrase concepts extracted per chunk. 

1169 concept_max_per_chunk: int = ConfigField( 

1170 default=5, ge=1, writable=True, profile=ProfileScope.RETRIEVAL 

1171 ) 

1172 

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

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

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

1176 # LILBEE_CONCEPT_ALLOWED_ENT_TYPES as a comma-separated list. 

1177 concept_allowed_ent_types: frozenset[str] = Field( 

1178 default=DEFAULT_ALLOWED_NER_LABELS, 

1179 description=( 

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

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

1182 ), 

1183 ) 

1184 

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

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

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

1188 # not by the extractor. NER_CONCEPTS_PLUS_LLM_TYPES layers an 

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

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

1191 # NER_ENTITIES. 

1192 wiki_entity_mode: WikiEntityMode = ConfigField( 

1193 default=WikiEntityMode.NER_ENTITIES, writable=True 

1194 ) 

1195 

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

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

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

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

1200 

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

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

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

1204 # bulk import from firing hundreds of LLM calls. 

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

1206 

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

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

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

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

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

1212 

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

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

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

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

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

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

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

1220 

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

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

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

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

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

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

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

1228 wiki_entity_page_prompt: str = ConfigField( 

1229 writable=True, 

1230 default=( 

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

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

1233 "Rules:\n" 

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

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

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

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

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

1239 "[*inference*].\n" 

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

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

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

1243 "---\n" 

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

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

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

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

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

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

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

1251 ), 

1252 ) 

1253 wiki_entity_batch_prompt: str = ConfigField( 

1254 writable=True, 

1255 default=( 

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

1257 "{concept_instruction}" 

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

1259 "Format each section exactly as:\n" 

1260 "## Name\n" 

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

1262 "Rules:\n" 

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

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

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

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

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

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

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

1270 "---\n" 

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

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

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

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

1275 ), 

1276 ) 

1277 

1278 # Class variable: not a settings field 

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

1280 

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

1282 @classmethod 

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

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

1285 return value.strip() 

1286 

1287 @field_validator( 

1288 "temperature", 

1289 "top_p", 

1290 "repeat_penalty", 

1291 "top_k_sampling", 

1292 "num_ctx", 

1293 "seed", 

1294 mode="before", 

1295 ) 

1296 @classmethod 

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

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

1299 return None 

1300 return v 

1301 

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

1303 @classmethod 

1304 def _normalize_chat_mode(cls, v: Any) -> ChatMode: 

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

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

1307 return ChatMode.SEARCH 

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

1309 try: 

1310 return ChatMode(candidate) 

1311 except ValueError as exc: 

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

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

1314 

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

1316 @classmethod 

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

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

1319 

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

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

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

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

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

1325 English, since xberg errors on an empty list. 

1326 """ 

1327 if isinstance(v, str): 

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

1329 items = v or [] 

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

1331 for lang in langs: 

1332 if not _TESSERACT_LANGUAGE_CODE.fullmatch(lang): 

1333 raise ValueError( 

1334 f"ocr_language: {lang!r} is not a Tesseract language code " 

1335 "(examples: eng, deu, chi_sim, jpn_vert)" 

1336 ) 

1337 return langs or ["eng"] 

1338 

1339 @field_validator("force_ocr_pages", mode="before") 

1340 @classmethod 

1341 def _parse_force_ocr_pages(cls, v: Any) -> list[int]: 

1342 """Accept a list, a string or one int; split string items on commas and newlines. 

1343 

1344 Newlines are accepted because ``app.settings`` joins list values with 

1345 ``\\n`` when it persists them to config.toml. Returns sorted unique pages. 

1346 """ 

1347 items = v if isinstance(v, list) else [v] 

1348 parts = [p for item in items for p in _split_page_item(item)] 

1349 return sorted({_page_number(part) for part in parts if str(part).strip()}) 

1350 

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

1352 @classmethod 

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

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

1355 if v is None: 

1356 return None 

1357 if isinstance(v, bool): 

1358 return v 

1359 if isinstance(v, str): 

1360 try: 

1361 return parse_tristate_bool(v) 

1362 except ValueError: 

1363 raise ValueError("use true, false or auto") from None 

1364 return bool(v) 

1365 

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

1367 @classmethod 

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

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

1370 if v is None: 

1371 return None 

1372 if isinstance(v, str): 

1373 try: 

1374 return parse_optional_int(v, aliases={"cpu": 0}) 

1375 except ValueError: 

1376 raise ValueError("use a whole number, cpu or auto") from None 

1377 return int(v) 

1378 

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

1380 @classmethod 

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

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

1383 if v is None: 

1384 return None 

1385 if isinstance(v, str): 

1386 try: 

1387 return parse_gpu_device_list(v) 

1388 except ValueError: 

1389 raise ValueError("use GPU indexes separated by commas, or auto") from None 

1390 return str(v) 

1391 

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

1393 @classmethod 

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

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

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

1397 

1398 if v is None: 

1399 return None 

1400 if isinstance(v, PlacementSpec): 

1401 json_str = v.to_json() 

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

1403 return json_str 

1404 if isinstance(v, str): 

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

1406 return None 

1407 PlacementSpec.from_json(v) 

1408 return v 

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

1410 

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

1412 @classmethod 

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

1414 """A bool as it stands, a string through parse_bool.""" 

1415 if isinstance(v, bool): 

1416 return v 

1417 if isinstance(v, str): 

1418 try: 

1419 return parse_bool(v) 

1420 except ValueError: 

1421 raise ValueError("use true or false") from None 

1422 return bool(v) 

1423 

1424 @field_validator( 

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

1426 ) 

1427 @classmethod 

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

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

1430 return canonical_model_ref(v) 

1431 

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

1433 @classmethod 

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

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

1436 return v.rstrip("/") 

1437 

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

1439 @classmethod 

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

1441 if isinstance(v, str): 

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

1443 return v 

1444 

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

1446 @classmethod 

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

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

1449 

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

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

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

1453 TOML lists and JSON arrays pass through unchanged. 

1454 """ 

1455 if isinstance(v, str): 

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

1457 return v 

1458 

1459 @field_validator("crawl_user_agent", mode="after") 

1460 @classmethod 

1461 def _validate_crawl_user_agent(cls, v: str) -> str: 

1462 """The user agent without surrounding whitespace; a control character is refused.""" 

1463 user_agent = v.strip() 

1464 if CONTROL_CHARACTER.search(user_agent): 

1465 raise ValueError("crawl_user_agent holds a control character") 

1466 return user_agent 

1467 

1468 @field_validator("crawl_login_host", mode="after") 

1469 @classmethod 

1470 def _validate_crawl_login_host(cls, v: str) -> str: 

1471 """The login host as a bare host name; a URL is refused, as in the settings write.""" 

1472 refuse_unless_host(v) 

1473 return v 

1474 

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

1476 @classmethod 

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

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

1479 

1480 TOML lists and JSON arrays pass through unchanged. Loading does not ask crawlberg to 

1481 compile the patterns: that loads its native library at every start. A settings write 

1482 checks them, and a crawl refuses a bad one when it starts. 

1483 """ 

1484 return exclude_pattern_lines(v) 

1485 

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

1487 @classmethod 

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

1489 if isinstance(v, str): 

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

1491 return DEFAULT_IGNORE_DIRS | extra 

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

1493 return DEFAULT_IGNORE_DIRS | frozenset(v) 

1494 return DEFAULT_IGNORE_DIRS 

1495 

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

1497 @classmethod 

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

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

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

1501 wants exactly those kinds. Accepts comma-separated strings 

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

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

1504 env var does not silently disable the gate. 

1505 """ 

1506 if isinstance(v, str): 

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

1508 return parts or DEFAULT_ALLOWED_NER_LABELS 

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

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

1511 return parts or DEFAULT_ALLOWED_NER_LABELS 

1512 return DEFAULT_ALLOWED_NER_LABELS 

1513 

1514 @model_validator(mode="before") 

1515 @classmethod 

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

1517 from lilbee.core.system import ( 

1518 canonical_data_root, 

1519 canonical_models_dir, 

1520 default_data_dir, 

1521 find_local_root, 

1522 ) 

1523 

1524 if not isinstance(data, dict): 

1525 return data 

1526 

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

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

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

1530 data["data_root"] = None 

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

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

1533 if data_env: 

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

1535 else: 

1536 local = find_local_root() 

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

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

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

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

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

1542 data["data_root"] = root 

1543 for key, folder in root_folders(root).items(): 

1544 if data.get(key) in (None, _UNSET_PATH): 

1545 data[key] = folder 

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

1547 data["models_dir"] = canonical_models_dir() 

1548 

1549 return data 

1550 

1551 @classmethod 

1552 def settings_customise_sources( 

1553 cls, 

1554 settings_cls: type[BaseSettings], 

1555 init_settings: Any, 

1556 env_settings: Any, 

1557 dotenv_settings: Any, 

1558 file_secret_settings: Any, 

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

1560 plain_env = _PlainEnvSource(settings_cls) 

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

1562 toml_path = _config_file() 

1563 if toml_path is not None: 

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

1565 sources.append(_ProfileSource(settings_cls, toml_path)) 

1566 return tuple(sources) 

1567 

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

1569 """Merge the user config with per-call overrides, dropping None.""" 

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

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

1572 user_fields: dict[str, Any] = { 

1573 "temperature": self.temperature, 

1574 "top_p": self.top_p, 

1575 "top_k": self.top_k_sampling, 

1576 "repeat_penalty": self.repeat_penalty, 

1577 "num_ctx": self.num_ctx, 

1578 "seed": self.seed, 

1579 "num_predict": self.max_tokens, 

1580 } 

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

1582 if v is not None: 

1583 result[k] = v 

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

1585 if v is not None: 

1586 result[k] = v 

1587 return result 

1588 

1589 

1590def _config_file() -> Path | None: 

1591 """The config.toml a load reads, or None when there is none or the load skips it.""" 

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

1593 

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

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

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

1597 if data_env: 

1598 toml_dir = Path(data_env) 

1599 else: 

1600 local = find_local_root() 

1601 toml_dir = local if local else default_data_dir() 

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

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

1604 toml_path = canonical_data_root(toml_dir) / CONFIG_FILE_NAME 

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

1606 return toml_path 

1607 return None 

1608 

1609 

1610def _enum_of(settings_cls: type[BaseSettings], key: str) -> type[Enum] | None: 

1611 """The enum the field *key* is typed as, or None for any other type.""" 

1612 annotation = settings_cls.model_fields[key].annotation 

1613 # An annotation is a class, a union or a generic alias; only a class can be an enum. 

1614 if isinstance(annotation, type) and issubclass(annotation, Enum): 

1615 return annotation 

1616 return None 

1617 

1618 

1619def _variable(settings_cls: type[BaseSettings], field_name: str) -> str: 

1620 """The environment variable that sets *field_name*.""" 

1621 return f"{settings_cls.model_config.get('env_prefix', '')}{field_name.upper()}" 

1622 

1623 

1624def _not_a_setting(name: str, key: str) -> str: 

1625 """The warning for *name*, which sets *key* although *key* is not a setting.""" 

1626 return f"{name} is not a setting and is ignored; {(_NOT_SETTINGS | _NOT_FROM_ENV)[key]}" 

1627 

1628 

1629def _expected(error: ErrorDetails) -> str: 

1630 """What one validation error says about the value, in the words of a settings file.""" 

1631 words = _EXPECTED_BY_ERROR.get(error["type"]) 

1632 if words is None: 

1633 return f"is refused ({error['msg'].removeprefix(_VALUE_ERROR_PREFIX)})" 

1634 return "is not " + words.format(**error.get("ctx", {})) 

1635 

1636 

1637def _refusal(probe: BaseSettings, key: str, value: Any) -> str | None: 

1638 """Why the field's own validators refuse *value* on the throwaway *probe*, or None.""" 

1639 settings_cls = type(probe) 

1640 try: 

1641 settings_cls.__pydantic_validator__.validate_assignment(probe, key, value) 

1642 except ValidationError as exc: 

1643 enum = _enum_of(settings_cls, key) 

1644 if enum is not None: 

1645 return "is not one of " + ", ".join(str(member.value) for member in enum) 

1646 return _expected(next(iter(exc.errors()))) 

1647 except TypeError: 

1648 # A validator handed a TOML type it has no branch for. 

1649 return f"is not a value {key} accepts" 

1650 return None 

1651 

1652 

1653def _refusals(settings_cls: type[BaseSettings], values: dict[str, Any]) -> dict[str, str]: 

1654 """Why its own field refuses each of *values* that one refuses, by key.""" 

1655 known = [key for key in values if key in settings_cls.model_fields] 

1656 if not known: 

1657 return {} 

1658 probe = settings_cls.model_construct() 

1659 reasons = {key: _refusal(probe, key, values[key]) for key in known} 

1660 return {key: reason for key, reason in reasons.items() if reason is not None} 

1661 

1662 

1663class _PlainEnvSource: 

1664 """Reads LILBEE_* env vars as plain strings; a refused one stops the load or falls back.""" 

1665 

1666 def __init__(self, settings_cls: type[BaseSettings]) -> None: 

1667 self._settings_cls = settings_cls 

1668 

1669 def _read(self) -> tuple[dict[str, Any], dict[str, str]]: 

1670 """Each set variable's string by field, and why its setting refuses the ones it does.""" 

1671 values: dict[str, Any] = {} 

1672 for field_name in self._settings_cls.model_fields: 

1673 if field_name in _NOT_FROM_ENV: 

1674 continue 

1675 raw = os.environ.get(_variable(self._settings_cls, field_name)) 

1676 if value_is_set(field_name, raw): 

1677 values[field_name] = raw 

1678 return values, _refusals(self._settings_cls, values) 

1679 

1680 def _stop(self, values: dict[str, Any], refused: dict[str, str]) -> None: 

1681 """Refuse the variables in *refused*, less the ones that take their default.""" 

1682 lines = [ 

1683 f"{_variable(self._settings_cls, key)} = {values[key]!r} {reason}" 

1684 for key, reason in refused.items() 

1685 if key not in _TAKES_DEFAULT_WHEN_REFUSED 

1686 ] 

1687 if lines: 

1688 refuse_variable("; ".join(lines)) 

1689 

1690 def refuse(self) -> None: 

1691 """Raise RefusedVariableError for the variables that stop a command.""" 

1692 self._stop(*self._read()) 

1693 

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

1695 values, refused = self._read() 

1696 self._stop(values, refused) 

1697 for key in [key for key in refused if key in _TAKES_DEFAULT_WHEN_REFUSED]: 

1698 variable = _variable(self._settings_cls, key) 

1699 warn_on_load(f"{variable} = {values[key]!r} {refused[key]}; {key} uses its default") 

1700 values[key] = self._settings_cls.model_fields[key].default 

1701 del refused[key] 

1702 for key in (*_NOT_SETTINGS, *_NOT_FROM_ENV): 

1703 variable = _variable(self._settings_cls, key) 

1704 if os.environ.get(variable, "").strip(): 

1705 warn_on_load(_not_a_setting(variable, key)) 

1706 return {key: value for key, value in values.items() if key not in refused} 

1707 

1708 

1709class _TomlSource: 

1710 """Reads config.toml as a settings source; *label* names the file in a warning. 

1711 

1712 With *only*, it reads that one key and no other. It reports nothing in 

1713 *reported*, the warnings of the load that built cfg from this file, and 

1714 with *per_change* it reports the rest once per change of the file. 

1715 """ 

1716 

1717 def __init__( 

1718 self, 

1719 settings_cls: type[BaseSettings], 

1720 path: Path, 

1721 label: str = CONFIG_FILE_NAME, 

1722 otherwise: str = _USES_ITS_DEFAULT, 

1723 reported: tuple[str, ...] = (), 

1724 only: str | None = None, 

1725 per_change: bool = False, 

1726 ) -> None: 

1727 self._settings_cls = settings_cls 

1728 self._path = path 

1729 self._label = label 

1730 self._otherwise = otherwise 

1731 self._reported = reported 

1732 self._only = only 

1733 self._per_change = per_change 

1734 

1735 def _file_version(self) -> str: 

1736 """What changes when the file does: its size and the time it was last written.""" 

1737 try: 

1738 stat = self._path.stat() 

1739 except OSError: 

1740 return "" 

1741 return f"{stat.st_mtime_ns}:{stat.st_size}" 

1742 

1743 def _warn(self, message: str) -> None: 

1744 """Report *message* once, and with *per_change* once more for each change of the file.""" 

1745 warn_on_load(message, self._file_version() if self._per_change else "") 

1746 

1747 def _line( 

1748 self, label: str, otherwise: str, refused: tuple[str, str], values: dict[str, Any] 

1749 ) -> str: 

1750 """The warning for one *refused* key and reason, as a read under *label* words it.""" 

1751 key, reason = refused 

1752 fallback = f"{key} {otherwise}" 

1753 if env_value(key) is not None: 

1754 fallback = f"{_variable(self._settings_cls, key)} sets {key}" 

1755 return f"{label}: {key} = {values[key]!r} {reason}; {fallback}" 

1756 

1757 def _refuse_not_settings(self, data: dict[str, Any]) -> None: 

1758 """Report each key in *data* that is not a setting, as a refused value is reported.""" 

1759 for key in _NOT_SETTINGS.keys() & data.keys(): 

1760 if self._only not in (None, key): 

1761 continue 

1762 if f"{CONFIG_FILE_NAME}: {_not_a_setting(key, key)}" not in self._reported: 

1763 self._warn(f"{self._label}: {_not_a_setting(key, key)}") 

1764 

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

1766 import tomllib 

1767 

1768 try: 

1769 with self._path.open("rb") as f: 

1770 data = tomllib.load(f) 

1771 except (ValueError, OSError): 

1772 unreadable = f"Failed to read {self._path}, ignoring" 

1773 if unreadable not in self._reported: 

1774 self._warn(unreadable) 

1775 return {} 

1776 self._refuse_not_settings(data) 

1777 # A blank string is unset (the field default applies, since pydantic 

1778 # cannot coerce "" to int|None), except on a clearable model role, 

1779 # where it clears the model. TOML's native types pass through as-is. 

1780 values = {k: v for k, v in data.items() if value_is_set(k, v) and self._only in (None, k)} 

1781 refused = _refusals(self._settings_cls, values) 

1782 for item in refused.items(): 

1783 # The load that built cfg words its report of this file its own way. 

1784 if self._line(CONFIG_FILE_NAME, _USES_ITS_DEFAULT, item, values) not in self._reported: 

1785 self._warn(self._line(self._label, self._otherwise, item, values)) 

1786 return {key: value for key, value in values.items() if key not in refused} 

1787 

1788 

1789class _ProfileSource: 

1790 """Reads the ``[profile]`` values of config.toml, the layer under the file's own keys.""" 

1791 

1792 def __init__(self, settings_cls: type[BaseSettings], path: Path) -> None: 

1793 self._settings_cls = settings_cls 

1794 self._path = path 

1795 

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

1797 # circular: resolve -> model via Config 

1798 from .resolve import read_layers, read_profile_table 

1799 

1800 root = self._path.parent 

1801 values = dict(read_layers(root).profile) 

1802 refused = _refusals(self._settings_cls, values) 

1803 if refused: 

1804 name = read_profile_table(root).name 

1805 label = f"the {name!r} profile" if name else "the applied profile" 

1806 for key, reason in refused.items(): 

1807 warn_on_load( 

1808 f"{label}: {key} = {values[key]!r} {reason}; {key} {_USES_ITS_DEFAULT}" 

1809 ) 

1810 return {key: value for key, value in values.items() if key not in refused} 

1811 

1812 

1813def env_value(field_name: str) -> str | None: 

1814 """What LILBEE_<FIELD_NAME> holds, or None when it is unset or blank.""" 

1815 raw = os.environ.get(_variable(Config, field_name)) 

1816 return raw if value_is_set(field_name, raw) else None 

1817 

1818 

1819def default_refusal(key: str, value: Any) -> str | None: 

1820 """The report of a *value* that sends *key* to its default, or None when it does not.""" 

1821 # These settings refuse only text they cannot read; any other type goes to the validator. 

1822 if key not in _TAKES_DEFAULT_WHEN_REFUSED or not isinstance(value, str): 

1823 return None 

1824 reason = _refusals(Config, {key: value}).get(key) 

1825 return None if reason is None else f"{value!r} {reason}; {key} {_USES_ITS_DEFAULT}" 

1826 

1827 

1828def hard_refusals(values: Mapping[str, Any]) -> dict[str, str]: 

1829 """Why its own field refuses each of *values* that one refuses, by key. 

1830 

1831 A setting that takes its default when refused is left out: its value is kept and replaced 

1832 by :func:`default_refusal`. 

1833 """ 

1834 checked = {key: v for key, v in values.items() if key not in _TAKES_DEFAULT_WHEN_REFUSED} 

1835 return _refusals(Config, checked) 

1836 

1837 

1838def refusal_sentence(key: str, value: Any) -> str: 

1839 """One plain sentence for a *value* that *key* refuses, with no validator dump.""" 

1840 reason = _refusals(Config, {key: value}).get(key, "is not a value it accepts") 

1841 return f"{key} = {value!r} {reason}" 

1842 

1843 

1844def canonical_model_ref(ref: str) -> str: 

1845 """The form a model ref takes in cfg; a blank ref stays blank.""" 

1846 if not ref or not ref.strip(): 

1847 return "" 

1848 from lilbee.providers.model_ref import parse_model_ref 

1849 

1850 return parse_model_ref(ref).for_openai_prefix() 

1851 

1852 

1853def checked_write(key: str, value: Any) -> Any: 

1854 """*value* for a write to *key*; raises ValueError when the setting refuses its text. 

1855 

1856 A load takes the default for these settings. A write names the refusal instead. 

1857 """ 

1858 if key in _TAKES_DEFAULT_WHEN_REFUSED and isinstance(value, str): 

1859 reason = _refusals(Config, {key: value}).get(key) 

1860 if reason is not None: 

1861 raise ValueError(f"{key} = {value!r} {reason}") 

1862 return value 

1863 

1864 

1865def root_folders(root: Path) -> dict[str, Path]: 

1866 """The folder each setting that derives from the data root takes when nothing sets it.""" 

1867 return { 

1868 "documents_dir": root / "documents", 

1869 "data_dir": root / "data", 

1870 "lancedb_dir": root / "data" / "lancedb", 

1871 } 

1872 

1873 

1874def toml_values(path: Path, only: str | None = None) -> dict[str, Any]: 

1875 """What the config.toml at *path* sets over a loaded cfg, less each value Config refuses. 

1876 

1877 With *only*, it reads that one key and no other. A file that does not parse 

1878 and a refused value are each reported once per change of the file, less 

1879 what the load reported when the file is the one cfg is built from. 

1880 """ 

1881 return _TomlSource( 

1882 Config, 

1883 path, 

1884 label=str(path), 

1885 otherwise="keeps its value", 

1886 reported=load_warnings if path == loaded_config_file else (), 

1887 only=only, 

1888 per_change=True, 

1889 )() 

1890 

1891 

1892def toml_value(path: Path, key: str) -> Any: 

1893 """What the config.toml at *path* sets for *key*, or None when it sets none Config accepts.""" 

1894 return toml_values(path, only=key).get(key) 

1895 

1896 

1897def refuse_environment() -> None: 

1898 """Raise RefusedVariableError for a variable that holds a value its setting refuses.""" 

1899 _PlainEnvSource(Config).refuse() 

1900 

1901 

1902def _build_cfg() -> tuple[Config, tuple[str, ...]]: 

1903 """Build cfg, with the warnings its sources reported about refused values.""" 

1904 with collecting() as found: 

1905 built = Config() 

1906 return built, tuple(found) 

1907 

1908 

1909# The config.toml that cfg is built from; a later read of it reports only what is new. 

1910loaded_config_file = _config_file() 

1911cfg, load_warnings = _build_cfg() 

1912# Once, at import on the main thread: a later warning stays in this process, because 

1913# a write from a loading thread would race native readers of the environment. 

1914if shown := shown_marks(): 

1915 os.environ[SHOWN_ENV_VAR] = shown 

1916 

1917 

1918def export_data_root() -> None: 

1919 """Name cfg's data root in ``LILBEE_DATA`` when the variable names none. 

1920 

1921 Spawn-context worker subprocesses then inherit the same root, and the CLI 

1922 loads every root through the one overlay that records the reading a config 

1923 follower starts from. A user-set value is kept; a blank one names no root. 

1924 """ 

1925 if not os.environ.get("LILBEE_DATA", "").strip(): 

1926 os.environ["LILBEE_DATA"] = str(cfg.data_root) 

1927 

1928 

1929export_data_root() 

1930 

1931 

1932def export_download_environment() -> None: 

1933 """Publish ``fast_model_downloads`` to hf_xet, which reads it from the environment. 

1934 

1935 Call once at startup on the main thread: hf_xet caches the value when its 

1936 session is built, so a later change lands on restart, and a write from a 

1937 download thread would race native readers of the environment. 

1938 """ 

1939 if cfg.fast_model_downloads: 

1940 os.environ["HF_XET_HIGH_PERFORMANCE"] = "1" 

1941 else: 

1942 os.environ.pop("HF_XET_HIGH_PERFORMANCE", None) 

1943 

1944 

1945export_download_environment()