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
« 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.
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 """
91 enable_ocr: bool | None = None
92 force_rebuild: bool = False
93 retry_skipped: bool = False
96class AddRequest(BaseModel):
97 """Request body for /api/add."""
99 paths: list[str]
100 force: bool = False
101 enable_ocr: bool | None = None
102 ocr_timeout: float | None = None
105class SetModelRequest(BaseModel):
106 """Request body for /api/models/chat."""
108 model: str
111class SourceContentResponse(BaseModel):
112 """JSON body for ``GET /api/source`` (``raw=0``); empty ``markdown`` for binary types."""
114 markdown: str
115 content_type: str
116 title: str | None = None
119class ChatMessage(BaseModel):
120 """A single message in a chat conversation."""
122 role: Literal["user", "assistant"]
123 content: str
126class CleanedChunk(BaseModel):
127 """A search result chunk with vector stripped and distance renamed."""
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
150class StatusSourceInfo(BaseModel):
151 """A single indexed source in a status response."""
153 filename: str
154 file_hash: str
155 chunk_count: int
156 ingested_at: str
159class StatusConfigInfo(BaseModel):
160 """Configuration section of a status response.
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 """
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
175class StatusEntityInfo(BaseModel):
176 """Entity-extraction section of a status response (present when enabled)."""
178 types: list[str]
179 rows: int
182class StatusResponse(BaseModel):
183 """Response for GET /api/status."""
185 command: str = "status"
186 config: StatusConfigInfo
187 sources: list[StatusSourceInfo]
188 total_chunks: int
189 entities: StatusEntityInfo | None = None
192class ShutdownResponse(BaseModel):
193 """Response for /api/shutdown."""
195 status: Literal["shutting_down"]
198class HealthResponse(BaseModel):
199 """Response for /api/health."""
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.
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.
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."""
226class CompactionInfo(BaseModel):
227 """What one pre-turn compaction folded out of a conversation."""
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."""
236class AskResponse(BaseModel):
237 """Response for /api/ask and /api/chat.
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 """
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."""
250class SetModelResponse(BaseModel):
251 """Response for PUT /api/models/{chat|embedding|vision|reranker}.
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 """
259 model: str
260 reindex_required: bool = False
263class ConfigUpdateResponse(BaseModel):
264 """Response for PATCH /api/config."""
266 updated: list[str]
267 reindex_required: bool
270class CrawlRequest(BaseModel):
271 """Request body for /api/crawl.
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 """
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)
288class DocumentInfo(BaseModel):
289 """A single indexed document in a list response."""
291 filename: str
292 chunk_count: int = 0
293 ingested_at: str = ""
296class DocumentListResponse(BaseModel):
297 """Response for GET /api/documents."""
299 documents: list[DocumentInfo]
300 total: int
301 limit: int
302 offset: int
303 has_more: bool = False
306class DocumentRemoveResponse(BaseModel):
307 """Response for POST /api/documents/remove."""
309 removed: list[str]
310 not_found: list[str]
313class ConfigResponse(BaseModel):
314 """Response for GET /api/config."""
316 model_config = {"extra": "allow"}
319class ModelsShowResponse(BaseModel):
320 """Response for POST /api/models/show."""
322 model_config = {"extra": "allow"}
325class CatalogEntryResponse(BaseModel):
326 """A single model in the catalog browser.
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 """
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
356class ModelsCatalogResponse(BaseModel):
357 """Response for GET /api/models/catalog."""
359 total: int
360 limit: int
361 offset: int
362 models: list[CatalogEntryResponse]
363 has_more: bool = False
366class InstalledModelEntry(BaseModel):
367 """A single installed model."""
369 name: str
370 source: ModelSource
373class ModelsInstalledResponse(BaseModel):
374 """Response for GET /api/models/installed."""
376 models: list[InstalledModelEntry]
379class ModelsDeleteResponse(BaseModel):
380 """Response for DELETE /api/models/{model}."""
382 deleted: bool
383 model: str
384 freed_gb: float
387class ExternalModelsResponse(BaseModel):
388 """Response for GET /api/models/external."""
390 models: list[str]
391 error: str | None = None
394class SyncSummary(BaseModel):
395 """Embedded sync result within an add-files response."""
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
407class AddSummary(BaseModel):
408 """Summary returned by the add-files handler."""
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.
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.
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 """
431class WikiCitationRecord(BaseModel):
432 """A citation record from the store, used in reverse lookup responses."""
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 = ""
448class WikiEntityCandidateResponse(BaseModel):
449 """One NER entity candidate, with the evidence a page would be built from."""
451 slug: str
452 label: str = ""
453 kind: EntityKind = EntityKind.ENTITY
454 type_hint: str = ""
455 mentions: int = 0
456 sources: list[str] = []
459class WikiBuildDryRunResult(BaseModel):
460 """Entity candidates a build would cover, with no LLM call made."""
462 dry_run: bool = True
463 entities: list[WikiEntityCandidateResponse] = []
464 count: int = 0
465 note: str = ""
468class WikiPageDetail(BaseModel):
469 """Full content of a single wiki page, with its parsed frontmatter."""
471 slug: str
472 title: str = ""
473 content: str = ""
474 frontmatter: dict[str, Any] = {}
477class WikiCitationsResult(BaseModel):
478 """Citations attached to a single wiki page."""
480 slug: str
481 citations: list[WikiCitationRecord] = []
484class WikiLintIssueItem(BaseModel):
485 """A single lint finding on a wiki page."""
487 wiki_source: str = ""
488 issue_type: str = ""
489 severity: str = ""
490 message: str = ""
493class WikiLintResult(BaseModel):
494 """Result of a wiki lint run, whole-wiki or single-page."""
496 issues: list[WikiLintIssueItem] = []
497 total: int = 0
498 errors: int = 0
499 warnings: int = 0
502class WikiPruneRecordResponse(BaseModel):
503 """A single pruning action."""
505 wiki_source: str
506 action: str
507 reason: str
510class WikiPruneResult(BaseModel):
511 """Result of wiki pruning."""
513 records: list[WikiPruneRecordResponse] = []
514 archived: int = 0
515 flagged: int = 0
516 reconciled: int = 0
519class WikiIndexResult(BaseModel):
520 """Result of rebuilding the browse index. Costs no LLM call."""
522 entries: int = 0
525class WikiGenerateResult(BaseModel):
526 """Result of generating one indexed page."""
528 slug: str
529 path: str
532class WikiWipeResult(BaseModel):
533 """Result of wiping the wiki.
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 """
539 pages_removed: int = 0
540 sources_cleared: int = 0
541 rows_deleted: bool = True
544class WikiStatusResult(BaseModel):
545 """Wiki layer status counters."""
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
555class DraftInfoResponse(BaseModel):
556 """Metadata about a single wiki draft, mirroring ``DraftInfo.to_dict()``.
558 ``pending_kind`` distinguishes drift drafts (``None``) from
559 batched-generation markers (``"parse"``, ``"collision"``).
560 """
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
573class WikiDraftDiffResponse(BaseModel):
574 """Unified diff of a draft against its published counterpart."""
576 slug: str
577 diff: str
580class WikiDraftAcceptResponse(BaseModel):
581 """Outcome of accepting a draft: where it landed and how many chunks reindexed.
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 """
589 slug: str
590 requested_slug: str
591 moved_to: str
592 reindexed_chunks: int
595class WikiDraftRejectResponse(BaseModel):
596 """Outcome of rejecting a draft."""
598 slug: str
601class RememberRequest(BaseModel):
602 """Request body for ``POST /api/memories``."""
604 text: str
605 kind: MemoryKind = MemoryKind.FACT
606 shared: bool = False
609class RememberResponse(BaseModel):
610 """Outcome of storing a memory."""
612 id: str
613 kind: MemoryKind
616class MemoryItem(BaseModel):
617 """A single stored memory in a list response."""
619 id: str
620 kind: MemoryKind
621 shared: bool
622 text: str
625class MemoryListResponse(BaseModel):
626 """Body for ``GET /api/memories``."""
628 memories: list[MemoryItem]
631class MemorySharedRequest(BaseModel):
632 """Request body for ``PATCH /api/memories/{memory_id}``."""
634 shared: bool
637class MemoryFlagsResponse(BaseModel):
638 """Outcome of a flag update; ``updated`` is False when the id was unknown."""
640 id: str
641 updated: bool
644class MemoryRemoveResponse(BaseModel):
645 """Outcome of deleting a memory; ``deleted`` is False when the id was unknown."""
647 id: str
648 deleted: bool
651class MemoryExtractedItem(BaseModel):
652 """A single memory created by auto-extraction during a chat turn."""
654 id: str
655 kind: MemoryKind
656 text: str
659class MemoryExtractedEvent(BaseModel):
660 """``memory_extracted`` SSE payload: how many memories a turn auto-saved.
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 """
667 count: int
668 items: list[MemoryExtractedItem]
671class GpuInfoResponse(BaseModel):
672 """One GPU as returned by GET /api/gpus and embedded in PlacementResponse."""
674 index: int
675 backend: str
676 label: str
677 name: str
678 total_bytes: int
679 free_bytes: int
682class GpusResponse(BaseModel):
683 """GET /api/gpus envelope: detected GPUs plus the host-level util notice."""
685 gpus: list[GpuInfoResponse]
686 notice: str | None = None
689class RolePlacementResponse(BaseModel):
690 """Where one role's model is placed in the resolved plan."""
692 role: WorkerRole
693 model: str
694 devices: list[int]
695 tensor_split: list[int] | None
696 replicas: int
699class SkippedRoleResponse(BaseModel):
700 """A configured role left unplaced because its model isn't downloaded."""
702 role: WorkerRole
703 model: str
706class PlacementResponse(BaseModel):
707 """Response for placement read, preview, set, and clear routes."""
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
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 )
745class PlacementSpecBody(BaseModel):
746 """Request body for placement routes that accept a manual spec."""
748 spec: dict[str, dict[str, object]] | None = None
751class SessionMetaItem(BaseModel):
752 """A session's metadata in a list or detail response."""
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."""
766class SessionListResponse(BaseModel):
767 """Body for ``GET /api/sessions``."""
769 sessions: list[SessionMetaItem]
772class SessionMessageItem(BaseModel):
773 """One message in a session transcript."""
775 role: MessageRole
776 content: str
777 sources: list[str]
778 ts: str
781class SessionDetailResponse(BaseModel):
782 """Body for ``GET /api/sessions/{session_id}``: metadata plus transcript.
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 """
791 meta: SessionMetaItem
792 messages: list[SessionMessageItem]
793 summary: str = ""
796class SessionCreateRequest(BaseModel):
797 """Request body for ``POST /api/sessions``."""
799 model_ref: str
800 scope: str
803class SessionMessageCreateRequest(BaseModel):
804 """Request body for ``POST /api/sessions/{session_id}/messages``."""
806 role: MessageRole
807 content: str
808 sources: list[str] = []
811class SessionSummaryRequest(BaseModel):
812 """Request body for ``PUT /api/sessions/{session_id}/summary``."""
814 summary: str
817class SessionRenameRequest(BaseModel):
818 """Request body for ``PATCH /api/sessions/{session_id}``."""
820 title: str
823class SessionRenameResponse(BaseModel):
824 """Outcome of a rename."""
826 id: str
827 title: str
830class SessionDeleteResponse(BaseModel):
831 """Outcome of a delete."""
833 id: str
834 deleted: bool
837class AgentClientDetection(BaseModel):
838 """Whether one agent client's CLI is installed on the machine lilbee runs on."""
840 client: AgentClient
841 cli_detected: bool
842 cli_path: str | None
845class AgentConfigIndexResponse(BaseModel):
846 """Response for ``GET /api/agent-config``: every client lilbee can configure."""
848 clients: list[AgentClientDetection]
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 )
865class AgentConfigResponse(BaseModel):
866 """Response for ``GET /api/agent-config/{client}``: one client's live config.
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 """
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
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 )