Custom HTTP tools an administrator defines
A row in custom_tools becomes a ToolDef like any built-in, offered beside the thirteen. The registry had to stop being an import-time constant for that: `resolve_tools` now returns the schemas *and* the runners together, carried to the loop on the ToolContext. That closes a hole on the way. `run_tool` looked names up in the global REGISTRY with no reference to what had been offered, so a model naming a tool its chat was gated out of -- a family switched off, a permission the reader lacks -- had it run anyway. The resolved set is now authoritative. Arguments come from a model, so an argument may fill a hole but never move the target: the scheme and host of a URL template are literal, values are escaped for where they land, and the origin is pinned afterwards. Every redirect hop is checked the way services/fetch.py checks one, and the secret is dropped if a hop leaves the origin it was issued for. Also fixes the tool-activity block claiming every library tool had "searched the web", which it has done since the second family landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,15 +24,23 @@ router = APIRouter(tags=["admin-models"])
|
||||
# these are an administrator's assertion.
|
||||
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
|
||||
|
||||
# Which built-in tools this model is given. Distinct from the above: `tools` is
|
||||
# whether a tools array may be sent at all, these are what goes in it. Every one
|
||||
# of them is meaningless unless `tools` is on.
|
||||
# Which tools this model is given. Distinct from the above: `tools` is whether a
|
||||
# tools array may be sent at all, these are what goes in it. Every one of them is
|
||||
# meaningless unless `tools` is on.
|
||||
#
|
||||
# The last two are gates rather than single tools: one covers every custom HTTP
|
||||
# tool an administrator has defined, the other every MCP server. Which of those
|
||||
# a particular person gets is the tool's own group list, not a flag here -- a
|
||||
# server can advertise forty tools, and a model page listing all of them is a
|
||||
# page nobody can read.
|
||||
TOOL_CAPABILITIES = (
|
||||
("tool_web_search", "Web search"),
|
||||
("tool_knowledge", "Knowledge"),
|
||||
("tool_notes", "Notes"),
|
||||
("tool_memory", "Memory"),
|
||||
("tool_skills", "Skills"),
|
||||
("tool_custom", "Custom tools"),
|
||||
("tool_mcp", "MCP servers"),
|
||||
)
|
||||
|
||||
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
|
||||
|
||||
@@ -29,15 +29,20 @@ router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
|
||||
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
|
||||
|
||||
|
||||
def _families_of(names: list[str]) -> list[str]:
|
||||
"""Keep only real family names, in the registry's order."""
|
||||
def _families_of(db: Db, names: list[str]) -> list[str]:
|
||||
"""Keep only real family names, in the registry's order.
|
||||
|
||||
Read from the database rather than the constant: a family can belong to an
|
||||
administrator-defined tool, and one the preview cannot name is one whose
|
||||
guidance cannot be checked here.
|
||||
"""
|
||||
wanted = set(names)
|
||||
return [family for family in tools_service.FAMILIES if family in wanted]
|
||||
return [family for family in tools_service.families(db) if family in wanted]
|
||||
|
||||
|
||||
def _tool_names(families: list[str]) -> str:
|
||||
def _tool_names(db: Db, families: list[str]) -> str:
|
||||
return ", ".join(
|
||||
name for name, tool in tools_service.REGISTRY.items() if tool.family in families
|
||||
name for name, tool in tools_service.registry(db).items() if tool.family in families
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +72,7 @@ def _variables(
|
||||
values.update(
|
||||
{
|
||||
"model_name": model_name,
|
||||
"tool_names": _tool_names(families),
|
||||
"tool_names": _tool_names(db, families),
|
||||
"memories": memories_service.block(db, user) if "memory" in families else "",
|
||||
"skills": skills_service.index_block(db, user) if "skills" in families else "",
|
||||
"knowledge_bases": bases if "knowledge" in families else "",
|
||||
@@ -89,7 +94,7 @@ def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
|
||||
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
stored = prompts_service.stored(db)
|
||||
models = chat_service.available_models(db, user)
|
||||
families = list(tools_service.FAMILIES)
|
||||
families = list(tools_service.families(db))
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -115,7 +120,7 @@ async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool =
|
||||
"models": models,
|
||||
"families": families,
|
||||
"registry": sorted(
|
||||
tools_service.REGISTRY.values(), key=lambda t: (t.family, t.name)
|
||||
tools_service.registry(db).values(), key=lambda t: (t.family, t.name)
|
||||
),
|
||||
"max_harness_chars": settings_store.get(
|
||||
db, "max_harness_chars", key=settings_store.PROMPTS
|
||||
@@ -164,7 +169,7 @@ async def preview(request: Request, db: Db, user: AdminUser):
|
||||
"""
|
||||
form = await request.form()
|
||||
overrides = _submitted(db, form)
|
||||
families = _families_of([str(value) for value in form.getlist("preview_family")])
|
||||
families = _families_of(db, [str(value) for value in form.getlist("preview_family")])
|
||||
model_name = str(form.get("preview_model") or "")
|
||||
bases = str(form.get("preview_bases") or "").strip()
|
||||
documents = str(form.get("preview_documents") or "").strip()
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Administration for the tools an administrator defines.
|
||||
|
||||
List-plus-detail, like `/admin/models` and for the same reason: a tool has
|
||||
fifteen fields and a page that renders fifteen fields per row is unusable. The
|
||||
list is compact and searchable; the whole form lives at `/admin/tools/{id}/edit`.
|
||||
|
||||
Validation reports back into the form rather than raising a 422. The fields here
|
||||
are a JSON schema, a URL template and a secret; getting one wrong is normal, and
|
||||
losing the other fourteen because of it is not acceptable. So a rejected save
|
||||
re-renders the form from what was submitted, with the reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_MODES,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_NONE,
|
||||
SECRET_PLACEMENTS,
|
||||
CustomTool,
|
||||
Group,
|
||||
)
|
||||
from lembas.services import custom_tools
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["admin-tools"])
|
||||
|
||||
PAGE_SIZE = 40
|
||||
|
||||
# The slug is the function name sent to the endpoint, so it is bound by the
|
||||
# charset those accept, and it is half of this tool's prompt-fragment key, so it
|
||||
# is bound by that pattern too. The intersection is this.
|
||||
SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
|
||||
|
||||
FILTERS: dict[str, tuple[str, object]] = {
|
||||
"all": ("All", lambda t: True),
|
||||
"enabled": ("Enabled", lambda t: t.enabled),
|
||||
"disabled": ("Disabled", lambda t: not t.enabled),
|
||||
"restricted": ("Restricted", lambda t: not t.public),
|
||||
}
|
||||
|
||||
RESPONSE_LABELS = (
|
||||
(RESPONSE_TEXT, "Text — HTML reduced to prose"),
|
||||
(RESPONSE_JSON, "JSON — parsed, narrowed by the path below"),
|
||||
(RESPONSE_RAW, "Raw — exactly as it arrived"),
|
||||
)
|
||||
|
||||
SECRET_LABELS = (
|
||||
(SECRET_NONE, "None — this endpoint needs no credential"),
|
||||
("bearer", "Bearer token in a header"),
|
||||
("header", "The header named below, verbatim"),
|
||||
("query", "A query parameter named below"),
|
||||
)
|
||||
|
||||
|
||||
def _tool(db: Db, tool_id: str) -> CustomTool:
|
||||
tool = db.get(CustomTool, tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That tool no longer exists.")
|
||||
return tool
|
||||
|
||||
|
||||
def _ordered(db: Db) -> list[CustomTool]:
|
||||
return list(db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)))
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/admin/tools?saved={message}" if message else "/admin/tools"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- Form <-> row ------------------------------------------------------------
|
||||
def _headers_text(headers: dict) -> str:
|
||||
return "\n".join(f"{name}: {value}" for name, value in (headers or {}).items())
|
||||
|
||||
|
||||
def _parse_headers(text: str) -> dict[str, str]:
|
||||
"""One `Name: value` per line. Blank lines and lines with no colon are dropped."""
|
||||
out: dict[str, str] = {}
|
||||
for line in (text or "").splitlines():
|
||||
name, _, value = line.partition(":")
|
||||
if name.strip() and _:
|
||||
out[name.strip()] = value.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _number(raw: str, *, default: int, low: int, high: int) -> int:
|
||||
text = str(raw or "").strip()
|
||||
if not text.lstrip("-").isdigit():
|
||||
return default
|
||||
return min(max(int(text), low), high)
|
||||
|
||||
|
||||
def _populate(tool: CustomTool, form) -> None:
|
||||
"""Copy a submitted form onto a row (or a draft of one).
|
||||
|
||||
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
|
||||
`x`, and an absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
tool.name = str(form.get("name") or "").strip()[:120]
|
||||
tool.description = str(form.get("description") or "").strip()
|
||||
tool.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
|
||||
tool.method = str(form.get("method") or "GET").strip().upper()
|
||||
tool.url_template = str(form.get("url_template") or "").strip()[:1000]
|
||||
tool.body_template = str(form.get("body_template") or "").replace("\r\n", "\n")
|
||||
tool.headers_json = _parse_headers(str(form.get("headers") or ""))
|
||||
|
||||
placement = str(form.get("secret_placement") or SECRET_NONE)
|
||||
tool.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
|
||||
tool.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
|
||||
|
||||
mode = str(form.get("response_mode") or RESPONSE_TEXT)
|
||||
tool.response_mode = mode if mode in RESPONSE_MODES else RESPONSE_TEXT
|
||||
tool.response_path = str(form.get("response_path") or "").strip()[:300]
|
||||
|
||||
tool.max_chars = _number(
|
||||
form.get("max_chars"),
|
||||
default=8000,
|
||||
low=custom_tools.MIN_CHARS,
|
||||
high=custom_tools.MAX_CHARS,
|
||||
)
|
||||
tool.timeout = _number(
|
||||
form.get("timeout"),
|
||||
default=20,
|
||||
low=custom_tools.MIN_TIMEOUT,
|
||||
high=custom_tools.MAX_TIMEOUT,
|
||||
)
|
||||
tool.position = _number(form.get("position"), default=tool.position or 0, low=0, high=999)
|
||||
|
||||
tool.allow_private = "allow_private" in form
|
||||
tool.enabled = "enabled" in form
|
||||
tool.public = "public" in form
|
||||
|
||||
|
||||
def _problem(db: Db, tool: CustomTool, form, *, existing_id: str = "") -> str:
|
||||
"""Why this cannot be saved, or an empty string."""
|
||||
if not tool.name:
|
||||
return "A tool needs a name."
|
||||
|
||||
slug = str(form.get("slug") or "").strip().lower()
|
||||
if not SLUG_PATTERN.match(slug):
|
||||
return (
|
||||
"The identifier must be lowercase letters, digits, hyphens or "
|
||||
"underscores, start with a letter or digit, and be at most 48 "
|
||||
"characters. It is the name the model calls."
|
||||
)
|
||||
if slug in tools_service.REGISTRY:
|
||||
return f"“{slug}” is the name of a built-in tool. Choose another."
|
||||
clash = db.scalar(select(CustomTool).where(CustomTool.slug == slug))
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"There is already a tool called “{slug}”."
|
||||
tool.slug = slug
|
||||
|
||||
if tool.method not in custom_tools.ALLOWED_METHODS:
|
||||
return f"{tool.method} is not a method this can send."
|
||||
|
||||
raw = str(form.get("parameters") or "").strip() or '{"type": "object", "properties": {}}'
|
||||
try:
|
||||
parameters = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return f"The parameters are not valid JSON: {exc}"
|
||||
if not isinstance(parameters, dict) or parameters.get("type") != "object":
|
||||
return 'The parameters must be a JSON object whose "type" is "object".'
|
||||
tool.parameters_json = parameters
|
||||
|
||||
# The same check the runner makes, so a template that could never be called
|
||||
# is refused here rather than at the first call.
|
||||
try:
|
||||
custom_tools.fill_url(custom_tools.spec_from(tool), {})
|
||||
except Exception as exc: # noqa: BLE001 - any refusal is a message for the form
|
||||
return str(getattr(exc, "message", exc))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _detail(request: Request, db: Db, tool: CustomTool, *, is_new: bool, error: str = "", **extra):
|
||||
key = f"tool.custom_{tool.slug}" if tool.slug else ""
|
||||
return render(
|
||||
request,
|
||||
"admin/tool_detail.html",
|
||||
{
|
||||
"tool": tool,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"selected_groups": extra.pop(
|
||||
"selected_groups", {group.id for group in (tool.groups if tool.id else [])}
|
||||
),
|
||||
"headers_text": extra.pop("headers_text", _headers_text(tool.headers_json)),
|
||||
"parameters_text": extra.pop(
|
||||
"parameters_text", json.dumps(tool.parameters_json or {}, indent=2)
|
||||
),
|
||||
"masked": mask(decrypt(tool.secret_encrypted)) if tool.secret_encrypted else "",
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"methods": custom_tools.ALLOWED_METHODS,
|
||||
"response_modes": RESPONSE_LABELS,
|
||||
"secret_placements": SECRET_LABELS,
|
||||
"prompt_key": key,
|
||||
"prompt_overridden": key in prompts_service.stored(db),
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- The list ----------------------------------------------------------------
|
||||
@router.get("/admin/tools")
|
||||
async def tools_page(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
saved: str = "",
|
||||
q: str = "",
|
||||
filter: str = "all",
|
||||
page: int = 1,
|
||||
):
|
||||
everything = _ordered(db)
|
||||
predicate = FILTERS.get(filter, FILTERS["all"])[1]
|
||||
needle = q.strip().lower()
|
||||
matching = [
|
||||
tool
|
||||
for tool in everything
|
||||
if predicate(tool)
|
||||
and (not needle or needle in tool.slug.lower() or needle in (tool.name or "").lower())
|
||||
]
|
||||
|
||||
pages = max(1, -(-len(matching) // PAGE_SIZE))
|
||||
page = max(1, min(page, pages))
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/tools.html",
|
||||
{
|
||||
"tools": matching[start : start + PAGE_SIZE],
|
||||
"total": len(everything),
|
||||
"matched": len(matching),
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"page_start": start,
|
||||
"counts": {
|
||||
key: sum(1 for tool in everything if rule(tool))
|
||||
for key, (_label, rule) in FILTERS.items()
|
||||
},
|
||||
"filters": {key: label for key, (label, _rule) in FILTERS.items()},
|
||||
"active_filter": filter if filter in FILTERS else "all",
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before /{tool_id}: FastAPI matches in registration order, so with
|
||||
# the parameterised route first "new" is captured as an id and the handler 404s
|
||||
# on a tool that does not exist. This has already been a bug once, in
|
||||
# /admin/models.
|
||||
@router.get("/admin/tools/new")
|
||||
async def new_tool_page(request: Request, db: Db, user: AdminUser):
|
||||
draft = CustomTool(
|
||||
name="",
|
||||
slug="",
|
||||
method="GET",
|
||||
url_template="https://",
|
||||
parameters_json={"type": "object", "properties": {}, "required": []},
|
||||
secret_placement=SECRET_NONE,
|
||||
response_mode=RESPONSE_TEXT,
|
||||
max_chars=8000,
|
||||
timeout=20,
|
||||
enabled=True,
|
||||
public=True,
|
||||
position=0,
|
||||
)
|
||||
return _detail(request, db, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/admin/tools")
|
||||
async def create_tool(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
form = await request.form()
|
||||
draft = CustomTool(headers_json={}, parameters_json={})
|
||||
_populate(draft, form)
|
||||
draft.position = db.scalar(select(func.coalesce(func.max(CustomTool.position), -1))) + 1
|
||||
|
||||
problem = _problem(db, draft, form)
|
||||
if problem:
|
||||
return _detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=True,
|
||||
error=problem,
|
||||
headers_text=str(form.get("headers") or ""),
|
||||
parameters_text=str(form.get("parameters") or ""),
|
||||
selected_groups=set(form.getlist("group_ids")),
|
||||
)
|
||||
|
||||
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
|
||||
draft.groups = _chosen_groups(db, form, public=draft.public)
|
||||
db.add(draft)
|
||||
db.commit()
|
||||
log.info("%s added custom tool %s", user.email, draft.slug)
|
||||
return _back(f"Added {draft.name}.")
|
||||
|
||||
|
||||
def _chosen_groups(db: Db, form, *, public: bool) -> list[Group]:
|
||||
"""A public tool holds no groups, the way a public model holds none."""
|
||||
if public:
|
||||
return []
|
||||
ids = set(form.getlist("group_ids"))
|
||||
return list(db.scalars(select(Group).where(Group.id.in_(ids)))) if ids else []
|
||||
|
||||
|
||||
@router.get("/admin/tools/{tool_id}/edit")
|
||||
async def edit_tool_page(request: Request, db: Db, user: AdminUser, tool_id: str):
|
||||
return _detail(request, db, _tool(db, tool_id), is_new=False)
|
||||
|
||||
|
||||
@router.post("/admin/tools/{tool_id}/test")
|
||||
async def test_tool(request: Request, db: Db, user: AdminUser, tool_id: str):
|
||||
"""Call the stored row once, with arguments the administrator typed.
|
||||
|
||||
The stored row rather than the submitted form, so what is tested is what a
|
||||
chat would actually do -- the same reason `/admin/search/test` reads the
|
||||
saved provider settings.
|
||||
"""
|
||||
tool = _tool(db, tool_id)
|
||||
form = await request.form()
|
||||
raw = str(form.get("arguments") or "").strip() or "{}"
|
||||
|
||||
try:
|
||||
arguments = json.loads(raw)
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError("Arguments must be a JSON object.")
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
return render(
|
||||
request,
|
||||
"admin/_tool_test.html",
|
||||
{"tool": tool, "error": f"Those arguments are not a JSON object: {exc}"},
|
||||
)
|
||||
|
||||
outcome = await custom_tools.call(custom_tools.spec_from(tool), arguments)
|
||||
tool.last_error = str(outcome.event.get("error") or "")
|
||||
tool.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/_tool_test.html",
|
||||
{
|
||||
"tool": tool,
|
||||
"outcome": outcome,
|
||||
"error": outcome.event.get("error") or "",
|
||||
"detail": outcome.event.get("detail") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/tools/{tool_id}/delete")
|
||||
async def delete_tool(db: Db, user: AdminUser, tool_id: str) -> Response:
|
||||
tool = _tool(db, tool_id)
|
||||
name = tool.name
|
||||
db.delete(tool)
|
||||
db.commit()
|
||||
log.info("%s deleted custom tool %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/admin/tools/{tool_id}")
|
||||
async def update_tool(request: Request, db: Db, user: AdminUser, tool_id: str) -> Response:
|
||||
tool = _tool(db, tool_id)
|
||||
form = await request.form()
|
||||
|
||||
# Validated against a draft so that a rejected save leaves the stored row
|
||||
# untouched and the form still holds what was typed.
|
||||
draft = CustomTool(headers_json={}, parameters_json={}, position=tool.position)
|
||||
_populate(draft, form)
|
||||
problem = _problem(db, draft, form, existing_id=tool.id)
|
||||
if problem:
|
||||
draft.id = tool.id
|
||||
draft.secret_encrypted = tool.secret_encrypted
|
||||
return _detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=False,
|
||||
error=problem,
|
||||
headers_text=str(form.get("headers") or ""),
|
||||
parameters_text=str(form.get("parameters") or ""),
|
||||
selected_groups=set(form.getlist("group_ids")),
|
||||
)
|
||||
|
||||
_populate(tool, form)
|
||||
tool.slug = draft.slug
|
||||
tool.parameters_json = draft.parameters_json
|
||||
tool.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), tool.secret_encrypted)
|
||||
tool.groups = _chosen_groups(db, form, public=tool.public)
|
||||
db.commit()
|
||||
|
||||
log.info("%s updated custom tool %s", user.email, tool.slug)
|
||||
return _back(f"Saved {tool.name}.")
|
||||
Reference in New Issue
Block a user