"""Configuration schema using Pydantic.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings from nanobot.cron.types import CronSchedule if TYPE_CHECKING: from nanobot.agent.tools.cli_apps import CliAppsToolConfig from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.shell import ExecToolConfig from nanobot.agent.tools.web import WebToolsConfig class Base(BaseModel): """Base model that accepts both camelCase and snake_case keys.""" model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class ChannelsConfig(Base): """Configuration for chat channels. Built-in and plugin channel configs are stored as extra fields (dicts). Each channel parses its own config in __init__. Per-channel "streaming": true enables streaming output (requires send_delta impl). """ model_config = ConfigDict(extra="allow") send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) show_reasoning: bool = True # surface model reasoning when channel implements it extract_document_text: bool = True # extract text from document attachments before sending to the model send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included) transcription_provider: str = "groq" # Deprecated: use top-level transcription.provider transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Deprecated: use top-level transcription.language class TranscriptionConfig(Base): """Cross-channel audio transcription configuration.""" enabled: bool = True provider: str | None = None # Validated by nanobot.audio.transcription_registry. model: str | None = None language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") max_duration_sec: int = Field(default=120, ge=1, le=600) max_upload_mb: int = Field(default=25, ge=1, le=100) class DreamConfig(Base): """Dream memory consolidation configuration.""" _HOUR_MS = 3_600_000 enabled: bool = True # Register the periodic Dream consolidation job on startup interval_h: int = Field(default=2, ge=1) # Every 2 hours by default cron: str | None = Field(default=None, exclude=True) # Legacy cron expression override model_override: str | None = Field( default=None, validation_alias=AliasChoices("modelOverride", "model", "model_override"), ) # Override model for Dream sessions (pending implementation) max_batch_size: int = Field(default=20, ge=1) # Deprecated: no longer used max_iterations: int = Field(default=15, ge=1) # Deprecated: no longer used annotate_line_ages: bool = True # Deprecated: no longer used def build_schedule(self, timezone: str) -> CronSchedule: """Build the runtime schedule, preferring the legacy cron override if present.""" if self.cron: return CronSchedule(kind="cron", expr=self.cron, tz=timezone) return CronSchedule(kind="every", every_ms=self.interval_h * self._HOUR_MS) def describe_schedule(self) -> str: """Return a human-readable summary for logs and startup output.""" if self.cron: return f"cron {self.cron} (legacy)" hours = self.interval_h return f"every {hours}h" class InlineFallbackConfig(Base): """One inline fallback model configuration.""" model: str provider: str max_tokens: int | None = None context_window_tokens: int | None = None temperature: float | None = None reasoning_effort: str | None = None FallbackCandidate = str | InlineFallbackConfig class ModelPresetConfig(Base): """A named set of model + generation parameters for quick switching.""" label: str | None = None model: str provider: str = "auto" max_tokens: int = 8192 context_window_tokens: int = 65_536 temperature: float = 0.1 reasoning_effort: str | None = None def to_generation_settings(self) -> Any: from nanobot.providers.base import GenerationSettings return GenerationSettings( temperature=self.temperature, max_tokens=self.max_tokens, reasoning_effort=self.reasoning_effort, ) class AgentDefaults(Base): """Default agent configuration.""" workspace: str = "~/.nanobot/workspace" model_preset: str | None = None # Active preset name — takes precedence over fields below model: str = "anthropic/claude-opus-4-5" provider: str = ( "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection ) max_tokens: int = 8192 context_window_tokens: int = 65_536 context_block_limit: int | None = None temperature: float = 0.1 fallback_models: list[FallbackCandidate] = Field(default_factory=list) max_tool_iterations: int = 200 max_concurrent_subagents: int = Field(default=1, ge=1) max_tool_result_chars: int = 16_000 provider_retry_mode: Literal["standard", "persistent"] = "standard" tool_hint_max_length: int = Field( default=40, ge=20, le=500, validation_alias=AliasChoices("toolHintMaxLength"), serialization_alias="toolHintMaxLength", ) # Max characters for tool hint display (e.g. "$ cd …/project && npm test") reasoning_effort: str | None = None # low / medium / high / adaptive / none — LLM thinking effort; None preserves the provider default timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...") bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit unified_session: bool = False # Share one session across all channels (single-user multi-device) disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"]) session_ttl_minutes: int = Field( default=0, ge=0, validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"), serialization_alias="idleCompactAfterMinutes", ) # Auto-compact idle threshold in minutes (0 = disabled) max_messages: int = Field( default=120, ge=0, ) # Max messages to replay from session history (0 = use default 120, respects token budget) consolidation_ratio: float = Field( default=0.5, ge=0.1, le=0.95, validation_alias=AliasChoices("consolidationRatio"), serialization_alias="consolidationRatio", ) # Consolidation target ratio (0.5 = 50% of budget retained after compression) dream: DreamConfig = Field(default_factory=DreamConfig) class AgentsConfig(Base): """Agent configuration.""" defaults: AgentDefaults = Field(default_factory=AgentDefaults) class ProviderConfig(Base): """LLM provider configuration.""" api_key: str | None = Field(default=None, repr=False) api_base: str | None = None api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways) class BedrockProviderConfig(ProviderConfig): """AWS Bedrock Runtime provider configuration.""" region: str | None = None # AWS region, falls back to AWS_REGION/AWS_DEFAULT_REGION/profile profile: str | None = None # Optional AWS shared config profile class ProvidersConfig(Base): """Configuration for LLM providers.""" custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name) bedrock: BedrockProviderConfig = Field(default_factory=BedrockProviderConfig) # AWS Bedrock Converse anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) assemblyai: ProviderConfig = Field(default_factory=ProviderConfig) # AssemblyAI voice transcription huggingface: ProviderConfig = Field(default_factory=ProviderConfig) skywork: ProviderConfig = Field(default_factory=ProviderConfig) # Skywork / APIFree API gateway deepseek: ProviderConfig = Field(default_factory=ProviderConfig) groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) vllm: ProviderConfig = Field(default_factory=ProviderConfig) ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models atomic_chat: ProviderConfig = Field(default_factory=ProviderConfig) # Atomic Chat local models ovms: ProviderConfig = Field(default_factory=ProviderConfig) # OpenVINO Model Server (OVMS) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking) mistral: ProviderConfig = Field(default_factory=ProviderConfig) stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米) longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international) byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth) qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆) nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys) @model_validator(mode="after") def _validate_api_type_scope(self) -> "ProvidersConfig": for name in self.__class__.model_fields: if name == "openai": continue provider = getattr(self, name, None) if isinstance(provider, ProviderConfig) and provider.api_type != "auto": raise ValueError("providers..api_type is only supported for providers.openai") return self class HeartbeatConfig(Base): """Heartbeat service configuration (now backed by cron).""" enabled: bool = True interval_s: int = 30 * 60 # 30 minutes keep_recent_messages: int = 8 class ApiConfig(Base): """OpenAI-compatible API server configuration.""" host: str = "127.0.0.1" # Safer default: local-only bind. port: int = 8900 timeout: float = 120.0 # Per-request timeout in seconds. class GatewayConfig(Base): """Gateway/server configuration.""" host: str = "127.0.0.1" # Safer default: local-only bind. port: int = 18790 heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted command: str = "" # Stdio: command to run (e.g. "npx") args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars cwd: str = "" # Stdio: working directory for MCP server runtime artifacts url: str = "" # HTTP/SSE: endpoint URL headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers tool_timeout: int = 30 # seconds before a tool call is cancelled enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp__ names; ["*"] = all tools; [] = no tools def _lazy_default(module_path: str, class_name: str) -> Any: """Deferred import helper for ToolsConfig default factories.""" import importlib module = importlib.import_module(module_path) return getattr(module, class_name)() class ToolsConfig(Base): """Tools configuration. Field types for tool-specific sub-configs are resolved via model_rebuild() at the bottom of this file to avoid circular imports (tool modules import Base from schema.py). """ web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig")) exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig")) cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig")) my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig")) image_generation: ImageGenerationToolConfig = Field( default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"), ) restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible webui_allow_local_service_access: bool = Field( default=True, validation_alias=AliasChoices( "webuiAllowLocalServiceAccess", "webui_allow_local_service_access", "allowLocalPreviewAccess", "allow_local_preview_access", ), ) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) class Config(BaseSettings): """Root configuration for nanobot.""" agents: AgentsConfig = Field(default_factory=AgentsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig) transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) api: ApiConfig = Field(default_factory=ApiConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) model_presets: dict[str, ModelPresetConfig] = Field( default_factory=dict, validation_alias=AliasChoices("modelPresets", "model_presets"), ) def __init__(self, **values: Any) -> None: if not type(self).__pydantic_complete__: _resolve_tool_config_refs() super().__init__(**values) @model_validator(mode="after") def _validate_model_preset(self) -> "Config": if "default" in self.model_presets: raise ValueError("model_preset name 'default' is reserved for agents.defaults") name = self.agents.defaults.model_preset if name and name != "default" and name not in self.model_presets: raise ValueError(f"model_preset {name!r} not found in model_presets") for fallback in self.agents.defaults.fallback_models: if isinstance(fallback, str) and fallback not in self.model_presets: raise ValueError(f"fallback_models entry {fallback!r} not found in model_presets") return self def resolve_default_preset(self) -> ModelPresetConfig: """Return the implicit `default` preset from agents.defaults fields.""" d = self.agents.defaults return ModelPresetConfig( model=d.model, provider=d.provider, max_tokens=d.max_tokens, context_window_tokens=d.context_window_tokens, temperature=d.temperature, reasoning_effort=d.reasoning_effort, ) def resolve_preset(self, name: str | None = None) -> ModelPresetConfig: """Return effective model params from a named preset or the implicit default.""" name = self.agents.defaults.model_preset if name is None else name if not name or name == "default": return self.resolve_default_preset() if name not in self.model_presets: raise KeyError(f"model_preset {name!r} not found in model_presets") return self.model_presets[name] @property def workspace_path(self) -> Path: """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() def _match_provider( self, model: str | None = None, *, preset: ModelPresetConfig | None = None, ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS, find_by_name resolved = preset or self.resolve_preset() forced = resolved.provider if forced != "auto": spec = find_by_name(forced) if spec: p = getattr(self.providers, spec.name, None) return (p, spec.name) if p else (None, None) return None, None model_lower = (model or resolved.model).lower() model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") def _kw_matches(kw: str) -> bool: kw = kw.lower() return kw in model_lower or kw.replace("-", "_") in model_normalized # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex. for spec in PROVIDERS: if spec.is_transcription_only: continue p = getattr(self.providers, spec.name, None) if p and model_prefix and normalized_prefix == spec.name: if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key: return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: if spec.is_transcription_only: continue p = getattr(self.providers, spec.name, None) if p and any(_kw_matches(kw) for kw in spec.keywords): if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key: return p, spec.name # Fallback: configured local providers can route models without # provider-specific keywords (for example plain "llama3.2" on Ollama). # Prefer providers whose detect_by_base_keyword matches the configured api_base # (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order. local_fallback: tuple[ProviderConfig, str] | None = None for spec in PROVIDERS: if not spec.is_local: continue p = getattr(self.providers, spec.name, None) if not (p and p.api_base): continue if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base: return p, spec.name if local_fallback is None: local_fallback = (p, spec.name) if local_fallback: return local_fallback # Fallback: gateways first, then others (follows registry order) # OAuth providers are NOT valid fallbacks — they require explicit model selection for spec in PROVIDERS: if spec.is_oauth or spec.is_transcription_only: continue p = getattr(self.providers, spec.name, None) if p and p.api_key: return p, spec.name return None, None def get_provider( self, model: str | None = None, *, preset: ModelPresetConfig | None = None, ) -> ProviderConfig | None: """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available.""" p, _ = self._match_provider(model, preset=preset) return p def get_provider_name( self, model: str | None = None, *, preset: ModelPresetConfig | None = None, ) -> str | None: """Get the registry name of the matched provider (e.g. "deepseek", "openrouter").""" _, name = self._match_provider(model, preset=preset) return name def get_api_key( self, model: str | None = None, *, preset: ModelPresetConfig | None = None, ) -> str | None: """Get API key for the given model. Falls back to first available key.""" p = self.get_provider(model, preset=preset) return p.api_key if p else None def get_api_base( self, model: str | None = None, *, preset: ModelPresetConfig | None = None, ) -> str | None: """Get API base URL for the given model, falling back to the provider default when present.""" from nanobot.providers.registry import find_by_name p, name = self._match_provider(model, preset=preset) if p and p.api_base: return p.api_base if name: spec = find_by_name(name) if spec and spec.default_api_base: return spec.default_api_base return None model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") def _resolve_tool_config_refs() -> None: """Resolve forward references in ToolsConfig by importing tool config classes. Must be called after all modules are loaded (breaks circular imports). Re-exports the classes into this module's namespace so existing imports like ``from nanobot.config.schema import ExecToolConfig`` continue to work. """ import sys from nanobot.agent.tools.cli_apps import CliAppsToolConfig from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.shell import ExecToolConfig from nanobot.agent.tools.web import WebFetchConfig, WebSearchConfig, WebToolsConfig # Re-export into this module's namespace mod = sys.modules[__name__] mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined] mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined] mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined] mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined] mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined] mod.MyToolConfig = MyToolConfig # type: ignore[attr-defined] mod.ImageGenerationToolConfig = ImageGenerationToolConfig # type: ignore[attr-defined] ToolsConfig.model_rebuild() Config.model_rebuild() # Eagerly resolve when the import chain allows it (no circular deps at this # point). If it fails (first import triggers a cycle), the rebuild will # happen lazily when Config/ToolsConfig is first used at runtime. try: _resolve_tool_config_refs() except ImportError: pass