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