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

418 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-31 21:55 +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 ``prune_ignored`` drops sources a ``.lilbeeignore`` now excludes. Off by default: 

90 the patterns govern what sync takes in, not what a past sync already indexed. 

91 """ 

92 

93 enable_ocr: bool | None = None 

94 force_rebuild: bool = False 

95 retry_skipped: bool = False 

96 prune_ignored: bool = False 

97 

98 

99class AddRequest(BaseModel): 

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

101 

102 paths: list[str] 

103 force: bool = False 

104 enable_ocr: bool | None = None 

105 ocr_timeout: float | None = None 

106 

107 

108class SetModelRequest(BaseModel): 

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

110 

111 model: str 

112 

113 

114class SourceContentResponse(BaseModel): 

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

116 

117 markdown: str 

118 content_type: str 

119 title: str | None = None 

120 

121 

122class ChatMessage(BaseModel): 

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

124 

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

126 content: str 

127 

128 

129class CleanedChunk(BaseModel): 

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

131 

132 source: str 

133 content_type: str 

134 chunk: str 

135 distance: float | None = None 

136 relevance_score: float | None = None 

137 rerank_score: float | None = None 

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

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

140 score: float | None = None 

141 page_start: int = 0 

142 page_end: int = 0 

143 line_start: int = 0 

144 line_end: int = 0 

145 chunk_index: int = 0 

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

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

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

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

150 vault_path: str | None = None 

151 

152 

153class StatusSourceInfo(BaseModel): 

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

155 

156 filename: str 

157 file_hash: str 

158 chunk_count: int 

159 ingested_at: str 

160 

161 

162class StatusConfigInfo(BaseModel): 

163 """Configuration section of a status response. 

164 

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

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

167 """ 

168 

169 documents_dir: str 

170 data_dir: str 

171 chat_model: str 

172 embedding_model: str 

173 vision_model: str = "" 

174 reranker_model: str = "" 

175 enable_ocr: bool | None = None 

176 

177 

178class StatusEntityInfo(BaseModel): 

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

180 

181 types: list[str] 

182 rows: int 

183 

184 

185class StatusResponse(BaseModel): 

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

187 

188 command: str = "status" 

189 config: StatusConfigInfo 

190 sources: list[StatusSourceInfo] 

191 total_chunks: int 

192 entities: StatusEntityInfo | None = None 

193 

194 

195class ShutdownResponse(BaseModel): 

196 """Response for /api/shutdown.""" 

197 

198 status: Literal["shutting_down"] 

199 

200 

201class HealthResponse(BaseModel): 

202 """Response for /api/health.""" 

203 

204 status: str 

205 version: str 

206 chat_ready: bool = False 

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

208 

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

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

211 """ 

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

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

214 

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

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

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

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

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

220 chat_error: str | None = None 

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

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

223 the cause instead of retrying forever.""" 

224 chat_ctx: int | None = None 

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

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

227 chat_slots: int | None = None 

228 """Batching slots the chat engine serves (its real request concurrency), so a 

229 script driving parallel agents can read the granted shape instead of assuming 

230 the configured one. None until the engine is up.""" 

231 chat_prefill_processed: int | None = None 

232 """Prompt tokens the chat engine has processed for a prefill in flight. A 

233 large model's first agent turn can spend minutes here with nothing streamed; 

234 polling this tells a working engine apart from a hung one. None when idle.""" 

235 chat_prefill_total: int | None = None 

236 """Prompt tokens the in-flight chat prefill will process in total. None when 

237 no prefill is running.""" 

238 

239 

240class CompactionInfo(BaseModel): 

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

242 

243 summary: str 

244 condensed: int 

245 """Turns folded into the notes.""" 

246 stranded: int 

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

248 

249 

250class AskResponse(BaseModel): 

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

252 

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

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

255 """ 

256 

257 answer: str 

258 sources: list[CleanedChunk] 

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

260 compaction: CompactionInfo | None = None 

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

262 

263 

264class SetModelResponse(BaseModel): 

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

266 

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

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

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

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

271 """ 

272 

273 model: str 

274 reindex_required: bool = False 

275 

276 

277class ConfigUpdateResponse(BaseModel): 

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

279 

280 updated: list[str] 

281 reindex_required: bool 

282 

283 

284class CrawlRequest(BaseModel): 

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

286 

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

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

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

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

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

292 with JavaScript. 

293 """ 

294 

295 url: str 

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

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

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

299 include_subdomains: bool = Field(default=False) 

300 

301 

302class DocumentInfo(BaseModel): 

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

304 

305 filename: str 

306 chunk_count: int = 0 

307 ingested_at: str = "" 

308 

309 

310class DocumentListResponse(BaseModel): 

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

312 

313 documents: list[DocumentInfo] 

314 total: int 

315 limit: int 

316 offset: int 

317 has_more: bool = False 

318 

319 

320class DocumentRemoveResponse(BaseModel): 

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

322 

323 removed: list[str] 

324 not_found: list[str] 

325 

326 

327class ConfigResponse(BaseModel): 

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

329 

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

331 

332 

333class ModelsShowResponse(BaseModel): 

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

335 

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

337 

338 

339class CatalogEntryResponse(BaseModel): 

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

341 

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

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

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

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

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

347 """ 

348 

349 hf_repo: str 

350 gguf_filename: str 

351 task: ModelTask 

352 display_name: str 

353 param_count: str 

354 size_gb: float 

355 min_ram_gb: float 

356 description: str 

357 quality_tier: str 

358 featured: bool 

359 downloads: int 

360 installed: bool 

361 source: ModelSource 

362 fit: FitLevel | None = None 

363 size_variants: list[SizeVariantInfo] = [] 

364 architecture: str = "" 

365 compat: ModelCompat = ModelCompat.UNKNOWN 

366 provider: str = "" 

367 key_status: KeyStatus | None = None 

368 

369 

370class ModelsCatalogResponse(BaseModel): 

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

372 

373 total: int 

374 limit: int 

375 offset: int 

376 models: list[CatalogEntryResponse] 

377 has_more: bool = False 

378 

379 

380class InstalledModelEntry(BaseModel): 

381 """A single installed model.""" 

382 

383 name: str 

384 source: ModelSource 

385 

386 

387class ModelsInstalledResponse(BaseModel): 

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

389 

390 models: list[InstalledModelEntry] 

391 

392 

393class ModelsDeleteResponse(BaseModel): 

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

395 

396 deleted: bool 

397 model: str 

398 freed_gb: float 

399 

400 

401class ExternalModelsResponse(BaseModel): 

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

403 

404 models: list[str] 

405 error: str | None = None 

406 

407 

408class SyncSummary(BaseModel): 

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

410 

411 added: list[str] = [] 

412 updated: list[str] = [] 

413 removed: list[str] = [] 

414 unchanged: int = 0 

415 relocated: list[str] = [] 

416 failed: list[str] = [] 

417 skipped: list[str] = [] 

418 truncated: int = 0 

419 

420 

421class AddSummary(BaseModel): 

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

423 

424 copied: list[str] 

425 skipped: list[str] 

426 errors: list[str] 

427 tracked: list[str] = [] 

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

429 

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

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

432 the sync in the same request covers them. 

433 """ 

434 sync: SyncSummary | None = None 

435 already_ingesting: list[str] = [] 

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

437 

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

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

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

441 frames can still tell the batch was partial. 

442 """ 

443 

444 

445class WikiCitationRecord(BaseModel): 

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

447 

448 wiki_source: str = "" 

449 wiki_chunk_index: int = 0 

450 citation_key: str = "" 

451 claim_type: str = "fact" 

452 source_filename: str = "" 

453 source_hash: str = "" 

454 page_start: int = 0 

455 page_end: int = 0 

456 line_start: int = 0 

457 line_end: int = 0 

458 excerpt: str = "" 

459 created_at: str = "" 

460 

461 

462class WikiEntityCandidateResponse(BaseModel): 

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

464 

465 slug: str 

466 label: str = "" 

467 kind: EntityKind = EntityKind.ENTITY 

468 type_hint: str = "" 

469 mentions: int = 0 

470 sources: list[str] = [] 

471 

472 

473class WikiBuildDryRunResult(BaseModel): 

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

475 

476 dry_run: bool = True 

477 entities: list[WikiEntityCandidateResponse] = [] 

478 count: int = 0 

479 note: str = "" 

480 

481 

482class WikiPageDetail(BaseModel): 

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

484 

485 slug: str 

486 title: str = "" 

487 content: str = "" 

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

489 

490 

491class WikiCitationsResult(BaseModel): 

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

493 

494 slug: str 

495 citations: list[WikiCitationRecord] = [] 

496 

497 

498class WikiLintIssueItem(BaseModel): 

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

500 

501 wiki_source: str = "" 

502 issue_type: str = "" 

503 severity: str = "" 

504 message: str = "" 

505 

506 

507class WikiLintResult(BaseModel): 

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

509 

510 issues: list[WikiLintIssueItem] = [] 

511 total: int = 0 

512 errors: int = 0 

513 warnings: int = 0 

514 

515 

516class WikiPruneRecordResponse(BaseModel): 

517 """A single pruning action.""" 

518 

519 wiki_source: str 

520 action: str 

521 reason: str 

522 

523 

524class WikiPruneResult(BaseModel): 

525 """Result of wiki pruning.""" 

526 

527 records: list[WikiPruneRecordResponse] = [] 

528 archived: int = 0 

529 flagged: int = 0 

530 reconciled: int = 0 

531 

532 

533class WikiIndexResult(BaseModel): 

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

535 

536 entries: int = 0 

537 

538 

539class WikiGenerateResult(BaseModel): 

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

541 

542 slug: str 

543 path: str 

544 

545 

546class WikiWipeResult(BaseModel): 

547 """Result of wiping the wiki. 

548 

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

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

551 """ 

552 

553 pages_removed: int = 0 

554 sources_cleared: int = 0 

555 rows_deleted: bool = True 

556 

557 

558class WikiStatusResult(BaseModel): 

559 """Wiki layer status counters.""" 

560 

561 wiki_enabled: bool 

562 summaries: int = 0 

563 drafts: int = 0 

564 pages: int = 0 

565 lint_errors: int = 0 

566 lint_warnings: int = 0 

567 

568 

569class DraftInfoResponse(BaseModel): 

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

571 

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

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

574 """ 

575 

576 slug: str 

577 path: str 

578 drift_ratio: float | None = None 

579 faithfulness_score: float | None = None 

580 bad_title: bool = False 

581 published_path: str | None = None 

582 published_exists: bool = False 

583 mtime: float = 0.0 

584 pending_kind: str | None = None 

585 

586 

587class WikiDraftDiffResponse(BaseModel): 

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

589 

590 slug: str 

591 diff: str 

592 

593 

594class WikiDraftAcceptResponse(BaseModel): 

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

596 

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

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

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

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

601 """ 

602 

603 slug: str 

604 requested_slug: str 

605 moved_to: str 

606 reindexed_chunks: int 

607 

608 

609class WikiDraftRejectResponse(BaseModel): 

610 """Outcome of rejecting a draft.""" 

611 

612 slug: str 

613 

614 

615class RememberRequest(BaseModel): 

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

617 

618 text: str 

619 kind: MemoryKind = MemoryKind.FACT 

620 shared: bool = False 

621 

622 

623class RememberResponse(BaseModel): 

624 """Outcome of storing a memory.""" 

625 

626 id: str 

627 kind: MemoryKind 

628 

629 

630class MemoryItem(BaseModel): 

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

632 

633 id: str 

634 kind: MemoryKind 

635 shared: bool 

636 text: str 

637 

638 

639class MemoryListResponse(BaseModel): 

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

641 

642 memories: list[MemoryItem] 

643 

644 

645class MemorySharedRequest(BaseModel): 

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

647 

648 shared: bool 

649 

650 

651class MemoryFlagsResponse(BaseModel): 

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

653 

654 id: str 

655 updated: bool 

656 

657 

658class MemoryRemoveResponse(BaseModel): 

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

660 

661 id: str 

662 deleted: bool 

663 

664 

665class MemoryExtractedItem(BaseModel): 

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

667 

668 id: str 

669 kind: MemoryKind 

670 text: str 

671 

672 

673class MemoryExtractedEvent(BaseModel): 

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

675 

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

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

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

679 """ 

680 

681 count: int 

682 items: list[MemoryExtractedItem] 

683 

684 

685class GpuInfoResponse(BaseModel): 

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

687 

688 index: int 

689 backend: str 

690 label: str 

691 name: str 

692 total_bytes: int 

693 free_bytes: int 

694 

695 

696class GpusResponse(BaseModel): 

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

698 

699 gpus: list[GpuInfoResponse] 

700 notice: str | None = None 

701 

702 

703class RolePlacementResponse(BaseModel): 

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

705 

706 role: WorkerRole 

707 model: str 

708 devices: list[int] 

709 tensor_split: list[int] | None 

710 replicas: int 

711 

712 

713class SkippedRoleResponse(BaseModel): 

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

715 

716 role: WorkerRole 

717 model: str 

718 

719 

720class PlacementResponse(BaseModel): 

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

722 

723 gpus: list[GpuInfoResponse] 

724 roles: list[RolePlacementResponse] 

725 unplaceable: list[str] 

726 manual: bool 

727 spec_json: str | None 

728 skipped_not_installed: list[SkippedRoleResponse] = [] 

729 co_tenants: list[str] = [] 

730 notice: str | None = None 

731 rejected_spec_json: str | None = None 

732 

733 @classmethod 

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

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

736 return cls( 

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

738 roles=[ 

739 RolePlacementResponse( 

740 role=r.role, 

741 model=r.model, 

742 devices=list(r.devices), 

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

744 replicas=r.replicas, 

745 ) 

746 for r in view.roles 

747 ], 

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

749 manual=view.manual, 

750 spec_json=view.spec_json, 

751 skipped_not_installed=[ 

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

753 ], 

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

755 rejected_spec_json=view.rejected_spec_json, 

756 ) 

757 

758 

759class PlacementSpecBody(BaseModel): 

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

761 

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

763 

764 

765class SessionMetaItem(BaseModel): 

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

767 

768 id: str 

769 title: str 

770 created_at: str 

771 updated_at: str 

772 model_ref: str 

773 scope: str 

774 message_count: int 

775 origin: str = "tui" 

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

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

778 

779 

780class SessionListResponse(BaseModel): 

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

782 

783 sessions: list[SessionMetaItem] 

784 

785 

786class SessionMessageItem(BaseModel): 

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

788 

789 role: MessageRole 

790 content: str 

791 sources: list[str] 

792 ts: str 

793 

794 

795class SessionDetailResponse(BaseModel): 

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

797 

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

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

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

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

802 the context overflow compaction exists to prevent. 

803 """ 

804 

805 meta: SessionMetaItem 

806 messages: list[SessionMessageItem] 

807 summary: str = "" 

808 

809 

810class SessionCreateRequest(BaseModel): 

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

812 

813 model_ref: str 

814 scope: str 

815 

816 

817class SessionMessageCreateRequest(BaseModel): 

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

819 

820 role: MessageRole 

821 content: str 

822 sources: list[str] = [] 

823 

824 

825class SessionSummaryRequest(BaseModel): 

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

827 

828 summary: str 

829 

830 

831class SessionRenameRequest(BaseModel): 

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

833 

834 title: str 

835 

836 

837class SessionRenameResponse(BaseModel): 

838 """Outcome of a rename.""" 

839 

840 id: str 

841 title: str 

842 

843 

844class SessionDeleteResponse(BaseModel): 

845 """Outcome of a delete.""" 

846 

847 id: str 

848 deleted: bool 

849 

850 

851class AgentClientDetection(BaseModel): 

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

853 

854 client: AgentClient 

855 cli_detected: bool 

856 cli_path: str | None 

857 

858 

859class AgentConfigIndexResponse(BaseModel): 

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

861 

862 clients: list[AgentClientDetection] 

863 

864 @classmethod 

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

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

867 return cls( 

868 clients=[ 

869 AgentClientDetection( 

870 client=found.client, 

871 cli_detected=found.cli_detected, 

872 cli_path=found.cli_path, 

873 ) 

874 for found in detections 

875 ] 

876 ) 

877 

878 

879class AgentConfigResponse(BaseModel): 

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

881 

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

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

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

885 """ 

886 

887 client: AgentClient 

888 format: ConfigFormat 

889 surfaces: list[AgentSurface] 

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

891 content: str | None = None 

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

893 

894 @classmethod 

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

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

897 return cls( 

898 client=document.client, 

899 format=document.format, 

900 surfaces=list(document.surfaces), 

901 config=document.config, 

902 content=document.content, 

903 stdio_config=document.stdio_config, 

904 )