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-02-28 20:55:43 +08:00
from pydantic import 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-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__.
"""
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-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-02-23 08:24:53 +00:00
temperature : float = 0.1
2026-02-23 09:13:08 +00:00
max_tool_iterations : int = 40
2026-03-20 09:44:06 +00:00
reasoning_effort : str | None = None # low / medium / high - enables LLM thinking mode
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-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-02-07 08:10:05 +00:00
aihubmix : ProviderConfig = Field ( default_factory = ProviderConfig ) # AiHubMix API gateway
2026-03-06 07:13:04 +00:00
siliconflow : ProviderConfig = Field ( default_factory = ProviderConfig ) # SiliconFlow (硅基流动)
volcengine : ProviderConfig = Field ( default_factory = ProviderConfig ) # VolcEngine (火山引擎)
2026-03-12 15:22:15 +00: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
2026-02-16 11:43:36 +00:00
openai_codex : ProviderConfig = Field ( default_factory = ProviderConfig ) # OpenAI Codex (OAuth)
2026-03-20 16:25:12 +00:00
github_copilot : ProviderConfig = Field ( default_factory = ProviderConfig , exclude = True ) # Github Copilot (OAuth)
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-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-13 05:44:16 +00:00
provider : str = "brave" # brave, tavily, duckduckgo, searxng, jina
api_key : str = ""
base_url : str = "" # SearXNG base URL
2026-02-01 07:36:42 +00:00
max_results : int = 5
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-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-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-02-06 09:28:08 +00:00
restrict_to_workspace : bool = False # If true, 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-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 )
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-02-08 07:29:31 +00:00
from nanobot.providers.registry import PROVIDERS
2026-02-17 15:19:21 +01:00
2026-02-26 02:15:42 +00:00
forced = self . agents . defaults . provider
if forced != "auto" :
p = getattr ( self . providers , forced , None )
return ( p , forced ) if p else ( None , None )
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
# (like Moonshot) set their base URL via env vars in _setup_env
# to avoid polluting the global litellm.api_base.
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 = "__" )