Files
ai-video-fullstack/backend/routes/tools.py
T

259 lines
8.4 KiB
Python

"""Reusable tool CRUD. Runtime execution is implemented per supported tool type."""
import json
import re
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
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
router = APIRouter(
prefix="/api/tools",
tags=["tools"],
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,
"type": definition["type"],
"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
),
"description": body.description.strip(),
"definition": definition,
"secrets": secrets,
"status": body.status,
}
def _duplicate_function_name(source: str) -> str:
candidate = f"{source}_copy"
if len(candidate) <= 64:
return candidate
return f"{source[:57]}_copy"
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)
return tool_to_out(tool)
@router.get("", response_model=list[ToolOut])
async def list_tools(session: AsyncSession = Depends(get_session)):
rows = (
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]
@router.post("", response_model=ToolOut)
async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)):
if body.definition.type == "mcp":
raise HTTPException(400, "MCP 工具请通过 MCP Server 同步创建")
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, "工具不存在")
if tool.type == "system":
raise HTTPException(404, "工具不存在")
return tool_to_out(tool)
@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, "工具不存在")
if tool.type == "system":
raise HTTPException(404, "工具不存在")
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 和远端工具名称不能修改")
for key, value in _payload(body, tool.secrets or {}).items():
setattr(tool, key, value)
return await _commit(session, tool)
@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, "工具不存在")
if source.type == "system":
raise HTTPException(404, "工具不存在")
if source.type == "mcp":
raise HTTPException(400, "MCP 工具由 Server 同步维护,不能单独复制")
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,
mcp_server_id=source.mcp_server_id,
remote_tool_name=source.remote_tool_name,
description=source.description,
definition=dict(source.definition or {}),
secrets=dict(source.secrets or {}),
status=source.status,
)
session.add(tool)
return await _commit(session, tool)
@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, "工具不存在")
if tool.type == "system":
raise HTTPException(404, "工具不存在")
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}