✨ 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> llm keys <ProviderName>
- 查看指定提供商的所有API Key状态。 - 查看指定提供商的所有API Key状态。
llm reset [ProviderName]
- 重置 API Key 的熔断与冷却状态。
- 带参数: 仅重置指定提供商的所有 Key。
- 不带参数: 全局重置所有提供商的所有 Key。
llm mcp [action] [targets...] llm mcp [action] [targets...]
- 管理 MCP (Model Context Protocol) 服务。 - 管理 MCP (Model Context Protocol) 服务。
- 不带参数: 查看当前配置的 MCP 服务列表及序号。 - 不带参数: 查看当前配置的 MCP 服务列表及序号。
@@ -74,6 +79,9 @@ llm_cmd = on_alconna(
"test", Args["model_name", str], alias=["ping"], help_text="测试模型连通性" "test", Args["model_name", str], alias=["ping"], help_text="测试模型连通性"
), ),
Subcommand("keys", Args["provider_name", str], help_text="查看API密钥状态"), Subcommand("keys", Args["provider_name", str], help_text="查看API密钥状态"),
Subcommand(
"reset", Args["provider_name", str, ""], help_text="重置API密钥状态"
),
Subcommand( Subcommand(
"mcp", "mcp",
Option("添加", Args["json_strs", MultiVar(str)], alias=["add"]), 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)) 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") @llm_cmd.assign("mcp")
async def handle_mcp(arp: Arparma, event: Event): async def handle_mcp(arp: Arparma, event: Event):
"""处理 'llm mcp' 命令""" """处理 '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.core.exceptions import LLMException
from zhenxun.services.ai.llm.api import chat from zhenxun.services.ai.llm.api import chat
from zhenxun.services.ai.llm.manager import ( from zhenxun.services.ai.llm.manager import (
get_configured_providers,
get_model_instance, get_model_instance,
list_available_models, list_available_models,
reset_key_status,
) )
from zhenxun.services.ai.tools.providers.mcp.provider import mcp_provider from zhenxun.services.ai.tools.providers.mcp.provider import mcp_provider
@@ -105,6 +107,34 @@ class DataSource:
return sorted_stats_list 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 @staticmethod
async def get_mcp_list() -> list[dict[str, Any]]: async def get_mcp_list() -> list[dict[str, Any]]:
"""获取排序后的 MCP 列表""" """获取排序后的 MCP 列表"""
+54 -28
View File
@@ -1,24 +1,27 @@
from typing import Any from typing import Any
from zhenxun.services.ai.core.messages import ImagePart, LLMMessage
from zhenxun.services.ai.core.models import ( from zhenxun.services.ai.core.models import (
ModelCapabilities, ModelCapabilities,
ModelDetail, ModelDetail,
ModelIdentity,
) )
from zhenxun.services.ai.core.options import GenerationConfig from zhenxun.services.ai.core.options import GenerationConfig, ResponseFormat
from .handlers.openai_handlers import ( from .handlers.openai_handlers import (
OpenAIConfigMapper, OpenAIResponsesTextHandler,
OpenAITextHandler, ResponsesConfigMapper,
OpenAIToolSerializer, ResponsesMessageConverter,
ResponsesResponseParser,
ResponsesToolSerializer,
) )
from .openai import OpenAICompatAdapter from .openai import OpenAIResponsesAdapter
class DeepSeekToolSerializer(OpenAIToolSerializer): class DeepSeekToolSerializer(ResponsesToolSerializer):
""" """
专门针对 DeepSeek 的工具序列化器。 专门针对 DeepSeek 的工具序列化器。
负责抹平 Pydantic Schema 与 DeepSeek Strict Mode 之间的差异。 负责抹平 Pydantic Schema 与 DeepSeek Strict Mode 之间的差异。
由于继承了 ResponsesToolSerializer,已自动获得 web_search 工具的原生格式支持。
""" """
def __init__(self, api_type: str = "deepseek"): def __init__(self, api_type: str = "deepseek"):
@@ -68,7 +71,35 @@ class DeepSeekToolSerializer(OpenAIToolSerializer):
return pipeline.run(schema) 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 的专属配置映射器""" """DeepSeek 的专属配置映射器"""
def map_config( def map_config(
@@ -77,39 +108,38 @@ class DeepSeekConfigMapper(OpenAIConfigMapper):
model_detail: ModelDetail | None = None, model_detail: ModelDetail | None = None,
capabilities: ModelCapabilities | None = None, capabilities: ModelCapabilities | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""映射生成参数并处理 DeepSeek 专有 `thinking` 与响应格式差异。""" """拦截并处理 DeepSeek 目前无法严格遵循复杂 json_schema 的问题"""
params = super().map_config(config, model_detail, capabilities) if (
config.output.response_format == ResponseFormat.JSON
and config.output.response_schema
):
config.output.response_schema = None
if "response_format" in params: return super().map_config(config, model_detail, capabilities)
rf = params["response_format"]
if isinstance(rf, dict) and rf.get("type") == "json_schema":
params["response_format"] = {"type": "json_object"}
return params
class DeepSeekTextHandler(OpenAITextHandler): class DeepSeekTextHandler(OpenAIResponsesTextHandler):
"""DeepSeek 专有文本处理器,替换了特定序列化组件""" """DeepSeek 专有 Responses 文本对话处理器,组装所有定制化子件"""
def __init__(self, api_type: str = "deepseek"): def __init__(self, api_type: str = "deepseek"):
"""替换 OpenAI 默认组件为 DeepSeek 专用实现。"""
super().__init__(api_type=api_type) super().__init__(api_type=api_type)
self.converter = DeepSeekMessageConverter(api_type=api_type)
self.serializer = DeepSeekToolSerializer(api_type=api_type) self.serializer = DeepSeekToolSerializer(api_type=api_type)
self.mapper = DeepSeekConfigMapper(api_type=api_type) self.mapper = DeepSeekConfigMapper(api_type=api_type)
self.parser = ResponsesResponseParser()
class DeepSeekAdapter(OpenAICompatAdapter): class DeepSeekAdapter(OpenAIResponsesAdapter):
"""DeepSeek 官方 API 适配器""" """DeepSeek Responses API 适配器"""
def __init__(self): def __init__(self):
"""初始化 DeepSeek 适配器并挂载文本处理器。"""
super().__init__() super().__init__()
self.text_handler = DeepSeekTextHandler(api_type=self.api_type) self.text_handler = DeepSeekTextHandler(api_type=self.api_type)
@property @property
def log_sanitization_context(self) -> str: def log_sanitization_context(self) -> str:
"""返回 DeepSeek 请求日志清洗上下文。""" """复用 Responses API 的日志清洗上下文。"""
return "openai_request" return "openai_responses_request"
@property @property
def api_type(self) -> str: def api_type(self) -> str:
@@ -120,7 +150,3 @@ class DeepSeekAdapter(OpenAICompatAdapter):
def supported_api_types(self) -> list[str]: def supported_api_types(self) -> list[str]:
"""当前适配器支持的 API 类型列表。""" """当前适配器支持的 API 类型列表。"""
return ["deepseek"] 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 .doubao import DoubaoAdapter
from .gemini import GeminiAdapter from .gemini import GeminiAdapter
from .glm import GLMAdapter from .glm import GLMAdapter
from .grok import GrokAdapter
from .jina import JinaAdapter from .jina import JinaAdapter
from .mimo import MiMoAdapter from .mimo import MiMoAdapter
from .minimax import MiniMaxAdapter from .minimax import MiniMaxAdapter
@@ -48,6 +49,7 @@ class LLMAdapterFactory:
cls.register_adapter(MiMoAdapter()) cls.register_adapter(MiMoAdapter())
cls.register_adapter(MiniMaxAdapter()) cls.register_adapter(MiniMaxAdapter())
cls.register_adapter(DoubaoAdapter()) cls.register_adapter(DoubaoAdapter())
cls.register_adapter(GrokAdapter())
@classmethod @classmethod
def register_adapter(cls, adapter: BaseAdapter) -> None: def register_adapter(cls, adapter: BaseAdapter) -> None:
@@ -114,6 +116,7 @@ class SmartAdapter(BaseAdapter):
("*gemini*", "gemini"), ("*gemini*", "gemini"),
("*deepseek*", "deepseek"), ("*deepseek*", "deepseek"),
("*minimax*", "minimax"), ("*minimax*", "minimax"),
("*grok*", "grok"),
("*gpt*", "openai_responses"), ("*gpt*", "openai_responses"),
] ]
_DEFAULT_API_TYPE: ClassVar[str] = "openai" _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): class ResponsesMessageConverter(MessageConverter):
"""针对 OpenAI Responses API 的消息转换器""" """针对 OpenAI Responses API 的消息转换器"""
def __init__(self, api_type: str = "openai_responses"):
self.api_type = api_type
async def convert_messages_async( async def convert_messages_async(
self, messages: list[LLMMessage] self, messages: list[LLMMessage]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -543,11 +546,14 @@ class ResponsesMessageConverter(MessageConverter):
continue continue
content_list: list[dict[str, Any]] = [] content_list: list[dict[str, Any]] = []
thought_text = ""
for part in msg.content: for part in msg.content:
if part is None: if part is None:
continue 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" c_type = "output_text" if role == "assistant" else "input_text"
content_list.append({"type": c_type, "text": part.text}) content_list.append({"type": c_type, "text": part.text})
elif isinstance(part, ImagePart): elif isinstance(part, ImagePart):
@@ -573,6 +579,26 @@ class ResponsesMessageConverter(MessageConverter):
{"type": "input_image", "image_url": image_src} {"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: if content_list:
input_items.append({"role": role, "content": content_list}) input_items.append({"role": role, "content": content_list})
@@ -661,6 +687,10 @@ class ResponsesResponseParser(OpenAIResponseParser):
) )
) )
elif item.get("type") == "reasoning": 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", []): for summary_item in item.get("summary", []):
if summary_item.get("type") == "summary_text": if summary_item.get("type") == "summary_text":
thought_content += summary_item.get("text", "") thought_content += summary_item.get("text", "")
@@ -868,7 +898,7 @@ class OpenAIResponsesTextHandler(OpenAITextHandler):
def __init__(self, api_type: str = "openai_responses"): def __init__(self, api_type: str = "openai_responses"):
super().__init__(api_type=api_type) super().__init__(api_type=api_type)
self.converter = ResponsesMessageConverter() self.converter = ResponsesMessageConverter(api_type=api_type)
self.serializer = ResponsesToolSerializer(api_type=api_type) self.serializer = ResponsesToolSerializer(api_type=api_type)
self.mapper = ResponsesConfigMapper(api_type=api_type) self.mapper = ResponsesConfigMapper(api_type=api_type)
self.parser = ResponsesResponseParser() self.parser = ResponsesResponseParser()
@@ -301,7 +301,7 @@ class LoggingMiddleware:
smart_adapter = cast(SmartAdapter, self.adapter) smart_adapter = cast(SmartAdapter, self.adapter)
delegate_adapter = smart_adapter._get_delegate_adapter(self.identity) 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: else:
sanitizer_req_context = self.adapter.log_sanitization_context 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", "glm": "https://open.bigmodel.cn",
"gemini": "https://generativelanguage.googleapis.com", "gemini": "https://generativelanguage.googleapis.com",
"openrouter": "https://openrouter.ai/api", "openrouter": "https://openrouter.ai/api",
"grok": "https://api.x.ai/v1",
"smart": None, "smart": None,
"openai_responses": 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_1_05M = 1_050_000
CTX_1M = 1_000_000 CTX_1M = 1_000_000
CTX_500K = 500_000
CTX_400K = 400_000 CTX_400K = 400_000
CTX_256K = 256_000 CTX_256K = 256_000
CTX_200K = 204_800 CTX_200K = 204_800
@@ -122,7 +123,16 @@ CAP_OPENAI_MULTIMODAL = ModelCapabilities(
}, },
reasoning_effort_map={"max": "xhigh", "minimal": "none"}, 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}, input_modalities={ModelModality.TEXT},
output_modalities={ModelModality.TEXT}, output_modalities={ModelModality.TEXT},
supports_tool_calling=True, supports_tool_calling=True,
@@ -130,6 +140,14 @@ CAP_DEEPSEEK_V4 = ModelCapabilities(
reasoning_mode=ReasoningMode.EFFORT, reasoning_mode=ReasoningMode.EFFORT,
reasoning_visibility="visible", reasoning_visibility="visible",
reasoning_effort_map={"minimal": "low"}, 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( CAP_MINIMAX_REASONING = ModelCapabilities(
input_modalities={ModelModality.TEXT}, 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), (["*gemini*image*", "*nano-banana*"], CAP_GEMINI_IMAGE, CTX_128K),
(["glm-4.6v*"], CAP_GLM_MULTIMODAL, CTX_128K), (["glm-4.6v*"], CAP_GLM_MULTIMODAL, CTX_128K),
(["glm-4.7-flash*"], STANDARD_TEXT_TOOL_CAPABILITIES, 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), (["glm-4-long*"], STANDARD_TEXT_TOOL_CAPABILITIES, CTX_1M),
(["*MiniMax-M3*"], CAP_MINIMAX_MULTIMODAL, CTX_1M), (["*MiniMax-M3*"], CAP_MINIMAX_MULTIMODAL, CTX_1M),
(["mimo-v2.5-pro*", "mimo-v2-pro*", "mimo-v2-flash*"], CAP_MIMO_TEXT, 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 import json
from typing import Any from typing import Any
import anyio
from anyio import create_memory_object_stream, create_task_group from anyio import create_memory_object_stream, create_task_group
from mcp.shared.message import SessionMessage from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCMessage from mcp.types import JSONRPCMessage
@@ -61,16 +62,25 @@ class UniversalMcpExtension(BaseMcpProxyExtension):
if not line.strip(): if not line.strip():
continue continue
try: try:
msg = model_validate( msg_obj = model_validate(
JSONRPCMessage, json.loads(line) JSONRPCMessage, json.loads(line)
) )
await read_prod.send(SessionMessage(message=msg)) await read_prod.send(
SessionMessage(message=msg_obj)
)
except Exception as exc: except Exception as exc:
await read_prod.send(exc) await read_prod.send(exc)
except Exception: except anyio.ClosedResourceError:
pass pass
except BaseException as e:
logger.debug(
f"🔇 [MCP Universal] 读流异常: {type(e).__name__}: {e}"
)
finally: finally:
await read_prod.aclose() try:
await read_prod.aclose()
except Exception:
pass
async def stream_writer(): async def stream_writer():
try: try:
@@ -82,8 +92,12 @@ class UniversalMcpExtension(BaseMcpProxyExtension):
+ b"\n" + b"\n"
) )
await process_stream.write(data) await process_stream.write(data)
except Exception: except anyio.ClosedResourceError:
pass pass
except BaseException as e:
logger.debug(
f"🔇 [MCP Universal] 写流异常: {type(e).__name__}: {e}"
)
async with create_task_group() as tg: async with create_task_group() as tg:
tg.start_soon(stream_reader) tg.start_soon(stream_reader)
@@ -30,6 +30,23 @@ class CodeExecutionTool(ServerSideTool):
self.timeout = timeout 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): class ComputerUseTool(ServerSideTool):
"""原生的桌面环境控制工具""" """原生的桌面环境控制工具"""
@@ -70,10 +87,7 @@ class UrlContextTool(ServerSideTool):
class Native: class Native:
""" """
云端原生工具命名空间工厂 (Namespace Factory)。 云端原生工具命名空间工厂
为开发者提供统一的云端内置工具调用入口,享受顶级 IDE 补全体验。
此类工具仅会向大模型提供描述,物理执行发生在各大模型厂商的服务端。
""" """
@classmethod @classmethod
@@ -89,6 +103,17 @@ class Native:
""" """
return WebSearchTool(name, description, dynamic_threshold, domain_filters) 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 @classmethod
def code_execution( def code_execution(
cls, name: str = "code_execution", timeout: int | None = None cls, name: str = "code_execution", timeout: int | None = None
@@ -64,8 +64,10 @@ class MCPServerConfig(BaseModel):
values["transport"] = values.pop("type") values["transport"] = values.pop("type")
transport_val = values.get("transport") transport_val = values.get("transport")
if isinstance(transport_val, str) and transport_val.lower() == "streamablehttp": if isinstance(transport_val, str):
values["transport"] = "streamable-http" t_lower = transport_val.lower()
if t_lower in ("streamablehttp", "http"):
values["transport"] = "streamable-http"
headers = values.get("headers") headers = values.get("headers")
if isinstance(headers, dict): if isinstance(headers, dict):
@@ -98,8 +98,15 @@ async def filtered_stdio_client(
await filtered_send.send(item) await filtered_send.send(item)
except anyio.ClosedResourceError: except anyio.ClosedResourceError:
pass pass
except BaseException as e:
logger.debug(
f"🔇 [MCP Stdout Filter] 捕获到底层流异常: {type(e).__name__}: {e}"
)
finally: finally:
await filtered_send.aclose() try:
await filtered_send.aclose()
except Exception:
pass
async with anyio.create_task_group() as tg: async with anyio.create_task_group() as tg:
tg.start_soon(_forward_stdout) tg.start_soon(_forward_stdout)
@@ -540,7 +547,12 @@ class MCPToolkit(BaseToolkit):
await self._stop_event.wait() await self._stop_event.wait()
return 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 from zhenxun.services.ai.core.exceptions import SandboxFatalError
if isinstance(e, SandboxFatalError): if isinstance(e, SandboxFatalError):
@@ -548,23 +560,30 @@ class MCPToolkit(BaseToolkit):
if attempt < max_attempts: if attempt < max_attempts:
logger.warning( 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 self._shared_session = None
if not self._is_initialized: if not self._is_initialized:
self._is_initialized = False self._is_initialized = False
continue continue
if not isinstance(e, Exception):
raise Exception(f"底层致命异常: {e}") from e
raise e raise e
except Exception as e: except BaseException as e:
self._init_exception = e if isinstance(e, asyncio.CancelledError | KeyboardInterrupt | SystemExit):
raise e
self._init_exception = e if isinstance(e, Exception) else Exception(str(e))
logger.error( logger.error(
f"MCP 服务器 '{self.server_name}' " f"MCP 服务器 '{self.server_name}' "
f"初始化连接失败(模式: {self.transport})。" f"初始化连接或运行期间发生底层故障(模式: {self.transport})。"
f"错误原因: {e}" f"错误类型: {type(e).__name__}, 原因: {e}"
) )
if "Connection closed" in str(e): if "Connection closed" in str(e):
logger.error( logger.error(