✨ feat(llm): 优化 DeepSeek 适配器 (#2160)
检查bot是否运行正常 / bot check (push) Waiting to run
CodeQL Code Security Analysis / Analyze (python) (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Ruff Lint / Ruff Lint (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Update Version / update-version (push) Waiting to run

* ✨ feat(llm): 新增 Grok 适配器及 API 密钥重置指令

- 新增 Grok 适配器并支持原生推特检索工具 `x_search`
- 优化 DeepSeek 适配器以迁移至 Responses 协议,增强多模态支持
- 在 LLM 管理插件中新增 `llm reset` 子命令以重置 Key 状态
- 增强 MCP 连接异常处理及流关闭容错能力

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2026-09-22 09:02:15 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent 39ed1ade14
commit 33d6ea1335
13 changed files with 307 additions and 52 deletions
@@ -44,6 +44,11 @@ __plugin_meta__ = PluginMetadata(
llm keys <ProviderName>
- 查看指定提供商的所有API Key状态。
llm reset [ProviderName]
- 重置 API Key 的熔断与冷却状态。
- 带参数: 仅重置指定提供商的所有 Key。
- 不带参数: 全局重置所有提供商的所有 Key。
llm mcp [action] [targets...]
- 管理 MCP (Model Context Protocol) 服务。
- 不带参数: 查看当前配置的 MCP 服务列表及序号。
@@ -74,6 +79,9 @@ llm_cmd = on_alconna(
"test", Args["model_name", str], alias=["ping"], help_text="测试模型连通性"
),
Subcommand("keys", Args["provider_name", str], help_text="查看API密钥状态"),
Subcommand(
"reset", Args["provider_name", str, ""], help_text="重置API密钥状态"
),
Subcommand(
"mcp",
Option("添加", Args["json_strs", MultiVar(str)], alias=["add"]),
@@ -173,6 +181,22 @@ async def handle_keys(arp: Arparma, provider_name: Match[str]):
await llm_cmd.finish(MessageUtils.build_message(image))
@llm_cmd.assign("reset")
async def handle_reset(arp: Arparma):
"""处理 'llm reset' 命令"""
provider_name = arp.query("reset.provider_name", "").strip()
target_log = provider_name if provider_name else "ALL"
logger.info(
f"执行 API Key 重置操作: {target_log}",
command="LLM Manage",
session=arp.header_result,
)
_success, msg = await DataSource.reset_keys(
provider_name if provider_name else None
)
await llm_cmd.finish(msg)
@llm_cmd.assign("mcp")
async def handle_mcp(arp: Arparma, event: Event):
"""处理 'llm mcp' 命令"""
@@ -6,8 +6,10 @@ from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.ai.core.exceptions import LLMException
from zhenxun.services.ai.llm.api import chat
from zhenxun.services.ai.llm.manager import (
get_configured_providers,
get_model_instance,
list_available_models,
reset_key_status,
)
from zhenxun.services.ai.tools.providers.mcp.provider import mcp_provider
@@ -105,6 +107,34 @@ class DataSource:
return sorted_stats_list
@staticmethod
async def reset_keys(provider_name: str | None = None) -> tuple[bool, str]:
"""重置指定或所有提供商的 API Key 状态"""
providers = get_configured_providers()
if provider_name:
target = next(
(p for p in providers if p.name.lower() == provider_name.lower()), None
)
if not target:
return False, f"❌ 未找到提供商 '{provider_name}',请检查名称是否正确。"
await reset_key_status(target.name)
return (
True,
f"✅ 已成功重置提供商 '{target.name}'"
"的所有 API Key 状态为健康 (HEALTHY)。",
)
else:
count = 0
for p in providers:
await reset_key_status(p.name)
count += 1
return (
True,
f"✅ 已成功重置所有提供商 (共 {count} 个) "
"的 API Key 状态为健康 (HEALTHY)。",
)
@staticmethod
async def get_mcp_list() -> list[dict[str, Any]]:
"""获取排序后的 MCP 列表"""
+54 -28
View File
@@ -1,24 +1,27 @@
from typing import Any
from zhenxun.services.ai.core.messages import ImagePart, LLMMessage
from zhenxun.services.ai.core.models import (
ModelCapabilities,
ModelDetail,
ModelIdentity,
)
from zhenxun.services.ai.core.options import GenerationConfig
from zhenxun.services.ai.core.options import GenerationConfig, ResponseFormat
from .handlers.openai_handlers import (
OpenAIConfigMapper,
OpenAITextHandler,
OpenAIToolSerializer,
OpenAIResponsesTextHandler,
ResponsesConfigMapper,
ResponsesMessageConverter,
ResponsesResponseParser,
ResponsesToolSerializer,
)
from .openai import OpenAICompatAdapter
from .openai import OpenAIResponsesAdapter
class DeepSeekToolSerializer(OpenAIToolSerializer):
class DeepSeekToolSerializer(ResponsesToolSerializer):
"""
专门针对 DeepSeek 的工具序列化器。
负责抹平 Pydantic Schema 与 DeepSeek Strict Mode 之间的差异。
由于继承了 ResponsesToolSerializer,已自动获得 web_search 工具的原生格式支持。
"""
def __init__(self, api_type: str = "deepseek"):
@@ -68,7 +71,35 @@ class DeepSeekToolSerializer(OpenAIToolSerializer):
return pipeline.run(schema)
class DeepSeekConfigMapper(OpenAIConfigMapper):
class DeepSeekMessageConverter(ResponsesMessageConverter):
"""DeepSeek 专属 Responses 消息转换器"""
def __init__(self, api_type: str = "deepseek"):
super().__init__(api_type=api_type)
async def convert_messages_async(
self, messages: list[LLMMessage]
) -> list[dict[str, Any]]:
input_items = await super().convert_messages_async(messages)
image_parts = [
p for msg in messages for p in msg.content if isinstance(p, ImagePart)
]
img_idx = 0
for item in input_items:
if item.get("role") in ("user", "developer") and "content" in item:
for c in item["content"]:
if c.get("type") == "input_image":
if img_idx < len(image_parts):
part = image_parts[img_idx]
img_idx += 1
if getattr(part, "media_resolution", None):
c["detail"] = str(part.media_resolution).lower()
return input_items
class DeepSeekConfigMapper(ResponsesConfigMapper):
"""DeepSeek 的专属配置映射器"""
def map_config(
@@ -77,39 +108,38 @@ class DeepSeekConfigMapper(OpenAIConfigMapper):
model_detail: ModelDetail | None = None,
capabilities: ModelCapabilities | None = None,
) -> dict[str, Any]:
"""映射生成参数并处理 DeepSeek 专有 `thinking` 与响应格式差异。"""
params = super().map_config(config, model_detail, capabilities)
"""拦截并处理 DeepSeek 目前无法严格遵循复杂 json_schema 的问题"""
if (
config.output.response_format == ResponseFormat.JSON
and config.output.response_schema
):
config.output.response_schema = None
if "response_format" in params:
rf = params["response_format"]
if isinstance(rf, dict) and rf.get("type") == "json_schema":
params["response_format"] = {"type": "json_object"}
return params
return super().map_config(config, model_detail, capabilities)
class DeepSeekTextHandler(OpenAITextHandler):
"""DeepSeek 专有文本处理器,替换了特定序列化组件"""
class DeepSeekTextHandler(OpenAIResponsesTextHandler):
"""DeepSeek 专有 Responses 文本对话处理器,组装所有定制化子件"""
def __init__(self, api_type: str = "deepseek"):
"""替换 OpenAI 默认组件为 DeepSeek 专用实现。"""
super().__init__(api_type=api_type)
self.converter = DeepSeekMessageConverter(api_type=api_type)
self.serializer = DeepSeekToolSerializer(api_type=api_type)
self.mapper = DeepSeekConfigMapper(api_type=api_type)
self.parser = ResponsesResponseParser()
class DeepSeekAdapter(OpenAICompatAdapter):
"""DeepSeek 官方 API 适配器"""
class DeepSeekAdapter(OpenAIResponsesAdapter):
"""DeepSeek Responses API 适配器"""
def __init__(self):
"""初始化 DeepSeek 适配器并挂载文本处理器。"""
super().__init__()
self.text_handler = DeepSeekTextHandler(api_type=self.api_type)
@property
def log_sanitization_context(self) -> str:
"""返回 DeepSeek 请求日志清洗上下文。"""
return "openai_request"
"""复用 Responses API 的日志清洗上下文。"""
return "openai_responses_request"
@property
def api_type(self) -> str:
@@ -120,7 +150,3 @@ class DeepSeekAdapter(OpenAICompatAdapter):
def supported_api_types(self) -> list[str]:
"""当前适配器支持的 API 类型列表。"""
return ["deepseek"]
def get_chat_endpoint(self, identity: ModelIdentity) -> str:
"""返回对话端点,优先使用模型级自定义端点。"""
return "/v1/chat/completions"
@@ -31,6 +31,7 @@ class LLMAdapterFactory:
from .doubao import DoubaoAdapter
from .gemini import GeminiAdapter
from .glm import GLMAdapter
from .grok import GrokAdapter
from .jina import JinaAdapter
from .mimo import MiMoAdapter
from .minimax import MiniMaxAdapter
@@ -48,6 +49,7 @@ class LLMAdapterFactory:
cls.register_adapter(MiMoAdapter())
cls.register_adapter(MiniMaxAdapter())
cls.register_adapter(DoubaoAdapter())
cls.register_adapter(GrokAdapter())
@classmethod
def register_adapter(cls, adapter: BaseAdapter) -> None:
@@ -114,6 +116,7 @@ class SmartAdapter(BaseAdapter):
("*gemini*", "gemini"),
("*deepseek*", "deepseek"),
("*minimax*", "minimax"),
("*grok*", "grok"),
("*gpt*", "openai_responses"),
]
_DEFAULT_API_TYPE: ClassVar[str] = "openai"
+60
View File
@@ -0,0 +1,60 @@
from typing import Any
from zhenxun.services.ai.core.models import ModelCapabilities
from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import (
OpenAIResponsesTextHandler,
ResponsesToolSerializer,
)
from zhenxun.services.ai.llm.adapters.openai import OpenAIResponsesAdapter
class GrokToolSerializer(ResponsesToolSerializer):
"""Grok 专属工具序列化器"""
def serialize_server_tools(
self, tools: list[Any], capabilities: ModelCapabilities
) -> list[dict[str, Any]]:
res = []
for t in tools:
type_id = getattr(t, "type_id", "unknown")
if type_id not in capabilities.supported_native_tools:
continue
if type_id == "web_search":
res.append({"type": "web_search"})
elif type_id == "x_search":
payload = {"type": "x_search"}
if getattr(t, "allowed_x_handles", None):
payload["allowed_x_handles"] = t.allowed_x_handles
if getattr(t, "excluded_x_handles", None):
payload["excluded_x_handles"] = t.excluded_x_handles
res.append(payload)
elif type_id == "code_execution":
res.append({"type": "code_interpreter"})
elif type_id == "file_search":
res.append({"type": "file_search"})
return res
class GrokTextHandler(OpenAIResponsesTextHandler):
"""Grok 文本处理器"""
def __init__(self, api_type: str = "grok"):
super().__init__(api_type=api_type)
self.serializer = GrokToolSerializer(api_type=api_type)
class GrokAdapter(OpenAIResponsesAdapter):
"""xAI Grok API 适配器"""
def __init__(self):
super().__init__()
self.text_handler = GrokTextHandler(api_type=self.api_type)
@property
def api_type(self) -> str:
return "grok"
@property
def supported_api_types(self) -> list[str]:
return ["grok"]
@@ -521,6 +521,9 @@ class ResponsesConfigMapper(OpenAIConfigMapper):
class ResponsesMessageConverter(MessageConverter):
"""针对 OpenAI Responses API 的消息转换器"""
def __init__(self, api_type: str = "openai_responses"):
self.api_type = api_type
async def convert_messages_async(
self, messages: list[LLMMessage]
) -> list[dict[str, Any]]:
@@ -543,11 +546,14 @@ class ResponsesMessageConverter(MessageConverter):
continue
content_list: list[dict[str, Any]] = []
thought_text = ""
for part in msg.content:
if part is None:
continue
if isinstance(part, TextPart):
if isinstance(part, ThoughtPart):
thought_text += part.thought_text
elif isinstance(part, TextPart):
c_type = "output_text" if role == "assistant" else "input_text"
content_list.append({"type": c_type, "text": part.text})
elif isinstance(part, ImagePart):
@@ -573,6 +579,26 @@ class ResponsesMessageConverter(MessageConverter):
{"type": "input_image", "image_url": image_src}
)
if role == "assistant" and thought_text:
if self.api_type == "deepseek":
input_items.append(
{
"type": "reasoning",
"content": [
{"type": "reasoning_text", "text": thought_text}
],
"summary": [],
}
)
else:
input_items.append(
{
"type": "reasoning",
"content": [],
"summary": [{"type": "summary_text", "text": thought_text}],
}
)
if content_list:
input_items.append({"role": role, "content": content_list})
@@ -661,6 +687,10 @@ class ResponsesResponseParser(OpenAIResponseParser):
)
)
elif item.get("type") == "reasoning":
for r_content in item.get("content", []):
if r_content.get("type") == "reasoning_text":
thought_content += r_content.get("text", "")
for summary_item in item.get("summary", []):
if summary_item.get("type") == "summary_text":
thought_content += summary_item.get("text", "")
@@ -868,7 +898,7 @@ class OpenAIResponsesTextHandler(OpenAITextHandler):
def __init__(self, api_type: str = "openai_responses"):
super().__init__(api_type=api_type)
self.converter = ResponsesMessageConverter()
self.converter = ResponsesMessageConverter(api_type=api_type)
self.serializer = ResponsesToolSerializer(api_type=api_type)
self.mapper = ResponsesConfigMapper(api_type=api_type)
self.parser = ResponsesResponseParser()
@@ -301,7 +301,7 @@ class LoggingMiddleware:
smart_adapter = cast(SmartAdapter, self.adapter)
delegate_adapter = smart_adapter._get_delegate_adapter(self.identity)
sanitizer_req_context = f"{delegate_adapter.api_type}_request"
sanitizer_req_context = delegate_adapter.log_sanitization_context
else:
sanitizer_req_context = self.adapter.log_sanitization_context
+1
View File
@@ -58,6 +58,7 @@ def get_default_api_base_for_type(api_type: str) -> str | None:
"glm": "https://open.bigmodel.cn",
"gemini": "https://generativelanguage.googleapis.com",
"openrouter": "https://openrouter.ai/api",
"grok": "https://api.x.ai/v1",
"smart": None,
"openai_responses": None,
}
+23 -2
View File
@@ -9,6 +9,7 @@ from zhenxun.utils.pydantic_compat import model_copy
CTX_1_05M = 1_050_000
CTX_1M = 1_000_000
CTX_500K = 500_000
CTX_400K = 400_000
CTX_256K = 256_000
CTX_200K = 204_800
@@ -122,7 +123,16 @@ CAP_OPENAI_MULTIMODAL = ModelCapabilities(
},
reasoning_effort_map={"max": "xhigh", "minimal": "none"},
)
CAP_DEEPSEEK_V4 = ModelCapabilities(
CAP_GROK = ModelCapabilities(
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
output_modalities={ModelModality.TEXT},
supports_tool_calling=True,
reasoning_mode=ReasoningMode.EFFORT,
reasoning_visibility="visible",
supported_native_tools={"web_search", "x_search", "code_execution", "file_search"},
)
CAP_DEEPSEEK_PRO = ModelCapabilities(
input_modalities={ModelModality.TEXT},
output_modalities={ModelModality.TEXT},
supports_tool_calling=True,
@@ -130,6 +140,14 @@ CAP_DEEPSEEK_V4 = ModelCapabilities(
reasoning_mode=ReasoningMode.EFFORT,
reasoning_visibility="visible",
reasoning_effort_map={"minimal": "low"},
supported_native_tools={"web_search"},
)
CAP_DEEPSEEK_FLASH = model_copy(
CAP_DEEPSEEK_PRO,
update={
"input_modalities": {ModelModality.TEXT, ModelModality.IMAGE},
},
)
CAP_MINIMAX_REASONING = ModelCapabilities(
input_modalities={ModelModality.TEXT},
@@ -279,7 +297,10 @@ _ROUTING_TABLE: list[tuple[list[str], ModelCapabilities, int]] = [
(["*gemini*image*", "*nano-banana*"], CAP_GEMINI_IMAGE, CTX_128K),
(["glm-4.6v*"], CAP_GLM_MULTIMODAL, CTX_128K),
(["glm-4.7-flash*"], STANDARD_TEXT_TOOL_CAPABILITIES, CTX_128K),
(["deepseek-v4-pro*", "deepseek-v4-flash*"], CAP_DEEPSEEK_V4, CTX_1M),
(["*grok-4.3*", "*grok-4.20*"], CAP_GROK, CTX_1M),
(["*grok*"], CAP_GROK, CTX_500K),
(["*deepseek*pro*"], CAP_DEEPSEEK_PRO, CTX_1M),
(["*deepseek*flash*"], CAP_DEEPSEEK_FLASH, CTX_1M),
(["glm-4-long*"], STANDARD_TEXT_TOOL_CAPABILITIES, CTX_1M),
(["*MiniMax-M3*"], CAP_MINIMAX_MULTIMODAL, CTX_1M),
(["mimo-v2.5-pro*", "mimo-v2-pro*", "mimo-v2-flash*"], CAP_MIMO_TEXT, CTX_1M),
@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
import json
from typing import Any
import anyio
from anyio import create_memory_object_stream, create_task_group
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCMessage
@@ -61,16 +62,25 @@ class UniversalMcpExtension(BaseMcpProxyExtension):
if not line.strip():
continue
try:
msg = model_validate(
msg_obj = model_validate(
JSONRPCMessage, json.loads(line)
)
await read_prod.send(SessionMessage(message=msg))
await read_prod.send(
SessionMessage(message=msg_obj)
)
except Exception as exc:
await read_prod.send(exc)
except Exception:
except anyio.ClosedResourceError:
pass
except BaseException as e:
logger.debug(
f"🔇 [MCP Universal] 读流异常: {type(e).__name__}: {e}"
)
finally:
await read_prod.aclose()
try:
await read_prod.aclose()
except Exception:
pass
async def stream_writer():
try:
@@ -82,8 +92,12 @@ class UniversalMcpExtension(BaseMcpProxyExtension):
+ b"\n"
)
await process_stream.write(data)
except Exception:
except anyio.ClosedResourceError:
pass
except BaseException as e:
logger.debug(
f"🔇 [MCP Universal] 写流异常: {type(e).__name__}: {e}"
)
async with create_task_group() as tg:
tg.start_soon(stream_reader)
@@ -30,6 +30,23 @@ class CodeExecutionTool(ServerSideTool):
self.timeout = timeout
class XSearchTool(ServerSideTool):
"""原生 X (推特) 搜索工具 (Grok独占)"""
type_id = "x_search"
def __init__(
self,
allowed_x_handles: list[str] | None = None,
excluded_x_handles: list[str] | None = None,
):
super().__init__(
name="x_search", description="在 X (Twitter) 上搜索推文、用户资料和时间线。"
)
self.allowed_x_handles = allowed_x_handles
self.excluded_x_handles = excluded_x_handles
class ComputerUseTool(ServerSideTool):
"""原生的桌面环境控制工具"""
@@ -70,10 +87,7 @@ class UrlContextTool(ServerSideTool):
class Native:
"""
云端原生工具命名空间工厂 (Namespace Factory)。
为开发者提供统一的云端内置工具调用入口,享受顶级 IDE 补全体验。
此类工具仅会向大模型提供描述,物理执行发生在各大模型厂商的服务端。
云端原生工具命名空间工厂
"""
@classmethod
@@ -89,6 +103,17 @@ class Native:
"""
return WebSearchTool(name, description, dynamic_threshold, domain_filters)
@classmethod
def x_search(
cls,
allowed_x_handles: list[str] | None = None,
excluded_x_handles: list[str] | None = None,
) -> XSearchTool:
"""
原生 X (Twitter) 检索工具 (Grok 独占)。
"""
return XSearchTool(allowed_x_handles, excluded_x_handles)
@classmethod
def code_execution(
cls, name: str = "code_execution", timeout: int | None = None
@@ -64,8 +64,10 @@ class MCPServerConfig(BaseModel):
values["transport"] = values.pop("type")
transport_val = values.get("transport")
if isinstance(transport_val, str) and transport_val.lower() == "streamablehttp":
values["transport"] = "streamable-http"
if isinstance(transport_val, str):
t_lower = transport_val.lower()
if t_lower in ("streamablehttp", "http"):
values["transport"] = "streamable-http"
headers = values.get("headers")
if isinstance(headers, dict):
@@ -98,8 +98,15 @@ async def filtered_stdio_client(
await filtered_send.send(item)
except anyio.ClosedResourceError:
pass
except BaseException as e:
logger.debug(
f"🔇 [MCP Stdout Filter] 捕获到底层流异常: {type(e).__name__}: {e}"
)
finally:
await filtered_send.aclose()
try:
await filtered_send.aclose()
except Exception:
pass
async with anyio.create_task_group() as tg:
tg.start_soon(_forward_stdout)
@@ -540,7 +547,12 @@ class MCPToolkit(BaseToolkit):
await self._stop_event.wait()
return
except Exception as e:
except BaseException as e:
if isinstance(
e, asyncio.CancelledError | KeyboardInterrupt | SystemExit
):
raise e
from zhenxun.services.ai.core.exceptions import SandboxFatalError
if isinstance(e, SandboxFatalError):
@@ -548,23 +560,30 @@ class MCPToolkit(BaseToolkit):
if attempt < max_attempts:
logger.warning(
f"⚠️ [{self.server_name}] 进程启动或运行异常崩溃,"
f"⚠️ [{self.server_name}] 进程启动或"
f"底层流异常崩溃 ({type(e).__name__}),"
"疑似环境损坏。触发自愈机制 (准备重试)..."
)
self._init_exception = e
self._init_exception = (
e if isinstance(e, Exception) else Exception(str(e))
)
self._shared_session = None
if not self._is_initialized:
self._is_initialized = False
continue
if not isinstance(e, Exception):
raise Exception(f"底层致命异常: {e}") from e
raise e
except Exception as e:
self._init_exception = e
except BaseException as e:
if isinstance(e, asyncio.CancelledError | KeyboardInterrupt | SystemExit):
raise e
self._init_exception = e if isinstance(e, Exception) else Exception(str(e))
logger.error(
f"MCP 服务器 '{self.server_name}' "
f"初始化连接失败(模式: {self.transport})。"
f"错误原因: {e}"
f"初始化连接或运行期间发生底层故障(模式: {self.transport})。"
f"错误类型: {type(e).__name__}, 原因: {e}"
)
if "Connection closed" in str(e):
logger.error(