376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""
|
||
聊天所用大模型的「逻辑 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 Key(DASHSCOPE_API_KEY)"
|
||
elif p == "deepseek":
|
||
if not settings.deepseek_api_key:
|
||
return "未配置 DeepSeek API Key(DEEPSEEK_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(
|
||
"通义使用 ChatTongyi(USE_ORIGIN_MODEL=true),dashscope.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}")
|