feat: organize tools and enable qwen realtime calls
This commit is contained in:
+97
-1
@@ -1,5 +1,7 @@
|
||||
"""Reusable tool CRUD. Runtime execution is implemented per supported tool type."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from db.models import AssistantToolBinding, Tool
|
||||
@@ -9,6 +11,7 @@ from schemas import ToolOut, ToolUpsert
|
||||
from services.auth import require_admin
|
||||
from services.masking import merge_secrets
|
||||
from services.tool_resources import tool_to_out
|
||||
from services.system_tools import RESERVED_TOOL_NAMES
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,17 +23,98 @@ router = APIRouter(
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
TEMPLATE_PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
||||
|
||||
|
||||
def _normalize_http_template_inputs(definition: dict) -> None:
|
||||
"""Keep Assistant-specific values out of a reusable HTTP resource."""
|
||||
|
||||
if definition.get("type") != "http":
|
||||
return
|
||||
config = definition.get("config") or {}
|
||||
source = json.dumps(
|
||||
{
|
||||
"url": config.get("url"),
|
||||
"headers": config.get("headers") or {},
|
||||
"body": config.get("body") or {},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
placeholders = set(TEMPLATE_PLACEHOLDER.findall(source))
|
||||
restricted_source = json.dumps(
|
||||
{"url": config.get("url"), "body": config.get("body") or {}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
misplaced_secrets = sorted(
|
||||
name
|
||||
for name in set(TEMPLATE_PLACEHOLDER.findall(restricted_source))
|
||||
if name.startswith("secret__")
|
||||
)
|
||||
if misplaced_secrets:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"工具密钥变量只能用于 HTTP Header: "
|
||||
+ ", ".join(misplaced_secrets),
|
||||
)
|
||||
invalid = sorted(
|
||||
name
|
||||
for name in placeholders
|
||||
if not name.startswith(("input__", "system__", "secret__"))
|
||||
)
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"HTTP 资源不能直接引用 Assistant 变量;"
|
||||
"请改为 {{input__name}}: " + ", ".join(invalid),
|
||||
)
|
||||
names = sorted(
|
||||
name.removeprefix("input__")
|
||||
for name in placeholders
|
||||
if name.startswith("input__")
|
||||
)
|
||||
existing = {
|
||||
str(item.get("name")): item
|
||||
for item in config.get("template_inputs") or []
|
||||
if item.get("name")
|
||||
}
|
||||
config["template_inputs"] = [
|
||||
existing.get(name)
|
||||
or {
|
||||
"name": name,
|
||||
"type": "string",
|
||||
"location": "body",
|
||||
"description": "HTTP 资源模板输入",
|
||||
"required": True,
|
||||
}
|
||||
for name in names
|
||||
]
|
||||
|
||||
|
||||
def _payload(
|
||||
body: ToolUpsert,
|
||||
stored_secrets: dict | None = None,
|
||||
) -> dict:
|
||||
definition = body.definition.model_dump()
|
||||
if body.function_name in RESERVED_TOOL_NAMES:
|
||||
raise HTTPException(400, "工具函数名由平台内建工具保留")
|
||||
_normalize_http_template_inputs(definition)
|
||||
secrets = (
|
||||
merge_secrets(body.secrets, stored_secrets or {})
|
||||
if definition["type"] == "http"
|
||||
else {}
|
||||
)
|
||||
secret_variables = (secrets.get("dynamic_variables") or {}) if secrets else {}
|
||||
invalid_secret_names = sorted(
|
||||
str(name)
|
||||
for name in secret_variables
|
||||
if not str(name).startswith("secret__")
|
||||
)
|
||||
if invalid_secret_names:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"工具密钥变量必须以 secret__ 开头: "
|
||||
+ ", ".join(invalid_secret_names),
|
||||
)
|
||||
return {
|
||||
"name": body.name.strip(),
|
||||
"function_name": body.function_name,
|
||||
@@ -68,7 +152,11 @@ async def _commit(session: AsyncSession, tool: Tool) -> ToolOut:
|
||||
@router.get("", response_model=list[ToolOut])
|
||||
async def list_tools(session: AsyncSession = Depends(get_session)):
|
||||
rows = (
|
||||
await session.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
||||
await session.execute(
|
||||
select(Tool)
|
||||
.where(Tool.type.in_(("http", "mcp", "client")))
|
||||
.order_by(Tool.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [tool_to_out(tool) for tool in rows]
|
||||
|
||||
@@ -87,6 +175,8 @@ async def get_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
|
||||
tool = await session.get(Tool, tool_id)
|
||||
if not tool:
|
||||
raise HTTPException(404, "工具不存在")
|
||||
if tool.type == "system":
|
||||
raise HTTPException(404, "工具不存在")
|
||||
return tool_to_out(tool)
|
||||
|
||||
|
||||
@@ -99,6 +189,8 @@ async def update_tool(
|
||||
tool = await session.get(Tool, tool_id)
|
||||
if not tool:
|
||||
raise HTTPException(404, "工具不存在")
|
||||
if tool.type == "system":
|
||||
raise HTTPException(404, "工具不存在")
|
||||
if tool.type == "mcp":
|
||||
if body.definition.type != "mcp":
|
||||
raise HTTPException(400, "同步生成的 MCP 工具不能修改类型")
|
||||
@@ -117,6 +209,8 @@ async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_sessi
|
||||
source = await session.get(Tool, tool_id)
|
||||
if not source:
|
||||
raise HTTPException(404, "工具不存在")
|
||||
if source.type == "system":
|
||||
raise HTTPException(404, "工具不存在")
|
||||
if source.type == "mcp":
|
||||
raise HTTPException(400, "MCP 工具由 Server 同步维护,不能单独复制")
|
||||
function_name = _duplicate_function_name(source.function_name)
|
||||
@@ -148,6 +242,8 @@ async def delete_tool(tool_id: str, session: AsyncSession = Depends(get_session)
|
||||
tool = await session.get(Tool, tool_id)
|
||||
if not tool:
|
||||
raise HTTPException(404, "工具不存在")
|
||||
if tool.type == "system":
|
||||
raise HTTPException(404, "工具不存在")
|
||||
in_use = (
|
||||
await session.execute(
|
||||
select(AssistantToolBinding.assistant_id)
|
||||
|
||||
Reference in New Issue
Block a user