| 1 | """ |
| 2 | Provider Registry — single source of truth for LLM provider metadata. |
| 3 | |
| 4 | Adding a new provider: |
| 5 | 1. Add a ProviderSpec to PROVIDERS below. |
| 6 | 2. Add a field to ProvidersConfig in config/schema.py. |
| 7 | Done. Env vars, config matching, status display all derive from here. |
| 8 | |
| 9 | Order matters — it controls match priority and fallback. Gateways first. |
| 10 | Every entry writes out all fields so you can copy-paste as a template. |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | from dataclasses import dataclass |
| 16 | from typing import Any |
| 17 | |
| 18 | from pydantic.alias_generators import to_snake |
| 19 | |
| 20 | |
| 21 | @dataclass(frozen=True) |
| 22 | class ProviderSpec: |
| 23 | """One LLM provider's metadata. See PROVIDERS below for real examples. |
| 24 | |
| 25 | Placeholders in env_extras values: |
| 26 | {api_key} — the user's API key |
| 27 | {api_base} — api_base from config, or this spec's default_api_base |
| 28 | """ |
| 29 | |
| 30 | # identity |
| 31 | name: str # config field name, e.g. "dashscope" |
| 32 | keywords: tuple[str, ...] # model-name keywords for matching (lowercase) |
| 33 | env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY" |
| 34 | display_name: str = "" # shown in `nanobot status` |
| 35 | |
| 36 | # which provider implementation to use |
| 37 | # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" |
| 38 | backend: str = "openai_compat" |
| 39 | |
| 40 | # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) |
| 41 | env_extras: tuple[tuple[str, str], ...] = () |
| 42 | |
| 43 | # gateway / local detection |
| 44 | is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) |
| 45 | is_local: bool = False # local deployment (vLLM, Ollama) |
| 46 | detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" |
| 47 | detect_by_base_keyword: str = "" # match substring in api_base URL |
| 48 | default_api_base: str = "" # OpenAI-compatible base URL for this provider |
| 49 | |
| 50 | # gateway behavior |
| 51 | strip_model_prefix: bool = False # strip "provider/" before sending to gateway |
| 52 | supports_max_completion_tokens: bool = False |
| 53 | |
| 54 | # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) |
| 55 | model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () |
| 56 | |
| 57 | # OAuth-based providers (e.g., OpenAI Codex) don't use API keys |
| 58 | is_oauth: bool = False |
| 59 | |
| 60 | # Direct providers skip API-key validation (user supplies everything) |
| 61 | is_direct: bool = False |
| 62 | |
| 63 | # Provider supports cache_control on content blocks (e.g. Anthropic prompt caching) |
| 64 | supports_prompt_caching: bool = False |
| 65 | |
| 66 | # How to inject the thinking on/off toggle into extra_body. |
| 67 | # "" — no extra_body needed (default) |
| 68 | # "thinking_type" — {"thinking": {"type": "enabled"/"disabled"}} |
| 69 | # (DeepSeek, VolcEngine, BytePlus) |
| 70 | # "enable_thinking" — {"enable_thinking": true/false} (DashScope) |
| 71 | # "reasoning_split" — {"reasoning_split": true/false} (MiniMax) |
| 72 | thinking_style: str = "" |
| 73 | |
| 74 | @property |
| 75 | def label(self) -> str: |
| 76 | return self.display_name or self.name.title() |
| 77 | |
| 78 | |
| 79 | # --------------------------------------------------------------------------- |
| 80 | # PROVIDERS — the registry. Order = priority. Copy any entry as template. |
| 81 | # --------------------------------------------------------------------------- |
| 82 | |
| 83 | PROVIDERS: tuple[ProviderSpec, ...] = ( |
| 84 | # === Custom (direct OpenAI-compatible endpoint) ======================== |
| 85 | ProviderSpec( |
| 86 | name="custom", |
| 87 | keywords=(), |
| 88 | env_key="", |
| 89 | display_name="Custom", |
| 90 | backend="openai_compat", |
| 91 | is_direct=True, |
| 92 | ), |
| 93 | |
| 94 | # === Azure OpenAI (direct API calls with API version 2024-10-21) ===== |
| 95 | ProviderSpec( |
| 96 | name="azure_openai", |
| 97 | keywords=("azure", "azure-openai"), |
| 98 | env_key="", |
| 99 | display_name="Azure OpenAI", |
| 100 | backend="azure_openai", |
| 101 | is_direct=True, |
| 102 | ), |
| 103 | # === Gateways (detected by api_key / api_base, not model name) ========= |
| 104 | # Gateways can route any model, so they win in fallback. |
| 105 | # OpenRouter: global gateway, keys start with "sk-or-" |
| 106 | ProviderSpec( |
| 107 | name="openrouter", |
| 108 | keywords=("openrouter",), |
| 109 | env_key="OPENROUTER_API_KEY", |
| 110 | display_name="OpenRouter", |
| 111 | backend="openai_compat", |
| 112 | is_gateway=True, |
| 113 | detect_by_key_prefix="sk-or-", |
| 114 | detect_by_base_keyword="openrouter", |
| 115 | default_api_base="https://openrouter.ai/api/v1", |
| 116 | supports_prompt_caching=True, |
| 117 | ), |
| 118 | # AiHubMix: global gateway, OpenAI-compatible interface. |
| 119 | # strip_model_prefix=True: doesn't understand "anthropic/claude-3", |
| 120 | # strips to bare "claude-3". |
| 121 | ProviderSpec( |
| 122 | name="aihubmix", |
| 123 | keywords=("aihubmix",), |
| 124 | env_key="OPENAI_API_KEY", |
| 125 | display_name="AiHubMix", |
| 126 | backend="openai_compat", |
| 127 | is_gateway=True, |
| 128 | detect_by_base_keyword="aihubmix", |
| 129 | default_api_base="https://aihubmix.com/v1", |
| 130 | strip_model_prefix=True, |
| 131 | ), |
| 132 | # SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix |
| 133 | ProviderSpec( |
| 134 | name="siliconflow", |
| 135 | keywords=("siliconflow",), |
| 136 | env_key="OPENAI_API_KEY", |
| 137 | display_name="SiliconFlow", |
| 138 | backend="openai_compat", |
| 139 | is_gateway=True, |
| 140 | detect_by_base_keyword="siliconflow", |
| 141 | default_api_base="https://api.siliconflow.cn/v1", |
| 142 | ), |
| 143 | |
| 144 | # VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models |
| 145 | ProviderSpec( |
| 146 | name="volcengine", |
| 147 | keywords=("volcengine", "volces", "ark"), |
| 148 | env_key="OPENAI_API_KEY", |
| 149 | display_name="VolcEngine", |
| 150 | backend="openai_compat", |
| 151 | is_gateway=True, |
| 152 | detect_by_base_keyword="volces", |
| 153 | default_api_base="https://ark.cn-beijing.volces.com/api/v3", |
| 154 | thinking_style="thinking_type", |
| 155 | ), |
| 156 | |
| 157 | # VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine |
| 158 | ProviderSpec( |
| 159 | name="volcengine_coding_plan", |
| 160 | keywords=("volcengine-plan",), |
| 161 | env_key="OPENAI_API_KEY", |
| 162 | display_name="VolcEngine Coding Plan", |
| 163 | backend="openai_compat", |
| 164 | is_gateway=True, |
| 165 | default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3", |
| 166 | strip_model_prefix=True, |
| 167 | thinking_style="thinking_type", |
| 168 | ), |
| 169 | |
| 170 | # BytePlus: VolcEngine international, pay-per-use models |
| 171 | ProviderSpec( |
| 172 | name="byteplus", |
| 173 | keywords=("byteplus",), |
| 174 | env_key="OPENAI_API_KEY", |
| 175 | display_name="BytePlus", |
| 176 | backend="openai_compat", |
| 177 | is_gateway=True, |
| 178 | detect_by_base_keyword="bytepluses", |
| 179 | default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3", |
| 180 | strip_model_prefix=True, |
| 181 | thinking_style="thinking_type", |
| 182 | ), |
| 183 | |
| 184 | # BytePlus Coding Plan: same key as byteplus |
| 185 | ProviderSpec( |
| 186 | name="byteplus_coding_plan", |
| 187 | keywords=("byteplus-plan",), |
| 188 | env_key="OPENAI_API_KEY", |
| 189 | display_name="BytePlus Coding Plan", |
| 190 | backend="openai_compat", |
| 191 | is_gateway=True, |
| 192 | default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3", |
| 193 | strip_model_prefix=True, |
| 194 | thinking_style="thinking_type", |
| 195 | ), |
| 196 | |
| 197 | |
| 198 | # === Standard providers (matched by model-name keywords) =============== |
| 199 | # Anthropic: native Anthropic SDK |
| 200 | ProviderSpec( |
| 201 | name="anthropic", |
| 202 | keywords=("anthropic", "claude"), |
| 203 | env_key="ANTHROPIC_API_KEY", |
| 204 | display_name="Anthropic", |
| 205 | backend="anthropic", |
| 206 | supports_prompt_caching=True, |
| 207 | ), |
| 208 | # OpenAI: SDK default base URL (no override needed) |
| 209 | ProviderSpec( |
| 210 | name="openai", |
| 211 | keywords=("openai", "gpt"), |
| 212 | env_key="OPENAI_API_KEY", |
| 213 | display_name="OpenAI", |
| 214 | backend="openai_compat", |
| 215 | supports_max_completion_tokens=True, |
| 216 | ), |
| 217 | # OpenAI Codex: OAuth-based, dedicated provider |
| 218 | ProviderSpec( |
| 219 | name="openai_codex", |
| 220 | keywords=("openai-codex",), |
| 221 | env_key="", |
| 222 | display_name="OpenAI Codex", |
| 223 | backend="openai_codex", |
| 224 | detect_by_base_keyword="codex", |
| 225 | default_api_base="https://chatgpt.com/backend-api", |
| 226 | is_oauth=True, |
| 227 | ), |
| 228 | # GitHub Copilot: OAuth-based |
| 229 | ProviderSpec( |
| 230 | name="github_copilot", |
| 231 | keywords=("github_copilot", "copilot"), |
| 232 | env_key="", |
| 233 | display_name="Github Copilot", |
| 234 | backend="github_copilot", |
| 235 | default_api_base="https://api.githubcopilot.com", |
| 236 | strip_model_prefix=True, |
| 237 | is_oauth=True, |
| 238 | supports_max_completion_tokens=True, |
| 239 | ), |
| 240 | # DeepSeek: OpenAI-compatible at api.deepseek.com |
| 241 | ProviderSpec( |
| 242 | name="deepseek", |
| 243 | keywords=("deepseek",), |
| 244 | env_key="DEEPSEEK_API_KEY", |
| 245 | display_name="DeepSeek", |
| 246 | backend="openai_compat", |
| 247 | default_api_base="https://api.deepseek.com", |
| 248 | thinking_style="thinking_type", |
| 249 | ), |
| 250 | # Gemini: Google's OpenAI-compatible endpoint |
| 251 | ProviderSpec( |
| 252 | name="gemini", |
| 253 | keywords=("gemini",), |
| 254 | env_key="GEMINI_API_KEY", |
| 255 | display_name="Gemini", |
| 256 | backend="openai_compat", |
| 257 | default_api_base="https://generativelanguage.googleapis.com/v1beta/openai/", |
| 258 | ), |
| 259 | # Zhipu (智谱): OpenAI-compatible at open.bigmodel.cn |
| 260 | ProviderSpec( |
| 261 | name="zhipu", |
| 262 | keywords=("zhipu", "glm", "zai"), |
| 263 | env_key="ZAI_API_KEY", |
| 264 | display_name="Zhipu AI", |
| 265 | backend="openai_compat", |
| 266 | env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), |
| 267 | default_api_base="https://open.bigmodel.cn/api/paas/v4", |
| 268 | ), |
| 269 | # DashScope (通义): Qwen models, OpenAI-compatible endpoint |
| 270 | ProviderSpec( |
| 271 | name="dashscope", |
| 272 | keywords=("qwen", "dashscope"), |
| 273 | env_key="DASHSCOPE_API_KEY", |
| 274 | display_name="DashScope", |
| 275 | backend="openai_compat", |
| 276 | default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", |
| 277 | thinking_style="enable_thinking", |
| 278 | ), |
| 279 | # Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0. |
| 280 | ProviderSpec( |
| 281 | name="moonshot", |
| 282 | keywords=("moonshot", "kimi"), |
| 283 | env_key="MOONSHOT_API_KEY", |
| 284 | display_name="Moonshot", |
| 285 | backend="openai_compat", |
| 286 | default_api_base="https://api.moonshot.ai/v1", |
| 287 | model_overrides=( |
| 288 | ("kimi-k2.5", {"temperature": 1.0}), |
| 289 | ("kimi-k2.6", {"temperature": 1.0}), |
| 290 | ), |
| 291 | ), |
| 292 | # MiniMax: OpenAI-compatible API |
| 293 | ProviderSpec( |
| 294 | name="minimax", |
| 295 | keywords=("minimax",), |
| 296 | env_key="MINIMAX_API_KEY", |
| 297 | display_name="MiniMax", |
| 298 | backend="openai_compat", |
| 299 | default_api_base="https://api.minimax.io/v1", |
| 300 | thinking_style="reasoning_split", |
| 301 | ), |
| 302 | # MiniMax Anthropic-compatible endpoint: supports thinking mode |
| 303 | ProviderSpec( |
| 304 | name="minimax_anthropic", |
| 305 | keywords=("minimax_anthropic",), |
| 306 | env_key="MINIMAX_API_KEY", |
| 307 | display_name="MiniMax (Anthropic)", |
| 308 | backend="anthropic", |
| 309 | default_api_base="https://api.minimax.io/anthropic", |
| 310 | ), |
| 311 | # Mistral AI: OpenAI-compatible API |
| 312 | ProviderSpec( |
| 313 | name="mistral", |
| 314 | keywords=("mistral",), |
| 315 | env_key="MISTRAL_API_KEY", |
| 316 | display_name="Mistral", |
| 317 | backend="openai_compat", |
| 318 | default_api_base="https://api.mistral.ai/v1", |
| 319 | ), |
| 320 | # Step Fun (阶跃星辰): OpenAI-compatible API |
| 321 | ProviderSpec( |
| 322 | name="stepfun", |
| 323 | keywords=("stepfun", "step"), |
| 324 | env_key="STEPFUN_API_KEY", |
| 325 | display_name="Step Fun", |
| 326 | backend="openai_compat", |
| 327 | default_api_base="https://api.stepfun.com/v1", |
| 328 | ), |
| 329 | # Xiaomi MIMO (小米): OpenAI-compatible API |
| 330 | ProviderSpec( |
| 331 | name="xiaomi_mimo", |
| 332 | keywords=("xiaomi_mimo", "mimo"), |
| 333 | env_key="XIAOMIMIMO_API_KEY", |
| 334 | display_name="Xiaomi MIMO", |
| 335 | backend="openai_compat", |
| 336 | default_api_base="https://api.xiaomimimo.com/v1", |
| 337 | ), |
| 338 | # === Local deployment (matched by config key, NOT by api_base) ========= |
| 339 | # vLLM / any OpenAI-compatible local server |
| 340 | ProviderSpec( |
| 341 | name="vllm", |
| 342 | keywords=("vllm",), |
| 343 | env_key="HOSTED_VLLM_API_KEY", |
| 344 | display_name="vLLM/Local", |
| 345 | backend="openai_compat", |
| 346 | is_local=True, |
| 347 | ), |
| 348 | # Ollama (local, OpenAI-compatible) |
| 349 | ProviderSpec( |
| 350 | name="ollama", |
| 351 | keywords=("ollama", "nemotron"), |
| 352 | env_key="OLLAMA_API_KEY", |
| 353 | display_name="Ollama", |
| 354 | backend="openai_compat", |
| 355 | is_local=True, |
| 356 | detect_by_base_keyword="11434", |
| 357 | default_api_base="http://localhost:11434/v1", |
| 358 | ), |
| 359 | # LM Studio (local, OpenAI-compatible) |
| 360 | ProviderSpec( |
| 361 | name="lm_studio", |
| 362 | keywords=("lm-studio", "lmstudio", "lm_studio"), |
| 363 | env_key="LM_STUDIO_API_KEY", |
| 364 | display_name="LM Studio", |
| 365 | backend="openai_compat", |
| 366 | is_local=True, |
| 367 | detect_by_base_keyword="1234", |
| 368 | default_api_base="http://localhost:1234/v1", |
| 369 | ), |
| 370 | # === OpenVINO Model Server (direct, local, OpenAI-compatible at /v3) === |
| 371 | ProviderSpec( |
| 372 | name="ovms", |
| 373 | keywords=("openvino", "ovms"), |
| 374 | env_key="", |
| 375 | display_name="OpenVINO Model Server", |
| 376 | backend="openai_compat", |
| 377 | is_direct=True, |
| 378 | is_local=True, |
| 379 | default_api_base="http://localhost:8000/v3", |
| 380 | ), |
| 381 | # === Auxiliary (not a primary LLM provider) ============================ |
| 382 | # Groq: mainly used for Whisper voice transcription, also usable for LLM |
| 383 | ProviderSpec( |
| 384 | name="groq", |
| 385 | keywords=("groq",), |
| 386 | env_key="GROQ_API_KEY", |
| 387 | display_name="Groq", |
| 388 | backend="openai_compat", |
| 389 | default_api_base="https://api.groq.com/openai/v1", |
| 390 | ), |
| 391 | # Qianfan (百度千帆): OpenAI-compatible API |
| 392 | ProviderSpec( |
| 393 | name="qianfan", |
| 394 | keywords=("qianfan", "ernie"), |
| 395 | env_key="QIANFAN_API_KEY", |
| 396 | display_name="Qianfan", |
| 397 | backend="openai_compat", |
| 398 | default_api_base="https://qianfan.baidubce.com/v2" |
| 399 | ), |
| 400 | ) |
| 401 | |
| 402 | |
| 403 | # --------------------------------------------------------------------------- |
| 404 | # Lookup helpers |
| 405 | # --------------------------------------------------------------------------- |
| 406 | |
| 407 | |
| 408 | def find_by_name(name: str) -> ProviderSpec | None: |
| 409 | """Find a provider spec by config field name, e.g. "dashscope".""" |
| 410 | normalized = to_snake(name.replace("-", "_")) |
| 411 | for spec in PROVIDERS: |
| 412 | if spec.name == normalized: |
| 413 | return spec |
| 414 | return None |
| 415 |