"""Pydantic request/response models for the Hermes dashboard web server. Extracted verbatim from ``hermes_cli/web_server.py`` (pure schema move). ``web_server`` re-exports every name here, so existing imports like ``from hermes_cli.web_server import ConfigUpdate`` keep working. """ from __future__ import annotations import math from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, SecretStr, field_validator # --- from web_server.py (originally lines 1273-1372) --- class ConfigUpdate(BaseModel): config: dict profile: Optional[str] = None class EnvVarUpdate(BaseModel): key: str value: str profile: Optional[str] = None # Optional bearer key for the connectivity probe of a custom/local endpoint # (``key == "OPENAI_BASE_URL"``). Self-hosted endpoints that gate # ``/v1/models`` behind auth otherwise look "reachable but empty"; sending # the key lets the probe enumerate the served models. Ignored for the # regular PUT /api/env path (which only reads key/value). api_key: str = "" class EnvVarDelete(BaseModel): key: str profile: Optional[str] = None class EnvVarReveal(BaseModel): key: str profile: Optional[str] = None class MemoryProviderConfigUpdate(BaseModel): values: Dict[str, Any] = {} class MemoryProviderSetupRequest(BaseModel): values: Dict[str, Any] = {} class CustomEndpointUpdate(BaseModel): id: str = "" name: str base_url: str model: str api_key: Optional[str] = None context_length: Optional[int] = None discover_models: bool = True make_default: bool = False models: Optional[List[str]] = None class MessagingPlatformUpdate(BaseModel): enabled: Optional[bool] = None env: Dict[str, str] = {} clear_env: List[str] = [] # Explicit body profile beats the query param injected by the global # dashboard profile switcher (same precedence as other scoped writes). profile: Optional[str] = None class TelegramOnboardingStart(BaseModel): bot_name: Optional[str] = None class TelegramOnboardingApply(BaseModel): allowed_user_ids: List[str] profile: Optional[str] = None class WhatsAppOnboardingStart(BaseModel): mode: Optional[str] = "bot" allowed_users: Optional[str] = "" profile: Optional[str] = None class WhatsAppOnboardingApply(BaseModel): mode: Optional[str] = None allowed_users: Optional[str] = None profile: Optional[str] = None class AudioTranscriptionRequest(BaseModel): data_url: str mime_type: Optional[str] = None class ManagedFileUpload(BaseModel): path: str data_url: str overwrite: bool = True class ChatImageUpload(BaseModel): data_url: str filename: Optional[str] = None class ManagedDirectoryCreate(BaseModel): path: str class ManagedFileDelete(BaseModel): path: str recursive: bool = False # --- from web_server.py (originally lines 1398-1491) --- class ModelAssignment(BaseModel): """Payload for POST /api/model/set — assign a provider/model to a slot. scope="main" → writes model.provider + model.default scope="auxiliary" → writes auxiliary..provider + auxiliary..model scope="auxiliary" with task="" → applied to every auxiliary.* slot scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" """ scope: str provider: str model: str task: str = "" # Optional OpenAI-compatible endpoint URL. Honored for custom/local # providers on the main slot AND on auxiliary slots — lets the GUI wire a # self-hosted endpoint (vLLM, llama.cpp, Ollama, …) that needs no API key. # The runtime resolvers read model.base_url / auxiliary..base_url # from config (they ignore OPENAI_BASE_URL), so this is the path that # actually wires a local endpoint into resolution. base_url: str = "" # Optional API key for a custom/local endpoint. Persisted to # ``model.api_key`` (main slot) or ``auxiliary..api_key`` (aux # slots) — where the runtime resolvers read it — so a self-hosted # endpoint that requires auth works from the GUI. Mirrors the key the # ``hermes model`` custom flow collects. api_key: str = "" confirm_expensive_model: bool = False profile: Optional[str] = None class MoaModelSlot(BaseModel): provider: str = "" model: str = "" # Optional per-slot reasoning effort. Declared so a client round-tripping # the GET payload doesn't have it stripped at parse time and wiped on save. reasoning_effort: Optional[str] = None enabled: bool = True class _MoaReferenceControls(BaseModel): # None = no per-preset override; the fan-out inherits # auxiliary.moa_reference.timeout (900s default). reference_timeout: Optional[float] = None degraded_reference_policy: Literal["loud", "silent"] = "loud" @field_validator("reference_timeout", mode="before") @classmethod def _validate_reference_timeout(cls, value: Any) -> Optional[float]: """Reject JSON booleans/non-finite values before float coercion.""" if value is None or value == "": return None if isinstance(value, bool): raise ValueError("reference_timeout must be a finite positive number") try: timeout = float(value) except (TypeError, ValueError) as exc: raise ValueError( "reference_timeout must be a finite positive number" ) from exc if not math.isfinite(timeout) or timeout <= 0: raise ValueError("reference_timeout must be a finite positive number") return timeout class MoaPresetPayload(_MoaReferenceControls): reference_models: list[MoaModelSlot] = [] aggregator: MoaModelSlot = MoaModelSlot() # None = temperature omitted from API calls (provider default), matching # single-model agent behavior. reference_temperature: Optional[float] = None aggregator_temperature: Optional[float] = None max_tokens: int = 4096 # Newer per-preset knobs (see moa_config._normalize_preset). Optional so # older clients that never send them keep working; declared so clients # that round-trip the GET payload don't silently erase hand-set values. reference_max_tokens: Optional[int] = None fanout: Optional[str] = None enabled: bool = True class MoaConfigPayload(_MoaReferenceControls): default_preset: str = "default" active_preset: str = "" presets: dict[str, MoaPresetPayload] = {} # Backward-compatible flat payload fields used by older dashboard/desktop # clients during this PR's transition window. reference_models: list[MoaModelSlot] = [] aggregator: MoaModelSlot = MoaModelSlot() reference_temperature: Optional[float] = None aggregator_temperature: Optional[float] = None max_tokens: int = 4096 reference_max_tokens: Optional[int] = None fanout: Optional[str] = None enabled: bool = True profile: Optional[str] = None # --- from web_server.py (originally lines 2718-2720) --- class FsWriteText(BaseModel): path: str content: str # --- from web_server.py (originally lines 2826-2856) --- class GitPathBody(BaseModel): path: str class GitFileBody(BaseModel): path: str file: Optional[str] = None class GitCommitBody(BaseModel): path: str message: str push: bool = False class GitWorktreeAddBody(BaseModel): path: str name: Optional[str] = None branch: Optional[str] = None base: Optional[str] = None existingBranch: Optional[str] = None class GitWorktreeRemoveBody(BaseModel): path: str worktreePath: str force: bool = False class GitBranchSwitchBody(BaseModel): path: str branch: str # --- from web_server.py (originally lines 3610-3611) --- class CuratorPause(BaseModel): paused: bool # --- from web_server.py (originally lines 3649-3657) --- class LearningNodeRef(BaseModel): id: str profile: Optional[str] = None class LearningNodeEdit(BaseModel): id: str content: str profile: Optional[str] = None # --- from web_server.py (originally lines 3786-3792) --- class DebugShareRequest(BaseModel): # Redaction is ON by default — force-mode scrubs credential-shaped tokens # out of log content before it leaves the machine. The toggle exists so an # operator who knows the logs are clean can opt out for fuller fidelity. redact: bool = True # Recent log lines included in the summary tail (full logs are separate). lines: int = 200 # --- from web_server.py (originally lines 4492-4493) --- class TTSSpeakRequest(BaseModel): text: str # --- from web_server.py (originally lines 11549-11551) --- class OAuthSubmitBody(BaseModel): session_id: str code: str # --- from web_server.py (originally lines 11708-11715) --- class BulkDeleteSessions(BaseModel): ids: List[str] profile: Optional[str] = None class SessionImport(BaseModel): sessions: List[Dict[str, Any]] profile: Optional[str] = None # --- from web_server.py (originally lines 12082-12090) --- class SessionRename(BaseModel): title: Optional[str] = None archived: Optional[bool] = None # Durable "keep" flag mirrored from the Desktop sidebar's pins; pinned # sessions are exempt from the sessions.auto_archive stale sweep. pinned: Optional[bool] = None # Mutate a session belonging to another profile (opens its state.db). Omit # for the current/default profile. profile: Optional[str] = None # --- from web_server.py (originally lines 12149-12174) --- class SessionPrune(BaseModel): older_than_days: Optional[float] = 90 source: Optional[str] = None profile: Optional[str] = None # Extended filters (all optional, AND together — mirrors the CLI flags) started_before: Optional[float] = None # epoch seconds started_after: Optional[float] = None # epoch seconds title_like: Optional[str] = None end_reason: Optional[str] = None cwd_prefix: Optional[str] = None min_messages: Optional[int] = None max_messages: Optional[int] = None model_like: Optional[str] = None provider: Optional[str] = None user_id: Optional[str] = None chat_id: Optional[str] = None chat_type: Optional[str] = None branch_like: Optional[str] = None min_tokens: Optional[int] = None max_tokens: Optional[int] = None min_cost: Optional[float] = None max_cost: Optional[float] = None min_tool_calls: Optional[int] = None max_tool_calls: Optional[int] = None include_archived: bool = False dry_run: bool = False # --- from web_server.py (originally lines 12335-12352) --- class CronJobCreate(BaseModel): prompt: str = "" schedule: str name: str = "" deliver: str = "local" skills: Optional[List[str]] = None model: Optional[str] = None provider: Optional[str] = None base_url: Optional[str] = None script: Optional[str] = None context_from: Optional[Any] = None enabled_toolsets: Optional[List[str]] = None workdir: Optional[str] = None no_agent: bool = False class CronJobUpdate(BaseModel): updates: dict # --- from web_server.py (originally lines 12924-12926) --- class AutomationBlueprintInstantiate(BaseModel): blueprint: str # blueprint key, e.g. "morning-brief" values: Dict[str, Any] = {} # filled slot values from the form # --- from web_server.py (originally lines 13002-13019) --- class MCPServerCreate(BaseModel): name: str url: Optional[str] = None command: Optional[str] = None args: List[str] = [] # env: KEY=VALUE map for stdio servers (API keys, etc.) env: Dict[str, str] = {} # auth: "none" | "oauth" | "header" | None auth: Optional[str] = None # One-time provisioning input; persisted only to the profile's .env. bearer_token: Optional[SecretStr] = None profile: Optional[str] = None class MCPServersReplace(BaseModel): # Whole-map replace (name → raw server config) for the GUI mcp.json editor. servers: Dict[str, Dict[str, Any]] = {} profile: Optional[str] = None # --- from web_server.py (originally lines 13518-13520) --- class MCPEnabledToggle(BaseModel): enabled: bool profile: Optional[str] = None # --- from web_server.py (originally lines 13622-13627) --- class MCPCatalogInstall(BaseModel): name: str # env: KEY=VALUE map for catalog entries that declare required env vars. env: Dict[str, str] = {} enable: bool = True profile: Optional[str] = None # --- from web_server.py (originally lines 13716-13723) --- class PairingApprove(BaseModel): platform: str code: str = "" request_id: str = "" profile: Optional[str] = None class PairingRevoke(BaseModel): platform: str user_id: str profile: Optional[str] = None # --- from web_server.py (originally lines 13793-13804) --- class WebhookCreate(BaseModel): name: str description: Optional[str] = None events: List[str] = [] prompt: Optional[str] = None script: Optional[str] = None skills: List[str] = [] deliver: str = "log" deliver_only: bool = False deliver_chat_id: Optional[str] = None # secret: omit to auto-generate secret: Optional[str] = None # --- from web_server.py (originally lines 13930-13931) --- class WebhookEnabledToggle(BaseModel): enabled: bool # --- from web_server.py (originally lines 13997-14002) --- class CredentialPoolAdd(BaseModel): provider: str # api_key for API-key providers; OAuth pooling stays CLI-only (it needs # an interactive browser flow that doesn't belong in a single POST). api_key: str label: Optional[str] = None # --- from web_server.py (originally lines 14171-14178) --- class MemoryProviderSelect(BaseModel): # "" or "built-in" disables the external provider (built-in only). provider: str class MemoryReset(BaseModel): # "all" | "memory" | "user" target: str = "all" # --- from web_server.py (originally lines 14274-14276) --- class BackupRequest(BaseModel): # Optional output path; defaults to a timestamped zip in the home dir. output: Optional[str] = None # --- from web_server.py (originally lines 14339-14348) --- class ImportRequest(BaseModel): archive: str # Pass --force to `hermes import`. The spawned action runs with # stdin=DEVNULL, so the CLI's interactive "Continue? [y/N]" overwrite # prompt hits EOF and auto-aborts ("Aborted.", exit 1) whenever the # target already has a config — which it always does when the dashboard # itself is running from it. The dashboard shows its own confirm modal # before calling this endpoint, then sends force=True so the restore # proceeds non-interactively. force: bool = False # --- from web_server.py (originally lines 14505-14513) --- class HookCreate(BaseModel): event: str command: str matcher: Optional[str] = None timeout: Optional[int] = None # approve: write the consent allowlist entry too (the operator using the # authenticated dashboard is giving consent). Without it the hook is # configured but won't fire until approved. approve: bool = True # --- from web_server.py (originally lines 14573-14575) --- class HookDelete(BaseModel): event: str command: str # --- from web_server.py (originally lines 14667-14669) --- class SkillInstallRequest(BaseModel): identifier: str profile: Optional[str] = None # --- from web_server.py (originally lines 14724-14726) --- class SkillUninstallRequest(BaseModel): name: str profile: Optional[str] = None # --- from web_server.py (originally lines 14748-14749) --- class SkillsUpdateRequest(BaseModel): profile: Optional[str] = None # --- from web_server.py (originally lines 15116-15166) --- class ProfileCreate(BaseModel): name: str clone_from: Optional[str] = None # Backward compatibility for older dashboard/desktop clients. New clients # send clone_from="default" (or another profile name) explicitly. clone_from_default: bool = False clone_all: bool = False no_skills: bool = False description: Optional[str] = None provider: Optional[str] = None model: Optional[str] = None # Profile-builder additions — all optional, all applied best-effort AFTER # the profile directory exists, so a hiccup in any of them never 500s the # create (the user can fix it from the relevant dashboard page afterward). # MCP servers to write into the new profile's config.yaml. mcp_servers: List["MCPServerCreate"] = [] # Built-in / optional skills to KEEP active. When this list is non-empty, # the builder uses "replace" semantics: the bundle is seeded, then every # seeded skill NOT in this list is added to the profile's disabled list. # Empty list = leave the seeded bundle untouched (legacy behaviour). keep_skills: List[str] = [] # Skills-hub identifiers to install into the new profile. Installed async # via a subprocess scoped to the profile (`hermes -p skills install`) # because skills_hub.SKILLS_DIR is import-time-bound and the HERMES_HOME # override can't redirect it. Returns spawned PIDs for the UI to poll. hub_skills: List[str] = [] class ProfileRename(BaseModel): new_name: str class ProfileSoulUpdate(BaseModel): content: str class ProfileActiveUpdate(BaseModel): name: str class ProfileDescriptionUpdate(BaseModel): description: str = "" class ProfileModelUpdate(BaseModel): provider: str model: str class ProfileDescribeAuto(BaseModel): overwrite: bool = False # --- from web_server.py (originally lines 15831-15834) --- class SkillToggle(BaseModel): name: str enabled: bool profile: Optional[str] = None # --- from web_server.py (originally lines 15883-15893) --- class SkillCreate(BaseModel): name: str content: str category: Optional[str] = None profile: Optional[str] = None class SkillContentUpdate(BaseModel): name: str content: str profile: Optional[str] = None # --- from web_server.py (originally lines 16022-16024) --- class ToolsetToggle(BaseModel): enabled: bool profile: Optional[str] = None # --- from web_server.py (originally lines 16199-16204) --- class ToolsetProviderSelect(BaseModel): provider: str # Web-only capability scope: 'search' | 'extract'. Omitted → whole-provider # selection through the legacy apply_provider_selection path (web.backend). capability: Optional[str] = None profile: Optional[str] = None # --- from web_server.py (originally lines 16324-16327) --- class ToolsetModelSelect(BaseModel): model: str provider: Optional[str] = None profile: Optional[str] = None # --- from web_server.py (originally lines 16510-16512) --- class ToolsetEnvUpdate(BaseModel): env: Dict[str, str] profile: Optional[str] = None # --- from web_server.py (originally lines 16570-16572) --- class ToolsetPostSetup(BaseModel): key: str profile: Optional[str] = None # --- from web_server.py (originally lines 16823-16825) --- class TerminalBackendSelect(BaseModel): backend: str profile: Optional[str] = None # --- from web_server.py (originally lines 16919-16921) --- class RawConfigUpdate(BaseModel): yaml_text: str profile: Optional[str] = None # --- from web_server.py (originally lines 19410-19411) --- class ThemeSetBody(BaseModel): name: str # --- from web_server.py (originally lines 19449-19450) --- class FontSetBody(BaseModel): font: str # --- from web_server.py (originally lines 19681-19684) --- class _AgentPluginInstallBody(BaseModel): identifier: str force: bool = False enable: bool = True # --- from web_server.py (originally lines 19896-19898) --- class _PluginProvidersPutBody(BaseModel): memory_provider: Optional[str] = None context_engine: Optional[str] = None # --- from web_server.py (originally lines 19919-19920) --- class _PluginVisibilityBody(BaseModel): hidden: bool