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

411 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 TYPE_CHECKING, Any, Literal 

9 

10from pydantic import BaseModel, Field, field_validator 

11 

12from lilbee.app.agent_configs.document import AgentClient, AgentSurface, ConfigFormat 

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

14from lilbee.core.config.enums import CrawlRenderMode 

15from lilbee.data.store import ChunkType, MemoryKind, scope_to_chunk_type 

16from lilbee.providers.roles import WorkerRole 

17from lilbee.runtime.hardware import FitLevel, SizeVariantInfo 

18from lilbee.sessions import MessageRole 

19from lilbee.wiki.entity_extractor import EntityKind 

20 

21if TYPE_CHECKING: 

22 from lilbee.app.agent_configs.detect import ClientDetection 

23 from lilbee.app.agent_configs.document import AgentConfigDocument 

24 from lilbee.app.placement import PlacementView 

25 

26 

27def decode_chunk_type(value: str | None) -> ChunkType | None: 

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

29 

30 Delegates to the canonical :func:`scope_to_chunk_type` so query-param and 

31 request-body routes share one decoder: only ``"raw"`` or ``"wiki"`` filter 

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

33 means no filter. Any other string raises ``ValueError`` with boundary- 

34 friendly guidance. 

35 """ 

36 try: 

37 return scope_to_chunk_type(value) 

38 except ValueError as exc: 

39 raise ValueError( 

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

41 ) from exc 

42 

43 

44class AskRequest(BaseModel): 

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

46 

47 question: str 

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

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

50 chunk_type: ChunkType | None = None 

51 

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

53 @classmethod 

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

55 return decode_chunk_type(v) 

56 

57 

58class ChatRequest(BaseModel): 

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

60 

61 question: str 

62 history: list[ChatMessage] = [] 

63 # None (unspecified) grounds with the configured top_k; an explicit 0 is a 

64 # pure-LLM call that skips retrieval entirely. 

65 top_k: int | None = Field(default=None, ge=0, le=100) 

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

67 chunk_type: ChunkType | None = None 

68 summary: str = "" 

69 """Carry-forward notes from earlier compactions, folded into the prompt.""" 

70 session_id: str | None = None 

71 """Session that receives the new summary when this turn compacts.""" 

72 

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

74 @classmethod 

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

76 return decode_chunk_type(v) 

77 

78 

79class SyncRequest(BaseModel): 

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

81 

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

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

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

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

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

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

88 incremental sync. 

89 """ 

90 

91 enable_ocr: bool | None = None 

92 force_rebuild: bool = False 

93 retry_skipped: bool = False 

94 

95 

96class AddRequest(BaseModel): 

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

98 

99 paths: list[str] 

100 force: bool = False 

101 enable_ocr: bool | None = None 

102 ocr_timeout: float | None = None 

103 

104 

105class SetModelRequest(BaseModel): 

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

107 

108 model: str 

109 

110 

111class SourceContentResponse(BaseModel): 

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

113 

114 markdown: str 

115 content_type: str 

116 title: str | None = None 

117 

118 

119class ChatMessage(BaseModel): 

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

121 

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

123 content: str 

124 

125 

126class CleanedChunk(BaseModel): 

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

128 

129 source: str 

130 content_type: str 

131 chunk: str 

132 distance: float | None = None 

133 relevance_score: float | None = None 

134 rerank_score: float | None = None 

135 # Canonical [0, 1] relevance from retrieval fusion; the ranking signal 

136 # HTTP clients should sort and threshold on (relevance_score is legacy). 

137 score: float | None = None 

138 page_start: int = 0 

139 page_end: int = 0 

140 line_start: int = 0 

141 line_end: int = 0 

142 chunk_index: int = 0 

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

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

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

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

147 vault_path: str | None = None 

148 

149 

150class StatusSourceInfo(BaseModel): 

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

152 

153 filename: str 

154 file_hash: str 

155 chunk_count: int 

156 ingested_at: str 

157 

158 

159class StatusConfigInfo(BaseModel): 

160 """Configuration section of a status response. 

161 

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

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

164 """ 

165 

166 documents_dir: str 

167 data_dir: str 

168 chat_model: str 

169 embedding_model: str 

170 vision_model: str = "" 

171 reranker_model: str = "" 

172 enable_ocr: bool | None = None 

173 

174 

175class StatusEntityInfo(BaseModel): 

176 """Entity-extraction section of a status response (present when enabled).""" 

177 

178 types: list[str] 

179 rows: int 

180 

181 

182class StatusResponse(BaseModel): 

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

184 

185 command: str = "status" 

186 config: StatusConfigInfo 

187 sources: list[StatusSourceInfo] 

188 total_chunks: int 

189 entities: StatusEntityInfo | None = None 

190 

191 

192class ShutdownResponse(BaseModel): 

193 """Response for /api/shutdown.""" 

194 

195 status: Literal["shutting_down"] 

196 

197 

198class HealthResponse(BaseModel): 

199 """Response for /api/health.""" 

200 

201 status: str 

202 version: str 

203 chat_ready: bool = False 

204 """True once the chat engine is loaded and ready to serve a first token. 

205 

206 A launcher polls this to wait out the cold model load before handing off to 

207 a client, so the client never lands on an apparently-dead stream. 

208 """ 

209 chat_status: Literal["ready", "loading", "not_started", "error"] = "not_started" 

210 """Finer-grained chat readiness than the ``chat_ready`` bool. 

211 

212 Lets a polling client tell a fleet that is still loading (wait) apart from one 

213 that never started warming (``not_started`` -- no chat model resolved / planned, 

214 so it will not come up on its own) or failed (``error``). Without this a bare 

215 ``chat_ready:false`` reads the same for "loading" and "hung", which looked like a 

216 silent hang on a fresh box with no chat model installed.""" 

217 chat_error: str | None = None 

218 """The reason the chat engine failed to come up when ``chat_status`` is 

219 ``error`` (e.g. a wedged GPU device probe), so a polling client can report 

220 the cause instead of retrying forever.""" 

221 chat_ctx: int | None = None 

222 """Per-slot context the chat engine serves, so a launcher can tell the client 

223 its window and the client trims history to fit. None until the engine is up.""" 

224 

225 

226class CompactionInfo(BaseModel): 

227 """What one pre-turn compaction folded out of a conversation.""" 

228 

229 summary: str 

230 condensed: int 

231 """Turns folded into the notes.""" 

232 stranded: int 

233 """Turns dropped with no notes; a client must say so rather than hide it.""" 

234 

235 

236class AskResponse(BaseModel): 

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

238 

239 ``sources`` is the full retrieved set; ``cited_sources`` is the subset the answer 

240 actually cited, so a client can tell a grounded answer from an off-corpus one. 

241 """ 

242 

243 answer: str 

244 sources: list[CleanedChunk] 

245 cited_sources: list[CleanedChunk] = Field(default_factory=list) 

246 compaction: CompactionInfo | None = None 

247 """Set when a /api/chat turn compacted its history before answering.""" 

248 

249 

250class SetModelResponse(BaseModel): 

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

252 

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

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

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

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

257 """ 

258 

259 model: str 

260 reindex_required: bool = False 

261 

262 

263class ConfigUpdateResponse(BaseModel): 

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

265 

266 updated: list[str] 

267 reindex_required: bool 

268 

269 

270class CrawlRequest(BaseModel): 

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

272 

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

274 only. Positive int = max depth. max_pages: null / omitted = the protective 

275 safety cap. 0 = explicitly unlimited (the CRAWL_PAGES_UNLIMITED sentinel the 

276 TUI and crawler honor). Positive int = explicit page cap. render_mode: null / 

277 omitted = configured default; "http" is browserless, "browser" runs Chromium 

278 with JavaScript. 

279 """ 

280 

281 url: str 

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

283 max_pages: int | None = Field(default=None, ge=0) 

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

285 include_subdomains: bool = Field(default=False) 

286 

287 

288class DocumentInfo(BaseModel): 

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

290 

291 filename: str 

292 chunk_count: int = 0 

293 ingested_at: str = "" 

294 

295 

296class DocumentListResponse(BaseModel): 

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

298 

299 documents: list[DocumentInfo] 

300 total: int 

301 limit: int 

302 offset: int 

303 has_more: bool = False 

304 

305 

306class DocumentRemoveResponse(BaseModel): 

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

308 

309 removed: list[str] 

310 not_found: list[str] 

311 

312 

313class ConfigResponse(BaseModel): 

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

315 

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

317 

318 

319class ModelsShowResponse(BaseModel): 

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

321 

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

323 

324 

325class CatalogEntryResponse(BaseModel): 

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

327 

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

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

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

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

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

333 """ 

334 

335 hf_repo: str 

336 gguf_filename: str 

337 task: ModelTask 

338 display_name: str 

339 param_count: str 

340 size_gb: float 

341 min_ram_gb: float 

342 description: str 

343 quality_tier: str 

344 featured: bool 

345 downloads: int 

346 installed: bool 

347 source: ModelSource 

348 fit: FitLevel | None = None 

349 size_variants: list[SizeVariantInfo] = [] 

350 architecture: str = "" 

351 compat: ModelCompat = ModelCompat.UNKNOWN 

352 provider: str = "" 

353 key_status: KeyStatus | None = None 

354 

355 

356class ModelsCatalogResponse(BaseModel): 

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

358 

359 total: int 

360 limit: int 

361 offset: int 

362 models: list[CatalogEntryResponse] 

363 has_more: bool = False 

364 

365 

366class InstalledModelEntry(BaseModel): 

367 """A single installed model.""" 

368 

369 name: str 

370 source: ModelSource 

371 

372 

373class ModelsInstalledResponse(BaseModel): 

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

375 

376 models: list[InstalledModelEntry] 

377 

378 

379class ModelsDeleteResponse(BaseModel): 

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

381 

382 deleted: bool 

383 model: str 

384 freed_gb: float 

385 

386 

387class ExternalModelsResponse(BaseModel): 

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

389 

390 models: list[str] 

391 error: str | None = None 

392 

393 

394class SyncSummary(BaseModel): 

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

396 

397 added: list[str] = [] 

398 updated: list[str] = [] 

399 removed: list[str] = [] 

400 unchanged: int = 0 

401 relocated: list[str] = [] 

402 failed: list[str] = [] 

403 skipped: list[str] = [] 

404 truncated: int = 0 

405 

406 

407class AddSummary(BaseModel): 

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

409 

410 copied: list[str] 

411 skipped: list[str] 

412 errors: list[str] 

413 tracked: list[str] = [] 

414 """Named sources the knowledge base already tracks, so nothing was registered. 

415 

416 Distinct from ``skipped``, which is what could not be registered because the 

417 name is held by a different source. These need no action from the caller: 

418 the sync in the same request covers them. 

419 """ 

420 sync: SyncSummary | None = None 

421 already_ingesting: list[str] = [] 

422 """Sources another ingest held a lock on, so this run never attempted them. 

423 

424 Distinct from ``skipped``, which means the file was examined and needed no 

425 work. These were not looked at and are worth retrying. Carried on the 

426 terminal event so a client that missed the earlier ``already_ingesting`` 

427 frames can still tell the batch was partial. 

428 """ 

429 

430 

431class WikiCitationRecord(BaseModel): 

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

433 

434 wiki_source: str = "" 

435 wiki_chunk_index: int = 0 

436 citation_key: str = "" 

437 claim_type: str = "fact" 

438 source_filename: str = "" 

439 source_hash: str = "" 

440 page_start: int = 0 

441 page_end: int = 0 

442 line_start: int = 0 

443 line_end: int = 0 

444 excerpt: str = "" 

445 created_at: str = "" 

446 

447 

448class WikiEntityCandidateResponse(BaseModel): 

449 """One NER entity candidate, with the evidence a page would be built from.""" 

450 

451 slug: str 

452 label: str = "" 

453 kind: EntityKind = EntityKind.ENTITY 

454 type_hint: str = "" 

455 mentions: int = 0 

456 sources: list[str] = [] 

457 

458 

459class WikiBuildDryRunResult(BaseModel): 

460 """Entity candidates a build would cover, with no LLM call made.""" 

461 

462 dry_run: bool = True 

463 entities: list[WikiEntityCandidateResponse] = [] 

464 count: int = 0 

465 note: str = "" 

466 

467 

468class WikiPageDetail(BaseModel): 

469 """Full content of a single wiki page, with its parsed frontmatter.""" 

470 

471 slug: str 

472 title: str = "" 

473 content: str = "" 

474 frontmatter: dict[str, Any] = {} 

475 

476 

477class WikiCitationsResult(BaseModel): 

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

479 

480 slug: str 

481 citations: list[WikiCitationRecord] = [] 

482 

483 

484class WikiLintIssueItem(BaseModel): 

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

486 

487 wiki_source: str = "" 

488 issue_type: str = "" 

489 severity: str = "" 

490 message: str = "" 

491 

492 

493class WikiLintResult(BaseModel): 

494 """Result of a wiki lint run, whole-wiki or single-page.""" 

495 

496 issues: list[WikiLintIssueItem] = [] 

497 total: int = 0 

498 errors: int = 0 

499 warnings: int = 0 

500 

501 

502class WikiPruneRecordResponse(BaseModel): 

503 """A single pruning action.""" 

504 

505 wiki_source: str 

506 action: str 

507 reason: str 

508 

509 

510class WikiPruneResult(BaseModel): 

511 """Result of wiki pruning.""" 

512 

513 records: list[WikiPruneRecordResponse] = [] 

514 archived: int = 0 

515 flagged: int = 0 

516 reconciled: int = 0 

517 

518 

519class WikiIndexResult(BaseModel): 

520 """Result of rebuilding the browse index. Costs no LLM call.""" 

521 

522 entries: int = 0 

523 

524 

525class WikiGenerateResult(BaseModel): 

526 """Result of generating one indexed page.""" 

527 

528 slug: str 

529 path: str 

530 

531 

532class WikiWipeResult(BaseModel): 

533 """Result of wiping the wiki. 

534 

535 ``rows_deleted`` is false when the pages went but the store delete failed, 

536 so a client is never told the wiki is gone while its rows still answer. 

537 """ 

538 

539 pages_removed: int = 0 

540 sources_cleared: int = 0 

541 rows_deleted: bool = True 

542 

543 

544class WikiStatusResult(BaseModel): 

545 """Wiki layer status counters.""" 

546 

547 wiki_enabled: bool 

548 summaries: int = 0 

549 drafts: int = 0 

550 pages: int = 0 

551 lint_errors: int = 0 

552 lint_warnings: int = 0 

553 

554 

555class DraftInfoResponse(BaseModel): 

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

557 

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

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

560 """ 

561 

562 slug: str 

563 path: str 

564 drift_ratio: float | None = None 

565 faithfulness_score: float | None = None 

566 bad_title: bool = False 

567 published_path: str | None = None 

568 published_exists: bool = False 

569 mtime: float = 0.0 

570 pending_kind: str | None = None 

571 

572 

573class WikiDraftDiffResponse(BaseModel): 

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

575 

576 slug: str 

577 diff: str 

578 

579 

580class WikiDraftAcceptResponse(BaseModel): 

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

582 

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

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

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

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

587 """ 

588 

589 slug: str 

590 requested_slug: str 

591 moved_to: str 

592 reindexed_chunks: int 

593 

594 

595class WikiDraftRejectResponse(BaseModel): 

596 """Outcome of rejecting a draft.""" 

597 

598 slug: str 

599 

600 

601class RememberRequest(BaseModel): 

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

603 

604 text: str 

605 kind: MemoryKind = MemoryKind.FACT 

606 shared: bool = False 

607 

608 

609class RememberResponse(BaseModel): 

610 """Outcome of storing a memory.""" 

611 

612 id: str 

613 kind: MemoryKind 

614 

615 

616class MemoryItem(BaseModel): 

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

618 

619 id: str 

620 kind: MemoryKind 

621 shared: bool 

622 text: str 

623 

624 

625class MemoryListResponse(BaseModel): 

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

627 

628 memories: list[MemoryItem] 

629 

630 

631class MemorySharedRequest(BaseModel): 

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

633 

634 shared: bool 

635 

636 

637class MemoryFlagsResponse(BaseModel): 

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

639 

640 id: str 

641 updated: bool 

642 

643 

644class MemoryRemoveResponse(BaseModel): 

645 """Outcome of deleting a memory; ``deleted`` is False when the id was unknown.""" 

646 

647 id: str 

648 deleted: bool 

649 

650 

651class MemoryExtractedItem(BaseModel): 

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

653 

654 id: str 

655 kind: MemoryKind 

656 text: str 

657 

658 

659class MemoryExtractedEvent(BaseModel): 

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

661 

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

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

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

665 """ 

666 

667 count: int 

668 items: list[MemoryExtractedItem] 

669 

670 

671class GpuInfoResponse(BaseModel): 

672 """One GPU as returned by GET /api/gpus and embedded in PlacementResponse.""" 

673 

674 index: int 

675 backend: str 

676 label: str 

677 name: str 

678 total_bytes: int 

679 free_bytes: int 

680 

681 

682class GpusResponse(BaseModel): 

683 """GET /api/gpus envelope: detected GPUs plus the host-level util notice.""" 

684 

685 gpus: list[GpuInfoResponse] 

686 notice: str | None = None 

687 

688 

689class RolePlacementResponse(BaseModel): 

690 """Where one role's model is placed in the resolved plan.""" 

691 

692 role: WorkerRole 

693 model: str 

694 devices: list[int] 

695 tensor_split: list[int] | None 

696 replicas: int 

697 

698 

699class SkippedRoleResponse(BaseModel): 

700 """A configured role left unplaced because its model isn't downloaded.""" 

701 

702 role: WorkerRole 

703 model: str 

704 

705 

706class PlacementResponse(BaseModel): 

707 """Response for placement read, preview, set, and clear routes.""" 

708 

709 gpus: list[GpuInfoResponse] 

710 roles: list[RolePlacementResponse] 

711 unplaceable: list[str] 

712 manual: bool 

713 spec_json: str | None 

714 skipped_not_installed: list[SkippedRoleResponse] = [] 

715 co_tenants: list[str] = [] 

716 notice: str | None = None 

717 rejected_spec_json: str | None = None 

718 

719 @classmethod 

720 def from_view(cls, view: PlacementView) -> PlacementResponse: 

721 """The canonical serialized placement view, shared by the HTTP, MCP, and CLI surfaces.""" 

722 return cls( 

723 gpus=[GpuInfoResponse(**vars(g)) for g in view.gpus], 

724 roles=[ 

725 RolePlacementResponse( 

726 role=r.role, 

727 model=r.model, 

728 devices=list(r.devices), 

729 tensor_split=list(r.tensor_split) if r.tensor_split else None, 

730 replicas=r.replicas, 

731 ) 

732 for r in view.roles 

733 ], 

734 unplaceable=[r.value for r in view.unplaceable], 

735 manual=view.manual, 

736 spec_json=view.spec_json, 

737 skipped_not_installed=[ 

738 SkippedRoleResponse(role=s.role, model=s.model) for s in view.skipped_not_installed 

739 ], 

740 co_tenants=[r.value for r in view.co_tenants], 

741 rejected_spec_json=view.rejected_spec_json, 

742 ) 

743 

744 

745class PlacementSpecBody(BaseModel): 

746 """Request body for placement routes that accept a manual spec.""" 

747 

748 spec: dict[str, dict[str, object]] | None = None 

749 

750 

751class SessionMetaItem(BaseModel): 

752 """A session's metadata in a list or detail response.""" 

753 

754 id: str 

755 title: str 

756 created_at: str 

757 updated_at: str 

758 model_ref: str 

759 scope: str 

760 message_count: int 

761 origin: str = "tui" 

762 """Owning surface. tui/http/cli are one domain and append freely to each 

763 other's sessions; appends across the human/agent (mcp) boundary are 409.""" 

764 

765 

766class SessionListResponse(BaseModel): 

767 """Body for ``GET /api/sessions``.""" 

768 

769 sessions: list[SessionMetaItem] 

770 

771 

772class SessionMessageItem(BaseModel): 

773 """One message in a session transcript.""" 

774 

775 role: MessageRole 

776 content: str 

777 sources: list[str] 

778 ts: str 

779 

780 

781class SessionDetailResponse(BaseModel): 

782 """Body for ``GET /api/sessions/{session_id}``: metadata plus transcript. 

783 

784 ``summary`` carries what compaction folded the oldest turns into (empty when 

785 a conversation has not been compacted). A client that resumes and continues 

786 the conversation needs it: without it, it rebuilds history from the raw 

787 transcript, re-sending turns the summary had already condensed and risking 

788 the context overflow compaction exists to prevent. 

789 """ 

790 

791 meta: SessionMetaItem 

792 messages: list[SessionMessageItem] 

793 summary: str = "" 

794 

795 

796class SessionCreateRequest(BaseModel): 

797 """Request body for ``POST /api/sessions``.""" 

798 

799 model_ref: str 

800 scope: str 

801 

802 

803class SessionMessageCreateRequest(BaseModel): 

804 """Request body for ``POST /api/sessions/{session_id}/messages``.""" 

805 

806 role: MessageRole 

807 content: str 

808 sources: list[str] = [] 

809 

810 

811class SessionSummaryRequest(BaseModel): 

812 """Request body for ``PUT /api/sessions/{session_id}/summary``.""" 

813 

814 summary: str 

815 

816 

817class SessionRenameRequest(BaseModel): 

818 """Request body for ``PATCH /api/sessions/{session_id}``.""" 

819 

820 title: str 

821 

822 

823class SessionRenameResponse(BaseModel): 

824 """Outcome of a rename.""" 

825 

826 id: str 

827 title: str 

828 

829 

830class SessionDeleteResponse(BaseModel): 

831 """Outcome of a delete.""" 

832 

833 id: str 

834 deleted: bool 

835 

836 

837class AgentClientDetection(BaseModel): 

838 """Whether one agent client's CLI is installed on the machine lilbee runs on.""" 

839 

840 client: AgentClient 

841 cli_detected: bool 

842 cli_path: str | None 

843 

844 

845class AgentConfigIndexResponse(BaseModel): 

846 """Response for ``GET /api/agent-config``: every client lilbee can configure.""" 

847 

848 clients: list[AgentClientDetection] 

849 

850 @classmethod 

851 def from_detections(cls, detections: list[ClientDetection]) -> AgentConfigIndexResponse: 

852 """Serialize the probe results one entry per supported client.""" 

853 return cls( 

854 clients=[ 

855 AgentClientDetection( 

856 client=found.client, 

857 cli_detected=found.cli_detected, 

858 cli_path=found.cli_path, 

859 ) 

860 for found in detections 

861 ] 

862 ) 

863 

864 

865class AgentConfigResponse(BaseModel): 

866 """Response for ``GET /api/agent-config/{client}``: one client's live config. 

867 

868 ``config`` carries the block for a JSON client, ``content`` the rendered text 

869 for a YAML one. ``stdio_config`` is the alternative block for a client that 

870 can also run lilbee as a subprocess instead of calling this server. 

871 """ 

872 

873 client: AgentClient 

874 format: ConfigFormat 

875 surfaces: list[AgentSurface] 

876 config: dict[str, Any] | None = None 

877 content: str | None = None 

878 stdio_config: dict[str, Any] | None = None 

879 

880 @classmethod 

881 def from_document(cls, document: AgentConfigDocument) -> AgentConfigResponse: 

882 """The canonical serialized config document, shared by the HTTP and CLI surfaces.""" 

883 return cls( 

884 client=document.client, 

885 format=document.format, 

886 surfaces=list(document.surfaces), 

887 config=document.config, 

888 content=document.content, 

889 stdio_config=document.stdio_config, 

890 )