Merge remote-tracking branch 'upstream/main' into feature/codex-oauth
This commit is contained in:
commit
6bca38b89d
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,3 +17,4 @@ docs/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
tests/
|
||||||
48
README.md
48
README.md
@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-02-08** 🔧 Refactored Providers — adding a new LLM provider only takes just 2 steps! Check [here](#providers).
|
||||||
- **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-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-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
|
- **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-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
||||||
@ -355,6 +356,53 @@ Config file: `~/.nanobot/config.json`
|
|||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.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) |
|
| `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) | — |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Adding a New Provider (Developer Guide)</b></summary>
|
||||||
|
|
||||||
|
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) |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@ -111,6 +111,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self.session_manager = session_manager
|
self.session_manager = session_manager
|
||||||
self._app: Application | None = None
|
self._app: Application | None = None
|
||||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
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:
|
async def start(self) -> None:
|
||||||
"""Start the Telegram bot with long polling."""
|
"""Start the Telegram bot with long polling."""
|
||||||
@ -170,6 +171,10 @@ class TelegramChannel(BaseChannel):
|
|||||||
"""Stop the Telegram bot."""
|
"""Stop the Telegram bot."""
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
|
# Cancel all typing indicators
|
||||||
|
for chat_id in list(self._typing_tasks):
|
||||||
|
self._stop_typing(chat_id)
|
||||||
|
|
||||||
if self._app:
|
if self._app:
|
||||||
logger.info("Stopping Telegram bot...")
|
logger.info("Stopping Telegram bot...")
|
||||||
await self._app.updater.stop()
|
await self._app.updater.stop()
|
||||||
@ -183,6 +188,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
logger.warning("Telegram bot not running")
|
logger.warning("Telegram bot not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Stop typing indicator for this chat
|
||||||
|
self._stop_typing(msg.chat_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# chat_id should be the Telegram chat ID (integer)
|
# chat_id should be the Telegram chat ID (integer)
|
||||||
chat_id = int(msg.chat_id)
|
chat_id = int(msg.chat_id)
|
||||||
@ -335,10 +343,15 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
logger.debug(f"Telegram message from {sender_id}: {content[:50]}...")
|
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
|
# Forward to the message bus
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
chat_id=str(chat_id),
|
chat_id=str_chat_id,
|
||||||
content=content,
|
content=content,
|
||||||
media=media_paths,
|
media=media_paths,
|
||||||
metadata={
|
metadata={
|
||||||
@ -350,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:
|
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
|
||||||
"""Get file extension based on media type."""
|
"""Get file extension based on media type."""
|
||||||
if mime_type:
|
if mime_type:
|
||||||
|
|||||||
@ -671,25 +671,24 @@ def status():
|
|||||||
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
|
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
console.print(f"Model: {config.agents.defaults.model}")
|
console.print(f"Model: {config.agents.defaults.model}")
|
||||||
|
|
||||||
# Check API keys
|
# Check API keys from registry
|
||||||
has_openrouter = bool(config.providers.openrouter.api_key)
|
for spec in PROVIDERS:
|
||||||
has_anthropic = bool(config.providers.anthropic.api_key)
|
p = getattr(config.providers, spec.name, None)
|
||||||
has_openai = bool(config.providers.openai.api_key)
|
if p is None:
|
||||||
has_gemini = bool(config.providers.gemini.api_key)
|
continue
|
||||||
has_zhipu = bool(config.providers.zhipu.api_key)
|
if spec.is_local:
|
||||||
has_vllm = bool(config.providers.vllm.api_base)
|
# Local deployments show api_base instead of api_key
|
||||||
has_aihubmix = bool(config.providers.aihubmix.api_key)
|
if p.api_base:
|
||||||
|
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
|
||||||
console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}")
|
else:
|
||||||
console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
|
console.print(f"{spec.label}: [dim]not set[/dim]")
|
||||||
console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
|
else:
|
||||||
console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
|
has_key = bool(p.api_key)
|
||||||
console.print(f"Zhipu AI API: {'[green]✓[/green]' if has_zhipu else '[dim]not set[/dim]'}")
|
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key 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}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_ = get_codex_token()
|
_ = get_codex_token()
|
||||||
|
|||||||
@ -125,29 +125,23 @@ class Config(BaseSettings):
|
|||||||
"""Get expanded workspace path."""
|
"""Get expanded workspace path."""
|
||||||
return Path(self.agents.defaults.workspace).expanduser()
|
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:
|
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."""
|
"""Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
|
||||||
model = (model or self.agents.defaults.model).lower()
|
from nanobot.providers.registry import PROVIDERS
|
||||||
p = self.providers
|
model_lower = (model or self.agents.defaults.model).lower()
|
||||||
# Keyword → provider mapping (order matters: gateways first)
|
|
||||||
keyword_map = {
|
# Match by keyword (order follows PROVIDERS registry)
|
||||||
"aihubmix": p.aihubmix, "openrouter": p.openrouter,
|
for spec in PROVIDERS:
|
||||||
"deepseek": p.deepseek, "anthropic": p.anthropic, "claude": p.anthropic,
|
p = getattr(self.providers, spec.name, None)
|
||||||
"openai": p.openai, "gpt": p.openai, "gemini": p.gemini,
|
if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
|
||||||
"zhipu": p.zhipu, "glm": p.zhipu, "zai": p.zhipu,
|
return p
|
||||||
"dashscope": p.dashscope, "qwen": p.dashscope,
|
|
||||||
"groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot, "vllm": p.vllm,
|
# Fallback: gateways first, then others (follows registry order)
|
||||||
}
|
for spec in PROVIDERS:
|
||||||
for kw, provider in keyword_map.items():
|
p = getattr(self.providers, spec.name, None)
|
||||||
if kw in model and provider.api_key:
|
if p and p.api_key:
|
||||||
return provider
|
return p
|
||||||
# Fallback: gateways first (can serve any model), then specific providers
|
return None
|
||||||
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)
|
|
||||||
|
|
||||||
def get_api_key(self, model: str | None = None) -> str | 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."""
|
"""Get API key for the given model. Falls back to first available key."""
|
||||||
@ -156,13 +150,16 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
def get_api_base(self, model: str | None = None) -> str | None:
|
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."""
|
"""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)
|
p = self.get_provider(model)
|
||||||
if p and p.api_base:
|
if p and p.api_base:
|
||||||
return p.api_base
|
return p.api_base
|
||||||
# Default URLs for known gateways (openrouter, aihubmix)
|
# Only gateways get a default URL here. Standard providers (like Moonshot)
|
||||||
for name, url in self._GATEWAY_DEFAULTS.items():
|
# handle their base URL via env vars in _setup_env, NOT via api_base —
|
||||||
if p == getattr(self.providers, name):
|
# otherwise find_gateway() would misdetect them as local/vLLM.
|
||||||
return url
|
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
|
return None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""LiteLLM provider implementation for multi-provider support."""
|
"""LiteLLM provider implementation for multi-provider support."""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -7,6 +8,7 @@ import litellm
|
|||||||
from litellm import acompletion
|
from litellm import acompletion
|
||||||
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
from nanobot.providers.registry import find_by_model, find_gateway
|
||||||
|
|
||||||
|
|
||||||
class LiteLLMProvider(LLMProvider):
|
class LiteLLMProvider(LLMProvider):
|
||||||
@ -14,7 +16,8 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
LLM provider using LiteLLM for multi-provider support.
|
LLM provider using LiteLLM for multi-provider support.
|
||||||
|
|
||||||
Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through
|
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__(
|
def __init__(
|
||||||
@ -28,47 +31,17 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.extra_headers = extra_headers or {}
|
self.extra_headers = extra_headers or {}
|
||||||
|
|
||||||
# Detect OpenRouter by api_key prefix or explicit api_base
|
# Detect gateway / local deployment from api_key and api_base
|
||||||
self.is_openrouter = (
|
self._gateway = find_gateway(api_key, api_base)
|
||||||
(api_key and api_key.startswith("sk-or-")) or
|
|
||||||
(api_base and "openrouter" in api_base)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Detect AiHubMix by api_base
|
# Backwards-compatible flags (used by tests and possibly external code)
|
||||||
self.is_aihubmix = bool(api_base and "aihubmix" in api_base)
|
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.)
|
# Configure environment variables
|
||||||
self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_aihubmix
|
|
||||||
|
|
||||||
# Configure LiteLLM based on provider
|
|
||||||
if api_key:
|
if api_key:
|
||||||
if self.is_openrouter:
|
self._setup_env(api_key, api_base, default_model)
|
||||||
# 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")
|
|
||||||
|
|
||||||
if api_base:
|
if api_base:
|
||||||
litellm.api_base = api_base
|
litellm.api_base = api_base
|
||||||
@ -76,6 +49,55 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
# Disable LiteLLM logging noise
|
# Disable LiteLLM logging noise
|
||||||
litellm.suppress_debug_info = True
|
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(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@ -97,35 +119,8 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
Returns:
|
Returns:
|
||||||
LLMResponse with content and/or tool calls.
|
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/",)),
|
|
||||||
]
|
|
||||||
if not (self.is_vllm or self.is_openrouter or self.is_aihubmix):
|
|
||||||
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] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
@ -133,6 +128,9 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
"temperature": temperature,
|
"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.)
|
# Pass api_base directly for custom endpoints (vLLM, etc.)
|
||||||
if self.api_base:
|
if self.api_base:
|
||||||
kwargs["api_base"] = self.api_base
|
kwargs["api_base"] = self.api_base
|
||||||
@ -166,7 +164,6 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
# Parse arguments from JSON string if needed
|
# Parse arguments from JSON string if needed
|
||||||
args = tc.function.arguments
|
args = tc.function.arguments
|
||||||
if isinstance(args, str):
|
if isinstance(args, str):
|
||||||
import json
|
|
||||||
try:
|
try:
|
||||||
args = json.loads(args)
|
args = json.loads(args)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
|||||||
323
nanobot/providers/registry.py
Normal file
323
nanobot/providers/registry.py
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user