2026-02-01 07:36:42 +00:00
"""Configuration schema using Pydantic."""
2026-05-11 14:03:38 +08:00
from __future__ import annotations
2026-02-01 07:36:42 +00:00
from pathlib import Path
2026-05-11 14:03:38 +08:00
from typing import TYPE_CHECKING , Any , Literal
2026-02-10 15:30:39 +01:00
2026-06-13 01:48:45 +08:00
from pydantic import AliasChoices , ConfigDict , Field , model_validator
2026-02-01 07:36:42 +00:00
from pydantic_settings import BaseSettings
2026-06-13 01:48:45 +08:00
from nanobot.config_base import Base
2026-04-04 10:01:45 +00:00
from nanobot.cron.types import CronSchedule
2026-05-11 14:03:38 +08:00
if TYPE_CHECKING :
2026-05-22 22:25:12 +08:00
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
2026-06-01 10:06:05 +03:00
from nanobot.agent.tools.filesystem import FileToolsConfig
2026-05-11 14:03:38 +08:00
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
2026-02-01 07:36:42 +00:00
2026-02-17 15:19:21 +01:00
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-05-13 06:27:53 +00:00
show_reasoning : bool = True # surface model reasoning when channel implements it
2026-05-29 11:29:20 +08:00
extract_document_text : bool = True # extract text from document attachments before sending to the model
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-06-09 01:08:49 +08:00
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
2026-06-06 12:25:03 -05:00
provider : str | None = None # Validated by nanobot.audio.transcription_registry.
2026-06-09 01:08:49 +08:00
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 )
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
2026-05-29 22:07:24 +08:00
enabled : bool = True # Register the periodic Dream consolidation job on startup
2026-04-04 10:01:45 +00:00
interval_h : int = Field ( default = 2 , ge = 1 ) # Every 2 hours by default
2026-06-02 22:46:47 +08:00
cron : str | None = Field ( default = None , exclude = True ) # Legacy cron expression override
2026-04-04 10:01:45 +00:00
model_override : str | None = Field (
default = None ,
validation_alias = AliasChoices ( "modelOverride" , "model" , "model_override" ),
2026-06-02 22:46:47 +08:00
) # 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
2026-03-31 10:58:57 +08:00
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-05-13 15:34:03 +00:00
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
2026-05-09 15:30:47 +08:00
class ModelPresetConfig ( Base ):
"""A named set of model + generation parameters for quick switching."""
2026-05-24 13:38:37 +08:00
label : str | None = None
2026-05-09 15:30:47 +08:00
model : str
provider : str = "auto"
2026-05-15 17:19:47 +00:00
max_tokens : int = 8192
2026-06-22 16:27:30 +08:00
context_window_tokens : int = 200_000
2026-05-09 15:30:47 +08:00
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 ,
)
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"
2026-05-09 15:30:47 +08:00
model_preset : str | None = None # Active preset name — takes precedence over fields below
2026-02-01 07:36:42 +00:00
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-05-15 17:19:47 +00:00
max_tokens : int = 8192
2026-06-22 16:27:30 +08:00
context_window_tokens : int = 200_000
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-05-13 15:34:03 +00:00
fallback_models : list [ FallbackCandidate ] = Field ( default_factory = list )
2026-04-01 19:12:49 +00:00
max_tool_iterations : int = 200
2026-05-05 21:11:27 +08:00
max_concurrent_subagents : int = Field ( default = 1 , ge = 1 )
2026-04-01 19:12:49 +00:00
max_tool_result_chars : int = 16_000
provider_retry_mode : Literal [ "standard" , "persistent" ] = "standard"
2026-05-04 17:56:49 +00:00
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")
2026-05-11 14:00:49 +08:00
reasoning_effort : str | None = None # low / medium / high / adaptive / none — LLM thinking effort; None preserves the provider default
2026-03-25 10:15:47 +00:00
timezone : str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
2026-05-10 15:15:24 -06:00
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
2026-04-07 21:47:58 +08:00
unified_session : bool = False # Share one session across all channels (single-user multi-device)
2026-04-09 14:11:47 +08:00
disabled_skills : list [ str ] = Field ( default_factory = list ) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
2026-04-11 07:32:56 +00:00
session_ttl_minutes : int = Field (
2026-06-17 00:14:32 +08:00
default = 15 ,
2026-04-11 07:32:56 +00:00
ge = 0 ,
validation_alias = AliasChoices ( "idleCompactAfterMinutes" , "sessionTtlMinutes" ),
serialization_alias = "idleCompactAfterMinutes" ,
) # Auto-compact idle threshold in minutes (0 = disabled)
2026-04-27 15:29:52 +03:00
max_messages : int = Field (
2026-04-28 06:39:59 +00:00
default = 120 ,
2026-04-27 15:29:52 +03:00
ge = 0 ,
2026-04-28 06:39:59 +00:00
) # Max messages to replay from session history (0 = use default 120, respects token budget)
2026-04-18 19:47:09 +05:30
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)
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-06-09 01:08:49 +08:00
api_key : str | None = Field ( default = None , repr = False )
2026-02-01 07:36:42 +00:00
api_base : str | None = None
2026-05-23 18:04:40 +08:00
api_type : Literal [ "auto" , "chat_completions" , "responses" ] = "auto" # Request API surface
2026-02-07 08:10:05 +00:00
extra_headers : dict [ str , str ] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
2026-05-23 19:13:07 +08:00
extra_body : dict [ str , Any ] | None = None # Extra provider request fields; shape depends on provider/API surface
2026-06-06 14:54:37 +08:00
extra_query : dict [ str , str ] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
2026-02-01 07:36:42 +00:00
2026-05-01 10:46:31 +00:00
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
2026-02-17 15:19:21 +01:00
class ProvidersConfig ( Base ):
2026-04-17 11:21:06 +08:00
"""Configuration for LLM providers.
Supports custom providers via extra fields — any additional field
becomes an OpenAI-compatible custom provider.
"""
model_config = ConfigDict ( extra = "allow" )
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-05-01 10:46:31 +00:00
bedrock : BedrockProviderConfig = Field ( default_factory = BedrockProviderConfig ) # AWS Bedrock Converse
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-06-06 12:25:03 -05:00
assemblyai : ProviderConfig = Field ( default_factory = ProviderConfig ) # AssemblyAI voice transcription
2026-04-27 18:47:51 +01:00
huggingface : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-05-20 00:04:39 +08:00
skywork : ProviderConfig = Field ( default_factory = ProviderConfig ) # Skywork / APIFree API gateway
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-04-15 13:14:39 -04:00
lm_studio : ProviderConfig = Field ( default_factory = ProviderConfig ) # LM Studio local models
2026-05-11 19:26:54 +03:00
atomic_chat : ProviderConfig = Field ( default_factory = ProviderConfig ) # Atomic Chat 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-04-15 13:48:49 +08:00
minimax_anthropic : ProviderConfig = Field ( default_factory = ProviderConfig ) # MiniMax Anthropic endpoint (thinking)
2026-03-16 08:13:43 +01:00
mistral : ProviderConfig = Field ( default_factory = ProviderConfig )
2026-06-09 17:27:13 +08:00
stepfun : ProviderConfig = Field ( default_factory = ProviderConfig ) # Step Fun (阶跃星辰) — LLM + ASR (set apiBase to Plan URL for ASR)
2026-04-03 14:40:31 +08:00
xiaomi_mimo : ProviderConfig = Field ( default_factory = ProviderConfig ) # Xiaomi MIMO (小米)
2026-04-13 23:45:26 +08:00
longcat : ProviderConfig = Field ( default_factory = ProviderConfig ) # LongCat
2026-05-18 21:12:22 +08:00
ant_ling : ProviderConfig = Field ( default_factory = ProviderConfig ) # Ant Ling
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 (硅基流动)
2026-05-20 16:19:37 +08:00
novita : ProviderConfig = Field ( default_factory = ProviderConfig ) # Novita AI
2026-03-12 15:22:15 +00:00
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-05-09 11:17:26 +08:00
nvidia : ProviderConfig = Field ( default_factory = ProviderConfig ) # NVIDIA NIM (nvapi- keys)
2026-06-24 00:06:01 +08:00
opencode_zen : ProviderConfig = Field ( default_factory = ProviderConfig ) # OpenCode Zen (curated coding models)
opencode_go : ProviderConfig = Field ( default_factory = ProviderConfig ) # OpenCode Go (low-cost coding models)
2026-02-01 07:36:42 +00:00
2026-06-11 13:19:15 +08:00
@model_validator ( mode = "after" )
def convert_extra_providers ( self ):
"""Convert extra fields (custom providers) to ProviderConfig objects."""
if self . model_extra :
2026-06-11 22:21:44 +08:00
from nanobot.providers.registry import find_by_name
2026-06-11 13:19:15 +08:00
for key , value in self . model_extra . items ():
2026-06-11 22:21:44 +08:00
if spec := find_by_name ( key ):
raise ValueError (
f "providers. { key } conflicts with built-in provider { spec . name !r} ; "
"use the built-in provider key or choose a different custom provider name"
)
2026-06-11 13:19:15 +08:00
if isinstance ( value , dict ):
self . model_extra [ key ] = ProviderConfig . model_validate ( value )
return self
2026-05-23 19:13:07 +08:00
@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.<name>.api_type is only supported for providers.openai" )
2026-06-11 13:19:15 +08:00
for provider in ( self . model_extra or {}) . values ():
if isinstance ( provider , ProviderConfig ) and provider . api_type != "auto" :
raise ValueError ( "providers.<name>.api_type is only supported for providers.openai" )
2026-04-17 11:21:06 +08:00
return self
2026-02-01 07:36:42 +00:00
2026-02-24 11:04:56 +00:00
class HeartbeatConfig ( Base ):
2026-05-27 21:28:59 +08:00
"""Heartbeat service configuration (now backed by cron)."""
2026-02-24 11:04:56 +00:00
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-04-14 07:19:38 +00:00
host : str = "127.0.0.1" # Safer default: local-only bind.
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 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-05-24 13:38:37 +08:00
cwd : str = "" # Stdio: working directory for MCP server runtime artifacts
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-04-16 15:58:20 +00:00
2026-05-11 14:03:38 +08:00
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 )()
2026-05-08 09:40:15 +00:00
2026-02-17 15:19:21 +01:00
class ToolsConfig ( Base ):
2026-05-11 14:03:38 +08:00
"""Tools configuration.
2026-02-17 15:19:21 +01:00
2026-05-11 14:03:38 +08:00
Field types for tool-specific sub-configs are resolved via model_rebuild()
2026-06-13 01:48:45 +08:00
at the bottom of this file so tool config classes can stay next to their
tool implementations.
2026-05-11 14:03:38 +08:00
"""
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" ))
2026-06-01 10:06:05 +03:00
file : FileToolsConfig = Field ( default_factory = lambda : _lazy_default ( "nanobot.agent.tools.filesystem" , "FileToolsConfig" ))
2026-05-22 22:25:12 +08:00
cli_apps : CliAppsToolConfig = Field ( default_factory = lambda : _lazy_default ( "nanobot.agent.tools.cli_apps" , "CliAppsToolConfig" ))
2026-05-11 14:03:38 +08:00
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" ),
)
2026-05-29 03:42:53 +08:00
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
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 )
2026-06-09 01:08:49 +08:00
transcription : TranscriptionConfig = Field ( default_factory = TranscriptionConfig )
2026-02-01 07:36:42 +00:00
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-05-12 10:04:14 +00:00
model_presets : dict [ str , ModelPresetConfig ] = Field (
default_factory = dict ,
validation_alias = AliasChoices ( "modelPresets" , "model_presets" ),
)
2026-05-09 15:30:47 +08:00
2026-05-29 03:42:53 +08:00
def __init__ ( self , ** values : Any ) -> None :
if not type ( self ) . __pydantic_complete__ :
_resolve_tool_config_refs ()
super () . __init__ ( ** values )
2026-05-09 15:30:47 +08:00
@model_validator ( mode = "after" )
def _validate_model_preset ( self ) -> "Config" :
2026-05-12 11:28:56 +00:00
if "default" in self . model_presets :
raise ValueError ( "model_preset name 'default' is reserved for agents.defaults" )
2026-05-09 15:30:47 +08:00
name = self . agents . defaults . model_preset
2026-05-12 10:20:35 +00:00
if name and name != "default" and name not in self . model_presets :
2026-05-09 15:30:47 +08:00
raise ValueError ( f "model_preset { name !r} not found in model_presets" )
2026-05-13 15:34:03 +00:00
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" )
2026-05-09 15:30:47 +08:00
return self
2026-05-12 10:20:35 +00:00
def resolve_default_preset ( self ) -> ModelPresetConfig :
"""Return the implicit `default` preset from agents.defaults fields."""
2026-05-09 15:30:47 +08:00
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 ,
)
2026-02-17 15:19:21 +01:00
2026-05-12 10:20:35 +00:00
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 ]
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 (
2026-05-12 07:55:01 +00:00
self , model : str | None = None ,
* ,
preset : ModelPresetConfig | None = None ,
2026-03-04 01:06:04 +08:00
) -> 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-04-17 11:21:06 +08:00
from nanobot.providers.registry import (
PROVIDERS ,
find_by_name ,
)
2026-02-17 15:19:21 +01:00
2026-05-12 07:55:01 +00:00
resolved = preset or self . resolve_preset ()
2026-05-09 15:30:47 +08:00
forced = resolved . provider
2026-06-11 16:41:31 +08:00
def _custom_provider_by_name ( name : str ) -> tuple [ ProviderConfig , str ] | None :
normalized = name . replace ( "-" , "_" ) . lower ()
for attr_name , provider in ( self . providers . model_extra or {}) . items ():
if not isinstance ( provider , ProviderConfig ):
continue
if attr_name . replace ( "-" , "_" ) . lower () == normalized :
return provider , attr_name
return None
2026-02-26 02:15:42 +00:00
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 )
2026-06-11 16:41:31 +08:00
custom = _custom_provider_by_name ( forced )
if custom is not None :
return custom
2026-03-24 03:03:59 +00:00
return None , None
2026-02-26 02:15:42 +00:00
2026-05-09 15:30:47 +08:00
model_lower = ( model or resolved . 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 :
2026-06-06 12:25:03 -05:00
if spec . is_transcription_only :
continue
2026-02-19 13:30:02 +08:00
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-05-01 10:46:31 +00:00
if spec . is_oauth or spec . is_local or spec . is_direct or p . api_key :
2026-02-19 13:30:02 +08:00
return p , spec . name
2026-02-08 07:29:31 +00:00
2026-06-11 16:41:31 +08:00
# Check for custom provider by prefix (e.g., "companyProxy/gpt-4").
# Return the matching provider even when apiBase is missing, so a
# malformed explicit prefix fails instead of falling through to a
# different custom provider.
2026-04-17 11:21:06 +08:00
if model_prefix :
2026-06-11 16:41:31 +08:00
custom = _custom_provider_by_name ( normalized_prefix )
if custom is not None :
return custom
2026-04-17 11:21:06 +08:00
2026-02-08 07:29:31 +00:00
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS :
2026-06-06 12:25:03 -05:00
if spec . is_transcription_only :
continue
2026-02-08 07:29:31 +00:00
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-05-01 10:46:31 +00:00
if spec . is_oauth or spec . is_local or spec . is_direct 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-06-06 12:25:03 -05:00
if spec . is_oauth or spec . is_transcription_only :
2026-02-16 11:43:36 +00:00
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
2026-04-17 11:21:06 +08:00
# Final fallback: check for any configured custom provider
2026-06-11 13:19:15 +08:00
for attr_name , p in ( self . providers . model_extra or {}) . items ():
2026-04-17 11:21:06 +08:00
if isinstance ( p , ProviderConfig ) and p . api_base :
return p , attr_name
2026-02-08 19:31:25 +00:00
return None , None
2026-02-07 08:10:05 +00:00
2026-05-12 07:55:01 +00:00
def get_provider (
self ,
model : str | None = None ,
* ,
preset : ModelPresetConfig | None = None ,
) -> ProviderConfig | None :
2026-02-07 08:10:05 +00:00
"""Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
2026-05-12 07:55:01 +00:00
p , _ = self . _match_provider ( model , preset = preset )
2026-02-08 19:31:25 +00:00
return p
2026-05-12 07:55:01 +00:00
def get_provider_name (
self ,
model : str | None = None ,
* ,
preset : ModelPresetConfig | None = None ,
) -> str | None :
2026-02-08 19:31:25 +00:00
"""Get the registry name of the matched provider (e.g. "deepseek", "openrouter")."""
2026-05-12 07:55:01 +00:00
_ , name = self . _match_provider ( model , preset = preset )
2026-02-08 19:31:25 +00:00
return name
2026-02-06 08:01:20 +00:00
2026-05-12 07:55:01 +00:00
def get_api_key (
self ,
model : str | None = None ,
* ,
preset : ModelPresetConfig | 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."""
2026-05-12 07:55:01 +00:00
p = self . get_provider ( model , preset = preset )
2026-02-07 08:10:05 +00:00
return p . api_key if p else None
2026-02-17 15:19:21 +01:00
2026-05-12 07:55:01 +00:00
def get_api_base (
self ,
model : str | None = None ,
* ,
preset : ModelPresetConfig | None = None ,
) -> str | None :
2026-04-13 23:42:58 +08:00
"""Get API base URL for the given model, falling back to the provider default when present."""
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-05-12 07:55:01 +00:00
p , name = self . _match_provider ( model , preset = preset )
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
if name :
spec = find_by_name ( name )
2026-04-13 23:42:58 +08:00
if spec 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 = "__" )
2026-05-11 14:03:38 +08:00
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
2026-05-22 22:25:12 +08:00
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
2026-06-01 10:06:05 +03:00
from nanobot.agent.tools.filesystem import FileToolsConfig
2026-05-11 14:03:38 +08:00
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]
2026-06-01 10:06:05 +03:00
mod . FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
2026-05-22 22:25:12 +08:00
mod . CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
2026-05-11 14:03:38 +08:00
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