huoyan-enterprise/backend/core/llm_catalog.py

376 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
聊天所用大模型的「逻辑 id ↔ 各家 API 模型名」映射,以及统一的模型构造工厂。
统一构造入口:`build_chat_model(...)` / `build_chat_model_for_completion(...)` 返回
`langchain_core.language_models.chat_models.BaseChatModel`(通常为 ``ChatOpenAI`` 或通义原生 ``ChatTongyi``)。
- **通义(默认)**``USE_ORIGIN_MODEL=False`` 时走 ``ChatOpenAI`` + ``DASHSCOPE_API_BASE``OpenAI 兼容网关);
``USE_ORIGIN_MODEL=True`` 时走 ``langchain_community.chat_models.ChatTongyi``DashScope 原生 ``Generation`` 协议,不读兼容 base_url
- **DeepSeek**:仍用 ``ChatOpenAI`` + 各 ``DEEPSEEK_*`` base聊天主入口另有 ``ChatDeepSeek``(见 ``build_chatdeepseek_model``)。
- 深度思考:兼容模式下 ``extra_body={"enable_thinking": True}``;通义原生模式下写入 ``ChatTongyi.model_kwargs["enable_thinking"]``。
逻辑 id 与 GET /api/chat/llm-options 返回的 models[].id 一致ChatRequest.llm_model 使用相同 id。
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from langchain_community.chat_models.tongyi import ChatTongyi
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_deepseek.chat_models import ChatDeepSeek
from langchain_openai import ChatOpenAI
from core import llm_env
from core.config import get_settings
from logger.logging import get_logger
logger = get_logger(__name__)
# ---------- 数据定义 ----------
@dataclass(frozen=True)
class _ModelRow:
id: str
label: str
api_model: str
description: str = ""
@dataclass(frozen=True)
class _ProviderRow:
id: str
label: str
models: Tuple[_ModelRow, ...]
_TONGYI_MODELS: Tuple[_ModelRow, ...] = (
_ModelRow("qwen3-max", "Qwen3-Max", "qwen3-max", "通义千问 Max"),
)
_DEEPSEEK_MODELS: Tuple[_ModelRow, ...] = (
_ModelRow("deepseek-chat", "DeepSeek Chat", "deepseek-chat", "通用对话"),
_ModelRow(
"deepseek-reasoner",
"DeepSeek Reasoner",
"deepseek-reasoner",
"深度推理模型",
),
)
_PROVIDERS: Tuple[_ProviderRow, ...] = (
_ProviderRow("tongyi", "通义千问", _TONGYI_MODELS),
_ProviderRow("deepseek", "DeepSeek", _DEEPSEEK_MODELS),
)
_DEFAULT_MODEL_BY_PROVIDER: Dict[str, str] = {
"tongyi": "qwen3-max",
"deepseek": "deepseek-chat",
}
# 旧版前端 logical id → 新版 id仅存根兼容不参与 llm-options 展示)
_LEGACY_DEEPSEEK_LOGICAL_ID: Dict[str, str] = {
"deepseek-v3": "deepseek-chat",
"deepseek-v2": "deepseek-chat",
}
_LEGACY_TONGYI_LOGICAL_ID: Dict[str, str] = {
"qwen3.5-plus": "qwen3-max",
"qwen3.6-plus": "qwen3-max",
}
# 聊天主入口DeepSeek 实际调用的 API 名仅由深度思考开关决定(与请求里的 llm_model 无关)
_DEEPSEEK_API_CHAT = "deepseek-chat"
_DEEPSEEK_API_REASONER = "deepseek-reasoner"
def deepseek_api_model_by_reasoner_setting(*, user_is_reasoner: bool) -> str:
"""用户开启深度思考则用 ``deepseek-reasoner``,否则 ``deepseek-chat``。"""
return _DEEPSEEK_API_REASONER if user_is_reasoner else _DEEPSEEK_API_CHAT
_LOGICAL_TO_API: Dict[Tuple[str, str], str] = {}
for _p in _PROVIDERS:
for _m in _p.models:
_LOGICAL_TO_API[(_p.id, _m.id)] = _m.api_model
# ---------- 提供方与模型 id 工具 ----------
def normalize_provider(raw: Optional[str]) -> str:
if not raw or not str(raw).strip():
return "tongyi"
s = str(raw).strip().lower()
if s in ("dashscope", "qwen", "tongyi", "通义", "通义千问"):
return "tongyi"
if s in ("deepseek", "ds"):
return "deepseek"
return "tongyi"
def coerce_model_id(provider: str, model: Optional[str]) -> str:
prov = normalize_provider(provider)
if model and str(model).strip():
return str(model).strip()
return _DEFAULT_MODEL_BY_PROVIDER.get(prov, _DEFAULT_MODEL_BY_PROVIDER["tongyi"])
def validate_request_can_use_provider(provider: str) -> Optional[str]:
"""若配置不允许使用该校验的提供方,返回中文错误说明,否则返回 None。"""
settings = get_settings()
p = normalize_provider(provider)
if p == "tongyi":
if not settings.dashscope_api_key:
return "未配置通义千问 API KeyDASHSCOPE_API_KEY"
elif p == "deepseek":
if not settings.deepseek_api_key:
return "未配置 DeepSeek API KeyDEEPSEEK_API_KEY"
else:
return f"不支持的模型提供方: {provider}"
return None
def resolve_to_api_model(provider: str, logical_id: str) -> str:
p = normalize_provider(provider)
lid = logical_id
if p == "deepseek" and lid in _LEGACY_DEEPSEEK_LOGICAL_ID:
lid = _LEGACY_DEEPSEEK_LOGICAL_ID[lid]
if p == "tongyi" and lid in _LEGACY_TONGYI_LOGICAL_ID:
lid = _LEGACY_TONGYI_LOGICAL_ID[lid]
key = (p, lid)
if key not in _LOGICAL_TO_API:
# 兼容上层直接传 api_model比如 "qwen-plus-latest"、"deepseek-chat"
# 找不到逻辑 id 时,原样作为 api_model 透传,而不是抛错。
return lid
return _LOGICAL_TO_API[key]
def list_llm_options_payload() -> Dict[str, Any]:
"""供 GET /api/chat/llm-options 使用:只返回当前环境**已配置密钥**的提供方及其模型。"""
settings = get_settings()
out_providers: List[Dict[str, Any]] = []
for prov in _PROVIDERS:
if prov.id == "tongyi" and not settings.dashscope_api_key:
continue
if prov.id == "deepseek" and not settings.deepseek_api_key:
continue
out_providers.append(
{
"id": prov.id,
"label": prov.label,
"models": [
{
"id": m.id,
"label": m.label,
**({"description": m.description} if m.description else {}),
}
for m in prov.models
],
}
)
default_provider = "tongyi"
if not any(p["id"] == default_provider for p in out_providers) and out_providers:
default_provider = out_providers[0]["id"]
default_model_by_provider = {
p["id"]: _DEFAULT_MODEL_BY_PROVIDER[p["id"]]
for p in out_providers
if p["id"] in _DEFAULT_MODEL_BY_PROVIDER
}
return {
"default_provider": default_provider,
"default_model_by_provider": default_model_by_provider,
"providers": out_providers,
}
# ---------- 统一构造工厂 ----------
def _tongyi_model_kwargs_from_chatopenai_extras(
temperature: float, extra_kwargs: Dict[str, Any]
) -> Dict[str, Any]:
"""将常见的 ChatOpenAI 风格参数映射为 ChatTongyi 的 ``model_kwargs``(传入 DashScope Generation"""
mk: Dict[str, Any] = {"temperature": temperature}
nested = extra_kwargs.get("model_kwargs")
if isinstance(nested, dict):
mk.update(nested)
if "max_tokens" in extra_kwargs:
mk["max_tokens"] = extra_kwargs["max_tokens"]
eb = extra_kwargs.get("extra_body")
if isinstance(eb, dict) and eb.get("enable_thinking"):
mk["enable_thinking"] = True
return mk
def _tongyi_chattongyi_must_use_openai_compatible(api_model: str) -> bool:
"""百炼:部分模型与 ``Generation.call``text-generation不匹配``ChatTongyi`` 仍会走该端点会报 ``url error``;须改用 OpenAI 兼容接口。"""
m = (api_model or "").strip().lower()
if any(x in m for x in ("-vl-", "vl-plus", "vl-max", "omni")):
return True
if m.startswith("qwen3.5") or m.startswith("qwen3.6"):
return True
if m.startswith("qwen2.5-vl") or m.startswith("qwen-vl"):
return True
return False
def build_chat_model(
provider: str,
api_model: str,
*,
streaming: bool = False,
temperature: float = 0.7,
**extra_kwargs: Any,
) -> BaseChatModel:
"""
统一构造聊天模型。
- ``provider=deepseek``:始终 ``ChatOpenAI``OpenAI 兼容)。
- ``provider=tongyi``:由 ``USE_ORIGIN_MODEL`` 决定 ``ChatTongyi``(原生)或 ``ChatOpenAI``(兼容网关)。
``extra_kwargs`` 在通义原生路径下仅识别 ``model_kwargs``、``max_tokens``、``extra_body.enable_thinking``
其余键仅适用于 ``ChatOpenAI`` 分支。
部分通义模型与 ``ChatTongyi`` 内部使用的 ``Generation`` 端点不兼容(百炼 ``url error``),此时即使开启 ``USE_ORIGIN_MODEL`` 也会自动回退 ``ChatOpenAI`` + ``DASHSCOPE_API_BASE``。
"""
p = normalize_provider(provider)
if p == "tongyi":
api_key = (os.getenv("DASHSCOPE_API_KEY") or "").strip()
if not api_key:
raise ValueError("缺少 DASHSCOPE_API_KEY")
base_url = llm_env.tongyi_openai_compatible_base_url().strip().rstrip("/")
use_native = get_settings().use_origin_model
if use_native and _tongyi_chattongyi_must_use_openai_compatible(api_model):
logger.info(
"通义模型 {} 与 ChatTongyi(Generation) 端点不兼容,改用 ChatOpenAI + 兼容网关",
api_model,
)
use_native = False
if use_native:
mk = _tongyi_model_kwargs_from_chatopenai_extras(temperature, extra_kwargs)
import dashscope
native_base = llm_env.dashscope_native_http_api_base().strip().rstrip("/")
dashscope.base_http_api_url = native_base
logger.debug(
"通义使用 ChatTongyiUSE_ORIGIN_MODEL=truedashscope.base_http_api_url={}",
native_base,
)
return ChatTongyi(
model=api_model,
api_key=api_key,
streaming=streaming,
model_kwargs=mk,
)
# 未走 ChatTongyi 时,与同提供方 OpenAI 兼容路径共用密钥与 base_url
elif p == "deepseek":
api_key = (os.getenv("DEEPSEEK_API_KEY") or "").strip()
if not api_key:
raise ValueError("缺少 DEEPSEEK_API_KEY")
base_url = llm_env.resolved_deepseek_chat_base_url().strip().rstrip("/")
else:
raise ValueError(f"未知提供方: {provider}")
return ChatOpenAI(
model=api_model,
api_key=api_key,
base_url=base_url,
streaming=streaming,
temperature=temperature,
**extra_kwargs,
)
# ---------- 兼容旧接口(保留命名,内部统一走 build_chat_model ----------
def build_streaming_chat_model(provider: str, api_model: str) -> BaseChatModel:
"""聊天主入口使用的流式模型。"""
return build_chat_model(provider, api_model, streaming=True, temperature=0.7)
def build_deepseek_reasoner_model() -> BaseChatModel:
"""DeepSeek 深度思考模型Reasoner"""
return build_chat_model(
"deepseek", "deepseek-reasoner", streaming=True, temperature=0.6
)
def _tongyi_openai_extra_url_for_thinking(*, enable_thinking: bool) -> Dict[str, Any]:
"""通义经 ChatOpenAI兼容网关是否附加 thinking。"""
if not enable_thinking:
return {}
return {"extra_body": {"enable_thinking": True}}
def build_tongyi_reasoning_model(api_model: str) -> BaseChatModel:
"""
通义深度思考:沿用当前选用的对话模型并开启思考输出。
- ``USE_ORIGIN_MODEL=False````extra_body={"enable_thinking": True}``OpenAI 兼容)。
- ``USE_ORIGIN_MODEL=True````ChatTongyi.model_kwargs`` 中 ``enable_thinking=True``DashScope 原生)。
"""
extra = _tongyi_openai_extra_url_for_thinking(enable_thinking=True)
return build_chat_model(
"tongyi", api_model, streaming=True, temperature=0.6, **extra
)
def build_chatdeepseek_model(api_model: str, *, enable_thinking: bool) -> ChatDeepSeek:
"""使用 LangChain ``langchain_deepseek.ChatDeepSeek`` 构造客户端(不在本仓库继承/改写)。"""
api_key = (os.getenv("DEEPSEEK_API_KEY") or "").strip()
if not api_key:
raise ValueError("缺少 DEEPSEEK_API_KEY")
base_url = llm_env.resolved_deepseek_chat_base_url().strip().rstrip("/")
logger.debug(
"DeepSeek 模型: api_model={} enable_thinking={} base_url={}",
api_model,
enable_thinking,
base_url,
)
kwargs: Dict[str, Any] = {
"model": api_model,
"api_key": api_key,
"base_url": base_url,
"streaming": True,
}
# ``deepseek-reasoner`` 为专用推理模型,勿再加 ``thinking`` 扩展体;仅 ``deepseek-chat`` 在用户开启深度思考时使用
if enable_thinking and api_model == "deepseek-chat":
kwargs["extra_body"] = {"thinking": {"type": "enabled"}}
return ChatDeepSeek(**kwargs)
def build_chat_model_for_completion(
provider: str,
api_model: str,
*,
enable_thinking: bool,
logical_llm_id: Optional[str] = None,
) -> BaseChatModel:
"""聊天主入口按提供方构造模型DeepSeek`ChatDeepSeek`;通义:由 ``USE_ORIGIN_MODEL`` 决定 ``ChatTongyi`` 或 ``ChatOpenAI``)。
深度思考:``enable_thinking=True`` 时,兼容模式用 ``extra_body``;通义原生模式写入 ``model_kwargs.enable_thinking``。
``logical_llm_id`` 主要来自 ``ChatRequest.llm_model``(通义等仍按该 id 解析)。
**DeepSeek**:聊天路由在读取 ``user_list.is_reasoner`` 后,会无视请求中的模型选择,
直接按 ``deepseek_api_model_by_reasoner_setting`` 选用 ``deepseek-chat`` 或
``deepseek-reasoner``;本函数收到的 ``api_model`` 应为该结果。
"""
p = normalize_provider(provider)
if p == "deepseek":
return build_chatdeepseek_model(api_model, enable_thinking=enable_thinking)
if p == "tongyi":
extra = _tongyi_openai_extra_url_for_thinking(enable_thinking=enable_thinking)
return build_chat_model(
"tongyi", api_model, streaming=True, temperature=0.7, **extra
)
raise ValueError(f"不支持的模型提供方: {provider}")