mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-29 16:50:02 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881ec1819e | ||
|
|
6ba511f4d6 |
@@ -7,7 +7,7 @@ ci:
|
|||||||
autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks"
|
autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks"
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.8.2
|
rev: v0.16.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [--fix]
|
args: [--fix]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__: v0.2.4-33d6ea1
|
__version__: v0.2.4-203754e
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ async def auth_admin(
|
|||||||
raise SkipPluginException(
|
raise SkipPluginException(
|
||||||
f"{plugin.name}({plugin.module}) 管理员权限不足...",
|
f"{plugin.name}({plugin.module}) 管理员权限不足...",
|
||||||
tip_message=(
|
tip_message=(
|
||||||
f"你的权限不足喔,该功能需要的权限等级: "
|
f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}"
|
||||||
f"{plugin.admin_level}"
|
|
||||||
),
|
),
|
||||||
tip_background=True,
|
tip_background=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -181,8 +181,7 @@ def event_cache_key(
|
|||||||
channel_id = entity.channel_id or ""
|
channel_id = entity.channel_id or ""
|
||||||
scope = platform_scope or platform
|
scope = platform_scope or platform
|
||||||
return (
|
return (
|
||||||
f"{scope}:{platform}:{bot_id}:{entity.user_id}:"
|
f"{scope}:{platform}:{bot_id}:{entity.user_id}:{group_id}:{channel_id}:{msg_id}"
|
||||||
f"{group_id}:{channel_id}:{msg_id}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -121,14 +121,16 @@ async def get_chat_history(
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
filter_date = now - timedelta(days=7)
|
filter_date = now - timedelta(days=7)
|
||||||
date_list = await _read_db(
|
date_list = await _read_db(
|
||||||
lambda: ChatHistory.filter(
|
lambda: (
|
||||||
user_id=user_id,
|
ChatHistory.filter(
|
||||||
group_id=group_id,
|
user_id=user_id,
|
||||||
create_time__gte=filter_date,
|
group_id=group_id,
|
||||||
)
|
create_time__gte=filter_date,
|
||||||
.annotate(date=RawSQL("DATE(create_time)"), count=Count("id"))
|
)
|
||||||
.group_by("date")
|
.annotate(date=RawSQL("DATE(create_time)"), count=Count("id"))
|
||||||
.values("date", "count"),
|
.group_by("date")
|
||||||
|
.values("date", "count")
|
||||||
|
),
|
||||||
"MyInfo.chat_history_chart",
|
"MyInfo.chat_history_chart",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,11 +44,6 @@ __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 服务列表及序号。
|
||||||
@@ -79,9 +74,6 @@ 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"]),
|
||||||
@@ -181,22 +173,6 @@ 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,10 +6,8 @@ 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
|
||||||
|
|
||||||
@@ -107,34 +105,6 @@ 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 列表"""
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async def _prune_stale_tags():
|
|||||||
deleted_count = await tag_manager.prune_stale_group_links()
|
deleted_count = await tag_manager.prune_stale_group_links()
|
||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"定时任务:成功清理了 {deleted_count} 个无效的群组标签" f"关联。",
|
f"定时任务:成功清理了 {deleted_count} 个无效的群组标签关联。",
|
||||||
"群组标签管理",
|
"群组标签管理",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ from .config import (
|
|||||||
lik2relation,
|
lik2relation,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert len(level2attitude) == len(lik2level) == len(lik2relation), (
|
||||||
len(level2attitude) == len(lik2level) == len(lik2relation)
|
"好感度态度、等级、关系长度不匹配!"
|
||||||
), "好感度态度、等级、关系长度不匹配!"
|
)
|
||||||
|
|
||||||
AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160"
|
AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160"
|
||||||
|
|
||||||
|
|||||||
@@ -426,8 +426,9 @@ async def handle_delete(names: Match[list[str]]):
|
|||||||
async def handle_clear():
|
async def handle_clear():
|
||||||
confirm = await prompt_until(
|
confirm = await prompt_until(
|
||||||
"【警告】此操作将删除所有群组标签,是否继续?\n请输入 `是` 或 `确定` 确认操作",
|
"【警告】此操作将删除所有群组标签,是否继续?\n请输入 `是` 或 `确定` 确认操作",
|
||||||
lambda msg: msg.extract_plain_text().lower()
|
lambda msg: (
|
||||||
in ["是", "确定", "yes", "confirm"],
|
msg.extract_plain_text().lower() in ["是", "确定", "yes", "confirm"]
|
||||||
|
),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
retry=1,
|
retry=1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
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, ResponseFormat
|
from zhenxun.services.ai.core.options import GenerationConfig
|
||||||
|
|
||||||
from .handlers.openai_handlers import (
|
from .handlers.openai_handlers import (
|
||||||
OpenAIResponsesTextHandler,
|
OpenAIConfigMapper,
|
||||||
ResponsesConfigMapper,
|
OpenAITextHandler,
|
||||||
ResponsesMessageConverter,
|
OpenAIToolSerializer,
|
||||||
ResponsesResponseParser,
|
|
||||||
ResponsesToolSerializer,
|
|
||||||
)
|
)
|
||||||
from .openai import OpenAIResponsesAdapter
|
from .openai import OpenAICompatAdapter
|
||||||
|
|
||||||
|
|
||||||
class DeepSeekToolSerializer(ResponsesToolSerializer):
|
class DeepSeekToolSerializer(OpenAIToolSerializer):
|
||||||
"""
|
"""
|
||||||
专门针对 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"):
|
||||||
@@ -71,35 +68,7 @@ class DeepSeekToolSerializer(ResponsesToolSerializer):
|
|||||||
return pipeline.run(schema)
|
return pipeline.run(schema)
|
||||||
|
|
||||||
|
|
||||||
class DeepSeekMessageConverter(ResponsesMessageConverter):
|
class DeepSeekConfigMapper(OpenAIConfigMapper):
|
||||||
"""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(
|
||||||
@@ -108,38 +77,39 @@ class DeepSeekConfigMapper(ResponsesConfigMapper):
|
|||||||
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 目前无法严格遵循复杂 json_schema 的问题"""
|
"""映射生成参数并处理 DeepSeek 专有 `thinking` 与响应格式差异。"""
|
||||||
if (
|
params = super().map_config(config, model_detail, capabilities)
|
||||||
config.output.response_format == ResponseFormat.JSON
|
|
||||||
and config.output.response_schema
|
|
||||||
):
|
|
||||||
config.output.response_schema = None
|
|
||||||
|
|
||||||
return super().map_config(config, model_detail, capabilities)
|
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
|
||||||
|
|
||||||
|
|
||||||
class DeepSeekTextHandler(OpenAIResponsesTextHandler):
|
class DeepSeekTextHandler(OpenAITextHandler):
|
||||||
"""DeepSeek 专有 Responses 文本对话处理器,组装所有定制化子件"""
|
"""DeepSeek 专有文本处理器,替换了特定序列化组件"""
|
||||||
|
|
||||||
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(OpenAIResponsesAdapter):
|
class DeepSeekAdapter(OpenAICompatAdapter):
|
||||||
"""DeepSeek Responses API 适配器"""
|
"""DeepSeek 官方 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:
|
||||||
"""复用 Responses API 的日志清洗上下文。"""
|
"""返回 DeepSeek 请求日志清洗上下文。"""
|
||||||
return "openai_responses_request"
|
return "openai_request"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_type(self) -> str:
|
def api_type(self) -> str:
|
||||||
@@ -150,3 +120,7 @@ class DeepSeekAdapter(OpenAIResponsesAdapter):
|
|||||||
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,7 +31,6 @@ 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
|
||||||
@@ -49,7 +48,6 @@ 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:
|
||||||
@@ -116,7 +114,6 @@ 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"
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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,9 +521,6 @@ 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]]:
|
||||||
@@ -546,14 +543,11 @@ 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, ThoughtPart):
|
if isinstance(part, TextPart):
|
||||||
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):
|
||||||
@@ -579,26 +573,6 @@ 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})
|
||||||
|
|
||||||
@@ -687,10 +661,6 @@ 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", "")
|
||||||
@@ -898,7 +868,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(api_type=api_type)
|
self.converter = ResponsesMessageConverter()
|
||||||
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 = delegate_adapter.log_sanitization_context
|
sanitizer_req_context = f"{delegate_adapter.api_type}_request"
|
||||||
else:
|
else:
|
||||||
sanitizer_req_context = self.adapter.log_sanitization_context
|
sanitizer_req_context = self.adapter.log_sanitization_context
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ class FallbackRouter(BaseModelRouter):
|
|||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ 节点 '{m_name}' "
|
f"⚠️ 节点 '{m_name}' 错误 ({e.__class__.__name__}),触发故障转移..."
|
||||||
f"错误 ({e.__class__.__name__}),触发故障转移..."
|
|
||||||
)
|
)
|
||||||
errors.append(f"{m_name}({e.__class__.__name__})")
|
errors.append(f"{m_name}({e.__class__.__name__})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -99,7 +98,7 @@ class FallbackRouter(BaseModelRouter):
|
|||||||
if all_nodes_bypassed and len(model_names) > 1:
|
if all_nodes_bypassed and len(model_names) > 1:
|
||||||
fallback_model = health_manager.get_best_fallback_route(model_names)
|
fallback_model = health_manager.get_best_fallback_route(model_names)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ 路由组所有节点均已宕机!" f"强制放行 '{fallback_model}' 探活..."
|
f"⚠️ 路由组所有节点均已宕机!强制放行 '{fallback_model}' 探活..."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with await get_model_instance(
|
async with await get_model_instance(
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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
|
||||||
@@ -123,16 +122,7 @@ CAP_OPENAI_MULTIMODAL = ModelCapabilities(
|
|||||||
},
|
},
|
||||||
reasoning_effort_map={"max": "xhigh", "minimal": "none"},
|
reasoning_effort_map={"max": "xhigh", "minimal": "none"},
|
||||||
)
|
)
|
||||||
CAP_GROK = ModelCapabilities(
|
CAP_DEEPSEEK_V4 = 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,
|
||||||
@@ -140,14 +130,6 @@ CAP_DEEPSEEK_PRO = 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},
|
||||||
@@ -297,10 +279,7 @@ _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),
|
||||||
(["*grok-4.3*", "*grok-4.20*"], CAP_GROK, CTX_1M),
|
(["deepseek-v4-pro*", "deepseek-v4-flash*"], CAP_DEEPSEEK_V4, 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,7 +3,6 @@ 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
|
||||||
@@ -62,25 +61,16 @@ class UniversalMcpExtension(BaseMcpProxyExtension):
|
|||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
msg_obj = model_validate(
|
msg = model_validate(
|
||||||
JSONRPCMessage, json.loads(line)
|
JSONRPCMessage, json.loads(line)
|
||||||
)
|
)
|
||||||
await read_prod.send(
|
await read_prod.send(SessionMessage(message=msg))
|
||||||
SessionMessage(message=msg_obj)
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await read_prod.send(exc)
|
await read_prod.send(exc)
|
||||||
except anyio.ClosedResourceError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except BaseException as e:
|
|
||||||
logger.debug(
|
|
||||||
f"🔇 [MCP Universal] 读流异常: {type(e).__name__}: {e}"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
await read_prod.aclose()
|
||||||
await read_prod.aclose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def stream_writer():
|
async def stream_writer():
|
||||||
try:
|
try:
|
||||||
@@ -92,12 +82,8 @@ class UniversalMcpExtension(BaseMcpProxyExtension):
|
|||||||
+ b"\n"
|
+ b"\n"
|
||||||
)
|
)
|
||||||
await process_stream.write(data)
|
await process_stream.write(data)
|
||||||
except anyio.ClosedResourceError:
|
except Exception:
|
||||||
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,23 +30,6 @@ 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):
|
||||||
"""原生的桌面环境控制工具"""
|
"""原生的桌面环境控制工具"""
|
||||||
|
|
||||||
@@ -87,7 +70,10 @@ class UrlContextTool(ServerSideTool):
|
|||||||
|
|
||||||
class Native:
|
class Native:
|
||||||
"""
|
"""
|
||||||
云端原生工具命名空间工厂
|
云端原生工具命名空间工厂 (Namespace Factory)。
|
||||||
|
|
||||||
|
为开发者提供统一的云端内置工具调用入口,享受顶级 IDE 补全体验。
|
||||||
|
此类工具仅会向大模型提供描述,物理执行发生在各大模型厂商的服务端。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -103,17 +89,6 @@ 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,10 +64,8 @@ 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):
|
if isinstance(transport_val, str) and transport_val.lower() == "streamablehttp":
|
||||||
t_lower = transport_val.lower()
|
values["transport"] = "streamable-http"
|
||||||
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,15 +98,8 @@ 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:
|
||||||
try:
|
await filtered_send.aclose()
|
||||||
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)
|
||||||
@@ -547,12 +540,7 @@ class MCPToolkit(BaseToolkit):
|
|||||||
await self._stop_event.wait()
|
await self._stop_event.wait()
|
||||||
return
|
return
|
||||||
|
|
||||||
except BaseException as e:
|
except Exception 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):
|
||||||
@@ -560,30 +548,23 @@ 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 = (
|
self._init_exception = e
|
||||||
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 BaseException as e:
|
except Exception as e:
|
||||||
if isinstance(e, asyncio.CancelledError | KeyboardInterrupt | SystemExit):
|
self._init_exception = e
|
||||||
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"错误类型: {type(e).__name__}, 原因: {e}"
|
f"错误原因: {e}"
|
||||||
)
|
)
|
||||||
if "Connection closed" in str(e):
|
if "Connection closed" in str(e):
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -339,9 +339,7 @@ def _index_sql(table: str, columns: tuple[str, ...], dialect: Dialect) -> str |
|
|||||||
index_sql = _quote_identifier(_index_name(table, columns), dialect)
|
index_sql = _quote_identifier(_index_name(table, columns), dialect)
|
||||||
columns_sql = ", ".join(_quote_identifier(column, dialect) for column in columns)
|
columns_sql = ", ".join(_quote_identifier(column, dialect) for column in columns)
|
||||||
if dialect in {"sqlite", "postgres"}:
|
if dialect in {"sqlite", "postgres"}:
|
||||||
return (
|
return f"CREATE INDEX IF NOT EXISTS {index_sql} ON {table_sql}({columns_sql})"
|
||||||
f"CREATE INDEX IF NOT EXISTS {index_sql} " f"ON {table_sql}({columns_sql})"
|
|
||||||
)
|
|
||||||
if dialect == "mysql":
|
if dialect == "mysql":
|
||||||
return f"CREATE INDEX {index_sql} ON {table_sql}({columns_sql})"
|
return f"CREATE INDEX {index_sql} ON {table_sql}({columns_sql})"
|
||||||
return None
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user