2026-03-14 15:44:48 +08:00
""" Configuration schema using Pydantic. """
from pathlib import Path
from typing import Literal
from pydantic import BaseModel , ConfigDict , Field
from pydantic . alias_generators import to_camel
from pydantic_settings import BaseSettings
class Base ( BaseModel ) :
""" Base model that accepts both camelCase and snake_case keys. """
model_config = ConfigDict ( alias_generator = to_camel , populate_by_name = True )
2026-03-28 01:01:13 +08:00
class ChannelsConfig ( Base ) :
""" Configuration for chat channels.
2026-03-14 15:44:48 +08:00
2026-03-28 01:01:13 +08:00
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).
"""
2026-03-14 15:44:48 +08:00
2026-03-28 01:01:13 +08:00
model_config = ConfigDict ( extra = " allow " )
2026-03-14 15:44:48 +08:00
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("…"))
2026-03-28 01:01:13 +08:00
send_max_retries : int = Field ( default = 3 , ge = 0 , le = 10 ) # Max delivery attempts (initial send included)
2026-03-14 15:44:48 +08:00
class AgentDefaults ( Base ) :
""" Default agent configuration. """
workspace : str = " ~/.nanobot/workspace "
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
2026-03-28 01:01:13 +08:00
context_window_tokens : int = 65_536
2026-03-14 15:44:48 +08:00
temperature : float = 0.1
max_tool_iterations : int = 40
2026-03-28 01:01:13 +08:00
reasoning_effort : str | None = None # low / medium / high - enables LLM thinking mode
timezone : str = " UTC " # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
2026-03-14 15:44:48 +08:00
class AgentsConfig ( Base ) :
""" Agent configuration. """
defaults : AgentDefaults = Field ( default_factory = AgentDefaults )
class ProviderConfig ( Base ) :
""" LLM provider configuration. """
api_key : str = " "
api_base : str | None = None
extra_headers : dict [ str , str ] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
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)
anthropic : ProviderConfig = Field ( default_factory = ProviderConfig )
openai : ProviderConfig = Field ( default_factory = ProviderConfig )
openrouter : ProviderConfig = Field ( default_factory = ProviderConfig )
deepseek : ProviderConfig = Field ( default_factory = ProviderConfig )
groq : ProviderConfig = Field ( default_factory = ProviderConfig )
zhipu : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-28 01:01:13 +08:00
dashscope : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-14 15:44:48 +08:00
vllm : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-28 01:01:13 +08:00
ollama : ProviderConfig = Field ( default_factory = ProviderConfig ) # Ollama local models
ovms : ProviderConfig = Field ( default_factory = ProviderConfig ) # OpenVINO Model Server (OVMS)
2026-03-14 15:44:48 +08:00
gemini : ProviderConfig = Field ( default_factory = ProviderConfig )
moonshot : ProviderConfig = Field ( default_factory = ProviderConfig )
minimax : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-03-28 01:01:13 +08:00
mistral : ProviderConfig = Field ( default_factory = ProviderConfig )
stepfun : ProviderConfig = Field ( default_factory = ProviderConfig ) # Step Fun (阶跃星辰)
2026-03-14 15:44:48 +08:00
aihubmix : ProviderConfig = Field ( default_factory = ProviderConfig ) # AiHubMix API gateway
siliconflow : ProviderConfig = Field ( default_factory = ProviderConfig ) # SiliconFlow (硅基流动)
volcengine : ProviderConfig = Field ( default_factory = ProviderConfig ) # VolcEngine (火山引擎)
2026-03-28 01:01:13 +08:00
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)
2026-03-14 15:44:48 +08:00
class HeartbeatConfig ( Base ) :
""" Heartbeat service configuration. """
enabled : bool = True
interval_s : int = 30 * 60 # 30 minutes
2026-03-28 01:01:13 +08:00
keep_recent_messages : int = 8
2026-03-14 15:44:48 +08:00
class GatewayConfig ( Base ) :
""" Gateway/server configuration. """
host : str = " 0.0.0.0 "
port : int = 18790
heartbeat : HeartbeatConfig = Field ( default_factory = HeartbeatConfig )
class WebSearchConfig ( Base ) :
""" Web search tool configuration. """
2026-03-28 01:01:13 +08:00
provider : str = " brave " # brave, tavily, duckduckgo, searxng, jina
api_key : str = " "
base_url : str = " " # SearXNG base URL
2026-03-14 15:44:48 +08:00
max_results : int = 5
class WebToolsConfig ( Base ) :
""" Web tools configuration. """
proxy : str | None = (
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
)
search : WebSearchConfig = Field ( default_factory = WebSearchConfig )
class ExecToolConfig ( Base ) :
""" Shell exec tool configuration. """
2026-03-28 01:01:13 +08:00
enable : bool = True
2026-03-14 15:44:48 +08:00
timeout : int = 60
path_append : str = " "
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
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-28 01:01:13 +08: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-03-14 15:44:48 +08:00
class ToolsConfig ( Base ) :
""" Tools configuration. """
web : WebToolsConfig = Field ( default_factory = WebToolsConfig )
exec : ExecToolConfig = Field ( default_factory = ExecToolConfig )
restrict_to_workspace : bool = False # If true, restrict all tool access to workspace directory
mcp_servers : dict [ str , MCPServerConfig ] = Field ( default_factory = dict )
class Config ( BaseSettings ) :
""" Root configuration for nanobot. """
agents : AgentsConfig = Field ( default_factory = AgentsConfig )
channels : ChannelsConfig = Field ( default_factory = ChannelsConfig )
providers : ProvidersConfig = Field ( default_factory = ProvidersConfig )
gateway : GatewayConfig = Field ( default_factory = GatewayConfig )
tools : ToolsConfig = Field ( default_factory = ToolsConfig )
@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
) - > tuple [ " ProviderConfig | None " , str | None ] :
""" Match provider config and its registry name. Returns (config, spec_name). """
2026-03-28 01:01:13 +08:00
from nanobot . providers . registry import PROVIDERS , find_by_name
2026-03-14 15:44:48 +08:00
forced = self . agents . defaults . provider
if forced != " auto " :
2026-03-28 01:01:13 +08: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-03-14 15:44:48 +08:00
model_lower = ( model or self . agents . defaults . 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 :
p = getattr ( self . providers , spec . name , None )
if p and model_prefix and normalized_prefix == spec . name :
2026-03-28 01:01:13 +08:00
if spec . is_oauth or spec . is_local or p . api_key :
2026-03-14 15:44:48 +08:00
return p , spec . name
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS :
p = getattr ( self . providers , spec . name , None )
if p and any ( _kw_matches ( kw ) for kw in spec . keywords ) :
2026-03-28 01:01:13 +08:00
if spec . is_oauth or spec . is_local or p . api_key :
2026-03-14 15:44:48 +08:00
return p , spec . name
2026-03-28 01:01:13 +08:00
# 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
2026-03-14 15:44:48 +08:00
# 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 :
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 ) - > ProviderConfig | None :
""" Get matched provider config (api_key, api_base, extra_headers). Falls back to first available. """
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
def get_api_key ( self , model : str | None = None ) - > str | None :
""" 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
def get_api_base ( self , model : str | None = None ) - > str | None :
2026-03-28 01:01:13 +08:00
""" Get API base URL for the given model. Applies default URLs for gateway/local providers. """
2026-03-14 15:44:48 +08:00
from nanobot . providers . registry import find_by_name
p , name = self . _match_provider ( model )
if p and p . api_base :
return p . api_base
# Only gateways get a default api_base here. Standard providers
2026-03-28 01:01:13 +08:00
# resolve their base URL from the registry in the provider constructor.
2026-03-14 15:44:48 +08:00
if name :
spec = find_by_name ( name )
2026-03-28 01:01:13 +08:00
if spec and ( spec . is_gateway or spec . is_local ) and spec . default_api_base :
2026-03-14 15:44:48 +08:00
return spec . default_api_base
return None
model_config = ConfigDict ( env_prefix = " NANOBOT_ " , env_nested_delimiter = " __ " )