diff --git a/.gitignore b/.gitignore
index 316e214..55338f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ docs/
__pycache__/
poetry.lock
.pytest_cache/
+tests/
\ No newline at end of file
diff --git a/README.md b/README.md
index 95a5625..8c5c387 100644
--- a/README.md
+++ b/README.md
@@ -16,11 +16,12 @@
β‘οΈ Delivers core agent functionality in just **~4,000** lines of code β **99% smaller** than Clawdbot's 430k+ lines.
-π Real-time line count: **3,422 lines** (run `bash core_agent_lines.sh` to verify anytime)
+π Real-time line count: **3,423 lines** (run `bash core_agent_lines.sh` to verify anytime)
## π’ News
-- **2026-02-07** π Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
+- **2026-02-08** π§ Refactored Providersβadding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
+- **2026-02-07** π Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** β¨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** β¨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
- **2026-02-04** π Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
@@ -398,6 +399,53 @@ Config file: `~/.nanobot/config.json`
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
+| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
+| `vllm` | LLM (local, any OpenAI-compatible server) | β |
+
+
+Adding a New Provider (Developer Guide)
+
+nanobot uses a **Provider Registry** (`nanobot/providers/registry.py`) as the single source of truth.
+Adding a new provider only takes **2 steps** β no if-elif chains to touch.
+
+**Step 1.** Add a `ProviderSpec` entry to `PROVIDERS` in `nanobot/providers/registry.py`:
+
+```python
+ProviderSpec(
+ name="myprovider", # config field name
+ keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching
+ env_key="MYPROVIDER_API_KEY", # env var for LiteLLM
+ display_name="My Provider", # shown in `nanobot status`
+ litellm_prefix="myprovider", # auto-prefix: model β myprovider/model
+ skip_prefixes=("myprovider/",), # don't double-prefix
+)
+```
+
+**Step 2.** Add a field to `ProvidersConfig` in `nanobot/config/schema.py`:
+
+```python
+class ProvidersConfig(BaseModel):
+ ...
+ myprovider: ProviderConfig = ProviderConfig()
+```
+
+That's it! Environment variables, model prefixing, config matching, and `nanobot status` display will all work automatically.
+
+**Common `ProviderSpec` options:**
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| `litellm_prefix` | Auto-prefix model names for LiteLLM | `"dashscope"` β `dashscope/qwen-max` |
+| `skip_prefixes` | Don't prefix if model already starts with these | `("dashscope/", "openrouter/")` |
+| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
+| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` |
+| `is_gateway` | Can route any model (like OpenRouter) | `True` |
+| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
+| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
+| `strip_model_prefix` | Strip existing prefix before re-prefixing | `True` (for AiHubMix) |
+
+
### Security
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b13113f..a65f3a5 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -45,6 +45,7 @@ class AgentLoop:
exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None,
restrict_to_workspace: bool = False,
+ session_manager: SessionManager | None = None,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
@@ -59,7 +60,7 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace
self.context = ContextBuilder(workspace)
- self.sessions = SessionManager(workspace)
+ self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
self.subagents = SubagentManager(
provider=provider,
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index c7ab7c3..1501a28 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -1,7 +1,9 @@
"""Channel manager for coordinating chat channels."""
+from __future__ import annotations
+
import asyncio
-from typing import Any
+from typing import Any, TYPE_CHECKING
from loguru import logger
@@ -10,6 +12,9 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
+if TYPE_CHECKING:
+ from nanobot.session.manager import SessionManager
+
class ChannelManager:
"""
@@ -21,9 +26,10 @@ class ChannelManager:
- Route outbound messages
"""
- def __init__(self, config: Config, bus: MessageBus):
+ def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None):
self.config = config
self.bus = bus
+ self.session_manager = session_manager
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
@@ -40,6 +46,7 @@ class ChannelManager:
self.config.channels.telegram,
self.bus,
groq_api_key=self.config.providers.groq.api_key,
+ session_manager=self.session_manager,
)
logger.info("Telegram channel enabled")
except ImportError as e:
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index f2b6d1f..ff46c86 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -1,17 +1,23 @@
"""Telegram channel implementation using python-telegram-bot."""
+from __future__ import annotations
+
import asyncio
import re
+from typing import TYPE_CHECKING
from loguru import logger
-from telegram import Update
-from telegram.ext import Application, MessageHandler, filters, ContextTypes
+from telegram import BotCommand, Update
+from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import TelegramConfig
+if TYPE_CHECKING:
+ from nanobot.session.manager import SessionManager
+
def _markdown_to_telegram_html(text: str) -> str:
"""
@@ -85,12 +91,27 @@ class TelegramChannel(BaseChannel):
name = "telegram"
- def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""):
+ # Commands registered with Telegram's command menu
+ BOT_COMMANDS = [
+ BotCommand("start", "Start the bot"),
+ BotCommand("reset", "Reset conversation history"),
+ BotCommand("help", "Show available commands"),
+ ]
+
+ def __init__(
+ self,
+ config: TelegramConfig,
+ bus: MessageBus,
+ groq_api_key: str = "",
+ session_manager: SessionManager | None = None,
+ ):
super().__init__(config, bus)
self.config: TelegramConfig = config
self.groq_api_key = groq_api_key
+ self.session_manager = session_manager
self._app: Application | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
+ self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
async def start(self) -> None:
"""Start the Telegram bot with long polling."""
@@ -106,6 +127,11 @@ class TelegramChannel(BaseChannel):
builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy)
self._app = builder.build()
+ # Add command handlers
+ self._app.add_handler(CommandHandler("start", self._on_start))
+ self._app.add_handler(CommandHandler("reset", self._on_reset))
+ self._app.add_handler(CommandHandler("help", self._on_help))
+
# Add message handler for text, photos, voice, documents
self._app.add_handler(
MessageHandler(
@@ -115,20 +141,22 @@ class TelegramChannel(BaseChannel):
)
)
- # Add /start command handler
- from telegram.ext import CommandHandler
- self._app.add_handler(CommandHandler("start", self._on_start))
-
logger.info("Starting Telegram bot (polling mode)...")
# Initialize and start polling
await self._app.initialize()
await self._app.start()
- # Get bot info
+ # Get bot info and register command menu
bot_info = await self._app.bot.get_me()
logger.info(f"Telegram bot @{bot_info.username} connected")
+ try:
+ await self._app.bot.set_my_commands(self.BOT_COMMANDS)
+ logger.debug("Telegram bot commands registered")
+ except Exception as e:
+ logger.warning(f"Failed to register bot commands: {e}")
+
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
allowed_updates=["message"],
@@ -143,6 +171,10 @@ class TelegramChannel(BaseChannel):
"""Stop the Telegram bot."""
self._running = False
+ # Cancel all typing indicators
+ for chat_id in list(self._typing_tasks):
+ self._stop_typing(chat_id)
+
if self._app:
logger.info("Stopping Telegram bot...")
await self._app.updater.stop()
@@ -156,6 +188,9 @@ class TelegramChannel(BaseChannel):
logger.warning("Telegram bot not running")
return
+ # Stop typing indicator for this chat
+ self._stop_typing(msg.chat_id)
+
try:
# chat_id should be the Telegram chat ID (integer)
chat_id = int(msg.chat_id)
@@ -187,9 +222,45 @@ class TelegramChannel(BaseChannel):
user = update.effective_user
await update.message.reply_text(
f"π Hi {user.first_name}! I'm nanobot.\n\n"
- "Send me a message and I'll respond!"
+ "Send me a message and I'll respond!\n"
+ "Type /help to see available commands."
)
+ async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /reset command β clear conversation history."""
+ if not update.message or not update.effective_user:
+ return
+
+ chat_id = str(update.message.chat_id)
+ session_key = f"{self.name}:{chat_id}"
+
+ if self.session_manager is None:
+ logger.warning("/reset called but session_manager is not available")
+ await update.message.reply_text("β οΈ Session management is not available.")
+ return
+
+ session = self.session_manager.get_or_create(session_key)
+ msg_count = len(session.messages)
+ session.clear()
+ self.session_manager.save(session)
+
+ logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)")
+ await update.message.reply_text("π Conversation history cleared. Let's start fresh!")
+
+ async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /help command β show available commands."""
+ if not update.message:
+ return
+
+ help_text = (
+ "π nanobot commands\n\n"
+ "/start β Start the bot\n"
+ "/reset β Reset conversation history\n"
+ "/help β Show this help message\n\n"
+ "Just send me a text message to chat!"
+ )
+ await update.message.reply_text(help_text, parse_mode="HTML")
+
async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming messages (text, photos, voice, documents)."""
if not update.message or not update.effective_user:
@@ -272,10 +343,15 @@ class TelegramChannel(BaseChannel):
logger.debug(f"Telegram message from {sender_id}: {content[:50]}...")
+ str_chat_id = str(chat_id)
+
+ # Start typing indicator before processing
+ self._start_typing(str_chat_id)
+
# Forward to the message bus
await self._handle_message(
sender_id=sender_id,
- chat_id=str(chat_id),
+ chat_id=str_chat_id,
content=content,
media=media_paths,
metadata={
@@ -287,6 +363,29 @@ class TelegramChannel(BaseChannel):
}
)
+ def _start_typing(self, chat_id: str) -> None:
+ """Start sending 'typing...' indicator for a chat."""
+ # Cancel any existing typing task for this chat
+ self._stop_typing(chat_id)
+ self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id))
+
+ def _stop_typing(self, chat_id: str) -> None:
+ """Stop the typing indicator for a chat."""
+ task = self._typing_tasks.pop(chat_id, None)
+ if task and not task.done():
+ task.cancel()
+
+ async def _typing_loop(self, chat_id: str) -> None:
+ """Repeatedly send 'typing' action until cancelled."""
+ try:
+ while self._app:
+ await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
+ await asyncio.sleep(4)
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ logger.debug(f"Typing indicator stopped for {chat_id}: {e}")
+
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
"""Get file extension based on media type."""
if mime_type:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 19e62e9..1dab818 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -179,6 +179,7 @@ def gateway(
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
+ from nanobot.session.manager import SessionManager
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -192,6 +193,7 @@ def gateway(
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
+ session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
@@ -208,6 +210,7 @@ def gateway(
exec_config=config.tools.exec,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
+ session_manager=session_manager,
)
# Set cron callback (needs agent)
@@ -242,7 +245,7 @@ def gateway(
)
# Create channel manager
- channels = ChannelManager(config, bus)
+ channels = ChannelManager(config, bus, session_manager=session_manager)
if channels.enabled_channels:
console.print(f"[green]β[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
@@ -632,25 +635,24 @@ def status():
console.print(f"Workspace: {workspace} {'[green]β[/green]' if workspace.exists() else '[red]β[/red]'}")
if config_path.exists():
+ from nanobot.providers.registry import PROVIDERS
+
console.print(f"Model: {config.agents.defaults.model}")
- # Check API keys
- has_openrouter = bool(config.providers.openrouter.api_key)
- has_anthropic = bool(config.providers.anthropic.api_key)
- has_openai = bool(config.providers.openai.api_key)
- has_gemini = bool(config.providers.gemini.api_key)
- has_zhipu = bool(config.providers.zhipu.api_key)
- has_vllm = bool(config.providers.vllm.api_base)
- has_aihubmix = bool(config.providers.aihubmix.api_key)
-
- console.print(f"OpenRouter API: {'[green]β[/green]' if has_openrouter else '[dim]not set[/dim]'}")
- console.print(f"Anthropic API: {'[green]β[/green]' if has_anthropic else '[dim]not set[/dim]'}")
- console.print(f"OpenAI API: {'[green]β[/green]' if has_openai else '[dim]not set[/dim]'}")
- console.print(f"Gemini API: {'[green]β[/green]' if has_gemini else '[dim]not set[/dim]'}")
- console.print(f"Zhipu AI API: {'[green]β[/green]' if has_zhipu else '[dim]not set[/dim]'}")
- console.print(f"AiHubMix API: {'[green]β[/green]' if has_aihubmix else '[dim]not set[/dim]'}")
- vllm_status = f"[green]β {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
- console.print(f"vLLM/Local: {vllm_status}")
+ # Check API keys from registry
+ for spec in PROVIDERS:
+ p = getattr(config.providers, spec.name, None)
+ if p is None:
+ continue
+ if spec.is_local:
+ # Local deployments show api_base instead of api_key
+ if p.api_base:
+ console.print(f"{spec.label}: [green]β {p.api_base}[/green]")
+ else:
+ console.print(f"{spec.label}: [dim]not set[/dim]")
+ else:
+ has_key = bool(p.api_key)
+ console.print(f"{spec.label}: {'[green]β[/green]' if has_key else '[dim]not set[/dim]'}")
if __name__ == "__main__":
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index e46b5df..ea2f1c1 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -134,29 +134,23 @@ class Config(BaseSettings):
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
- # Default base URLs for API gateways
- _GATEWAY_DEFAULTS = {"openrouter": "https://openrouter.ai/api/v1", "aihubmix": "https://aihubmix.com/v1"}
-
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."""
- model = (model or self.agents.defaults.model).lower()
- p = self.providers
- # Keyword β provider mapping (order matters: gateways first)
- keyword_map = {
- "aihubmix": p.aihubmix, "openrouter": p.openrouter,
- "deepseek": p.deepseek, "anthropic": p.anthropic, "claude": p.anthropic,
- "openai": p.openai, "gpt": p.openai, "gemini": p.gemini,
- "zhipu": p.zhipu, "glm": p.zhipu, "zai": p.zhipu,
- "dashscope": p.dashscope, "qwen": p.dashscope,
- "groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot, "vllm": p.vllm,
- }
- for kw, provider in keyword_map.items():
- if kw in model and provider.api_key:
- return provider
- # Fallback: gateways first (can serve any model), then specific providers
- all_providers = [p.openrouter, p.aihubmix, p.anthropic, p.openai, p.deepseek,
- p.gemini, p.zhipu, p.dashscope, p.moonshot, p.vllm, p.groq]
- return next((pr for pr in all_providers if pr.api_key), None)
+ from nanobot.providers.registry import PROVIDERS
+ model_lower = (model or self.agents.defaults.model).lower()
+
+ # Match by keyword (order follows PROVIDERS registry)
+ for spec in PROVIDERS:
+ p = getattr(self.providers, spec.name, None)
+ if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
+ return p
+
+ # Fallback: gateways first, then others (follows registry order)
+ for spec in PROVIDERS:
+ p = getattr(self.providers, spec.name, None)
+ if p and p.api_key:
+ return p
+ return None
def get_api_key(self, model: str | None = None) -> str | None:
"""Get API key for the given model. Falls back to first available key."""
@@ -165,13 +159,16 @@ class Config(BaseSettings):
def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for known gateways."""
+ from nanobot.providers.registry import PROVIDERS
p = self.get_provider(model)
if p and p.api_base:
return p.api_base
- # Default URLs for known gateways (openrouter, aihubmix)
- for name, url in self._GATEWAY_DEFAULTS.items():
- if p == getattr(self.providers, name):
- return url
+ # Only gateways get a default URL here. Standard providers (like Moonshot)
+ # handle their base URL via env vars in _setup_env, NOT via api_base β
+ # otherwise find_gateway() would misdetect them as local/vLLM.
+ for spec in PROVIDERS:
+ if spec.is_gateway and spec.default_api_base and p == getattr(self.providers, spec.name, None):
+ return spec.default_api_base
return None
class Config:
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 7a52e7c..5e9c22f 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -1,5 +1,6 @@
"""LiteLLM provider implementation for multi-provider support."""
+import json
import os
from typing import Any
@@ -7,6 +8,7 @@ import litellm
from litellm import acompletion
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+from nanobot.providers.registry import find_by_model, find_gateway
class LiteLLMProvider(LLMProvider):
@@ -14,7 +16,8 @@ class LiteLLMProvider(LLMProvider):
LLM provider using LiteLLM for multi-provider support.
Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through
- a unified interface.
+ a unified interface. Provider-specific logic is driven by the registry
+ (see providers/registry.py) β no if-elif chains needed here.
"""
def __init__(
@@ -28,47 +31,17 @@ class LiteLLMProvider(LLMProvider):
self.default_model = default_model
self.extra_headers = extra_headers or {}
- # Detect OpenRouter by api_key prefix or explicit api_base
- self.is_openrouter = (
- (api_key and api_key.startswith("sk-or-")) or
- (api_base and "openrouter" in api_base)
- )
+ # Detect gateway / local deployment from api_key and api_base
+ self._gateway = find_gateway(api_key, api_base)
- # Detect AiHubMix by api_base
- self.is_aihubmix = bool(api_base and "aihubmix" in api_base)
+ # Backwards-compatible flags (used by tests and possibly external code)
+ self.is_openrouter = bool(self._gateway and self._gateway.name == "openrouter")
+ self.is_aihubmix = bool(self._gateway and self._gateway.name == "aihubmix")
+ self.is_vllm = bool(self._gateway and self._gateway.is_local)
- # Track if using custom endpoint (vLLM, etc.)
- self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_aihubmix
-
- # Configure LiteLLM based on provider
+ # Configure environment variables
if api_key:
- if self.is_openrouter:
- # OpenRouter mode - set key
- os.environ["OPENROUTER_API_KEY"] = api_key
- elif self.is_aihubmix:
- # AiHubMix gateway - OpenAI-compatible
- os.environ["OPENAI_API_KEY"] = api_key
- elif self.is_vllm:
- # vLLM/custom endpoint - uses OpenAI-compatible API
- os.environ["HOSTED_VLLM_API_KEY"] = api_key
- elif "deepseek" in default_model:
- os.environ.setdefault("DEEPSEEK_API_KEY", api_key)
- elif "anthropic" in default_model:
- os.environ.setdefault("ANTHROPIC_API_KEY", api_key)
- elif "openai" in default_model or "gpt" in default_model:
- os.environ.setdefault("OPENAI_API_KEY", api_key)
- elif "gemini" in default_model.lower():
- os.environ.setdefault("GEMINI_API_KEY", api_key)
- elif "zhipu" in default_model or "glm" in default_model or "zai" in default_model:
- os.environ.setdefault("ZAI_API_KEY", api_key)
- os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
- elif "dashscope" in default_model or "qwen" in default_model.lower():
- os.environ.setdefault("DASHSCOPE_API_KEY", api_key)
- elif "groq" in default_model:
- os.environ.setdefault("GROQ_API_KEY", api_key)
- elif "moonshot" in default_model or "kimi" in default_model:
- os.environ.setdefault("MOONSHOT_API_KEY", api_key)
- os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1")
+ self._setup_env(api_key, api_base, default_model)
if api_base:
litellm.api_base = api_base
@@ -76,6 +49,55 @@ class LiteLLMProvider(LLMProvider):
# Disable LiteLLM logging noise
litellm.suppress_debug_info = True
+ def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None:
+ """Set environment variables based on detected provider."""
+ if self._gateway:
+ # Gateway / local: direct set (not setdefault)
+ os.environ[self._gateway.env_key] = api_key
+ return
+
+ # Standard provider: match by model name
+ spec = find_by_model(model)
+ if spec:
+ os.environ.setdefault(spec.env_key, api_key)
+ # Resolve env_extras placeholders:
+ # {api_key} β user's API key
+ # {api_base} β user's api_base, falling back to spec.default_api_base
+ effective_base = api_base or spec.default_api_base
+ for env_name, env_val in spec.env_extras:
+ resolved = env_val.replace("{api_key}", api_key)
+ resolved = resolved.replace("{api_base}", effective_base)
+ os.environ.setdefault(env_name, resolved)
+
+ def _resolve_model(self, model: str) -> str:
+ """Resolve model name by applying provider/gateway prefixes."""
+ if self._gateway:
+ # Gateway mode: apply gateway prefix, skip provider-specific prefixes
+ prefix = self._gateway.litellm_prefix
+ if self._gateway.strip_model_prefix:
+ model = model.split("/")[-1]
+ if prefix and not model.startswith(f"{prefix}/"):
+ model = f"{prefix}/{model}"
+ return model
+
+ # Standard mode: auto-prefix for known providers
+ spec = find_by_model(model)
+ if spec and spec.litellm_prefix:
+ if not any(model.startswith(s) for s in spec.skip_prefixes):
+ model = f"{spec.litellm_prefix}/{model}"
+
+ return model
+
+ def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
+ """Apply model-specific parameter overrides from the registry."""
+ model_lower = model.lower()
+ spec = find_by_model(model)
+ if spec:
+ for pattern, overrides in spec.model_overrides:
+ if pattern in model_lower:
+ kwargs.update(overrides)
+ return
+
async def chat(
self,
messages: list[dict[str, Any]],
@@ -97,34 +119,8 @@ class LiteLLMProvider(LLMProvider):
Returns:
LLMResponse with content and/or tool calls.
"""
- model = model or self.default_model
+ model = self._resolve_model(model or self.default_model)
- # Auto-prefix model names for known providers
- # (keywords, target_prefix, skip_if_starts_with)
- _prefix_rules = [
- (("glm", "zhipu"), "zai", ("zhipu/", "zai/", "openrouter/", "hosted_vllm/")),
- (("qwen", "dashscope"), "dashscope", ("dashscope/", "openrouter/")),
- (("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")),
- (("gemini",), "gemini", ("gemini/",)),
- ]
- model_lower = model.lower()
- for keywords, prefix, skip in _prefix_rules:
- if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip):
- model = f"{prefix}/{model}"
- break
-
- # Gateway/endpoint-specific prefixes (detected by api_base/api_key, not model name)
- if self.is_openrouter and not model.startswith("openrouter/"):
- model = f"openrouter/{model}"
- elif self.is_aihubmix:
- model = f"openai/{model.split('/')[-1]}"
- elif self.is_vllm:
- model = f"hosted_vllm/{model}"
-
- # kimi-k2.5 only supports temperature=1.0
- if "kimi-k2.5" in model.lower():
- temperature = 1.0
-
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
@@ -132,6 +128,9 @@ class LiteLLMProvider(LLMProvider):
"temperature": temperature,
}
+ # Apply model-specific overrides (e.g. kimi-k2.5 temperature)
+ self._apply_model_overrides(model, kwargs)
+
# Pass api_base directly for custom endpoints (vLLM, etc.)
if self.api_base:
kwargs["api_base"] = self.api_base
@@ -165,7 +164,6 @@ class LiteLLMProvider(LLMProvider):
# Parse arguments from JSON string if needed
args = tc.function.arguments
if isinstance(args, str):
- import json
try:
args = json.loads(args)
except json.JSONDecodeError:
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
new file mode 100644
index 0000000..aa4a76e
--- /dev/null
+++ b/nanobot/providers/registry.py
@@ -0,0 +1,323 @@
+"""
+Provider Registry β single source of truth for LLM provider metadata.
+
+Adding a new provider:
+ 1. Add a ProviderSpec to PROVIDERS below.
+ 2. Add a field to ProvidersConfig in config/schema.py.
+ Done. Env vars, prefixing, config matching, status display all derive from here.
+
+Order matters β it controls match priority and fallback. Gateways first.
+Every entry writes out all fields so you can copy-paste as a template.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class ProviderSpec:
+ """One LLM provider's metadata. See PROVIDERS below for real examples.
+
+ Placeholders in env_extras values:
+ {api_key} β the user's API key
+ {api_base} β api_base from config, or this spec's default_api_base
+ """
+
+ # identity
+ name: str # config field name, e.g. "dashscope"
+ keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
+ env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY"
+ display_name: str = "" # shown in `nanobot status`
+
+ # model prefixing
+ litellm_prefix: str = "" # "dashscope" β model becomes "dashscope/{model}"
+ skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these
+
+ # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
+ env_extras: tuple[tuple[str, str], ...] = ()
+
+ # gateway / local detection
+ is_gateway: bool = False # routes any model (OpenRouter, AiHubMix)
+ is_local: bool = False # local deployment (vLLM, Ollama)
+ detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-"
+ detect_by_base_keyword: str = "" # match substring in api_base URL
+ default_api_base: str = "" # fallback base URL
+
+ # gateway behavior
+ strip_model_prefix: bool = False # strip "provider/" before re-prefixing
+
+ # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
+ model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
+
+ @property
+ def label(self) -> str:
+ return self.display_name or self.name.title()
+
+
+# ---------------------------------------------------------------------------
+# PROVIDERS β the registry. Order = priority. Copy any entry as template.
+# ---------------------------------------------------------------------------
+
+PROVIDERS: tuple[ProviderSpec, ...] = (
+
+ # === Gateways (detected by api_key / api_base, not model name) =========
+ # Gateways can route any model, so they win in fallback.
+
+ # OpenRouter: global gateway, keys start with "sk-or-"
+ ProviderSpec(
+ name="openrouter",
+ keywords=("openrouter",),
+ env_key="OPENROUTER_API_KEY",
+ display_name="OpenRouter",
+ litellm_prefix="openrouter", # claude-3 β openrouter/claude-3
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="sk-or-",
+ detect_by_base_keyword="openrouter",
+ default_api_base="https://openrouter.ai/api/v1",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # AiHubMix: global gateway, OpenAI-compatible interface.
+ # strip_model_prefix=True: it doesn't understand "anthropic/claude-3",
+ # so we strip to bare "claude-3" then re-prefix as "openai/claude-3".
+ ProviderSpec(
+ name="aihubmix",
+ keywords=("aihubmix",),
+ env_key="OPENAI_API_KEY", # OpenAI-compatible
+ display_name="AiHubMix",
+ litellm_prefix="openai", # β openai/{model}
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="aihubmix",
+ default_api_base="https://aihubmix.com/v1",
+ strip_model_prefix=True, # anthropic/claude-3 β claude-3 β openai/claude-3
+ model_overrides=(),
+ ),
+
+ # === Standard providers (matched by model-name keywords) ===============
+
+ # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
+ ProviderSpec(
+ name="anthropic",
+ keywords=("anthropic", "claude"),
+ env_key="ANTHROPIC_API_KEY",
+ display_name="Anthropic",
+ litellm_prefix="",
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
+ ProviderSpec(
+ name="openai",
+ keywords=("openai", "gpt"),
+ env_key="OPENAI_API_KEY",
+ display_name="OpenAI",
+ litellm_prefix="",
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
+ ProviderSpec(
+ name="deepseek",
+ keywords=("deepseek",),
+ env_key="DEEPSEEK_API_KEY",
+ display_name="DeepSeek",
+ litellm_prefix="deepseek", # deepseek-chat β deepseek/deepseek-chat
+ skip_prefixes=("deepseek/",), # avoid double-prefix
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # Gemini: needs "gemini/" prefix for LiteLLM.
+ ProviderSpec(
+ name="gemini",
+ keywords=("gemini",),
+ env_key="GEMINI_API_KEY",
+ display_name="Gemini",
+ litellm_prefix="gemini", # gemini-pro β gemini/gemini-pro
+ skip_prefixes=("gemini/",), # avoid double-prefix
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # Zhipu: LiteLLM uses "zai/" prefix.
+ # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that).
+ # skip_prefixes: don't add "zai/" when already routed via gateway.
+ ProviderSpec(
+ name="zhipu",
+ keywords=("zhipu", "glm", "zai"),
+ env_key="ZAI_API_KEY",
+ display_name="Zhipu AI",
+ litellm_prefix="zai", # glm-4 β zai/glm-4
+ skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"),
+ env_extras=(
+ ("ZHIPUAI_API_KEY", "{api_key}"),
+ ),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # DashScope: Qwen models, needs "dashscope/" prefix.
+ ProviderSpec(
+ name="dashscope",
+ keywords=("qwen", "dashscope"),
+ env_key="DASHSCOPE_API_KEY",
+ display_name="DashScope",
+ litellm_prefix="dashscope", # qwen-max β dashscope/qwen-max
+ skip_prefixes=("dashscope/", "openrouter/"),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # Moonshot: Kimi models, needs "moonshot/" prefix.
+ # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint.
+ # Kimi K2.5 API enforces temperature >= 1.0.
+ ProviderSpec(
+ name="moonshot",
+ keywords=("moonshot", "kimi"),
+ env_key="MOONSHOT_API_KEY",
+ display_name="Moonshot",
+ litellm_prefix="moonshot", # kimi-k2.5 β moonshot/kimi-k2.5
+ skip_prefixes=("moonshot/", "openrouter/"),
+ env_extras=(
+ ("MOONSHOT_API_BASE", "{api_base}"),
+ ),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China
+ strip_model_prefix=False,
+ model_overrides=(
+ ("kimi-k2.5", {"temperature": 1.0}),
+ ),
+ ),
+
+ # === Local deployment (fallback: unknown api_base β assume local) ======
+
+ # vLLM / any OpenAI-compatible local server.
+ # If api_base is set but doesn't match a known gateway, we land here.
+ # Placed before Groq so vLLM wins the fallback when both are configured.
+ ProviderSpec(
+ name="vllm",
+ keywords=("vllm",),
+ env_key="HOSTED_VLLM_API_KEY",
+ display_name="vLLM/Local",
+ litellm_prefix="hosted_vllm", # Llama-3-8B β hosted_vllm/Llama-3-8B
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=True,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="", # user must provide in config
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # === Auxiliary (not a primary LLM provider) ============================
+
+ # Groq: mainly used for Whisper voice transcription, also usable for LLM.
+ # Needs "groq/" prefix for LiteLLM routing. Placed last β it rarely wins fallback.
+ ProviderSpec(
+ name="groq",
+ keywords=("groq",),
+ env_key="GROQ_API_KEY",
+ display_name="Groq",
+ litellm_prefix="groq", # llama3-8b-8192 β groq/llama3-8b-8192
+ skip_prefixes=("groq/",), # avoid double-prefix
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+)
+
+
+# ---------------------------------------------------------------------------
+# Lookup helpers
+# ---------------------------------------------------------------------------
+
+def find_by_model(model: str) -> ProviderSpec | None:
+ """Match a standard provider by model-name keyword (case-insensitive).
+ Skips gateways/local β those are matched by api_key/api_base instead."""
+ model_lower = model.lower()
+ for spec in PROVIDERS:
+ if spec.is_gateway or spec.is_local:
+ continue
+ if any(kw in model_lower for kw in spec.keywords):
+ return spec
+ return None
+
+
+def find_gateway(api_key: str | None, api_base: str | None) -> ProviderSpec | None:
+ """Detect gateway/local by api_key prefix or api_base substring.
+ Fallback: unknown api_base β treat as local (vLLM)."""
+ for spec in PROVIDERS:
+ if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix):
+ return spec
+ if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base:
+ return spec
+ if api_base:
+ return next((s for s in PROVIDERS if s.is_local), None)
+ return None
+
+
+def find_by_name(name: str) -> ProviderSpec | None:
+ """Find a provider spec by config field name, e.g. "dashscope"."""
+ for spec in PROVIDERS:
+ if spec.name == name:
+ return spec
+ return None