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
« 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.
3Typed pydantic models so Litestar's OpenAPI schema has field-level detail.
4"""
6from __future__ import annotations
8from typing import TYPE_CHECKING, Any, Literal
10from pydantic import BaseModel, Field, field_validator
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
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
27def decode_chunk_type(value: str | None) -> ChunkType | None:
28 """Decode a ``chunk_type`` string into a ``ChunkType`` at the HTTP boundary.
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
44class AskRequest(BaseModel):
45 """Request body for /api/ask."""
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
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)
58class ChatRequest(BaseModel):
59 """Request body for /api/chat."""
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."""
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)
79class SyncRequest(BaseModel):
80 """Request body for /api/sync.
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 """
93 enable_ocr: bool | None = None
94 force_rebuild: bool = False
95 retry_skipped: bool = False
96 prune_ignored: bool = False
99class AddRequest(BaseModel):
100 """Request body for /api/add."""
102 paths: list[str]
103 force: bool = False
104 enable_ocr: bool | None = None
105 ocr_timeout: float | None = None
108class SetModelRequest(BaseModel):
109 """Request body for /api/models/chat."""
111 model: str
114class SourceContentResponse(BaseModel):
115 """JSON body for ``GET /api/source`` (``raw=0``); empty ``markdown`` for binary types."""
117 markdown: str
118 content_type: str
119 title: str | None = None
122class ChatMessage(BaseModel):
123 """A single message in a chat conversation."""
125 role: Literal["user", "assistant"]
126 content: str
129class CleanedChunk(BaseModel):
130 """A search result chunk with vector stripped and distance renamed."""
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
153class StatusSourceInfo(BaseModel):
154 """A single indexed source in a status response."""
156 filename: str
157 file_hash: str
158 chunk_count: int
159 ingested_at: str
162class StatusConfigInfo(BaseModel):
163 """Configuration section of a status response.
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 """
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
178class StatusEntityInfo(BaseModel):
179 """Entity-extraction section of a status response (present when enabled)."""
181 types: list[str]
182 rows: int
185class StatusResponse(BaseModel):
186 """Response for GET /api/status."""
188 command: str = "status"
189 config: StatusConfigInfo
190 sources: list[StatusSourceInfo]
191 total_chunks: int
192 entities: StatusEntityInfo | None = None
195class ShutdownResponse(BaseModel):
196 """Response for /api/shutdown."""
198 status: Literal["shutting_down"]
201class HealthResponse(BaseModel):
202 """Response for /api/health."""
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.
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.
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."""
240class CompactionInfo(BaseModel):
241 """What one pre-turn compaction folded out of a conversation."""
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."""
250class AskResponse(BaseModel):
251 """Response for /api/ask and /api/chat.
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 """
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."""
264class SetModelResponse(BaseModel):
265 """Response for PUT /api/models/{chat|embedding|vision|reranker}.
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 """
273 model: str
274 reindex_required: bool = False
277class ConfigUpdateResponse(BaseModel):
278 """Response for PATCH /api/config."""
280 updated: list[str]
281 reindex_required: bool
284class CrawlRequest(BaseModel):
285 """Request body for /api/crawl.
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 """
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)
302class DocumentInfo(BaseModel):
303 """A single indexed document in a list response."""
305 filename: str
306 chunk_count: int = 0
307 ingested_at: str = ""
310class DocumentListResponse(BaseModel):
311 """Response for GET /api/documents."""
313 documents: list[DocumentInfo]
314 total: int
315 limit: int
316 offset: int
317 has_more: bool = False
320class DocumentRemoveResponse(BaseModel):
321 """Response for POST /api/documents/remove."""
323 removed: list[str]
324 not_found: list[str]
327class ConfigResponse(BaseModel):
328 """Response for GET /api/config."""
330 model_config = {"extra": "allow"}
333class ModelsShowResponse(BaseModel):
334 """Response for POST /api/models/show."""
336 model_config = {"extra": "allow"}
339class CatalogEntryResponse(BaseModel):
340 """A single model in the catalog browser.
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 """
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
370class ModelsCatalogResponse(BaseModel):
371 """Response for GET /api/models/catalog."""
373 total: int
374 limit: int
375 offset: int
376 models: list[CatalogEntryResponse]
377 has_more: bool = False
380class InstalledModelEntry(BaseModel):
381 """A single installed model."""
383 name: str
384 source: ModelSource
387class ModelsInstalledResponse(BaseModel):
388 """Response for GET /api/models/installed."""
390 models: list[InstalledModelEntry]
393class ModelsDeleteResponse(BaseModel):
394 """Response for DELETE /api/models/{model}."""
396 deleted: bool
397 model: str
398 freed_gb: float
401class ExternalModelsResponse(BaseModel):
402 """Response for GET /api/models/external."""
404 models: list[str]
405 error: str | None = None
408class SyncSummary(BaseModel):
409 """Embedded sync result within an add-files response."""
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
421class AddSummary(BaseModel):
422 """Summary returned by the add-files handler."""
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.
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.
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 """
445class WikiCitationRecord(BaseModel):
446 """A citation record from the store, used in reverse lookup responses."""
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 = ""
462class WikiEntityCandidateResponse(BaseModel):
463 """One NER entity candidate, with the evidence a page would be built from."""
465 slug: str
466 label: str = ""
467 kind: EntityKind = EntityKind.ENTITY
468 type_hint: str = ""
469 mentions: int = 0
470 sources: list[str] = []
473class WikiBuildDryRunResult(BaseModel):
474 """Entity candidates a build would cover, with no LLM call made."""
476 dry_run: bool = True
477 entities: list[WikiEntityCandidateResponse] = []
478 count: int = 0
479 note: str = ""
482class WikiPageDetail(BaseModel):
483 """Full content of a single wiki page, with its parsed frontmatter."""
485 slug: str
486 title: str = ""
487 content: str = ""
488 frontmatter: dict[str, Any] = {}
491class WikiCitationsResult(BaseModel):
492 """Citations attached to a single wiki page."""
494 slug: str
495 citations: list[WikiCitationRecord] = []
498class WikiLintIssueItem(BaseModel):
499 """A single lint finding on a wiki page."""
501 wiki_source: str = ""
502 issue_type: str = ""
503 severity: str = ""
504 message: str = ""
507class WikiLintResult(BaseModel):
508 """Result of a wiki lint run, whole-wiki or single-page."""
510 issues: list[WikiLintIssueItem] = []
511 total: int = 0
512 errors: int = 0
513 warnings: int = 0
516class WikiPruneRecordResponse(BaseModel):
517 """A single pruning action."""
519 wiki_source: str
520 action: str
521 reason: str
524class WikiPruneResult(BaseModel):
525 """Result of wiki pruning."""
527 records: list[WikiPruneRecordResponse] = []
528 archived: int = 0
529 flagged: int = 0
530 reconciled: int = 0
533class WikiIndexResult(BaseModel):
534 """Result of rebuilding the browse index. Costs no LLM call."""
536 entries: int = 0
539class WikiGenerateResult(BaseModel):
540 """Result of generating one indexed page."""
542 slug: str
543 path: str
546class WikiWipeResult(BaseModel):
547 """Result of wiping the wiki.
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 """
553 pages_removed: int = 0
554 sources_cleared: int = 0
555 rows_deleted: bool = True
558class WikiStatusResult(BaseModel):
559 """Wiki layer status counters."""
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
569class DraftInfoResponse(BaseModel):
570 """Metadata about a single wiki draft, mirroring ``DraftInfo.to_dict()``.
572 ``pending_kind`` distinguishes drift drafts (``None``) from
573 batched-generation markers (``"parse"``, ``"collision"``).
574 """
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
587class WikiDraftDiffResponse(BaseModel):
588 """Unified diff of a draft against its published counterpart."""
590 slug: str
591 diff: str
594class WikiDraftAcceptResponse(BaseModel):
595 """Outcome of accepting a draft: where it landed and how many chunks reindexed.
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 """
603 slug: str
604 requested_slug: str
605 moved_to: str
606 reindexed_chunks: int
609class WikiDraftRejectResponse(BaseModel):
610 """Outcome of rejecting a draft."""
612 slug: str
615class RememberRequest(BaseModel):
616 """Request body for ``POST /api/memories``."""
618 text: str
619 kind: MemoryKind = MemoryKind.FACT
620 shared: bool = False
623class RememberResponse(BaseModel):
624 """Outcome of storing a memory."""
626 id: str
627 kind: MemoryKind
630class MemoryItem(BaseModel):
631 """A single stored memory in a list response."""
633 id: str
634 kind: MemoryKind
635 shared: bool
636 text: str
639class MemoryListResponse(BaseModel):
640 """Body for ``GET /api/memories``."""
642 memories: list[MemoryItem]
645class MemorySharedRequest(BaseModel):
646 """Request body for ``PATCH /api/memories/{memory_id}``."""
648 shared: bool
651class MemoryFlagsResponse(BaseModel):
652 """Outcome of a flag update; ``updated`` is False when the id was unknown."""
654 id: str
655 updated: bool
658class MemoryRemoveResponse(BaseModel):
659 """Outcome of deleting a memory; ``deleted`` is False when the id was unknown."""
661 id: str
662 deleted: bool
665class MemoryExtractedItem(BaseModel):
666 """A single memory created by auto-extraction during a chat turn."""
668 id: str
669 kind: MemoryKind
670 text: str
673class MemoryExtractedEvent(BaseModel):
674 """``memory_extracted`` SSE payload: how many memories a turn auto-saved.
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 """
681 count: int
682 items: list[MemoryExtractedItem]
685class GpuInfoResponse(BaseModel):
686 """One GPU as returned by GET /api/gpus and embedded in PlacementResponse."""
688 index: int
689 backend: str
690 label: str
691 name: str
692 total_bytes: int
693 free_bytes: int
696class GpusResponse(BaseModel):
697 """GET /api/gpus envelope: detected GPUs plus the host-level util notice."""
699 gpus: list[GpuInfoResponse]
700 notice: str | None = None
703class RolePlacementResponse(BaseModel):
704 """Where one role's model is placed in the resolved plan."""
706 role: WorkerRole
707 model: str
708 devices: list[int]
709 tensor_split: list[int] | None
710 replicas: int
713class SkippedRoleResponse(BaseModel):
714 """A configured role left unplaced because its model isn't downloaded."""
716 role: WorkerRole
717 model: str
720class PlacementResponse(BaseModel):
721 """Response for placement read, preview, set, and clear routes."""
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
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 )
759class PlacementSpecBody(BaseModel):
760 """Request body for placement routes that accept a manual spec."""
762 spec: dict[str, dict[str, object]] | None = None
765class SessionMetaItem(BaseModel):
766 """A session's metadata in a list or detail response."""
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."""
780class SessionListResponse(BaseModel):
781 """Body for ``GET /api/sessions``."""
783 sessions: list[SessionMetaItem]
786class SessionMessageItem(BaseModel):
787 """One message in a session transcript."""
789 role: MessageRole
790 content: str
791 sources: list[str]
792 ts: str
795class SessionDetailResponse(BaseModel):
796 """Body for ``GET /api/sessions/{session_id}``: metadata plus transcript.
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 """
805 meta: SessionMetaItem
806 messages: list[SessionMessageItem]
807 summary: str = ""
810class SessionCreateRequest(BaseModel):
811 """Request body for ``POST /api/sessions``."""
813 model_ref: str
814 scope: str
817class SessionMessageCreateRequest(BaseModel):
818 """Request body for ``POST /api/sessions/{session_id}/messages``."""
820 role: MessageRole
821 content: str
822 sources: list[str] = []
825class SessionSummaryRequest(BaseModel):
826 """Request body for ``PUT /api/sessions/{session_id}/summary``."""
828 summary: str
831class SessionRenameRequest(BaseModel):
832 """Request body for ``PATCH /api/sessions/{session_id}``."""
834 title: str
837class SessionRenameResponse(BaseModel):
838 """Outcome of a rename."""
840 id: str
841 title: str
844class SessionDeleteResponse(BaseModel):
845 """Outcome of a delete."""
847 id: str
848 deleted: bool
851class AgentClientDetection(BaseModel):
852 """Whether one agent client's CLI is installed on the machine lilbee runs on."""
854 client: AgentClient
855 cli_detected: bool
856 cli_path: str | None
859class AgentConfigIndexResponse(BaseModel):
860 """Response for ``GET /api/agent-config``: every client lilbee can configure."""
862 clients: list[AgentClientDetection]
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 )
879class AgentConfigResponse(BaseModel):
880 """Response for ``GET /api/agent-config/{client}``: one client's live config.
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 """
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
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 )