2026-07-10 14:32:10 +08:00
|
|
|
"""Reusable tool CRUD. Runtime execution is implemented per supported tool type."""
|
2026-07-10 10:05:41 +08:00
|
|
|
|
2026-08-14 00:15:00 +08:00
|
|
|
import json
|
|
|
|
|
import re
|
2026-07-10 10:05:41 +08:00
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from db.models import AssistantToolBinding, Tool
|
|
|
|
|
from db.session import get_session
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from schemas import ToolOut, ToolUpsert
|
|
|
|
|
from services.auth import require_admin
|
2026-07-18 00:00:06 +08:00
|
|
|
from services.masking import merge_secrets
|
|
|
|
|
from services.tool_resources import tool_to_out
|
2026-08-14 00:15:00 +08:00
|
|
|
from services.system_tools import RESERVED_TOOL_NAMES
|
2026-07-10 10:05:41 +08:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(
|
|
|
|
|
prefix="/api/tools",
|
|
|
|
|
tags=["tools"],
|
|
|
|
|
dependencies=[Depends(require_admin)],
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-14 00:15:00 +08:00
|
|
|
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
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-10 10:05:41 +08:00
|
|
|
|
2026-07-18 00:00:06 +08:00
|
|
|
def _payload(
|
|
|
|
|
body: ToolUpsert,
|
|
|
|
|
stored_secrets: dict | None = None,
|
|
|
|
|
) -> dict:
|
2026-07-10 10:05:41 +08:00
|
|
|
definition = body.definition.model_dump()
|
2026-08-14 00:15:00 +08:00
|
|
|
if body.function_name in RESERVED_TOOL_NAMES:
|
|
|
|
|
raise HTTPException(400, "工具函数名由平台内建工具保留")
|
|
|
|
|
_normalize_http_template_inputs(definition)
|
2026-07-10 10:05:41 +08:00
|
|
|
secrets = (
|
|
|
|
|
merge_secrets(body.secrets, stored_secrets or {})
|
|
|
|
|
if definition["type"] == "http"
|
|
|
|
|
else {}
|
|
|
|
|
)
|
2026-08-14 00:15:00 +08:00
|
|
|
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),
|
|
|
|
|
)
|
2026-07-10 10:05:41 +08:00
|
|
|
return {
|
|
|
|
|
"name": body.name.strip(),
|
|
|
|
|
"function_name": body.function_name,
|
|
|
|
|
"type": definition["type"],
|
2026-07-18 00:00:06 +08:00
|
|
|
"mcp_server_id": body.mcp_server_id if definition["type"] == "mcp" else None,
|
|
|
|
|
"remote_tool_name": (
|
|
|
|
|
definition["config"]["remote_tool_name"]
|
|
|
|
|
if definition["type"] == "mcp"
|
|
|
|
|
else None
|
|
|
|
|
),
|
2026-07-10 10:05:41 +08:00
|
|
|
"description": body.description.strip(),
|
|
|
|
|
"definition": definition,
|
|
|
|
|
"secrets": secrets,
|
|
|
|
|
"status": body.status,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 16:45:51 +08:00
|
|
|
def _duplicate_function_name(source: str) -> str:
|
|
|
|
|
candidate = f"{source}_copy"
|
|
|
|
|
if len(candidate) <= 64:
|
|
|
|
|
return candidate
|
|
|
|
|
return f"{source[:57]}_copy"
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 10:05:41 +08:00
|
|
|
async def _commit(session: AsyncSession, tool: Tool) -> ToolOut:
|
|
|
|
|
try:
|
|
|
|
|
await session.commit()
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
await session.rollback()
|
|
|
|
|
raise HTTPException(409, "工具函数名已存在") from exc
|
|
|
|
|
await session.refresh(tool)
|
2026-07-18 00:00:06 +08:00
|
|
|
return tool_to_out(tool)
|
2026-07-10 10:05:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=list[ToolOut])
|
|
|
|
|
async def list_tools(session: AsyncSession = Depends(get_session)):
|
|
|
|
|
rows = (
|
2026-08-14 00:15:00 +08:00
|
|
|
await session.execute(
|
|
|
|
|
select(Tool)
|
|
|
|
|
.where(Tool.type.in_(("http", "mcp", "client")))
|
|
|
|
|
.order_by(Tool.updated_at.desc())
|
|
|
|
|
)
|
2026-07-10 10:05:41 +08:00
|
|
|
).scalars().all()
|
2026-07-18 00:00:06 +08:00
|
|
|
return [tool_to_out(tool) for tool in rows]
|
2026-07-10 10:05:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=ToolOut)
|
|
|
|
|
async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)):
|
2026-07-18 00:00:06 +08:00
|
|
|
if body.definition.type == "mcp":
|
|
|
|
|
raise HTTPException(400, "MCP 工具请通过 MCP Server 同步创建")
|
2026-07-10 10:05:41 +08:00
|
|
|
tool = Tool(id=f"tool_{uuid.uuid4().hex[:12]}", **_payload(body))
|
|
|
|
|
session.add(tool)
|
|
|
|
|
return await _commit(session, tool)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{tool_id}", response_model=ToolOut)
|
|
|
|
|
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, "工具不存在")
|
2026-08-14 00:15:00 +08:00
|
|
|
if tool.type == "system":
|
|
|
|
|
raise HTTPException(404, "工具不存在")
|
2026-07-18 00:00:06 +08:00
|
|
|
return tool_to_out(tool)
|
2026-07-10 10:05:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{tool_id}", response_model=ToolOut)
|
|
|
|
|
async def update_tool(
|
|
|
|
|
tool_id: str,
|
|
|
|
|
body: ToolUpsert,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
|
|
|
|
tool = await session.get(Tool, tool_id)
|
|
|
|
|
if not tool:
|
|
|
|
|
raise HTTPException(404, "工具不存在")
|
2026-08-14 00:15:00 +08:00
|
|
|
if tool.type == "system":
|
|
|
|
|
raise HTTPException(404, "工具不存在")
|
2026-07-18 00:00:06 +08:00
|
|
|
if tool.type == "mcp":
|
|
|
|
|
if body.definition.type != "mcp":
|
|
|
|
|
raise HTTPException(400, "同步生成的 MCP 工具不能修改类型")
|
|
|
|
|
if (
|
|
|
|
|
body.mcp_server_id != tool.mcp_server_id
|
|
|
|
|
or body.definition.config.remote_tool_name != tool.remote_tool_name
|
|
|
|
|
):
|
|
|
|
|
raise HTTPException(400, "MCP Server 和远端工具名称不能修改")
|
2026-07-10 10:05:41 +08:00
|
|
|
for key, value in _payload(body, tool.secrets or {}).items():
|
|
|
|
|
setattr(tool, key, value)
|
|
|
|
|
return await _commit(session, tool)
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 16:45:51 +08:00
|
|
|
@router.post("/{tool_id}/duplicate", response_model=ToolOut)
|
|
|
|
|
async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
|
|
|
|
|
source = await session.get(Tool, tool_id)
|
|
|
|
|
if not source:
|
|
|
|
|
raise HTTPException(404, "工具不存在")
|
2026-08-14 00:15:00 +08:00
|
|
|
if source.type == "system":
|
|
|
|
|
raise HTTPException(404, "工具不存在")
|
2026-07-18 00:00:06 +08:00
|
|
|
if source.type == "mcp":
|
|
|
|
|
raise HTTPException(400, "MCP 工具由 Server 同步维护,不能单独复制")
|
2026-07-10 16:45:51 +08:00
|
|
|
function_name = _duplicate_function_name(source.function_name)
|
|
|
|
|
existing = (
|
|
|
|
|
await session.execute(select(Tool).where(Tool.function_name == function_name))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if existing:
|
|
|
|
|
suffix = uuid.uuid4().hex[:6]
|
|
|
|
|
max_base = 64 - len(suffix) - 1
|
|
|
|
|
function_name = f"{source.function_name[:max_base]}_{suffix}"
|
|
|
|
|
tool = Tool(
|
|
|
|
|
id=f"tool_{uuid.uuid4().hex[:12]}",
|
|
|
|
|
name=f"{source.name} 副本",
|
|
|
|
|
function_name=function_name,
|
|
|
|
|
type=source.type,
|
2026-07-18 00:00:06 +08:00
|
|
|
mcp_server_id=source.mcp_server_id,
|
|
|
|
|
remote_tool_name=source.remote_tool_name,
|
2026-07-10 16:45:51 +08:00
|
|
|
description=source.description,
|
|
|
|
|
definition=dict(source.definition or {}),
|
|
|
|
|
secrets=dict(source.secrets or {}),
|
|
|
|
|
status=source.status,
|
|
|
|
|
)
|
|
|
|
|
session.add(tool)
|
|
|
|
|
return await _commit(session, tool)
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 10:05:41 +08:00
|
|
|
@router.delete("/{tool_id}")
|
|
|
|
|
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, "工具不存在")
|
2026-08-14 00:15:00 +08:00
|
|
|
if tool.type == "system":
|
|
|
|
|
raise HTTPException(404, "工具不存在")
|
2026-07-10 10:05:41 +08:00
|
|
|
in_use = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(AssistantToolBinding.assistant_id)
|
|
|
|
|
.where(AssistantToolBinding.tool_id == tool_id)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if in_use:
|
|
|
|
|
raise HTTPException(409, "工具正被助手引用,请先解绑")
|
|
|
|
|
await session.delete(tool)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return {"ok": True}
|