e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
504 lines
16 KiB
Python
504 lines
16 KiB
Python
"""Provider registry for DeepTutor LLM routing.
|
|
|
|
Single source of truth for provider metadata. Adding a new provider:
|
|
1. Add a ProviderSpec to PROVIDERS below.
|
|
Done. Env vars, config matching, status display all derive from here.
|
|
|
|
Order matters — it controls match priority and fallback. Gateways first.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from pydantic.alias_generators import to_snake
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderSpec:
|
|
"""Single provider metadata entry.
|
|
|
|
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
|
|
"""
|
|
|
|
name: str
|
|
keywords: tuple[str, ...]
|
|
env_key: str
|
|
display_name: str = ""
|
|
|
|
# Which provider implementation to use:
|
|
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot"
|
|
backend: str = "openai_compat"
|
|
|
|
env_extras: tuple[tuple[str, str], ...] = ()
|
|
is_gateway: bool = False
|
|
is_local: bool = False
|
|
detect_by_key_prefix: str = ""
|
|
detect_by_base_keyword: str = ""
|
|
default_api_base: str = ""
|
|
strip_model_prefix: bool = False
|
|
supports_max_completion_tokens: bool = False
|
|
supports_prompt_caching: bool = False
|
|
supports_stream_options: bool = True
|
|
model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
|
|
is_oauth: bool = False
|
|
is_direct: bool = False
|
|
thinking_style: str = ""
|
|
# Substring patterns (case-insensitive) marking models whose native
|
|
# reasoning trace should be surfaced. When the caller does not pass an
|
|
# explicit reasoning_effort, the provider auto-injects "high" so the
|
|
# thinking_style flag (e.g. extra_body.thinking.type=enabled) is sent.
|
|
reasoning_model_patterns: tuple[str, ...] = ()
|
|
|
|
@property
|
|
def mode(self) -> str:
|
|
if self.is_oauth:
|
|
return "oauth"
|
|
if self.is_direct:
|
|
return "direct"
|
|
if self.is_gateway:
|
|
return "gateway"
|
|
if self.is_local:
|
|
return "local"
|
|
return "standard"
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return self.display_name or self.name.title()
|
|
|
|
|
|
PROVIDER_ALIASES = {
|
|
"azure": "azure_openai",
|
|
"azure-openai": "azure_openai",
|
|
"azureopenai": "azure_openai",
|
|
"google": "gemini",
|
|
"google_genai": "gemini",
|
|
"claude": "anthropic",
|
|
"openai_compatible": "custom",
|
|
"openai-compatible": "custom",
|
|
"anthropic_compatible": "custom_anthropic",
|
|
"anthropic-compatible": "custom_anthropic",
|
|
"volcenginecodingplan": "volcengine_coding_plan",
|
|
"volcengineCodingPlan": "volcengine_coding_plan",
|
|
"bytepluscodingplan": "byteplus_coding_plan",
|
|
"byteplusCodingPlan": "byteplus_coding_plan",
|
|
"github-copilot": "github_copilot",
|
|
"openai-codex": "openai_codex",
|
|
"lm-studio": "lm_studio",
|
|
}
|
|
|
|
|
|
def canonical_provider_name(name: str | None) -> str | None:
|
|
"""Normalize incoming provider names and legacy aliases."""
|
|
if not name:
|
|
return None
|
|
key = name.strip()
|
|
if not key:
|
|
return None
|
|
key = to_snake(key.replace("-", "_"))
|
|
return PROVIDER_ALIASES.get(key, key)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PROVIDERS — the registry. Order = priority.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROVIDERS: tuple[ProviderSpec, ...] = (
|
|
# === Direct (user supplies everything, no auto-detection) ===============
|
|
ProviderSpec(
|
|
name="custom",
|
|
keywords=(),
|
|
env_key="",
|
|
display_name="Custom",
|
|
backend="openai_compat",
|
|
is_direct=True,
|
|
),
|
|
ProviderSpec(
|
|
name="custom_anthropic",
|
|
keywords=(),
|
|
env_key="",
|
|
display_name="Custom (Anthropic API)",
|
|
backend="anthropic",
|
|
is_direct=True,
|
|
),
|
|
ProviderSpec(
|
|
name="azure_openai",
|
|
keywords=("azure", "azure_openai"),
|
|
env_key="",
|
|
display_name="Azure OpenAI",
|
|
backend="azure_openai",
|
|
is_direct=True,
|
|
),
|
|
# === Gateways (detected by api_key / api_base, route any model) ========
|
|
ProviderSpec(
|
|
name="openrouter",
|
|
keywords=("openrouter",),
|
|
env_key="OPENROUTER_API_KEY",
|
|
display_name="OpenRouter",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
detect_by_key_prefix="sk-or-",
|
|
detect_by_base_keyword="openrouter",
|
|
default_api_base="https://openrouter.ai/api/v1",
|
|
supports_prompt_caching=True,
|
|
),
|
|
ProviderSpec(
|
|
name="aihubmix",
|
|
keywords=("aihubmix",),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="AiHubMix",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
detect_by_base_keyword="aihubmix",
|
|
default_api_base="https://aihubmix.com/v1",
|
|
strip_model_prefix=True,
|
|
),
|
|
ProviderSpec(
|
|
name="siliconflow",
|
|
keywords=("siliconflow",),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="SiliconFlow",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
detect_by_base_keyword="siliconflow",
|
|
default_api_base="https://api.siliconflow.cn/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="volcengine",
|
|
keywords=("volcengine", "volces", "ark"),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="VolcEngine",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
detect_by_base_keyword="volces",
|
|
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
|
|
thinking_style="thinking_type",
|
|
),
|
|
ProviderSpec(
|
|
name="volcengine_coding_plan",
|
|
keywords=("volcengine-plan",),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="VolcEngine Coding Plan",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3",
|
|
strip_model_prefix=True,
|
|
thinking_style="thinking_type",
|
|
),
|
|
ProviderSpec(
|
|
name="byteplus",
|
|
keywords=("byteplus",),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="BytePlus",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
detect_by_base_keyword="bytepluses",
|
|
default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3",
|
|
strip_model_prefix=True,
|
|
thinking_style="thinking_type",
|
|
),
|
|
ProviderSpec(
|
|
name="byteplus_coding_plan",
|
|
keywords=("byteplus-plan",),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="BytePlus Coding Plan",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3",
|
|
strip_model_prefix=True,
|
|
thinking_style="thinking_type",
|
|
),
|
|
# === Standard providers (matched by model-name keywords) ===============
|
|
ProviderSpec(
|
|
name="anthropic",
|
|
keywords=("anthropic", "claude"),
|
|
env_key="ANTHROPIC_API_KEY",
|
|
display_name="Anthropic",
|
|
backend="anthropic",
|
|
default_api_base="https://api.anthropic.com/v1",
|
|
supports_prompt_caching=True,
|
|
),
|
|
ProviderSpec(
|
|
name="openai",
|
|
keywords=("openai", "gpt"),
|
|
env_key="OPENAI_API_KEY",
|
|
display_name="OpenAI",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.openai.com/v1",
|
|
supports_max_completion_tokens=True,
|
|
),
|
|
ProviderSpec(
|
|
name="openai_codex",
|
|
keywords=("openai-codex",),
|
|
env_key="",
|
|
display_name="OpenAI Codex",
|
|
backend="openai_codex",
|
|
detect_by_base_keyword="codex",
|
|
is_oauth=True,
|
|
default_api_base="https://chatgpt.com/backend-api",
|
|
),
|
|
ProviderSpec(
|
|
name="github_copilot",
|
|
keywords=("github_copilot", "copilot"),
|
|
env_key="",
|
|
display_name="GitHub Copilot",
|
|
backend="github_copilot",
|
|
is_oauth=True,
|
|
default_api_base="https://api.githubcopilot.com",
|
|
strip_model_prefix=True,
|
|
supports_max_completion_tokens=True,
|
|
),
|
|
ProviderSpec(
|
|
name="deepseek",
|
|
keywords=("deepseek",),
|
|
env_key="DEEPSEEK_API_KEY",
|
|
display_name="DeepSeek",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.deepseek.com",
|
|
thinking_style="thinking_type",
|
|
reasoning_model_patterns=("deepseek-v4-pro", "deepseek-reasoner"),
|
|
),
|
|
ProviderSpec(
|
|
name="gemini",
|
|
keywords=("gemini",),
|
|
env_key="GEMINI_API_KEY",
|
|
display_name="Gemini",
|
|
backend="openai_compat",
|
|
default_api_base="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
),
|
|
ProviderSpec(
|
|
name="zhipu",
|
|
keywords=("zhipu", "glm", "zai"),
|
|
env_key="ZAI_API_KEY",
|
|
display_name="Zhipu AI",
|
|
backend="openai_compat",
|
|
env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),),
|
|
default_api_base="https://open.bigmodel.cn/api/paas/v4",
|
|
),
|
|
ProviderSpec(
|
|
name="dashscope",
|
|
keywords=("qwen", "dashscope"),
|
|
env_key="DASHSCOPE_API_KEY",
|
|
display_name="DashScope",
|
|
backend="openai_compat",
|
|
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
thinking_style="enable_thinking",
|
|
),
|
|
ProviderSpec(
|
|
name="moonshot",
|
|
keywords=("moonshot", "kimi"),
|
|
env_key="MOONSHOT_API_KEY",
|
|
display_name="Moonshot",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.moonshot.cn/v1",
|
|
model_overrides=(
|
|
("kimi-k2.5", {"temperature": 1.0}),
|
|
("kimi-k2.6", {"temperature": 1.0}),
|
|
),
|
|
),
|
|
ProviderSpec(
|
|
name="minimax",
|
|
keywords=("minimax",),
|
|
env_key="MINIMAX_API_KEY",
|
|
display_name="MiniMax",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.minimaxi.com/v1",
|
|
thinking_style="reasoning_split",
|
|
),
|
|
ProviderSpec(
|
|
name="minimax_anthropic",
|
|
keywords=("minimax_anthropic",),
|
|
env_key="MINIMAX_API_KEY",
|
|
display_name="MiniMax (Anthropic)",
|
|
backend="anthropic",
|
|
default_api_base="https://api.minimaxi.com/anthropic",
|
|
),
|
|
ProviderSpec(
|
|
name="mistral",
|
|
keywords=("mistral",),
|
|
env_key="MISTRAL_API_KEY",
|
|
display_name="Mistral",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.mistral.ai/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="stepfun",
|
|
keywords=("stepfun", "step"),
|
|
env_key="STEPFUN_API_KEY",
|
|
display_name="Step Fun",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.stepfun.com/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="xiaomi_mimo",
|
|
keywords=("xiaomi_mimo", "mimo"),
|
|
env_key="XIAOMIMIMO_API_KEY",
|
|
display_name="Xiaomi MIMO",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.xiaomimimo.com/v1",
|
|
),
|
|
# === Local deployment ==================================================
|
|
ProviderSpec(
|
|
name="vllm",
|
|
keywords=("vllm",),
|
|
env_key="HOSTED_VLLM_API_KEY",
|
|
display_name="vLLM/Local",
|
|
backend="openai_compat",
|
|
is_local=True,
|
|
),
|
|
ProviderSpec(
|
|
name="ollama",
|
|
keywords=("ollama", "nemotron"),
|
|
env_key="OLLAMA_API_KEY",
|
|
display_name="Ollama",
|
|
backend="openai_compat",
|
|
is_local=True,
|
|
detect_by_base_keyword="11434",
|
|
default_api_base="http://localhost:11434/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="lm_studio",
|
|
keywords=("lm-studio", "lmstudio", "lm_studio"),
|
|
env_key="LM_STUDIO_API_KEY",
|
|
display_name="LM Studio",
|
|
backend="openai_compat",
|
|
is_local=True,
|
|
detect_by_base_keyword="1234",
|
|
default_api_base="http://localhost:1234/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="llama_cpp",
|
|
keywords=("llama_cpp", "llama.cpp"),
|
|
env_key="",
|
|
display_name="llama.cpp",
|
|
backend="openai_compat",
|
|
is_local=True,
|
|
detect_by_base_keyword="8080",
|
|
default_api_base="http://localhost:8080/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="lemonade",
|
|
keywords=("lemonade",),
|
|
env_key="LEMONADE_API_KEY",
|
|
display_name="Lemonade",
|
|
backend="openai_compat",
|
|
is_local=True,
|
|
detect_by_base_keyword="13305",
|
|
default_api_base="http://localhost:13305/api/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="ovms",
|
|
keywords=("openvino", "ovms"),
|
|
env_key="",
|
|
display_name="OpenVINO Model Server",
|
|
backend="openai_compat",
|
|
is_direct=True,
|
|
is_local=True,
|
|
default_api_base="http://localhost:8000/v3",
|
|
),
|
|
# === Auxiliary ==========================================================
|
|
ProviderSpec(
|
|
name="nvidia_nim",
|
|
keywords=("nvidia_nim", "nvidia-nim", "nim"),
|
|
env_key="NVIDIA_NIM_API_KEY",
|
|
display_name="NVIDIA NIM",
|
|
backend="openai_compat",
|
|
is_gateway=True,
|
|
detect_by_key_prefix="nvapi-",
|
|
detect_by_base_keyword="api.nvidia.com",
|
|
default_api_base="https://integrate.api.nvidia.com/v1",
|
|
supports_stream_options=False,
|
|
),
|
|
ProviderSpec(
|
|
name="groq",
|
|
keywords=("groq",),
|
|
env_key="GROQ_API_KEY",
|
|
display_name="Groq",
|
|
backend="openai_compat",
|
|
default_api_base="https://api.groq.com/openai/v1",
|
|
),
|
|
ProviderSpec(
|
|
name="qianfan",
|
|
keywords=("qianfan", "ernie"),
|
|
env_key="QIANFAN_API_KEY",
|
|
display_name="Qianfan",
|
|
backend="openai_compat",
|
|
default_api_base="https://qianfan.baidubce.com/v2",
|
|
),
|
|
)
|
|
|
|
|
|
NANOBOT_LLM_PROVIDERS: tuple[str, ...] = tuple(spec.name for spec in PROVIDERS)
|
|
|
|
|
|
def find_by_name(name: str | None) -> ProviderSpec | None:
|
|
canonical = canonical_provider_name(name)
|
|
if not canonical:
|
|
return None
|
|
for spec in PROVIDERS:
|
|
if spec.name == canonical:
|
|
return spec
|
|
return None
|
|
|
|
|
|
def find_by_model(model: str | None) -> ProviderSpec | None:
|
|
if not model:
|
|
return None
|
|
model_lower = model.lower()
|
|
model_normalized = model_lower.replace("-", "_")
|
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
|
normalized_prefix = model_prefix.replace("-", "_")
|
|
standard_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local]
|
|
|
|
for spec in standard_specs:
|
|
if model_prefix and normalized_prefix == spec.name:
|
|
return spec
|
|
for spec in standard_specs:
|
|
if any(
|
|
kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords
|
|
):
|
|
return spec
|
|
return None
|
|
|
|
|
|
def find_gateway(
|
|
provider_name: str | None = None,
|
|
api_key: str | None = None,
|
|
api_base: str | None = None,
|
|
) -> ProviderSpec | None:
|
|
spec = find_by_name(provider_name)
|
|
if spec and (spec.is_gateway or spec.is_local):
|
|
return spec
|
|
|
|
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
|
|
return None
|
|
|
|
|
|
def strip_provider_prefix(model: str, spec: ProviderSpec | None) -> str:
|
|
"""Strip the provider/ prefix from a model name if applicable."""
|
|
if not model or not spec:
|
|
return model
|
|
if spec.strip_model_prefix and "/" in model:
|
|
return model.split("/", 1)[1]
|
|
return model
|
|
|
|
|
|
__all__ = [
|
|
"ProviderSpec",
|
|
"PROVIDERS",
|
|
"NANOBOT_LLM_PROVIDERS",
|
|
"PROVIDER_ALIASES",
|
|
"canonical_provider_name",
|
|
"find_by_name",
|
|
"find_by_model",
|
|
"find_gateway",
|
|
"strip_provider_prefix",
|
|
]
|