"""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}