""" 聊天所用大模型的「逻辑 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}")