Coverage for src / lilbee / server / models.py: 100%

272 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

1"""Request and response models for the lilbee HTTP API. 

2 

3Typed pydantic models so Litestar's OpenAPI schema has field-level detail. 

4""" 

5 

6from __future__ import annotations 

7 

8from typing import Any, Literal 

9 

10from pydantic import BaseModel, Field, field_validator 

11 

12from lilbee.catalog.types import KeyStatus, ModelCompat, ModelSource, ModelTask 

13from lilbee.core.config.enums import CrawlRenderMode 

14from lilbee.data.store import ChunkType, MemoryKind, SearchScope 

15from lilbee.runtime.hardware import FitLevel, SizeVariantInfo 

16 

17 

18def _validate_chunk_type(value: str | None) -> ChunkType | None: 

19 """Decode a ``chunk_type`` string into a ``ChunkType`` at the HTTP boundary. 

20 

21 Matches the CLI/MCP behaviour: only ``"raw"`` or ``"wiki"`` filter the 

22 pool; everything else (including ``None`` and the UI-side ``"both"``) 

23 means no filter. Any other string raises ``ValueError``. 

24 """ 

25 if value is None or value == SearchScope.BOTH.value: 

26 return None 

27 try: 

28 return ChunkType(value) 

29 except ValueError as exc: 

30 raise ValueError( 

31 f"chunk_type must be one of 'raw', 'wiki', 'both', or omitted; got {value!r}" 

32 ) from exc 

33 

34 

35class AskRequest(BaseModel): 

36 """Request body for /api/ask.""" 

37 

38 question: str 

39 top_k: int = Field(default=0, le=100) 

40 options: dict[str, Any] | None = None 

41 chunk_type: ChunkType | None = None 

42 

43 @field_validator("chunk_type", mode="before") 

44 @classmethod 

45 def _check_chunk_type(cls, v: str | None) -> ChunkType | None: 

46 return _validate_chunk_type(v) 

47 

48 

49class ChatRequest(BaseModel): 

50 """Request body for /api/chat.""" 

51 

52 question: str 

53 history: list[ChatMessage] = [] 

54 top_k: int = Field(default=0, le=100) 

55 options: dict[str, Any] | None = None 

56 chunk_type: ChunkType | None = None 

57 

58 @field_validator("chunk_type", mode="before") 

59 @classmethod 

60 def _check_chunk_type(cls, v: str | None) -> ChunkType | None: 

61 return _validate_chunk_type(v) 

62 

63 

64class SyncRequest(BaseModel): 

65 """Request body for /api/sync. 

66 

67 ``force_rebuild`` triggers a full drop-and-reingest equivalent to ``lilbee rebuild``. 

68 Use it to recover from an embedding-model switch (when the store refuses search 

69 or ingest because ``cfg.embedding_model`` no longer matches the persisted vectors). 

70 ``retry_skipped`` is the lighter recovery: it clears the markers for files that 

71 failed a previous sync (Tesseract timeout, decode failure, no usable text) so this 

72 sync attempts them again, without dropping the existing store. The default is an 

73 incremental sync. 

74 """ 

75 

76 enable_ocr: bool | None = None 

77 force_rebuild: bool = False 

78 retry_skipped: bool = False 

79 

80 

81class AddRequest(BaseModel): 

82 """Request body for /api/add.""" 

83 

84 paths: list[str] 

85 force: bool = False 

86 enable_ocr: bool | None = None 

87 ocr_timeout: float | None = None 

88 

89 

90class SetModelRequest(BaseModel): 

91 """Request body for /api/models/chat.""" 

92 

93 model: str 

94 

95 

96class SourceContentResponse(BaseModel): 

97 """JSON body for ``GET /api/source`` (``raw=0``); empty ``markdown`` for binary types.""" 

98 

99 markdown: str 

100 content_type: str 

101 title: str | None = None 

102 

103 

104class ChatMessage(BaseModel): 

105 """A single message in a chat conversation.""" 

106 

107 role: Literal["user", "assistant"] 

108 content: str 

109 

110 

111class CleanedChunk(BaseModel): 

112 """A search result chunk with vector stripped and distance renamed.""" 

113 

114 source: str 

115 content_type: str 

116 chunk: str 

117 distance: float | None = None 

118 relevance_score: float | None = None 

119 rerank_score: float | None = None 

120 page_start: int = 0 

121 page_end: int = 0 

122 line_start: int = 0 

123 line_end: int = 0 

124 chunk_index: int = 0 

125 # Vault-relative path when ``cfg.vault_base`` is set and the source file 

126 # lives inside the vault. Absent when the server is running headless or 

127 # the source isn't resolvable as a vault file. Clients use this to open 

128 # the source in a native editor instead of fetching ``/api/source``. 

129 vault_path: str | None = None 

130 

131 

132class StatusSourceInfo(BaseModel): 

133 """A single indexed source in a status response.""" 

134 

135 filename: str 

136 file_hash: str 

137 chunk_count: int 

138 ingested_at: str 

139 

140 

141class StatusConfigInfo(BaseModel): 

142 """Configuration section of a status response. 

143 

144 Exposes all four role-bound model fields so plugins/TUI can show 

145 what's active per role without a second round trip. 

146 """ 

147 

148 documents_dir: str 

149 data_dir: str 

150 chat_model: str 

151 embedding_model: str 

152 vision_model: str = "" 

153 reranker_model: str = "" 

154 enable_ocr: bool | None = None 

155 

156 

157class StatusResponse(BaseModel): 

158 """Response for GET /api/status.""" 

159 

160 command: str = "status" 

161 config: StatusConfigInfo 

162 sources: list[StatusSourceInfo] 

163 total_chunks: int 

164 

165 

166class HealthResponse(BaseModel): 

167 """Response for /api/health.""" 

168 

169 status: str 

170 version: str 

171 

172 

173class AskResponse(BaseModel): 

174 """Response for /api/ask and /api/chat.""" 

175 

176 answer: str 

177 sources: list[CleanedChunk] 

178 

179 

180class SetModelResponse(BaseModel): 

181 """Response for PUT /api/models/{chat|embedding|vision|reranker}. 

182 

183 ``reindex_required`` is ``True`` only when the new embedding model differs from 

184 the model that built the persisted vector store. The chat, vision, and reranker 

185 handlers always return ``False`` because their changes do not invalidate stored 

186 vectors. Mirrors the ``reindex_required`` flag on ``ConfigUpdateResponse``. 

187 """ 

188 

189 model: str 

190 reindex_required: bool = False 

191 

192 

193class ConfigUpdateResponse(BaseModel): 

194 """Response for PATCH /api/config.""" 

195 

196 updated: list[str] 

197 reindex_required: bool 

198 

199 

200class CrawlRequest(BaseModel): 

201 """Request body for /api/crawl. 

202 

203 depth: null / omitted = whole-site unbounded recursion. 0 = single URL 

204 only. Positive int = max depth. max_pages: null / omitted = no cap. 

205 Positive int = explicit page cap. render_mode: null / omitted = configured 

206 default; "http" is browserless, "browser" runs Chromium with JavaScript. 

207 """ 

208 

209 url: str 

210 depth: int | None = Field(default=None, ge=0) 

211 max_pages: int | None = Field(default=None, ge=1) 

212 render_mode: CrawlRenderMode | None = Field(default=None) 

213 

214 

215class DocumentInfo(BaseModel): 

216 """A single indexed document in a list response.""" 

217 

218 filename: str 

219 chunk_count: int = 0 

220 ingested_at: str = "" 

221 

222 

223class DocumentListResponse(BaseModel): 

224 """Response for GET /api/documents.""" 

225 

226 documents: list[DocumentInfo] 

227 total: int 

228 limit: int 

229 offset: int 

230 has_more: bool = False 

231 

232 

233class DocumentRemoveResponse(BaseModel): 

234 """Response for POST /api/documents/remove.""" 

235 

236 removed: list[str] 

237 not_found: list[str] 

238 

239 

240class ConfigResponse(BaseModel): 

241 """Response for GET /api/config.""" 

242 

243 model_config = {"extra": "allow"} 

244 

245 

246class ModelsShowResponse(BaseModel): 

247 """Response for POST /api/models/show.""" 

248 

249 model_config = {"extra": "allow"} 

250 

251 

252class CatalogEntryResponse(BaseModel): 

253 """A single model in the catalog browser. 

254 

255 ``fit`` and ``size_variants`` carry server-computed hardware-fit 

256 data so clients (TUI, plugin) can render fit chips and size strips 

257 without probing local memory themselves. ``fit`` is ``None`` when 

258 the row's footprint cannot be assessed against host memory (e.g. 

259 a future cloud-only entry whose weights live off-host). 

260 """ 

261 

262 hf_repo: str 

263 gguf_filename: str 

264 task: ModelTask 

265 display_name: str 

266 param_count: str 

267 size_gb: float 

268 min_ram_gb: float 

269 description: str 

270 quality_tier: str 

271 featured: bool 

272 downloads: int 

273 installed: bool 

274 source: ModelSource 

275 fit: FitLevel | None = None 

276 size_variants: list[SizeVariantInfo] = [] 

277 architecture: str = "" 

278 compat: ModelCompat = ModelCompat.UNKNOWN 

279 provider: str = "" 

280 key_status: KeyStatus | None = None 

281 

282 

283class ModelsCatalogResponse(BaseModel): 

284 """Response for GET /api/models/catalog.""" 

285 

286 total: int 

287 limit: int 

288 offset: int 

289 models: list[CatalogEntryResponse] 

290 has_more: bool = False 

291 

292 

293class InstalledModelEntry(BaseModel): 

294 """A single installed model.""" 

295 

296 name: str 

297 source: ModelSource 

298 

299 

300class ModelsInstalledResponse(BaseModel): 

301 """Response for GET /api/models/installed.""" 

302 

303 models: list[InstalledModelEntry] 

304 

305 

306class ModelsDeleteResponse(BaseModel): 

307 """Response for DELETE /api/models/{model}.""" 

308 

309 deleted: bool 

310 model: str 

311 freed_gb: float 

312 

313 

314class ExternalModelsResponse(BaseModel): 

315 """Response for GET /api/models/external.""" 

316 

317 models: list[str] 

318 error: str | None = None 

319 

320 

321class SyncSummary(BaseModel): 

322 """Embedded sync result within an add-files response.""" 

323 

324 added: list[str] = [] 

325 updated: list[str] = [] 

326 removed: list[str] = [] 

327 unchanged: int = 0 

328 failed: list[str] = [] 

329 skipped: list[str] = [] 

330 truncated: int = 0 

331 

332 

333class AddSummary(BaseModel): 

334 """Summary returned by the add-files handler.""" 

335 

336 copied: list[str] 

337 skipped: list[str] 

338 errors: list[str] 

339 sync: SyncSummary | None = None 

340 

341 

342class WikiPageSummary(BaseModel): 

343 """Summary of a wiki page for list endpoints.""" 

344 

345 slug: str 

346 title: str = "" 

347 page_type: str = "unknown" 

348 source_count: int = 0 

349 created_at: str = "" 

350 

351 

352class WikiCitationRecord(BaseModel): 

353 """A citation record from the store, used in reverse lookup responses.""" 

354 

355 wiki_source: str = "" 

356 wiki_chunk_index: int = 0 

357 citation_key: str = "" 

358 claim_type: str = "fact" 

359 source_filename: str = "" 

360 source_hash: str = "" 

361 page_start: int = 0 

362 page_end: int = 0 

363 line_start: int = 0 

364 line_end: int = 0 

365 excerpt: str = "" 

366 created_at: str = "" 

367 

368 

369class WikiPageDetail(BaseModel): 

370 """Full content of a single wiki page.""" 

371 

372 slug: str 

373 title: str = "" 

374 content: str = "" 

375 

376 

377class WikiCitationsResult(BaseModel): 

378 """Citations attached to a single wiki page.""" 

379 

380 slug: str 

381 citations: list[WikiCitationRecord] = [] 

382 

383 

384class WikiLintIssueItem(BaseModel): 

385 """A single lint finding on a wiki page.""" 

386 

387 wiki_source: str = "" 

388 issue_type: str = "" 

389 severity: str = "" 

390 message: str = "" 

391 

392 

393class WikiLintResult(BaseModel): 

394 """Result of a full wiki lint run.""" 

395 

396 issues: list[WikiLintIssueItem] = [] 

397 errors: int = 0 

398 warnings: int = 0 

399 

400 

401class WikiPruneRecordResponse(BaseModel): 

402 """A single pruning action.""" 

403 

404 wiki_source: str 

405 action: str 

406 reason: str 

407 

408 

409class WikiPruneResult(BaseModel): 

410 """Result of wiki pruning.""" 

411 

412 records: list[WikiPruneRecordResponse] = [] 

413 archived: int = 0 

414 flagged: int = 0 

415 

416 

417class WikiBuildResult(BaseModel): 

418 """Result of a full wiki build/update.""" 

419 

420 paths: list[str] = [] 

421 entities: int = 0 

422 count: int = 0 

423 

424 

425class WikiStatusResult(BaseModel): 

426 """Wiki layer status counters.""" 

427 

428 wiki_enabled: bool 

429 summaries: int = 0 

430 drafts: int = 0 

431 pages: int = 0 

432 lint_errors: int = 0 

433 lint_warnings: int = 0 

434 

435 

436class WikiSynthesizeResult(BaseModel): 

437 """Result of generating synthesis pages for cross-source concept clusters.""" 

438 

439 paths: list[str] = [] 

440 count: int = 0 

441 

442 

443class DraftInfoResponse(BaseModel): 

444 """Metadata about a single wiki draft, mirroring ``DraftInfo.to_dict()``. 

445 

446 ``pending_kind`` distinguishes drift drafts (``None``) from 

447 batched-generation markers (``"parse"``, ``"collision"``). 

448 """ 

449 

450 slug: str 

451 path: str 

452 drift_ratio: float | None = None 

453 faithfulness_score: float | None = None 

454 bad_title: bool = False 

455 published_path: str | None = None 

456 published_exists: bool = False 

457 mtime: float = 0.0 

458 pending_kind: str | None = None 

459 

460 

461class WikiDraftDiffResponse(BaseModel): 

462 """Unified diff of a draft against its published counterpart.""" 

463 

464 slug: str 

465 diff: str 

466 

467 

468class WikiDraftAcceptResponse(BaseModel): 

469 """Outcome of accepting a draft: where it landed and how many chunks reindexed. 

470 

471 ``slug`` is the slug where the content was published. 

472 ``requested_slug`` is the slug the client asked to accept. The two 

473 differ for PENDING-COLLISION drafts, where the request slug carries 

474 a ``-collision-<hash>`` suffix that is stripped on publish. 

475 """ 

476 

477 slug: str 

478 requested_slug: str 

479 moved_to: str 

480 reindexed_chunks: int 

481 

482 

483class WikiDraftRejectResponse(BaseModel): 

484 """Outcome of rejecting a draft.""" 

485 

486 slug: str 

487 

488 

489class RememberRequest(BaseModel): 

490 """Request body for ``POST /api/memories``.""" 

491 

492 text: str 

493 kind: MemoryKind = MemoryKind.FACT 

494 shared: bool = False 

495 

496 

497class RememberResponse(BaseModel): 

498 """Outcome of storing a memory.""" 

499 

500 id: str 

501 kind: MemoryKind 

502 

503 

504class MemoryItem(BaseModel): 

505 """A single stored memory in a list response.""" 

506 

507 id: str 

508 kind: MemoryKind 

509 shared: bool 

510 text: str 

511 

512 

513class MemoryListResponse(BaseModel): 

514 """Body for ``GET /api/memories``.""" 

515 

516 memories: list[MemoryItem] 

517 

518 

519class MemorySharedRequest(BaseModel): 

520 """Request body for ``PATCH /api/memories/{memory_id}``.""" 

521 

522 shared: bool 

523 

524 

525class MemoryFlagsResponse(BaseModel): 

526 """Outcome of a flag update; ``updated`` is False when the id was unknown.""" 

527 

528 id: str 

529 updated: bool 

530 

531 

532class MemoryRemoveResponse(BaseModel): 

533 """Outcome of deleting a memory.""" 

534 

535 removed: str 

536 

537 

538class MemoryExtractedItem(BaseModel): 

539 """A single memory created by auto-extraction during a chat turn.""" 

540 

541 id: str 

542 kind: MemoryKind 

543 text: str 

544 

545 

546class MemoryExtractedEvent(BaseModel): 

547 """``memory_extracted`` SSE payload: how many memories a turn auto-saved. 

548 

549 Emitted on the chat stream after ``done`` when auto-extraction is on and the 

550 turn produced at least one memory, so a REST client (the Obsidian plugin) can 

551 toast the count and refresh its memories view without a separate fetch. 

552 """ 

553 

554 count: int 

555 items: list[MemoryExtractedItem]