2026-02-01 07:36:42 +00:00
"""Configuration schema using Pydantic."""
from pathlib import Path
2026-02-10 15:30:39 +01:00
from typing import Literal
2026-04-04 10:01:45 +00:00
from pydantic import AliasChoices , BaseModel , ConfigDict , Field
2026-02-17 15:19:21 +01:00
from pydantic.alias_generators import to_camel
2026-02-01 07:36:42 +00:00
from pydantic_settings import BaseSettings
2026-04-04 10:01:45 +00:00
from nanobot.cron.types import CronSchedule
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
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 ):
2026-03-13 15:26:55 +00:00
"""Configuration for chat channels.
Built-in and plugin channel configs are stored as extra fields (dicts).
Each channel parses its own config in __init__.
2026-03-22 15:34:15 +00:00
Per-channel "streaming": true enables streaming output (requires send_delta impl).
2026-03-13 15:26:55 +00:00
"""
model_config = ConfigDict ( extra = "allow" )
2026-02-17 15:19:21 +01:00
2026-03-04 01:06:04 +08:00
send_progress : bool = True # stream agent's text progress to the channel
2026-02-23 07:12:41 +00:00
send_tool_hints : bool = False # stream tool-call hints (e.g. read_file("…"))
2026-03-25 14:34:37 +00:00
send_max_retries : int = Field ( default = 3 , ge = 0 , le = 10 ) # Max delivery attempts (initial send included)
2026-04-06 06:07:30 +00:00
transcription_provider : str = "groq" # Voice transcription backend: "groq" or "openai"
2026-02-01 07:36:42 +00:00
2026-03-31 10:58:57 +08:00
class DreamConfig ( Base ):
"""Dream memory consolidation configuration."""
2026-04-04 10:01:45 +00:00
_HOUR_MS = 3_600_000
interval_h : int = Field ( default = 2 , ge = 1 ) # Every 2 hours by default
cron : str | None = Field ( default = None , exclude = True ) # Legacy compatibility override
model_override : str | None = Field (
default = None ,
validation_alias = AliasChoices ( "modelOverride" , "model" , "model_override" ),
) # Optional Dream-specific model override
2026-03-31 10:58:57 +08:00
max_batch_size : int = Field ( default = 20 , ge = 1 ) # Max history entries per run
max_iterations : int = Field ( default = 10 , ge = 1 ) # Max tool calls per Phase 2
2026-04-04 10:01:45 +00:00
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"
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
class AgentDefaults ( Base ):
2026-02-01 07:36:42 +00:00
"""Default agent configuration."""
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
workspace : str = "~/.nanobot/workspace"
model : str = "anthropic/claude-opus-4-5"
2026-03-04 01:06:04 +08:00
provider : str = (
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
)
2026-02-01 07:36:42 +00:00
max_tokens : int = 8192
2026-03-10 19:55:06 +00:00
context_window_tokens : int = 65_536
2026-04-01 19:12:49 +00:00
context_block_limit : int | None = None
2026-02-23 08:24:53 +00:00
temperature : float = 0.1
2026-04-01 19:12:49 +00:00
max_tool_iterations : int = 200
max_tool_result_chars : int = 16_000
provider_retry_mode : Literal [ "standard" , "persistent" ] = "standard"
2026-04-07 14:52:44 +00:00
reasoning_effort : str | None = None # low / medium / high / adaptive - enables LLM thinking mode
2026-03-25 10:15:47 +00:00
timezone : str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
2026-04-07 21:47:58 +08:00
unified_session : bool = False # Share one session across all channels (single-user multi-device)
2026-03-31 10:58:57 +08:00
dream : DreamConfig = Field ( default_factory = DreamConfig )
2026-03-10 19:55:06 +00:00
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
class AgentsConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""Agent configuration."""
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
defaults : AgentDefaults = Field ( default_factory = AgentDefaults )
2026-02-17 15:19:21 +01:00
class ProviderConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""LLM provider configuration."""
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
api_key : str = ""
api_base : str | None = None
2026-02-07 08:10:05 +00:00
extra_headers : dict [ str , str ] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
class ProvidersConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""Configuration for LLM providers."""
2026-02-17 15:19:21 +01:00
2026-02-13 16:05:00 +00:00
custom : ProviderConfig = Field ( default_factory = ProviderConfig ) # Any OpenAI-compatible endpoint
2026-03-06 08:43:58 +00:00
azure_openai : ProviderConfig = Field ( default_factory = ProviderConfig ) # Azure OpenAI (model = deployment name)
2026-02-01 07:36:42 +00:00
anthropic : ProviderConfig = Field ( default_factory = ProviderConfig )
openai : ProviderConfig = Field ( default_factory = ProviderConfig )
openrouter : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-02-03 03:09:13 +00:00
deepseek : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-02-02 04:33:26 -05:00
groq : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-02-01 14:36:15 -05:00
zhipu : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-12 15:22:15 +00:00
dashscope : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-02-02 11:23:04 +11:00
vllm : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-12 14:58:03 +08:00
ollama : ProviderConfig = Field ( default_factory = ProviderConfig ) # Ollama local models
2026-03-18 15:02:47 +08:00
ovms : ProviderConfig = Field ( default_factory = ProviderConfig ) # OpenVINO Model Server (OVMS)
2026-02-02 11:21:41 +05:30
gemini : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-02-06 15:15:15 +08:00
moonshot : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-02-08 03:55:24 +08:00
minimax : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-16 08:13:43 +01:00
mistral : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-25 16:32:10 +08:00
stepfun : ProviderConfig = Field ( default_factory = ProviderConfig ) # Step Fun (阶跃星辰)
2026-04-03 14:40:31 +08:00
xiaomi_mimo : ProviderConfig = Field ( default_factory = ProviderConfig ) # Xiaomi MIMO (小米)
2026-02-07 08:10:05 +00:00
aihubmix : ProviderConfig = Field ( default_factory = ProviderConfig ) # AiHubMix API gateway
2026-03-12 15:22:15 +00:00
siliconflow : ProviderConfig = Field ( default_factory = ProviderConfig ) # SiliconFlow (硅基流动)
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
2026-03-20 19:19:02 +00:00
openai_codex : ProviderConfig = Field ( default_factory = ProviderConfig , exclude = True ) # OpenAI Codex (OAuth)
2026-03-20 16:25:12 +00:00
github_copilot : ProviderConfig = Field ( default_factory = ProviderConfig , exclude = True ) # Github Copilot (OAuth)
2026-04-02 22:16:25 +08:00
qianfan : ProviderConfig = Field ( default_factory = ProviderConfig ) # Qianfan (百度千帆)
2026-02-01 07:36:42 +00:00
2026-02-24 11:04:56 +00:00
class HeartbeatConfig ( Base ):
"""Heartbeat service configuration."""
enabled : bool = True
interval_s : int = 30 * 60 # 30 minutes
2026-03-23 16:27:20 +00:00
keep_recent_messages : int = 8
2026-02-24 11:04:56 +00:00
2026-03-29 15:32:33 +00:00
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.
2026-02-24 11:04:56 +00:00
2026-02-17 15:19:21 +01:00
class GatewayConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""Gateway/server configuration."""
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
host : str = "0.0.0.0"
2026-02-02 13:35:44 +08:00
port : int = 18790
2026-02-24 11:04:56 +00:00
heartbeat : HeartbeatConfig = Field ( default_factory = HeartbeatConfig )
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
class WebSearchConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""Web search tool configuration."""
2026-02-17 15:19:21 +01:00
2026-03-30 15:16:58 +08:00
provider : str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina
2026-03-13 05:44:16 +00:00
api_key : str = ""
base_url : str = "" # SearXNG base URL
2026-02-01 07:36:42 +00:00
max_results : int = 5
2026-04-05 09:12:49 +08:00
timeout : int = 30 # Wall-clock timeout (seconds) for search operations
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
class WebToolsConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""Web tools configuration."""
2026-02-17 15:19:21 +01:00
2026-03-30 15:16:58 +08:00
enable : bool = True
2026-03-04 01:06:04 +08:00
proxy : str | None = (
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
)
2026-02-01 07:36:42 +00:00
search : WebSearchConfig = Field ( default_factory = WebSearchConfig )
2026-02-17 15:19:21 +01:00
class ExecToolConfig ( Base ):
2026-02-04 03:45:26 +00:00
"""Shell exec tool configuration."""
2026-02-17 15:19:21 +01:00
2026-03-10 15:10:09 +08:00
enable : bool = True
2026-02-04 03:45:26 +00:00
timeout : int = 60
2026-02-24 12:13:52 +00:00
path_append : str = ""
2026-03-16 23:55:19 -07:00
sandbox : str = "" # sandbox backend: "" (none) or "bwrap"
2026-02-04 03:45:26 +00:00
2026-02-17 15:19:21 +01:00
class MCPServerConfig ( Base ):
2026-02-12 10:01:30 +01:00
"""MCP server connection configuration (stdio or HTTP)."""
2026-02-17 15:19:21 +01:00
2026-03-05 14:44:45 +00:00
type : Literal [ "stdio" , "sse" , "streamableHttp" ] | None = None # auto-detected if omitted
2026-02-12 10:01:30 +01:00
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
2026-03-05 14:44:45 +00:00
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
2026-03-14 10:26:15 +00:00
enabled_tools : list [ str ] = Field ( default_factory = lambda : [ "*" ]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
2026-02-12 10:01:30 +01:00
2026-02-17 15:19:21 +01:00
class ToolsConfig ( Base ):
2026-02-01 07:36:42 +00:00
"""Tools configuration."""
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
web : WebToolsConfig = Field ( default_factory = WebToolsConfig )
2026-02-04 03:45:26 +00:00
exec : ExecToolConfig = Field ( default_factory = ExecToolConfig )
2026-03-16 23:55:19 -07:00
restrict_to_workspace : bool = False # restrict all tool access to workspace directory
2026-02-12 10:01:30 +01:00
mcp_servers : dict [ str , MCPServerConfig ] = Field ( default_factory = dict )
2026-04-01 21:54:35 +08:00
ssrf_whitelist : list [ str ] = Field ( default_factory = list ) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
2026-02-01 07:36:42 +00:00
class Config ( BaseSettings ):
2026-02-01 18:45:42 +00:00
"""Root configuration for nanobot."""
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
agents : AgentsConfig = Field ( default_factory = AgentsConfig )
channels : ChannelsConfig = Field ( default_factory = ChannelsConfig )
providers : ProvidersConfig = Field ( default_factory = ProvidersConfig )
2026-03-29 15:32:33 +00:00
api : ApiConfig = Field ( default_factory = ApiConfig )
2026-02-01 07:36:42 +00:00
gateway : GatewayConfig = Field ( default_factory = GatewayConfig )
tools : ToolsConfig = Field ( default_factory = ToolsConfig )
2026-02-17 15:19:21 +01:00
2026-02-01 07:36:42 +00:00
@property
def workspace_path ( self ) -> Path :
"""Get expanded workspace path."""
return Path ( self . agents . defaults . workspace ) . expanduser ()
2026-02-17 15:19:21 +01:00
2026-03-04 01:06:04 +08:00
def _match_provider (
self , model : str | None = None
) -> tuple [ "ProviderConfig | None" , str | None ]:
2026-02-08 19:31:25 +00:00
"""Match provider config and its registry name. Returns (config, spec_name)."""
2026-03-24 03:03:59 +00:00
from nanobot.providers.registry import PROVIDERS , find_by_name
2026-02-17 15:19:21 +01:00
2026-02-26 02:15:42 +00:00
forced = self . agents . defaults . provider
if forced != "auto" :
2026-03-24 03:03:59 +00:00
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
2026-02-26 02:15:42 +00:00
2026-02-08 07:29:31 +00:00
model_lower = ( model or self . agents . defaults . model ) . lower ()
2026-02-19 13:30:02 +08:00
model_normalized = model_lower . replace ( "-" , "_" )
model_prefix = model_lower . split ( "/" , 1 )[ 0 ] if "/" in model_lower else ""
normalized_prefix = model_prefix . replace ( "-" , "_" )
2026-02-19 17:39:44 +00:00
def _kw_matches ( kw : str ) -> bool :
kw = kw . lower ()
return kw in model_lower or kw . replace ( "-" , "_" ) in model_normalized
2026-02-19 13:30:02 +08:00
2026-02-19 17:39:44 +00:00
# Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
2026-02-19 13:30:02 +08:00
for spec in PROVIDERS :
p = getattr ( self . providers , spec . name , None )
2026-02-19 17:39:44 +00:00
if p and model_prefix and normalized_prefix == spec . name :
2026-03-11 07:43:28 +04:00
if spec . is_oauth or spec . is_local or p . api_key :
2026-02-19 13:30:02 +08:00
return p , spec . name
2026-02-08 07:29:31 +00:00
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS :
p = getattr ( self . providers , spec . name , None )
2026-02-19 17:39:44 +00:00
if p and any ( _kw_matches ( kw ) for kw in spec . keywords ):
2026-03-11 07:43:28 +04:00
if spec . is_oauth or spec . is_local or p . api_key :
2026-02-09 15:13:11 +08:00
return p , spec . name
2026-02-08 07:29:31 +00:00
2026-03-11 08:42:12 +00:00
# Fallback: configured local providers can route models without
# provider-specific keywords (for example plain "llama3.2" on Ollama).
2026-03-12 15:22:15 +00:00
# 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
2026-03-11 08:42:12 +00:00
for spec in PROVIDERS :
if not spec . is_local :
continue
p = getattr ( self . providers , spec . name , None )
2026-03-12 15:22:15 +00:00
if not ( p and p . api_base ):
continue
if spec . detect_by_base_keyword and spec . detect_by_base_keyword in p . api_base :
2026-03-11 08:42:12 +00:00
return p , spec . name
2026-03-12 15:22:15 +00:00
if local_fallback is None :
local_fallback = ( p , spec . name )
if local_fallback :
return local_fallback
2026-03-11 08:42:12 +00:00
2026-02-08 07:29:31 +00:00
# Fallback: gateways first, then others (follows registry order)
2026-02-16 11:43:36 +00:00
# OAuth providers are NOT valid fallbacks — they require explicit model selection
2026-02-08 07:29:31 +00:00
for spec in PROVIDERS :
2026-02-16 11:43:36 +00:00
if spec . is_oauth :
continue
2026-02-08 07:29:31 +00:00
p = getattr ( self . providers , spec . name , None )
if p and p . api_key :
2026-02-08 19:31:25 +00:00
return p , spec . name
return None , None
2026-02-07 08:10:05 +00:00
def get_provider ( self , model : str | None = None ) -> ProviderConfig | None :
"""Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
2026-02-08 19:31:25 +00:00
p , _ = self . _match_provider ( model )
return p
def get_provider_name ( self , model : str | None = None ) -> str | None :
"""Get the registry name of the matched provider (e.g. "deepseek", "openrouter")."""
_ , name = self . _match_provider ( model )
return name
2026-02-06 08:01:20 +00:00
def get_api_key ( self , model : str | None = None ) -> str | None :
2026-02-07 08:10:05 +00:00
"""Get API key for the given model. Falls back to first available key."""
p = self . get_provider ( model )
return p . api_key if p else None
2026-02-17 15:19:21 +01:00
2026-02-06 08:01:20 +00:00
def get_api_base ( self , model : str | None = None ) -> str | None :
2026-03-11 08:42:12 +00:00
"""Get API base URL for the given model. Applies default URLs for gateway/local providers."""
2026-02-08 19:31:25 +00:00
from nanobot.providers.registry import find_by_name
2026-02-17 15:19:21 +01:00
2026-02-08 19:31:25 +00:00
p , name = self . _match_provider ( model )
2026-02-07 08:10:05 +00:00
if p and p . api_base :
return p . api_base
2026-02-08 19:31:25 +00:00
# Only gateways get a default api_base here. Standard providers
2026-03-24 17:53:35 +00:00
# resolve their base URL from the registry in the provider constructor.
2026-02-08 19:31:25 +00:00
if name :
spec = find_by_name ( name )
2026-03-11 08:42:12 +00:00
if spec and ( spec . is_gateway or spec . is_local ) and spec . default_api_base :
2026-02-08 07:29:31 +00:00
return spec . default_api_base
2026-02-01 07:36:42 +00:00
return None
2026-02-17 15:19:21 +01:00
model_config = ConfigDict ( env_prefix = "NANOBOT_" , env_nested_delimiter = "__" )