Coverage for src/lilbee/server/models.py: 100%
730 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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.app.models import ModelEntry
14from lilbee.app.profiles import (
15 ActiveProfile,
16 ApplyResult,
17 ChangeRow,
18 DiffRow,
19 DiscardResult,
20 ProfileDiff,
21 ProfileEffect,
22 ProfileLocation,
23 ProfileStatus,
24 SaveResult,
25 credit_line,
26)
27from lilbee.app.settings import SettingInfo, SettingsUpdateResult
28from lilbee.app.settings_map import SettingGroup
29from lilbee.catalog.types import KeyStatus, ModelCompat, ModelSource, ModelTask
30from lilbee.core.config.defaults import MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES
31from lilbee.core.config.enums import (
32 CrawlRenderMode,
33 FtsLanguage,
34 KvCacheType,
35 OcrMode,
36 SettingSource,
37)
38from lilbee.core.health_warnings import HealthWarning
39from lilbee.core.profile_files import ProfileEntry, ProfileFolder, ProfileValidation
40from lilbee.data.store import ChunkType, IndexMismatch, MemoryKind, scope_to_chunk_type
41from lilbee.data.types import SkippedSource
42from lilbee.providers.roles import EngineBackend, WorkerRole
43from lilbee.runtime.hardware import FitLevel, SizeVariantInfo
44from lilbee.runtime.progress import LibraryUpgradeEvent
45from lilbee.sessions import MessageRole
46from lilbee.wiki.entity_extractor import EntityKind
48if TYPE_CHECKING:
49 from lilbee.app.agent_configs.detect import ClientDetection
50 from lilbee.app.agent_configs.document import AgentConfigDocument
51 from lilbee.app.analyze import (
52 AnalyzeReport,
53 LanguageRow,
54 Recommendation,
55 SavedProfile,
56 TipState,
57 )
58 from lilbee.app.placement import PlacementView
59 from lilbee.data.analyze import PdfSignals
62def decode_chunk_type(value: str | None) -> ChunkType | None:
63 """Decode a ``chunk_type`` string into a ``ChunkType`` at the HTTP boundary.
65 Delegates to the canonical :func:`scope_to_chunk_type` so query-param and
66 request-body routes share one decoder: only ``"raw"`` or ``"wiki"`` filter
67 the pool; everything else (including ``None`` and the UI-side ``"both"``)
68 means no filter. Any other string raises ``ValueError`` with boundary-
69 friendly guidance.
70 """
71 try:
72 return scope_to_chunk_type(value)
73 except ValueError as exc:
74 raise ValueError(
75 f"chunk_type must be one of 'raw', 'wiki', 'both', or omitted; got {value!r}"
76 ) from exc
79class AskRequest(BaseModel):
80 """Request body for /api/ask."""
82 question: str
83 top_k: int = Field(default=0, ge=0, le=100)
84 options: dict[str, Any] | None = None
85 chunk_type: ChunkType | None = None
87 @field_validator("chunk_type", mode="before")
88 @classmethod
89 def _check_chunk_type(cls, v: str | None) -> ChunkType | None:
90 return decode_chunk_type(v)
93class ChatRequest(BaseModel):
94 """Request body for /api/chat."""
96 question: str
97 history: list[ChatMessage] = []
98 # None (unspecified) grounds with the configured top_k; an explicit 0 is a
99 # pure-LLM call that skips retrieval entirely.
100 top_k: int | None = Field(default=None, ge=0, le=100)
101 options: dict[str, Any] | None = None
102 chunk_type: ChunkType | None = None
103 summary: str = ""
104 """Carry-forward notes from earlier compactions, folded into the prompt."""
105 session_id: str | None = None
106 """Session that receives the new summary when this turn compacts."""
108 @field_validator("chunk_type", mode="before")
109 @classmethod
110 def _check_chunk_type(cls, v: str | None) -> ChunkType | None:
111 return decode_chunk_type(v)
114class _OcrRequest(BaseModel):
115 """Per-request OCR fields shared by /api/sync and /api/add."""
117 ocr: OcrMode | None = None
118 ocr_timeout: float | None = None
121class SyncRequest(_OcrRequest):
122 """Request body for /api/sync.
124 ``force_rebuild`` triggers a full re-ingest equivalent to ``lilbee rebuild``.
125 Use it to recover from an embedding-model switch (when the store refuses search
126 or ingest because ``cfg.embedding_model`` no longer matches the persisted vectors).
127 ``retry_skipped`` is the lighter recovery: it clears the markers for files that
128 failed a previous sync (Tesseract timeout, decode failure, no usable text) so this
129 sync attempts them again, without dropping the existing store. The default is an
130 incremental sync.
131 ``prune_ignored`` drops sources a ``.lilbeeignore`` now excludes. Off by default:
132 the patterns govern what sync takes in, not what a past sync already indexed.
133 """
135 force_rebuild: bool = False
136 retry_skipped: bool = False
137 prune_ignored: bool = False
140class AddRequest(_OcrRequest):
141 """Request body for /api/add."""
143 paths: list[str]
144 force: bool = False
147class SetModelRequest(BaseModel):
148 """Request body for /api/models/chat."""
150 model: str
153class SourceContentResponse(BaseModel):
154 """JSON body for ``GET /api/source`` (``raw=0``); empty ``markdown`` for binary types."""
156 markdown: str
157 content_type: str
158 title: str | None = None
161class ChatMessage(BaseModel):
162 """A single message in a chat conversation."""
164 role: Literal["user", "assistant"]
165 content: str
168class CleanedChunk(BaseModel):
169 """A search result chunk with vector stripped and distance renamed."""
171 source: str
172 content_type: str
173 chunk: str
174 distance: float | None = None
175 relevance_score: float | None = None
176 rerank_score: float | None = None
177 # Canonical [0, 1] relevance from retrieval fusion; the ranking signal
178 # HTTP clients should sort and threshold on (relevance_score is legacy).
179 score: float | None = None
180 page_start: int = 0
181 page_end: int = 0
182 line_start: int = 0
183 line_end: int = 0
184 chunk_index: int = 0
185 # Vault-relative path when ``cfg.vault_base`` is set and the source file
186 # lives inside the vault. Absent when the server is running headless or
187 # the source isn't resolvable as a vault file. Clients use this to open
188 # the source in a native editor instead of fetching ``/api/source``.
189 vault_path: str | None = None
190 # Set only on recalled-memory sources (``source`` is ``memory:<id>``), so
191 # clients can mark them as memory and link them to ``GET /api/memories``.
192 memory_id: str | None = None
193 # The plain one-line name of the source a client shows (``host · title`` for a
194 # crawled page). Never escaped: a client shows it as text.
195 label: str | None = None
196 # The ``[n]`` the answer cites this source by. Absent on a dropped chunk whose
197 # source no chunk in the answer's context holds.
198 citation_number: int | None = None
201class StatusSourceInfo(BaseModel):
202 """A single indexed source in a status response."""
204 filename: str
205 file_hash: str
206 chunk_count: int
207 ingested_at: str
210class StatusConfigInfo(BaseModel):
211 """Configuration section of a status response.
213 Exposes all four role-bound model fields so plugins/TUI can show
214 what's active per role without a second round trip.
215 """
217 documents_dir: str
218 data_dir: str
219 chat_model: str
220 embedding_model: str
221 vision_model: str = ""
222 reranker_model: str = ""
223 ocr: OcrMode = OcrMode.AUTO
224 num_ctx: int | None = None
225 num_ctx_max: int | None = None
226 chat_n_ctx_target: int | None = None
227 flash_attention: bool | None = None
228 kv_cache_type: KvCacheType | None = None
229 n_gpu_layers: int | None = None
230 cpu_moe: bool | None = None
231 n_cpu_moe: int | None = None
232 gpu_devices: str | None = None
235class StatusEntityInfo(BaseModel):
236 """Entity-extraction section of a status response (present when enabled)."""
238 types: list[str]
239 rows: int
242class StatusIndexInfo(BaseModel):
243 """The embedder that built the persisted index."""
245 embedding_model: str
246 embedding_dim: int
249class StatusResponse(BaseModel):
250 """Response for GET /api/status."""
252 command: str = "status"
253 config: StatusConfigInfo
254 sources: list[StatusSourceInfo]
255 """The newest sources, capped; ``document_count`` is the real count."""
256 source_kinds: dict[str, int] = {}
257 """How many sources each kind holds, over the whole library."""
258 document_count: int
259 total_chunks: int
260 index: StatusIndexInfo | None = None
261 """The embedder that built the index; absent before the first sync. Compare it
262 with ``config.embedding_model`` to tell a stale index before a search refuses it."""
263 entities: StatusEntityInfo | None = None
264 skipped: list[SkippedSource] = []
265 """Failed files held out of the index, capped; ``skipped_total`` is the real count."""
266 skipped_total: int = 0
267 ocr_note: str = ""
268 """What happens to scanned pages: skipped, or read by which engine."""
271class ShutdownResponse(BaseModel):
272 """Response for /api/shutdown."""
274 status: Literal["shutting_down"]
277class HealthResponse(BaseModel):
278 """Response for /api/health."""
280 status: str
281 version: str
282 chat_ready: bool = False
283 """True once the chat engine is loaded and ready to serve a first token.
285 A launcher polls this to wait out the cold model load before handing off to
286 a client, so the client never lands on an apparently-dead stream.
287 """
288 chat_status: Literal["ready", "loading", "not_started", "error"] = "not_started"
289 """Finer-grained chat readiness than the ``chat_ready`` bool.
291 Lets a polling client tell a fleet that is still loading (wait) apart from one
292 that never started warming (``not_started`` -- no chat model resolved / planned,
293 so it will not come up on its own) or failed (``error``). Without this a bare
294 ``chat_ready:false`` reads the same for "loading" and "hung", which looked like a
295 silent hang on a fresh box with no chat model installed."""
296 chat_error: str | None = None
297 """The reason the chat engine failed to come up when ``chat_status`` is
298 ``error`` (e.g. a wedged GPU device probe), so a polling client can report
299 the cause instead of retrying forever."""
300 chat_ctx: int | None = None
301 """Per-slot context the chat engine serves, so a launcher can tell the client
302 its window and the client trims history to fit. None until the engine is up."""
303 chat_slots: int | None = None
304 """Batching slots the chat engine serves (its real request concurrency), so a
305 script driving parallel agents can read the granted shape instead of assuming
306 the configured one. None until the engine is up."""
307 chat_prefill_processed: int | None = None
308 """Prompt tokens the chat engine has processed for a prefill in flight. A
309 large model's first agent turn can spend minutes here with nothing streamed;
310 polling this tells a working engine apart from a hung one. None when idle."""
311 chat_prefill_total: int | None = None
312 """Prompt tokens the in-flight chat prefill will process in total. None when
313 no prefill is running."""
314 embed_token_cap: int | None = None
315 """Tokens the embedding engine truncates one input to. The chunker bounds its
316 budget to this, so it is the largest chunk that reaches the index whole. None
317 when no managed embedder is configured."""
318 warnings: list[HealthWarning] = []
319 """Degradations that answer correctly but worse, and a library upgrade that runs.
321 Retrieval falling back to vector-only, or an index whose documents predate
322 the embedder's prefixes, both return results and look healthy."""
323 upgrade: LibraryUpgradeEvent | None = None
324 """The one-time library upgrade while it runs: its phase, tables done of the
325 total, the table in progress and the seconds since it began. None otherwise."""
328class CompactionInfo(BaseModel):
329 """What one pre-turn compaction folded out of a conversation."""
331 summary: str
332 condensed: int
333 """Turns folded into the notes."""
334 stranded: int
335 """Turns dropped with no notes; a client must say so rather than hide it."""
338class AskResponse(BaseModel):
339 """Response for /api/ask and /api/chat.
341 ``sources`` is the full retrieved set; ``cited_sources`` is the subset the answer
342 actually cited, so a client can tell a grounded answer from an off-corpus one.
343 """
345 answer: str
346 sources: list[CleanedChunk]
347 cited_sources: list[CleanedChunk] = Field(default_factory=list)
348 compaction: CompactionInfo | None = None
349 """Set when a /api/chat turn compacted its history before answering."""
350 retrieval_query: str | None = None
351 """The standalone rewrite retrieval ran on, when a follow-up was rewritten."""
352 dropped_sources: list[CleanedChunk] = Field(default_factory=list)
353 """Chunks the budget fit shed, so a client can say what was trimmed."""
356class SetModelResponse(BaseModel):
357 """Response for PUT /api/models/{chat|embedding|vision|reranker}.
359 ``reindex_required`` is ``True`` only when the new embedding model differs from
360 the model that built the persisted vector store. The chat, vision, and reranker
361 handlers always return ``False`` because their changes do not invalidate stored
362 vectors. Mirrors the ``reindex_required`` flag on ``ConfigUpdateResponse``.
363 ``warnings`` holds notices about the result, such as a vision model that
364 ``ocr = off`` leaves unused.
365 """
367 model: str
368 reindex_required: bool = False
369 warnings: list[str] = []
372class ConfigResetRequest(BaseModel):
373 """Request body for POST /api/config/reset."""
375 keys: list[str]
378class ConfigUpdateResponse(BaseModel):
379 """Response for PATCH /api/config and POST /api/config/reset."""
381 updated: list[str]
382 reindex_required: bool
383 warnings: list[str] = []
385 @classmethod
386 def from_result(cls, result: SettingsUpdateResult) -> ConfigUpdateResponse:
387 """The canonical serialized settings update, shared by REST and the CLI."""
388 return cls(
389 updated=result.updated,
390 reindex_required=result.reindex_required,
391 warnings=list(result.warnings),
392 )
395class CrawlLoginBody(BaseModel):
396 """The login a crawl sends to its seed host: an authorization header or a username and
397 password, and a cookie header. An empty field is unset."""
399 authorization: str = ""
400 username: str = ""
401 password: str = ""
402 cookie: str = ""
405class CrawlRequest(BaseModel):
406 """Request body for /api/crawl.
408 depth: null / omitted = whole-site unbounded recursion. 0 = single URL
409 only. Positive int = max depth. max_pages: null / omitted = the protective
410 safety cap. 0 = explicitly unlimited (the CRAWL_PAGES_UNLIMITED sentinel the
411 TUI and crawler honor). Positive int = explicit page cap. render_mode: null /
412 omitted = configured default; "http" is browserless, "browser" runs Chromium
413 with JavaScript.
414 """
416 url: str
417 depth: int | None = Field(default=None, ge=0, le=MAX_CRAWL_DEPTH)
418 max_pages: int | None = Field(default=None, ge=0, le=MAX_CRAWL_PAGES)
419 render_mode: CrawlRenderMode | None = Field(default=None)
420 include_subdomains: bool = Field(default=False)
421 login: CrawlLoginBody | None = Field(default=None)
424class DocumentInfo(BaseModel):
425 """A single indexed document in a list response."""
427 filename: str
428 chunk_count: int = 0
429 ingested_at: str = ""
432class DocumentListResponse(BaseModel):
433 """Response for GET /api/documents."""
435 documents: list[DocumentInfo]
436 total: int
437 limit: int
438 offset: int
439 has_more: bool = False
442class DocumentRemoveResponse(BaseModel):
443 """Response for POST /api/documents/remove."""
445 removed: list[str] = Field(
446 description="Names removed: indexed sources, files an ingestion failure held out, "
447 "and registered root labels."
448 )
449 not_found: list[str] = Field(description="Names that matched nothing removable.")
452class ConfigResponse(BaseModel):
453 """Response for GET /api/config."""
455 model_config = {"extra": "allow"}
458class ConfigSourcesResponse(BaseModel):
459 """Response for GET /api/config/sources: the layer that supplies each GET /api/config key."""
461 sources: dict[str, SettingSource]
464class ConfigFieldSchema(BaseModel):
465 """Metadata for one configuration field, so a client can render its control.
467 Field names match the MCP ``settings_list`` wire shape, which carries the
468 same metadata for agents.
469 """
471 key: str
472 type: str
473 nullable: bool
474 writable: bool
475 reindex_required: bool
476 group: SettingGroup
477 help: str
478 choices: list[str] | None
479 advanced: bool
482class ConfigSchemaResponse(BaseModel):
483 """Response for GET /api/config/schema."""
485 fields: list[ConfigFieldSchema]
488class SettingValueResponse(BaseModel):
489 """One setting's current value and source, for the CLI's ``lilbee settings`` JSON output.
491 Field names match the MCP ``settings_list``/``settings_get`` wire shape.
492 """
494 key: str
495 value: Any
496 default: Any
497 type: str
498 nullable: bool
499 group: SettingGroup
500 help: str
501 choices: list[str] | None
502 reindex_required: bool
503 source: SettingSource
504 advanced: bool
506 @classmethod
507 def from_info(cls, info: SettingInfo) -> SettingValueResponse:
508 return cls(
509 key=info.key,
510 value=info.value,
511 default=info.default,
512 type=info.type,
513 nullable=info.nullable,
514 group=info.group,
515 help=info.help_text,
516 choices=list(info.choices) if info.choices else None,
517 reindex_required=info.reindex_required,
518 source=info.source,
519 advanced=info.advanced,
520 )
523class SettingsListResponse(BaseModel):
524 """Response for the CLI's ``lilbee settings list --json``."""
526 settings: list[SettingValueResponse]
528 @classmethod
529 def from_infos(cls, infos: list[SettingInfo]) -> SettingsListResponse:
530 return cls(settings=[SettingValueResponse.from_info(info) for info in infos])
533class ModelsShowResponse(BaseModel):
534 """Response for POST /api/models/show."""
536 model_config = {"extra": "allow"}
539class CatalogEntryResponse(BaseModel):
540 """A single model in the catalog browser.
542 ``fit`` and ``size_variants`` carry server-computed hardware-fit
543 data so clients (TUI, plugin) can render fit chips and size strips
544 without probing local memory themselves. ``fit`` is ``None`` when
545 the row's footprint cannot be assessed against host memory (e.g.
546 a future cloud-only entry whose weights live off-host).
547 """
549 hf_repo: str
550 gguf_filename: str
551 task: ModelTask
552 display_name: str
553 param_count: str
554 size_gb: float
555 min_ram_gb: float
556 description: str
557 quality_tier: str
558 featured: bool
559 downloads: int
560 installed: bool
561 source: ModelSource
562 fit: FitLevel | None = None
563 size_variants: list[SizeVariantInfo] = []
564 architecture: str = ""
565 compat: ModelCompat = ModelCompat.UNKNOWN
566 safety_stripped: bool = False
567 provider: str = ""
568 key_status: KeyStatus | None = None
571class ModelsCatalogResponse(BaseModel):
572 """Response for GET /api/models/catalog.
574 Filters apply before paging. ``next_offset`` names the offset to request
575 next, None on the last page. ``truncated`` is True when the HuggingFace scan
576 stopped at its bound with rows left unread, so the listing is cut short.
577 """
579 total: int | None
580 limit: int
581 offset: int
582 models: list[CatalogEntryResponse]
583 has_more: bool = False
584 next_offset: int | None
585 truncated: bool = False
588class ModelsInstalledResponse(BaseModel):
589 """Response for GET /api/models/installed."""
591 models: list[ModelEntry]
594class ModelsDeleteResponse(BaseModel):
595 """Response for DELETE /api/models/{model}."""
597 deleted: bool
598 model: str
599 freed_gb: float
602class ExternalModelsResponse(BaseModel):
603 """Response for GET /api/models/external."""
605 models: list[str]
606 error: str | None = None
609class SyncSummary(BaseModel):
610 """Embedded sync result within an add-files response."""
612 added: list[str] = []
613 updated: list[str] = []
614 removed: list[str] = []
615 unchanged: int = 0
616 relocated: list[str] = []
617 failed: list[str] = []
618 skipped: list[str] = []
619 held_out: list[SkippedSource] = []
620 truncated: int = 0
621 unreadable_folders: list[str] = []
622 index_mismatch: IndexMismatch | None = None
625class AddSummary(BaseModel):
626 """Summary returned by the add-files handler."""
628 copied: list[str]
629 errors: list[str]
630 name_taken: list[str] = []
631 """Labels held by a different source; nothing was registered and no sync ran."""
632 overlapping: list[str] = []
633 """Paths inside a registered source, or around the documents directory; none is registered."""
634 absorbed: list[str] = []
635 """Registered sources a path in ``copied`` took in; their documents moved below its name."""
636 tracked: list[str] = []
637 """Named sources the knowledge base already tracks, so nothing was registered.
639 These need no action from the caller: the sync in the same request covers them.
640 """
641 sync: SyncSummary | None = None
642 already_ingesting: list[str] = []
643 """Sources another ingest held a lock on, so this run never attempted them.
645 Distinct from ``skipped``, which means the file was examined and needed no
646 work. These were not looked at and are worth retrying. Carried on the
647 terminal event so a client that missed the earlier ``already_ingesting``
648 frames can still tell the batch was partial.
649 """
652class WikiCitationRecord(BaseModel):
653 """A citation record from the store, used in reverse lookup responses."""
655 wiki_source: str = ""
656 wiki_chunk_index: int = 0
657 citation_key: str = ""
658 claim_type: str = "fact"
659 source_filename: str = ""
660 source_hash: str = ""
661 page_start: int = 0
662 page_end: int = 0
663 line_start: int = 0
664 line_end: int = 0
665 excerpt: str = ""
666 created_at: str = ""
669class WikiEntityCandidateResponse(BaseModel):
670 """One NER entity candidate, with the evidence a page would be built from."""
672 slug: str
673 label: str = ""
674 kind: EntityKind = EntityKind.ENTITY
675 type_hint: str = ""
676 mentions: int = 0
677 sources: list[str] = []
680class WikiBuildDryRunResult(BaseModel):
681 """Entity candidates a build would cover, with no LLM call made."""
683 dry_run: bool = True
684 entities: list[WikiEntityCandidateResponse] = []
685 count: int = 0
686 note: str = ""
689class WikiPageDetail(BaseModel):
690 """Full content of a single wiki page, with its parsed frontmatter."""
692 slug: str
693 title: str = ""
694 content: str = ""
695 frontmatter: dict[str, Any] = {}
698class WikiCitationsResult(BaseModel):
699 """Citations attached to a single wiki page."""
701 slug: str
702 citations: list[WikiCitationRecord] = []
705class WikiLintIssueItem(BaseModel):
706 """A single lint finding on a wiki page."""
708 wiki_source: str = ""
709 issue_type: str = ""
710 severity: str = ""
711 message: str = ""
714class WikiLintResult(BaseModel):
715 """Result of a wiki lint run, whole-wiki or single-page."""
717 issues: list[WikiLintIssueItem] = []
718 total: int = 0
719 errors: int = 0
720 warnings: int = 0
723class WikiPruneRecordResponse(BaseModel):
724 """A single pruning action."""
726 wiki_source: str
727 action: str
728 reason: str
731class WikiPruneResult(BaseModel):
732 """Result of wiki pruning."""
734 records: list[WikiPruneRecordResponse] = []
735 archived: int = 0
736 flagged: int = 0
737 reconciled: int = 0
740class WikiIndexResult(BaseModel):
741 """Result of rebuilding the browse index. Costs no LLM call."""
743 entries: int = 0
746class WikiGenerateResult(BaseModel):
747 """Result of generating one indexed page."""
749 slug: str
750 path: str
753class WikiWipeResult(BaseModel):
754 """Result of wiping the wiki.
756 ``rows_deleted`` is false when the pages went but the store delete failed,
757 so a client is never told the wiki is gone while its rows still answer.
758 """
760 pages_removed: int = 0
761 sources_cleared: int = 0
762 rows_deleted: bool = True
765class WikiStatusResult(BaseModel):
766 """Wiki layer status counters."""
768 wiki_enabled: bool
769 summaries: int = 0
770 drafts: int = 0
771 pages: int = 0
772 lint_errors: int = 0
773 lint_warnings: int = 0
776class DraftInfoResponse(BaseModel):
777 """Metadata about a single wiki draft, mirroring ``DraftInfo.to_dict()``.
779 ``pending_kind`` distinguishes drift drafts (``None``) from
780 batched-generation markers (``"parse"``, ``"collision"``).
781 """
783 slug: str
784 path: str
785 drift_ratio: float | None = None
786 faithfulness_score: float | None = None
787 bad_title: bool = False
788 published_path: str | None = None
789 published_exists: bool = False
790 mtime: float = 0.0
791 pending_kind: str | None = None
794class WikiDraftDiffResponse(BaseModel):
795 """Unified diff of a draft against its published counterpart."""
797 slug: str
798 diff: str
801class WikiDraftAcceptResponse(BaseModel):
802 """Outcome of accepting a draft: where it landed and how many chunks reindexed.
804 ``slug`` is the slug where the content was published.
805 ``requested_slug`` is the slug the client asked to accept. The two
806 differ for PENDING-COLLISION drafts, where the request slug carries
807 a ``-collision-<hash>`` suffix that is stripped on publish.
808 """
810 slug: str
811 requested_slug: str
812 moved_to: str
813 reindexed_chunks: int
816class WikiDraftRejectResponse(BaseModel):
817 """Outcome of rejecting a draft."""
819 slug: str
822class RememberRequest(BaseModel):
823 """Request body for ``POST /api/memories``."""
825 text: str
826 kind: MemoryKind = MemoryKind.FACT
827 shared: bool = False
830class RememberResponse(BaseModel):
831 """Outcome of storing a memory."""
833 id: str
834 kind: MemoryKind
837class MemoryItem(BaseModel):
838 """A single stored memory in a list response."""
840 id: str
841 kind: MemoryKind
842 shared: bool
843 text: str
846class MemoryListResponse(BaseModel):
847 """Body for ``GET /api/memories``."""
849 memories: list[MemoryItem]
852class MemorySharedRequest(BaseModel):
853 """Request body for ``PATCH /api/memories/{memory_id}``."""
855 shared: bool
858class MemoryFlagsResponse(BaseModel):
859 """Outcome of a flag update; ``updated`` is False when the id was unknown."""
861 id: str
862 updated: bool
865class MemoryRemoveResponse(BaseModel):
866 """Outcome of deleting a memory; ``deleted`` is False when the id was unknown."""
868 id: str
869 deleted: bool
872class MemoryExtractedItem(BaseModel):
873 """A single memory created by auto-extraction during a chat turn."""
875 id: str
876 kind: MemoryKind
877 text: str
880class MemoryExtractedEvent(BaseModel):
881 """``memory_extracted`` SSE payload: how many memories a turn auto-saved.
883 Emitted on the chat stream after ``done`` when auto-extraction is on and the
884 turn produced at least one memory, so a REST client (the Obsidian plugin) can
885 toast the count and refresh its memories view without a separate fetch.
886 """
888 count: int
889 items: list[MemoryExtractedItem]
892class GpuInfoResponse(BaseModel):
893 """One GPU as returned by GET /api/gpus and embedded in PlacementResponse."""
895 index: int
896 backend: str
897 label: str
898 name: str
899 total_bytes: int
900 free_bytes: int
903class GpusResponse(BaseModel):
904 """GET /api/gpus envelope: detected GPUs plus the host-level util notice."""
906 gpus: list[GpuInfoResponse]
907 notice: str | None = None
910class RolePlacementResponse(BaseModel):
911 """Where one role's model is placed in the resolved plan."""
913 role: WorkerRole
914 model: str
915 devices: list[int]
916 tensor_split: list[int] | None
917 replicas: int
920class SkippedRoleResponse(BaseModel):
921 """A configured role left unplaced because its model isn't downloaded."""
923 role: WorkerRole
924 model: str
927class PlacementResponse(BaseModel):
928 """Response for placement read, preview, set, and clear routes."""
930 gpus: list[GpuInfoResponse]
931 roles: list[RolePlacementResponse]
932 unplaceable: list[str]
933 manual: bool
934 spec_json: str | None
935 skipped_not_installed: list[SkippedRoleResponse] = []
936 co_tenants: list[str] = []
937 notice: str | None = None
938 rejected_spec_json: str | None = None
939 # The backend the engine selected, reported rather than inferred. ``gpus``
940 # being empty does not mean ``cpu``: a host whose device probe never answered
941 # reports ``unknown``, so a client never mislabels a GPU box as a CPU one.
942 engine_backend: EngineBackend = EngineBackend.UNKNOWN
944 @classmethod
945 def from_view(cls, view: PlacementView) -> PlacementResponse:
946 """The canonical serialized placement view, shared by the HTTP, MCP, and CLI surfaces."""
947 return cls(
948 gpus=[GpuInfoResponse(**vars(g)) for g in view.gpus],
949 roles=[
950 RolePlacementResponse(
951 role=r.role,
952 model=r.model,
953 devices=list(r.devices),
954 tensor_split=list(r.tensor_split) if r.tensor_split else None,
955 replicas=r.replicas,
956 )
957 for r in view.roles
958 ],
959 unplaceable=[r.value for r in view.unplaceable],
960 manual=view.manual,
961 spec_json=view.spec_json,
962 skipped_not_installed=[
963 SkippedRoleResponse(role=s.role, model=s.model) for s in view.skipped_not_installed
964 ],
965 co_tenants=[r.value for r in view.co_tenants],
966 rejected_spec_json=view.rejected_spec_json,
967 engine_backend=view.engine_backend,
968 )
971class PlacementSpecBody(BaseModel):
972 """Request body for placement routes that accept a manual spec."""
974 spec: dict[str, dict[str, object]] | None = None
977class SessionMetaItem(BaseModel):
978 """A session's metadata in a list or detail response."""
980 id: str
981 title: str
982 created_at: str
983 updated_at: str
984 model_ref: str
985 scope: str
986 message_count: int
987 origin: str = "tui"
988 """Owning surface. tui/http/cli are one domain and append freely to each
989 other's sessions; appends across the human/agent (mcp) boundary are 409."""
990 forked_from: str = ""
991 """Id of the session this one was forked from; empty when it is not a fork."""
994class SessionListResponse(BaseModel):
995 """Body for ``GET /api/sessions``."""
997 sessions: list[SessionMetaItem]
1000class SessionSourceRefInput(BaseModel):
1001 """One entry of a turn's Sources list as a client saves it."""
1003 source: str
1004 # The ``[n]`` the answer cites the source by; null when unknown.
1005 citation_number: int | None = None
1006 # The page or line span the answer read, such as "page 3"; "" when none.
1007 locator: str = ""
1010class SessionSourceRef(SessionSourceRefInput):
1011 """One entry of a turn's Sources list, with the label to show computed at read time."""
1013 # The plain one-line name of the source. Never escaped: a client shows it as text.
1014 label: str
1017class SessionMessageItem(BaseModel):
1018 """One message in a session transcript.
1020 ``content`` never holds lilbee's Sources list: ``source_refs`` carries it.
1021 """
1023 role: MessageRole
1024 content: str
1025 sources: list[str]
1026 ts: str
1027 source_refs: list[SessionSourceRef] = []
1030class SessionDetailResponse(BaseModel):
1031 """Body for ``GET /api/sessions/{session_id}``: metadata plus transcript.
1033 ``summary`` carries what compaction folded the oldest turns into (empty when
1034 a conversation has not been compacted). A client that resumes and continues
1035 the conversation needs it: without it, it rebuilds history from the raw
1036 transcript, re-sending turns the summary had already condensed and risking
1037 the context overflow compaction exists to prevent.
1038 """
1040 meta: SessionMetaItem
1041 messages: list[SessionMessageItem]
1042 summary: str = ""
1045class SessionCreateRequest(BaseModel):
1046 """Request body for ``POST /api/sessions``."""
1048 model_ref: str
1049 scope: str
1052class SessionMessageCreateRequest(BaseModel):
1053 """Request body for ``POST /api/sessions/{session_id}/messages``."""
1055 role: MessageRole
1056 content: str
1057 sources: list[str] = []
1058 # The answer's Sources list. When given, ``sources`` is derived from it.
1059 source_refs: list[SessionSourceRefInput] | None = None
1062class SessionForkRequest(BaseModel):
1063 """Request body for ``POST /api/sessions/{session_id}/fork``.
1065 ``message_count`` is the number of leading messages to copy; null copies all.
1066 """
1068 message_count: int | None = None
1070 @field_validator("message_count", mode="before")
1071 @classmethod
1072 def _check_message_count(cls, value: object) -> object:
1073 """Refuse ``true``, ``"2"`` and ``1.0`` rather than coerce them into a count."""
1074 # isinstance: the raw JSON value, before pydantic's lax coercion runs.
1075 if value is None or (isinstance(value, int) and not isinstance(value, bool)):
1076 return value
1077 raise ValueError("message_count must be a whole number or null")
1080class SessionSummaryRequest(BaseModel):
1081 """Request body for ``PUT /api/sessions/{session_id}/summary``."""
1083 summary: str
1086class SessionRenameRequest(BaseModel):
1087 """Request body for ``PATCH /api/sessions/{session_id}``."""
1089 title: str
1092class SessionRenameResponse(BaseModel):
1093 """Outcome of a rename."""
1095 id: str
1096 title: str
1099class SessionDeleteResponse(BaseModel):
1100 """Outcome of a delete."""
1102 id: str
1103 deleted: bool
1106class AgentClientDetection(BaseModel):
1107 """Whether one agent client's CLI is installed on the machine lilbee runs on."""
1109 client: AgentClient
1110 cli_detected: bool
1111 cli_path: str | None
1114class AgentConfigIndexResponse(BaseModel):
1115 """Response for ``GET /api/agent-config``: every client lilbee can configure."""
1117 clients: list[AgentClientDetection]
1119 @classmethod
1120 def from_detections(cls, detections: list[ClientDetection]) -> AgentConfigIndexResponse:
1121 """Serialize the probe results one entry per supported client."""
1122 return cls(
1123 clients=[
1124 AgentClientDetection(
1125 client=found.client,
1126 cli_detected=found.cli_detected,
1127 cli_path=found.cli_path,
1128 )
1129 for found in detections
1130 ]
1131 )
1134class AgentConfigResponse(BaseModel):
1135 """Response for ``GET /api/agent-config/{client}``: one client's live config.
1137 ``config`` carries the block for a JSON client, ``content`` the rendered text
1138 for a YAML one. ``stdio_config`` is the alternative block for a client that
1139 can also run lilbee as a subprocess instead of calling this server.
1140 """
1142 client: AgentClient
1143 format: ConfigFormat
1144 surfaces: list[AgentSurface]
1145 config: dict[str, Any] | None = None
1146 content: str | None = None
1147 stdio_config: dict[str, Any] | None = None
1149 @classmethod
1150 def from_document(cls, document: AgentConfigDocument) -> AgentConfigResponse:
1151 """The canonical serialized config document, shared by the HTTP and CLI surfaces."""
1152 return cls(
1153 client=document.client,
1154 format=document.format,
1155 surfaces=list(document.surfaces),
1156 config=document.config,
1157 content=document.content,
1158 stdio_config=document.stdio_config,
1159 )
1162class ProfileAuthorResponse(BaseModel):
1163 """One credited author of a profile."""
1165 name: str
1166 github: str | None
1169class ProfileEntryResponse(BaseModel):
1170 """One profile file; ``error`` names why a broken file cannot be used."""
1172 name: str
1173 folder: ProfileFolder
1174 path: str
1175 valid: bool
1176 error: str | None
1177 shadowed_by: ProfileFolder | None
1178 description: str | None
1179 authors: list[ProfileAuthorResponse]
1180 credit: str | None
1181 tested_on: str | None
1182 evidence: str | None
1183 min_lilbee: str | None
1184 values: dict[str, Any]
1186 @classmethod
1187 def from_entry(cls, entry: ProfileEntry) -> ProfileEntryResponse:
1188 """The canonical serialized profile, shared by the HTTP, MCP, and CLI surfaces."""
1189 profile = entry.file
1190 authors = profile.authors if profile is not None else ()
1191 return cls(
1192 name=entry.name,
1193 folder=entry.folder,
1194 path=entry.path.as_posix(),
1195 valid=profile is not None,
1196 error=entry.error,
1197 shadowed_by=entry.shadowed_by,
1198 description=profile.description if profile is not None else None,
1199 authors=[ProfileAuthorResponse(name=a.name, github=a.github) for a in authors],
1200 credit=credit_line(authors),
1201 tested_on=profile.tested_on if profile is not None else None,
1202 evidence=profile.evidence if profile is not None else None,
1203 min_lilbee=profile.min_lilbee if profile is not None else None,
1204 values=dict(profile.values) if profile is not None else {},
1205 )
1208class ProfileListResponse(BaseModel):
1209 """Response for GET /api/profiles: every profile file, Default first, built-ins next."""
1211 profiles: list[ProfileEntryResponse]
1214class ProfileChangeRowResponse(BaseModel):
1215 """A profile setting you set, and the value and source it falls back to without you."""
1217 key: str
1218 yours: Any
1219 profile_value: Any
1220 profile_source: SettingSource
1221 effect: ProfileEffect
1223 @classmethod
1224 def from_row(cls, row: ChangeRow) -> ProfileChangeRowResponse:
1225 """One serialized change row."""
1226 return cls(
1227 key=row.key,
1228 yours=row.yours,
1229 profile_value=row.profile_value,
1230 profile_source=row.profile_source,
1231 effect=row.effect,
1232 )
1235class ActiveProfileResponse(BaseModel):
1236 """The project's applied profile, its recorded values, your changes, and its file's state."""
1238 name: str
1239 status: ProfileStatus
1240 error: str | None
1241 values: dict[str, Any]
1242 changes: list[ProfileChangeRowResponse]
1243 profile: ProfileEntryResponse | None
1245 @classmethod
1246 def from_active(cls, current: ActiveProfile) -> ActiveProfileResponse:
1247 """The canonical serialized active profile, shared by every surface."""
1248 entry = current.entry
1249 return cls(
1250 name=current.name,
1251 status=current.status,
1252 error=current.error,
1253 values=dict(current.values),
1254 changes=[ProfileChangeRowResponse.from_row(row) for row in current.changes],
1255 profile=ProfileEntryResponse.from_entry(entry) if entry is not None else None,
1256 )
1259class ProfileDiffRowResponse(BaseModel):
1260 """One setting a profile apply changes, and when the change takes effect."""
1262 key: str
1263 current: Any
1264 current_source: SettingSource
1265 new: Any
1266 effect: ProfileEffect
1268 @classmethod
1269 def from_row(cls, row: DiffRow) -> ProfileDiffRowResponse:
1270 """One serialized diff row."""
1271 return cls(
1272 key=row.key,
1273 current=row.current,
1274 current_source=row.current_source,
1275 new=row.new,
1276 effect=row.effect,
1277 )
1280class ProfileDiffResponse(BaseModel):
1281 """Response for GET /api/profiles/{name}/diff."""
1283 name: str
1284 changes: list[ProfileDiffRowResponse]
1285 kept: list[str]
1286 untouched_count: int
1288 @classmethod
1289 def from_diff(cls, diff: ProfileDiff) -> ProfileDiffResponse:
1290 """The canonical serialized diff, shared by every surface."""
1291 return cls(
1292 name=diff.name,
1293 changes=[ProfileDiffRowResponse.from_row(row) for row in diff.changes],
1294 kept=list(diff.kept),
1295 untouched_count=diff.untouched_count,
1296 )
1299class ProfileApplyResponse(BaseModel):
1300 """Response for POST /api/profiles/{name}/apply."""
1302 name: str
1303 changes: list[ProfileDiffRowResponse]
1304 reindex_required: bool
1305 new_files_only: list[str]
1306 kept: list[str]
1307 warnings: list[str] = []
1309 @classmethod
1310 def from_result(cls, result: ApplyResult) -> ProfileApplyResponse:
1311 """The canonical serialized apply result, shared by every surface."""
1312 return cls(
1313 name=result.name,
1314 changes=[ProfileDiffRowResponse.from_row(row) for row in result.changes],
1315 reindex_required=result.reindex_required,
1316 new_files_only=list(result.new_files_only),
1317 kept=list(result.kept),
1318 warnings=list(result.warnings),
1319 )
1322class ProfileLocationResponse(BaseModel):
1323 """A profile file an operation wrote or removed."""
1325 name: str
1326 folder: ProfileFolder
1327 path: str
1329 @classmethod
1330 def from_location(cls, location: ProfileLocation) -> ProfileLocationResponse:
1331 """One serialized profile location."""
1332 return cls(name=location.name, folder=location.folder, path=location.path.as_posix())
1335class ProfileSaveResponse(ProfileLocationResponse):
1336 """A saved profile file, the settings of yours it took over, and any notice the write
1337 raises."""
1339 absorbed: list[str]
1340 warnings: list[str] = []
1342 @classmethod
1343 def from_save(cls, result: SaveResult) -> ProfileSaveResponse:
1344 """The canonical serialized save or update result, shared by every surface."""
1345 location = result.location
1346 return cls(
1347 name=location.name,
1348 folder=location.folder,
1349 path=location.path.as_posix(),
1350 absorbed=list(result.absorbed),
1351 warnings=list(result.warnings),
1352 )
1355class ProfileDiscardResponse(BaseModel):
1356 """Response for POST /api/profiles/discard: your settings removed, whether to rebuild,
1357 and any notice the discard raises."""
1359 dropped: list[str]
1360 reindex_required: bool
1361 warnings: list[str] = []
1363 @classmethod
1364 def from_result(cls, result: DiscardResult) -> ProfileDiscardResponse:
1365 """The canonical serialized discard result, shared by every surface."""
1366 return cls(
1367 dropped=list(result.dropped),
1368 reindex_required=result.reindex_required,
1369 warnings=list(result.warnings),
1370 )
1373class ProfileValidationResponse(BaseModel):
1374 """Every problem found in one profile file; ``valid`` when there is none."""
1376 name: str
1377 valid: bool
1378 problems: list[str]
1380 @classmethod
1381 def from_validation(cls, result: ProfileValidation) -> ProfileValidationResponse:
1382 """The canonical serialized validation, shared by every surface."""
1383 return cls(name=result.name, valid=result.valid, problems=list(result.problems))
1386class ProfileSaveRequest(BaseModel):
1387 """Request body for POST /api/profiles: save the project's settings as a new profile."""
1389 name: str
1390 target: ProfileFolder = ProfileFolder.GLOBAL
1393class ProfileNewRequest(BaseModel):
1394 """Request body for POST /api/profiles/new: a template, optionally from a profile."""
1396 name: str
1397 from_profile: str | None = None
1398 target: ProfileFolder = ProfileFolder.GLOBAL
1401class ProfileDuplicateRequest(BaseModel):
1402 """Request body for POST /api/profiles/{name}/duplicate."""
1404 new_name: str
1405 target: ProfileFolder = ProfileFolder.GLOBAL
1408class ProfileRenameRequest(BaseModel):
1409 """Request body for PATCH /api/profiles/{name}."""
1411 new_name: str
1414class ProfileImportRequest(BaseModel):
1415 """Request body for POST /api/profiles/import: a profile file's text and its file name."""
1417 content: str
1418 filename: str
1419 target: ProfileFolder = ProfileFolder.GLOBAL
1420 overwrite: bool = False
1423class ProfileValidateRequest(BaseModel):
1424 """Request body for POST /api/profiles/validate: a profile file's text and its file name."""
1426 content: str
1427 filename: str
1428 folder: ProfileFolder = ProfileFolder.GLOBAL
1431class AnalyzeFailureResponse(BaseModel):
1432 """A sampled file analyze could not read."""
1434 file: str
1435 error: str
1438class AnalyzePdfResponse(BaseModel):
1439 """PDF pages, scans and tables; ``scanned_share`` also counts image files at the sample rate."""
1441 files: int
1442 pages: int
1443 scanned_pages: int
1444 scanned_share: float
1445 files_with_tables: int
1446 tables: int
1447 median_pages: float | None
1449 @classmethod
1450 def from_signals(cls, pdf: PdfSignals) -> AnalyzePdfResponse:
1451 """The serialized PDF signals."""
1452 return cls(
1453 files=pdf.files,
1454 pages=pdf.pages,
1455 scanned_pages=pdf.scanned_pages,
1456 scanned_share=pdf.scanned_share,
1457 files_with_tables=pdf.files_with_tables,
1458 tables=pdf.tables,
1459 median_pages=pdf.median_pages,
1460 )
1463class AnalyzeLanguageResponse(BaseModel):
1464 """A detected language (ISO 639-3), its share of text files, its stemmer, and OCR support."""
1466 code: str
1467 share: float
1468 fts_language: FtsLanguage | None
1469 ocr_supported: bool
1471 @classmethod
1472 def from_row(cls, row: LanguageRow) -> AnalyzeLanguageResponse:
1473 """One serialized language row."""
1474 return cls(
1475 code=row.code,
1476 share=row.share,
1477 fts_language=row.fts_language,
1478 ocr_supported=row.ocr_supported,
1479 )
1482class AnalyzeReasonResponse(BaseModel):
1483 """Why the recommendation sets a setting; the key ``profile`` explains the built-in pick."""
1485 key: str
1486 text: str
1489class AnalyzeRecommendationResponse(BaseModel):
1490 """The picked built-in, the derived profile, and what applying it changes."""
1492 builtin: str
1493 name: str | None
1494 values: dict[str, Any]
1495 changes: list[ProfileDiffRowResponse]
1496 kept: list[str]
1497 reasons: list[AnalyzeReasonResponse]
1498 notes: list[str]
1500 @classmethod
1501 def from_recommendation(cls, rec: Recommendation) -> AnalyzeRecommendationResponse:
1502 """The serialized recommendation."""
1503 return cls(
1504 builtin=rec.builtin,
1505 name=rec.name,
1506 values=dict(rec.values),
1507 changes=[ProfileDiffRowResponse.from_row(row) for row in rec.changes],
1508 kept=list(rec.kept),
1509 reasons=[AnalyzeReasonResponse(key=r.key, text=r.text) for r in rec.reasons],
1510 notes=list(rec.notes),
1511 )
1514class AnalyzeSavedResponse(ProfileLocationResponse):
1515 """The profile analyze saved or switched to, its file, whether the project now uses it,
1516 and any notice it raises."""
1518 applied: bool
1519 warnings: list[str] = []
1521 @classmethod
1522 def from_saved(cls, saved: SavedProfile) -> AnalyzeSavedResponse:
1523 """The serialized saved profile."""
1524 return cls(
1525 name=saved.name,
1526 folder=saved.folder,
1527 path=saved.path.as_posix(),
1528 applied=saved.applied,
1529 warnings=list(saved.warnings),
1530 )
1533class AnalyzeResponse(BaseModel):
1534 """One analyze run: what it read and what it recommends; the shape every surface returns.
1536 ``files_read`` counts the documents extracted without error, out of ``documents_total``;
1537 ``files_counted`` code, image and archive files are counted and never read.
1538 """
1540 files_total: int
1541 documents_total: int
1542 files_read: int
1543 files_counted: int
1544 cap: int
1545 failed: list[AnalyzeFailureResponse]
1546 unreadable_folders: list[str]
1547 file_types: dict[str, int]
1548 code_share: float
1549 pdf: AnalyzePdfResponse
1550 median_chars: float | None
1551 languages: list[AnalyzeLanguageResponse]
1552 recommendation: AnalyzeRecommendationResponse
1553 saved: AnalyzeSavedResponse | None
1555 @classmethod
1556 def from_report(cls, report: AnalyzeReport) -> AnalyzeResponse:
1557 """The canonical serialized analyze report, shared by every surface."""
1558 signals = report.signals
1559 return cls(
1560 files_total=signals.files_total,
1561 documents_total=signals.documents_total,
1562 files_read=signals.files_read,
1563 files_counted=signals.files_counted,
1564 cap=signals.cap,
1565 failed=[AnalyzeFailureResponse(file=f.file, error=f.error) for f in signals.failed],
1566 unreadable_folders=list(signals.unreadable_folders),
1567 file_types=dict(signals.file_types),
1568 code_share=signals.code_share,
1569 pdf=AnalyzePdfResponse.from_signals(signals.pdf),
1570 median_chars=signals.median_chars,
1571 languages=[AnalyzeLanguageResponse.from_row(row) for row in report.languages],
1572 recommendation=AnalyzeRecommendationResponse.from_recommendation(report.recommendation),
1573 saved=None if report.saved is None else AnalyzeSavedResponse.from_saved(report.saved),
1574 )
1577class AnalyzeRequestBody(BaseModel):
1578 """Request body for POST /api/analyze.
1580 No ``directory`` reads the corpus; otherwise an absolute folder on the server.
1581 ``save`` names the saved profile, ``apply`` also switches to it, and ``target``
1582 (project or global) needs one of them.
1583 """
1585 directory: str | None = None
1586 apply: bool = False
1587 save: str | None = None
1588 target: ProfileFolder | None = None
1591class AnalyzeStateResponse(BaseModel):
1592 """Whether the project was analyzed, hid the analyze tip, and would see the tip now."""
1594 analyzed: bool
1595 tip_dismissed: bool
1596 tip_shows: bool
1598 @classmethod
1599 def from_state(cls, state: TipState) -> AnalyzeStateResponse:
1600 """The tip state as sent over HTTP and MCP."""
1601 return cls(
1602 analyzed=state.analyzed, tip_dismissed=state.tip_dismissed, tip_shows=state.tip_shows
1603 )