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
« 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.
3Typed pydantic models so Litestar's OpenAPI schema has field-level detail.
4"""
6from __future__ import annotations
8from typing import Any, Literal
10from pydantic import BaseModel, Field, field_validator
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
18def _validate_chunk_type(value: str | None) -> ChunkType | None:
19 """Decode a ``chunk_type`` string into a ``ChunkType`` at the HTTP boundary.
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
35class AskRequest(BaseModel):
36 """Request body for /api/ask."""
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
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)
49class ChatRequest(BaseModel):
50 """Request body for /api/chat."""
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
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)
64class SyncRequest(BaseModel):
65 """Request body for /api/sync.
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 """
76 enable_ocr: bool | None = None
77 force_rebuild: bool = False
78 retry_skipped: bool = False
81class AddRequest(BaseModel):
82 """Request body for /api/add."""
84 paths: list[str]
85 force: bool = False
86 enable_ocr: bool | None = None
87 ocr_timeout: float | None = None
90class SetModelRequest(BaseModel):
91 """Request body for /api/models/chat."""
93 model: str
96class SourceContentResponse(BaseModel):
97 """JSON body for ``GET /api/source`` (``raw=0``); empty ``markdown`` for binary types."""
99 markdown: str
100 content_type: str
101 title: str | None = None
104class ChatMessage(BaseModel):
105 """A single message in a chat conversation."""
107 role: Literal["user", "assistant"]
108 content: str
111class CleanedChunk(BaseModel):
112 """A search result chunk with vector stripped and distance renamed."""
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
132class StatusSourceInfo(BaseModel):
133 """A single indexed source in a status response."""
135 filename: str
136 file_hash: str
137 chunk_count: int
138 ingested_at: str
141class StatusConfigInfo(BaseModel):
142 """Configuration section of a status response.
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 """
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
157class StatusResponse(BaseModel):
158 """Response for GET /api/status."""
160 command: str = "status"
161 config: StatusConfigInfo
162 sources: list[StatusSourceInfo]
163 total_chunks: int
166class HealthResponse(BaseModel):
167 """Response for /api/health."""
169 status: str
170 version: str
173class AskResponse(BaseModel):
174 """Response for /api/ask and /api/chat."""
176 answer: str
177 sources: list[CleanedChunk]
180class SetModelResponse(BaseModel):
181 """Response for PUT /api/models/{chat|embedding|vision|reranker}.
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 """
189 model: str
190 reindex_required: bool = False
193class ConfigUpdateResponse(BaseModel):
194 """Response for PATCH /api/config."""
196 updated: list[str]
197 reindex_required: bool
200class CrawlRequest(BaseModel):
201 """Request body for /api/crawl.
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 """
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)
215class DocumentInfo(BaseModel):
216 """A single indexed document in a list response."""
218 filename: str
219 chunk_count: int = 0
220 ingested_at: str = ""
223class DocumentListResponse(BaseModel):
224 """Response for GET /api/documents."""
226 documents: list[DocumentInfo]
227 total: int
228 limit: int
229 offset: int
230 has_more: bool = False
233class DocumentRemoveResponse(BaseModel):
234 """Response for POST /api/documents/remove."""
236 removed: list[str]
237 not_found: list[str]
240class ConfigResponse(BaseModel):
241 """Response for GET /api/config."""
243 model_config = {"extra": "allow"}
246class ModelsShowResponse(BaseModel):
247 """Response for POST /api/models/show."""
249 model_config = {"extra": "allow"}
252class CatalogEntryResponse(BaseModel):
253 """A single model in the catalog browser.
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 """
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
283class ModelsCatalogResponse(BaseModel):
284 """Response for GET /api/models/catalog."""
286 total: int
287 limit: int
288 offset: int
289 models: list[CatalogEntryResponse]
290 has_more: bool = False
293class InstalledModelEntry(BaseModel):
294 """A single installed model."""
296 name: str
297 source: ModelSource
300class ModelsInstalledResponse(BaseModel):
301 """Response for GET /api/models/installed."""
303 models: list[InstalledModelEntry]
306class ModelsDeleteResponse(BaseModel):
307 """Response for DELETE /api/models/{model}."""
309 deleted: bool
310 model: str
311 freed_gb: float
314class ExternalModelsResponse(BaseModel):
315 """Response for GET /api/models/external."""
317 models: list[str]
318 error: str | None = None
321class SyncSummary(BaseModel):
322 """Embedded sync result within an add-files response."""
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
333class AddSummary(BaseModel):
334 """Summary returned by the add-files handler."""
336 copied: list[str]
337 skipped: list[str]
338 errors: list[str]
339 sync: SyncSummary | None = None
342class WikiPageSummary(BaseModel):
343 """Summary of a wiki page for list endpoints."""
345 slug: str
346 title: str = ""
347 page_type: str = "unknown"
348 source_count: int = 0
349 created_at: str = ""
352class WikiCitationRecord(BaseModel):
353 """A citation record from the store, used in reverse lookup responses."""
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 = ""
369class WikiPageDetail(BaseModel):
370 """Full content of a single wiki page."""
372 slug: str
373 title: str = ""
374 content: str = ""
377class WikiCitationsResult(BaseModel):
378 """Citations attached to a single wiki page."""
380 slug: str
381 citations: list[WikiCitationRecord] = []
384class WikiLintIssueItem(BaseModel):
385 """A single lint finding on a wiki page."""
387 wiki_source: str = ""
388 issue_type: str = ""
389 severity: str = ""
390 message: str = ""
393class WikiLintResult(BaseModel):
394 """Result of a full wiki lint run."""
396 issues: list[WikiLintIssueItem] = []
397 errors: int = 0
398 warnings: int = 0
401class WikiPruneRecordResponse(BaseModel):
402 """A single pruning action."""
404 wiki_source: str
405 action: str
406 reason: str
409class WikiPruneResult(BaseModel):
410 """Result of wiki pruning."""
412 records: list[WikiPruneRecordResponse] = []
413 archived: int = 0
414 flagged: int = 0
417class WikiBuildResult(BaseModel):
418 """Result of a full wiki build/update."""
420 paths: list[str] = []
421 entities: int = 0
422 count: int = 0
425class WikiStatusResult(BaseModel):
426 """Wiki layer status counters."""
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
436class WikiSynthesizeResult(BaseModel):
437 """Result of generating synthesis pages for cross-source concept clusters."""
439 paths: list[str] = []
440 count: int = 0
443class DraftInfoResponse(BaseModel):
444 """Metadata about a single wiki draft, mirroring ``DraftInfo.to_dict()``.
446 ``pending_kind`` distinguishes drift drafts (``None``) from
447 batched-generation markers (``"parse"``, ``"collision"``).
448 """
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
461class WikiDraftDiffResponse(BaseModel):
462 """Unified diff of a draft against its published counterpart."""
464 slug: str
465 diff: str
468class WikiDraftAcceptResponse(BaseModel):
469 """Outcome of accepting a draft: where it landed and how many chunks reindexed.
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 """
477 slug: str
478 requested_slug: str
479 moved_to: str
480 reindexed_chunks: int
483class WikiDraftRejectResponse(BaseModel):
484 """Outcome of rejecting a draft."""
486 slug: str
489class RememberRequest(BaseModel):
490 """Request body for ``POST /api/memories``."""
492 text: str
493 kind: MemoryKind = MemoryKind.FACT
494 shared: bool = False
497class RememberResponse(BaseModel):
498 """Outcome of storing a memory."""
500 id: str
501 kind: MemoryKind
504class MemoryItem(BaseModel):
505 """A single stored memory in a list response."""
507 id: str
508 kind: MemoryKind
509 shared: bool
510 text: str
513class MemoryListResponse(BaseModel):
514 """Body for ``GET /api/memories``."""
516 memories: list[MemoryItem]
519class MemorySharedRequest(BaseModel):
520 """Request body for ``PATCH /api/memories/{memory_id}``."""
522 shared: bool
525class MemoryFlagsResponse(BaseModel):
526 """Outcome of a flag update; ``updated`` is False when the id was unknown."""
528 id: str
529 updated: bool
532class MemoryRemoveResponse(BaseModel):
533 """Outcome of deleting a memory."""
535 removed: str
538class MemoryExtractedItem(BaseModel):
539 """A single memory created by auto-extraction during a chat turn."""
541 id: str
542 kind: MemoryKind
543 text: str
546class MemoryExtractedEvent(BaseModel):
547 """``memory_extracted`` SSE payload: how many memories a turn auto-saved.
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 """
554 count: int
555 items: list[MemoryExtractedItem]