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:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lembas"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -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}.")
|
||||
@@ -42,6 +42,21 @@ from lembas.db.models.library import (
|
||||
)
|
||||
from lembas.db.models.setting import Setting
|
||||
from lembas.db.models.suggestion import Suggestion
|
||||
from lembas.db.models.tool import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_MODES,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_BEARER,
|
||||
SECRET_HEADER,
|
||||
SECRET_NONE,
|
||||
SECRET_PLACEMENTS,
|
||||
SECRET_QUERY,
|
||||
CustomTool,
|
||||
McpServer,
|
||||
custom_tool_groups,
|
||||
mcp_server_groups,
|
||||
)
|
||||
from lembas.db.models.user import (
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
@@ -63,20 +78,31 @@ __all__ = [
|
||||
"RESOURCE_BASE",
|
||||
"RESOURCE_NOTE",
|
||||
"RESOURCE_SKILL",
|
||||
"RESPONSE_JSON",
|
||||
"RESPONSE_MODES",
|
||||
"RESPONSE_RAW",
|
||||
"RESPONSE_TEXT",
|
||||
"ROLE_ADMIN",
|
||||
"ROLE_ASSISTANT",
|
||||
"ROLE_PENDING",
|
||||
"ROLE_SYSTEM",
|
||||
"ROLE_TOOL",
|
||||
"ROLE_USER",
|
||||
"SECRET_BEARER",
|
||||
"SECRET_HEADER",
|
||||
"SECRET_NONE",
|
||||
"SECRET_PLACEMENTS",
|
||||
"SECRET_QUERY",
|
||||
"SOURCE_LINK",
|
||||
"SOURCE_UPLOAD",
|
||||
"Chat",
|
||||
"Connection",
|
||||
"CustomTool",
|
||||
"Document",
|
||||
"Folder",
|
||||
"Group",
|
||||
"KnowledgeBase",
|
||||
"McpServer",
|
||||
"Memory",
|
||||
"Message",
|
||||
"Model",
|
||||
@@ -89,6 +115,8 @@ __all__ = [
|
||||
"Suggestion",
|
||||
"User",
|
||||
"chat_knowledge_bases",
|
||||
"custom_tool_groups",
|
||||
"mcp_server_groups",
|
||||
"model_groups",
|
||||
"user_groups",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tools an administrator defined: HTTP endpoints and remote MCP servers.
|
||||
|
||||
Both are instance configuration rather than someone's content, so access is
|
||||
shaped like `Model` and not like a note: a row is either public or reachable
|
||||
through the groups it names, resolved the way `permissions.models_visible_to`
|
||||
resolves a model. There is deliberately no per-user tool. A tool is a credential
|
||||
pointed at a third party, and "anyone may define one" is a different feature
|
||||
with a different threat model.
|
||||
|
||||
The two tables are near-twins on purpose -- name, slug, secret, group list,
|
||||
last check -- because an administrator adding one should not have to learn a
|
||||
second screen. What differs is what sits between the row and the model: a
|
||||
custom tool *is* one call, described here in full, while an MCP server is a
|
||||
conversation whose tools are discovered and cached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict, JSONList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
||||
from lembas.db.models.user import Group
|
||||
|
||||
# How a row's secret is attached to a request. Stored values, so these are
|
||||
# schema rather than presentation.
|
||||
SECRET_NONE = "none"
|
||||
SECRET_BEARER = "bearer"
|
||||
SECRET_HEADER = "header"
|
||||
SECRET_QUERY = "query"
|
||||
|
||||
SECRET_PLACEMENTS = (SECRET_NONE, SECRET_BEARER, SECRET_HEADER, SECRET_QUERY)
|
||||
|
||||
# How a response becomes text for the model.
|
||||
RESPONSE_TEXT = "text" # prose; HTML reduced by fetch.html_to_text
|
||||
RESPONSE_JSON = "json" # parsed, narrowed by response_path, pretty-printed
|
||||
RESPONSE_RAW = "raw" # verbatim, truncated -- CSV, plain logs
|
||||
|
||||
RESPONSE_MODES = (RESPONSE_TEXT, RESPONSE_JSON, RESPONSE_RAW)
|
||||
|
||||
custom_tool_groups = Table(
|
||||
"custom_tool_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"tool_id", String(32), ForeignKey("custom_tools.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
mcp_server_groups = Table(
|
||||
"mcp_server_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"server_id", String(32), ForeignKey("mcp_servers.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class CustomTool(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One HTTP call, described well enough for a model to decide to make it."""
|
||||
|
||||
__tablename__ = "custom_tools"
|
||||
|
||||
# `slug` IS the function name sent to the endpoint, so it is bound by the
|
||||
# charset those accept and is fixed once the row exists: it is also half of
|
||||
# this tool's prompt-fragment key. `name` is the human label, shown in the
|
||||
# admin list and in the transcript.
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
# Sent verbatim in the tools array. The only thing the model has to decide
|
||||
# with, which is why the form insists on it.
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
parameters_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# The *default* text of this tool's harness fragment. An administrator's
|
||||
# edit on /admin/prompts is an override stored in the settings group like
|
||||
# any other, so a tool deleted and recreated under the same slug keeps the
|
||||
# wording somebody chose for it.
|
||||
guidance: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
method: Mapped[str] = mapped_column(String(8), default="GET", nullable=False)
|
||||
# {{name}} placeholders, filled from the call's arguments. The scheme and
|
||||
# the host must be literal -- see services/custom_tools.py for why.
|
||||
url_template: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
body_template: Mapped[str] = mapped_column(Text, default="")
|
||||
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
secret_placement: Mapped[str] = mapped_column(
|
||||
String(16), default=SECRET_BEARER, nullable=False
|
||||
)
|
||||
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
|
||||
|
||||
response_mode: Mapped[str] = mapped_column(String(16), default=RESPONSE_TEXT, nullable=False)
|
||||
# A dotted path into a JSON response: "data.items.0.title". Empty is the
|
||||
# whole document. Not JSONPath -- that is a dependency and a syntax nobody
|
||||
# would remember for the one field they want.
|
||||
response_path: Mapped[str] = mapped_column(String(300), default="")
|
||||
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
|
||||
timeout: Mapped[int] = mapped_column(Integer, default=20, nullable=False)
|
||||
|
||||
# Whether this row may reach loopback, private or link-local addresses. Per
|
||||
# row rather than the instance-wide search setting: an administrator naming
|
||||
# http://127.0.0.1:11434 by hand is not the same act as a model handing the
|
||||
# fetcher a URL it read on a page.
|
||||
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=custom_tool_groups, back_populates="custom_tools"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CustomTool {self.slug}>"
|
||||
|
||||
|
||||
class McpServer(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A remote MCP server, reached over streamable HTTP.
|
||||
|
||||
The tools it advertises are cached in `tools_json` rather than given a table
|
||||
of their own. A discovered tool carries exactly one administrator decision
|
||||
-- offered or not, which `tool_overrides_json` holds -- while credentials,
|
||||
guidance and access are all per server; and the whole list is replaced on
|
||||
every refresh, so a table would mean reconciling rows against a cache of
|
||||
somebody else's document.
|
||||
"""
|
||||
|
||||
__tablename__ = "mcp_servers"
|
||||
|
||||
# Prefixed onto every tool name this server advertises, so that two servers
|
||||
# both exposing "search" do not collide and neither shadows a built-in.
|
||||
slug: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
|
||||
guidance: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
secret_placement: Mapped[str] = mapped_column(
|
||||
String(16), default=SECRET_BEARER, nullable=False
|
||||
)
|
||||
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
|
||||
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
timeout: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
|
||||
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# The last tools/list, cached. One entry per tool:
|
||||
# {"name", "offer_name", "description", "schema"}.
|
||||
tools_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
|
||||
# Per-tool switch, keyed by the server's own name for it. Absent means on,
|
||||
# the same rule the model capability flags follow, so a newly advertised
|
||||
# tool works rather than silently doing nothing.
|
||||
tool_overrides_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
# What the server answered at initialize, for the admin list.
|
||||
protocol_version: Mapped[str] = mapped_column(String(32), default="")
|
||||
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=mcp_server_groups, back_populates="mcp_servers"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<McpServer {self.slug}>"
|
||||
@@ -14,6 +14,7 @@ from lembas.db.types import JSONDict
|
||||
if TYPE_CHECKING:
|
||||
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
||||
from lembas.db.models.connection import Model
|
||||
from lembas.db.models.tool import CustomTool, McpServer
|
||||
|
||||
# Roles are a simple ordered ladder rather than a permission matrix. Groups
|
||||
# (below) carry finer-grained permissions once the users/groups UI lands.
|
||||
@@ -72,6 +73,12 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
|
||||
models: Mapped[list[Model]] = relationship(
|
||||
"Model", secondary="model_groups", back_populates="groups"
|
||||
)
|
||||
custom_tools: Mapped[list[CustomTool]] = relationship(
|
||||
"CustomTool", secondary="custom_tool_groups", back_populates="groups"
|
||||
)
|
||||
mcp_servers: Mapped[list[McpServer]] = relationship(
|
||||
"McpServer", secondary="mcp_server_groups", back_populates="groups"
|
||||
)
|
||||
|
||||
|
||||
class Session(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
@@ -19,6 +19,7 @@ from lembas.api import (
|
||||
admin_prompts,
|
||||
admin_search,
|
||||
admin_suggestions,
|
||||
admin_tools,
|
||||
admin_users,
|
||||
audio,
|
||||
auth,
|
||||
@@ -120,6 +121,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(admin_search.router)
|
||||
app.include_router(admin_prompts.router)
|
||||
app.include_router(admin_suggestions.router)
|
||||
app.include_router(admin_tools.router)
|
||||
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
@@ -86,6 +86,22 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.custom",
|
||||
"Use custom tools",
|
||||
"Let a model call the HTTP tools an administrator has defined. Which "
|
||||
"ones depends on the groups each tool is restricted to.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.mcp",
|
||||
"Use MCP servers",
|
||||
"Let a model call tools from the MCP servers an administrator has "
|
||||
"added. Which ones depends on the groups each server is restricted to.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"audio.transcribe",
|
||||
"Dictate messages",
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Running the HTTP tools an administrator defined.
|
||||
|
||||
A row in `custom_tools` becomes a `ToolDef` like any built-in: same schema in
|
||||
the same array, same `ToolOutcome` back. What is different is that the arguments
|
||||
come from a model and the destination comes from a template, so two things have
|
||||
to hold.
|
||||
|
||||
**An argument may fill a hole; it may not move the target.** The scheme and host
|
||||
of the template are literal, checked when the row is saved and again here in
|
||||
case a row predates the check, and every value is escaped for the position it
|
||||
lands in -- percent-encoded with nothing safe in a URL, JSON-escaped in a body,
|
||||
stripped of line breaks in a header. `quote(value, safe="")` is what stops a
|
||||
value adding a path segment, a query parameter or a fragment; pinning the origin
|
||||
afterwards is what catches anything that got past it.
|
||||
|
||||
**Every hop is checked.** This is the same request-forgery problem
|
||||
`services/fetch.py` exists to solve, and the same answer: resolve and check the
|
||||
address, follow redirects by hand, refuse private ranges unless this particular
|
||||
row was allowed them. `fetch.fetch` itself cannot be reused -- it is GET-only,
|
||||
has no body, and refuses any content type that is not HTML or text, which is
|
||||
every JSON API there is -- so its redirect loop is deliberately copied rather
|
||||
than the module bent into a general HTTP client.
|
||||
|
||||
The secret is decrypted into the snapshot and goes nowhere else: not into the
|
||||
event, not into a log line, and not across a redirect that leaves the origin it
|
||||
was issued for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_BEARER,
|
||||
SECRET_HEADER,
|
||||
SECRET_QUERY,
|
||||
CustomTool,
|
||||
User,
|
||||
)
|
||||
from lembas.services import fetch as fetch_service
|
||||
from lembas.services import tool_access
|
||||
from lembas.services.crypto import decrypt
|
||||
from lembas.services.prompts import VARIABLE_PATTERN
|
||||
from lembas.services.tools import ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What a response may weigh before it is cut off. Well below the page fetcher's
|
||||
# ceiling, because this is text that will be sent back to a model rather than
|
||||
# stored for a person to read.
|
||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# How much of the response is kept on the message row for the transcript. Capped
|
||||
# separately from `max_chars`: what the model reads is spent once, what the event
|
||||
# holds is stored on every message forever.
|
||||
MAX_EVENT_CHARS = 2000
|
||||
|
||||
# How much of the arguments the transcript summarises.
|
||||
MAX_SUMMARY_CHARS = 200
|
||||
|
||||
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
||||
|
||||
# Bounds an administrator's number is clamped into. A tool that may return
|
||||
# 400 000 characters is a tool that can fill the context window in one call.
|
||||
MIN_CHARS, MAX_CHARS = 200, 40_000
|
||||
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpSpec:
|
||||
"""Everything one custom tool needs, read while the session was open.
|
||||
|
||||
A frozen snapshot rather than the row, for the reason `Endpoint` is one: a
|
||||
generation outlives the request that started it, and a detached SQLAlchemy
|
||||
instance is a trap. The decrypted secret lives here and nowhere else.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
label: str
|
||||
method: str
|
||||
url_template: str
|
||||
body_template: str = ""
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
secret: str = ""
|
||||
secret_placement: str = SECRET_BEARER
|
||||
secret_name: str = "Authorization"
|
||||
response_mode: str = RESPONSE_TEXT
|
||||
response_path: str = ""
|
||||
max_chars: int = 8000
|
||||
timeout: int = 20
|
||||
allow_private: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def secret_header(self) -> str:
|
||||
"""The header the secret rides in, if it rides in one."""
|
||||
if not self.secret or self.secret_placement not in (SECRET_BEARER, SECRET_HEADER):
|
||||
return ""
|
||||
return self.secret_name or "Authorization"
|
||||
|
||||
|
||||
def spec_from(row: CustomTool) -> HttpSpec:
|
||||
"""Snapshot a row, decrypting its secret. Call this with a session open."""
|
||||
return HttpSpec(
|
||||
slug=row.slug,
|
||||
label=row.name or row.slug,
|
||||
method=(row.method or "GET").upper(),
|
||||
url_template=row.url_template or "",
|
||||
body_template=row.body_template or "",
|
||||
headers=dict(row.headers_json or {}),
|
||||
secret=decrypt(row.secret_encrypted),
|
||||
secret_placement=row.secret_placement,
|
||||
secret_name=row.secret_name or "Authorization",
|
||||
response_mode=row.response_mode,
|
||||
response_path=row.response_path or "",
|
||||
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
|
||||
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
|
||||
allow_private=bool(row.allow_private),
|
||||
parameters=dict(row.parameters_json or {}),
|
||||
)
|
||||
|
||||
|
||||
def tool_defs(
|
||||
db: DBSession, user: User | None, *, everything: bool = False
|
||||
) -> list[ToolDef]:
|
||||
"""One `ToolDef` per custom tool this user may be offered."""
|
||||
return [
|
||||
ToolDef(
|
||||
name=row.slug,
|
||||
family=f"custom:{row.slug}",
|
||||
description=row.description or f"Call the {row.name} tool.",
|
||||
parameters=_schema_of(row),
|
||||
run=_runner(spec_from(row)),
|
||||
)
|
||||
for row in tool_access.visible_custom_tools(db, user, everything=everything)
|
||||
]
|
||||
|
||||
|
||||
def _schema_of(row: CustomTool) -> dict[str, Any]:
|
||||
schema = dict(row.parameters_json or {})
|
||||
if schema.get("type") != "object":
|
||||
# An endpoint expects an object here; anything else it will reject
|
||||
# outright, which fails the whole request rather than the one tool.
|
||||
return {"type": "object", "properties": {}}
|
||||
return schema
|
||||
|
||||
|
||||
def _runner(spec: HttpSpec):
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
return await call(spec, args)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
# --- Filling the template ----------------------------------------------------
|
||||
def _scalar(value: Any) -> str:
|
||||
"""One argument as text, before it is escaped for wherever it is going."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, int | float):
|
||||
return str(value)
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _for_url(value: str) -> str:
|
||||
# safe="" is the whole point: an argument must not be able to introduce a
|
||||
# path segment, a query separator or a fragment.
|
||||
return quote(value, safe="")
|
||||
|
||||
|
||||
def _for_body(value: str) -> str:
|
||||
# The inside of a JSON string, so a quote or a backslash in an argument
|
||||
# cannot end it early and add a field of its own.
|
||||
return json.dumps(value, ensure_ascii=False)[1:-1]
|
||||
|
||||
|
||||
def _for_header(value: str) -> str:
|
||||
# A newline in a header value is header injection. Other control characters
|
||||
# go with it; none of them mean anything in a header.
|
||||
return "".join(character for character in value if character.isprintable())
|
||||
|
||||
|
||||
def _substitute(template: str, spec: HttpSpec, args: dict[str, Any], escape) -> str:
|
||||
"""Fill `{{name}}` from the call's arguments.
|
||||
|
||||
Not `prompts.substitute`, though the grammar is shared. The rules differ,
|
||||
and the differences are the point: a name the tool does not declare never
|
||||
substitutes, an unrecognised one becomes nothing rather than passing through
|
||||
verbatim -- a literal `{{x}}` in a URL is not a feature -- and every value
|
||||
is escaped for where it lands.
|
||||
"""
|
||||
declared = set(spec.parameters.get("properties") or {})
|
||||
|
||||
def swap(match) -> str:
|
||||
name = match.group(1)
|
||||
if name not in declared:
|
||||
return ""
|
||||
return escape(_scalar(args.get(name)))
|
||||
|
||||
return VARIABLE_PATTERN.sub(swap, template)
|
||||
|
||||
|
||||
def _origin(url: str) -> tuple[str, str]:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise fetch_service.FetchError("A tool's URL must start with http:// or https://")
|
||||
if not parsed.netloc:
|
||||
raise fetch_service.FetchError("A tool's URL has no host.")
|
||||
return parsed.scheme, parsed.netloc
|
||||
|
||||
|
||||
def fill_url(spec: HttpSpec, args: dict[str, Any]) -> str:
|
||||
"""Fill the URL template, refusing anything that moved the host.
|
||||
|
||||
Checked twice over: the template's own scheme and authority must be literal,
|
||||
and the filled URL must still point at them. The first check is what stops
|
||||
`https://{{host}}/x` from ever being saved; the second is what catches a row
|
||||
that predates it, or an escaping mistake.
|
||||
"""
|
||||
template = spec.url_template.strip()
|
||||
scheme, netloc = _origin(template)
|
||||
if VARIABLE_PATTERN.search(f"{scheme}://{netloc}"):
|
||||
raise fetch_service.FetchError(
|
||||
"A tool's scheme and host must be literal, not filled from an argument."
|
||||
)
|
||||
|
||||
filled = _substitute(template, spec, args, _for_url)
|
||||
if _origin(filled) != (scheme, netloc):
|
||||
raise fetch_service.FetchError("That call would have pointed somewhere else.")
|
||||
return filled
|
||||
|
||||
|
||||
def _prepare(spec: HttpSpec, args: dict[str, Any]) -> tuple[str, dict[str, str], bytes | None]:
|
||||
"""The URL, headers and body for one call, secret included."""
|
||||
url = fill_url(spec, args)
|
||||
headers = {
|
||||
"User-Agent": fetch_service.USER_AGENT,
|
||||
"Accept": "application/json, text/*;q=0.9, */*;q=0.5",
|
||||
}
|
||||
for name, value in spec.headers.items():
|
||||
clean = _for_header(str(name)).strip()
|
||||
if clean:
|
||||
headers[clean] = _substitute(str(value), spec, args, _for_header)
|
||||
|
||||
body: bytes | None = None
|
||||
if spec.body_template.strip() and spec.method != "GET":
|
||||
body = _substitute(spec.body_template, spec, args, _for_body).encode("utf-8")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
if spec.secret:
|
||||
if spec.secret_placement == SECRET_BEARER:
|
||||
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
|
||||
elif spec.secret_placement == SECRET_HEADER:
|
||||
headers[spec.secret_name or "Authorization"] = spec.secret
|
||||
elif spec.secret_placement == SECRET_QUERY:
|
||||
# Only on the URL this call starts at. A redirect's Location
|
||||
# replaces the query, so the credential does not travel on by
|
||||
# itself -- which is the behaviour wanted anyway.
|
||||
joiner = "&" if urlparse(url).query else "?"
|
||||
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
|
||||
|
||||
return url, headers, body
|
||||
|
||||
|
||||
# --- Reading the response ----------------------------------------------------
|
||||
def _narrow(payload: Any, path: str) -> Any:
|
||||
"""Walk a dotted path into a decoded JSON document.
|
||||
|
||||
Integer segments index a list, so "data.0.title" works. A path that does not
|
||||
lead anywhere yields the whole document rather than nothing: an unhelpful
|
||||
answer beats a silent empty one when the model has to explain itself.
|
||||
"""
|
||||
current = payload
|
||||
for segment in [part for part in path.split(".") if part]:
|
||||
if isinstance(current, dict) and segment in current:
|
||||
current = current[segment]
|
||||
elif isinstance(current, list) and segment.lstrip("-").isdigit():
|
||||
try:
|
||||
current = current[int(segment)]
|
||||
except IndexError:
|
||||
return payload
|
||||
else:
|
||||
return payload
|
||||
return current
|
||||
|
||||
|
||||
def _decode(payload: bytes, response: httpx.Response) -> str:
|
||||
return payload.decode(response.encoding or "utf-8", "replace")
|
||||
|
||||
|
||||
def _as_text(spec: HttpSpec, payload: bytes, response: httpx.Response) -> str:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
if spec.response_mode == RESPONSE_JSON:
|
||||
try:
|
||||
document = json.loads(_decode(payload, response))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Falling back rather than failing: a JSON API answering with an
|
||||
# HTML error page is a thing the model can report usefully.
|
||||
return _decode(payload, response)
|
||||
value = _narrow(document, spec.response_path)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, indent=2, ensure_ascii=False)
|
||||
|
||||
if spec.response_mode == RESPONSE_RAW:
|
||||
return _decode(payload, response)
|
||||
|
||||
text = _decode(payload, response)
|
||||
if "html" in content_type or text.lstrip()[:1] == "<":
|
||||
_, text = fetch_service.html_to_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def _clip(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "\n… (truncated)"
|
||||
|
||||
|
||||
def _summary(args: dict[str, Any]) -> str:
|
||||
"""What the transcript shows the tool was asked for."""
|
||||
parts = [f"{name}={_scalar(value)!r}" for name, value in args.items()]
|
||||
return _clip(", ".join(parts), MAX_SUMMARY_CHARS)
|
||||
|
||||
|
||||
def _event(spec: HttpSpec, args: dict[str, Any], *, status: str, **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"name": spec.slug,
|
||||
"kind": "custom",
|
||||
"label": spec.label,
|
||||
"query": _summary(args),
|
||||
# The host, never the filled URL: a path or query segment can carry an
|
||||
# argument, and the event is rendered and stored.
|
||||
"detail": f"{spec.method} {urlparse(spec.url_template).netloc}",
|
||||
"status": status,
|
||||
"results": [],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# --- Making the call ---------------------------------------------------------
|
||||
async def call(spec: HttpSpec, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Run one custom tool. Reports its own failures rather than raising."""
|
||||
try:
|
||||
url, headers, body = _prepare(spec, args)
|
||||
current = fetch_service.check_url(url, allow_private=spec.allow_private)
|
||||
origin = _origin(current)
|
||||
response = await _send(spec, current, headers, body, origin)
|
||||
except fetch_service.FetchError as exc:
|
||||
return ToolOutcome(
|
||||
f"The {spec.label} tool could not be called: {exc.message}",
|
||||
_event(spec, args, status="error", error=exc.message),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
message = f"Could not reach the {spec.label} tool: {exc}"
|
||||
return ToolOutcome(message, _event(spec, args, status="error", error=str(exc)[:200]))
|
||||
|
||||
payload = response.content[:MAX_RESPONSE_BYTES]
|
||||
text = _clip(_as_text(spec, payload, response).strip(), spec.max_chars)
|
||||
|
||||
if response.status_code >= 400:
|
||||
note = f"{spec.label} returned HTTP {response.status_code}."
|
||||
return ToolOutcome(
|
||||
f"{note}\n\n{text}" if text else note,
|
||||
_event(
|
||||
spec,
|
||||
args,
|
||||
status="error",
|
||||
error=f"HTTP {response.status_code}",
|
||||
text=text[:MAX_EVENT_CHARS],
|
||||
),
|
||||
)
|
||||
|
||||
if not text:
|
||||
return ToolOutcome(
|
||||
f"{spec.label} returned nothing.",
|
||||
_event(spec, args, status="ok", text=""),
|
||||
)
|
||||
|
||||
return ToolOutcome(text, _event(spec, args, status="ok", text=text[:MAX_EVENT_CHARS]))
|
||||
|
||||
|
||||
async def _send(
|
||||
spec: HttpSpec,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: bytes | None,
|
||||
origin: tuple[str, str],
|
||||
) -> httpx.Response:
|
||||
"""Send the request, following redirects by hand so each hop is checked."""
|
||||
current = url
|
||||
async with httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False) as client:
|
||||
for _ in range(fetch_service.MAX_REDIRECTS + 1):
|
||||
response = await client.request(
|
||||
spec.method, current, headers=headers, content=body
|
||||
)
|
||||
if not response.is_redirect:
|
||||
return response
|
||||
|
||||
location = response.headers.get("location", "")
|
||||
if not location:
|
||||
raise fetch_service.FetchError("That tool redirected to nowhere.")
|
||||
current = fetch_service.check_url(
|
||||
str(response.url.join(location)), allow_private=spec.allow_private
|
||||
)
|
||||
if _origin(current) != origin:
|
||||
# A server that can redirect us anywhere must not be able to
|
||||
# redirect us at somebody else carrying the key.
|
||||
if spec.secret_header:
|
||||
headers.pop(spec.secret_header, None)
|
||||
origin = _origin(current)
|
||||
|
||||
raise fetch_service.FetchError("That tool redirected too many times.")
|
||||
|
||||
|
||||
__all__ = ["HttpSpec", "call", "fill_url", "spec_from", "tool_defs"]
|
||||
@@ -239,7 +239,11 @@ async def _run(generation: Generation) -> None:
|
||||
owner = db.get(User, chat.user_id)
|
||||
|
||||
# Read while the session is open: everything below outlives it.
|
||||
offered = tools_service.enabled_tools(db, chat, owner)
|
||||
# Resolved once, so that what the loop is allowed to *run* is the
|
||||
# same set the endpoint was *offered* -- not whatever happens to
|
||||
# exist by the time a call comes back.
|
||||
toolset = tools_service.resolve_tools(db, chat, owner)
|
||||
offered = toolset.schemas
|
||||
payload = chat_service.build_request(
|
||||
db, chat, upto=message, tools=offered, user=owner
|
||||
)
|
||||
@@ -248,7 +252,7 @@ async def _run(generation: Generation) -> None:
|
||||
# Read here, with the rest, because titling happens after this
|
||||
# session has closed and must not open another one.
|
||||
title_prompt = prompts_service.resolve(db, "task.title")
|
||||
tool_context = tools_service.context_for(db, owner, chat)
|
||||
tool_context = tools_service.context_for(db, owner, chat, tools=toolset)
|
||||
|
||||
model = chat_service.model_for(db, chat)
|
||||
generation.context_limit = model.context_length if model is not None else 0
|
||||
|
||||
@@ -57,16 +57,22 @@ MAX_HARNESS_CHARS = 8000
|
||||
MAX_NAMED_DOCUMENTS = 5
|
||||
|
||||
|
||||
def _families(tools: list[dict[str, Any]]) -> list[str]:
|
||||
"""Which families are represented in an offered tool list, in a fixed order."""
|
||||
from lembas.services.tools import FAMILIES, REGISTRY
|
||||
def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
|
||||
"""Which families are represented in an offered tool list, in a fixed order.
|
||||
|
||||
Resolved against the database rather than the import-time registry, because
|
||||
an administrator-defined tool is a row and would otherwise contribute no
|
||||
family at all -- which is to say its guidance would never be admitted.
|
||||
"""
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
book = tools_service.registry(db)
|
||||
offered = {
|
||||
REGISTRY[name].family
|
||||
book[name].family
|
||||
for tool in tools
|
||||
if (name := (tool.get("function") or {}).get("name")) in REGISTRY
|
||||
if (name := (tool.get("function") or {}).get("name")) in book
|
||||
}
|
||||
return [family for family in FAMILIES if family in offered]
|
||||
return [family for family in tools_service.families(db) if family in offered]
|
||||
|
||||
|
||||
def _tool_names(tools: list[dict[str, Any]]) -> str:
|
||||
@@ -109,7 +115,7 @@ def context_variables(
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
offered = tools or []
|
||||
families = _families(offered)
|
||||
families = _families(db, offered)
|
||||
stamp = datetime.now().astimezone()
|
||||
|
||||
values: dict[str, str] = {
|
||||
@@ -185,7 +191,7 @@ def compose(
|
||||
return compose_from(
|
||||
db,
|
||||
variables=context_variables(db, user, offered, chat),
|
||||
families=_families(offered),
|
||||
families=_families(db, offered),
|
||||
has_tools=bool(offered),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Who may be offered which administrator-defined tool.
|
||||
|
||||
One definition, used by the resolver that builds a chat's tool set and by the
|
||||
admin screens that say what a row will reach. It mirrors
|
||||
`permissions.models_visible_to` rather than `services/sharing.py`, and the
|
||||
difference matters: sharing has no admin branch because reading someone's
|
||||
private notes is not something a role should grant, while a tool is instance
|
||||
configuration an administrator could give themselves in one click anyway.
|
||||
|
||||
`everything=True` is for callers that need to map a tool *name* back to what it
|
||||
belongs to -- the harness, the prompt preview -- rather than to decide what to
|
||||
offer. Gating on visibility there would mean an administrator's own preview
|
||||
disagreeing with what a reader actually gets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import CustomTool, McpServer, User
|
||||
|
||||
|
||||
def _permitted(rows: list, user: User | None, *, everything: bool) -> list:
|
||||
if everything or (user is not None and user.is_admin):
|
||||
return rows
|
||||
if user is None:
|
||||
return []
|
||||
member_of = {group.id for group in user.groups}
|
||||
return [
|
||||
row for row in rows if row.public or member_of.intersection({g.id for g in row.groups})
|
||||
]
|
||||
|
||||
|
||||
def visible_custom_tools(
|
||||
db: DBSession, user: User | None, *, everything: bool = False
|
||||
) -> list[CustomTool]:
|
||||
"""Enabled custom tools this user may be offered, in position order."""
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(CustomTool)
|
||||
.where(CustomTool.enabled.is_(True))
|
||||
.order_by(CustomTool.position, CustomTool.slug)
|
||||
)
|
||||
)
|
||||
return _permitted(rows, user, everything=everything)
|
||||
|
||||
|
||||
def visible_mcp_servers(
|
||||
db: DBSession, user: User | None, *, everything: bool = False
|
||||
) -> list[McpServer]:
|
||||
"""Enabled MCP servers this user may be offered, in position order."""
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(McpServer)
|
||||
.where(McpServer.enabled.is_(True))
|
||||
.order_by(McpServer.position, McpServer.slug)
|
||||
)
|
||||
)
|
||||
return _permitted(rows, user, everything=everything)
|
||||
|
||||
|
||||
__all__ = ["visible_custom_tools", "visible_mcp_servers"]
|
||||
+180
-18
@@ -29,10 +29,12 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import search as search_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.library import documents as documents_service
|
||||
@@ -58,8 +60,24 @@ FAMILY_NOTES = "notes"
|
||||
FAMILY_MEMORY = "memory"
|
||||
FAMILY_SKILLS = "skills"
|
||||
|
||||
# A tool that is a database row gets a family of its own, so that it can carry
|
||||
# its own guidance -- "custom:weather", "mcp:github". Everything before the
|
||||
# colon is the *gate*: the capability flag and the permission are per gate, not
|
||||
# per row, because a server advertising forty tools must not mean forty
|
||||
# checkboxes on every model.
|
||||
FAMILY_CUSTOM = "custom"
|
||||
FAMILY_MCP = "mcp"
|
||||
|
||||
# The built-in families, in the order they are offered.
|
||||
FAMILIES = (FAMILY_SEARCH, FAMILY_KNOWLEDGE, FAMILY_NOTES, FAMILY_MEMORY, FAMILY_SKILLS)
|
||||
|
||||
GATES = (*FAMILIES, FAMILY_CUSTOM, FAMILY_MCP)
|
||||
|
||||
|
||||
def gate_of(family: str) -> str:
|
||||
"""The part a capability flag and a permission are named after."""
|
||||
return family.split(":", 1)[0]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
@@ -72,10 +90,14 @@ class ToolContext:
|
||||
|
||||
owner_id: str
|
||||
search_config: dict[str, Any] = field(default_factory=dict)
|
||||
allow_private_fetch: bool = False
|
||||
# Which knowledge bases this chat is scoped to. Empty means "everything the
|
||||
# owner can see", which is what a chat with none attached should do.
|
||||
base_ids: list[str] = field(default_factory=list)
|
||||
# Name -> definition for the tools actually offered on this request. None
|
||||
# means nobody resolved a set, and only then does `run_tool` fall back to
|
||||
# the import-time registry. A dict, *even an empty one*, is authoritative:
|
||||
# a model naming a tool it was not offered must not get it run.
|
||||
tools: dict[str, ToolDef] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -114,6 +136,31 @@ class ToolDef:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSet:
|
||||
"""What one request may call: the schemas to send, and how to run them.
|
||||
|
||||
The two halves have to travel together. `enabled_tools` used to return
|
||||
schemas alone, which worked only because every runner was reachable through
|
||||
the import-time `REGISTRY`. A tool that is a database row is not, so the
|
||||
resolution has to be carried from the session that made it to the loop that
|
||||
uses it.
|
||||
"""
|
||||
|
||||
defs: tuple[ToolDef, ...] = ()
|
||||
|
||||
@property
|
||||
def schemas(self) -> list[dict[str, Any]]:
|
||||
return [tool.schema for tool in self.defs]
|
||||
|
||||
@property
|
||||
def by_name(self) -> dict[str, ToolDef]:
|
||||
return {tool.name: tool for tool in self.defs}
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.defs)
|
||||
|
||||
|
||||
def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": properties, "required": required}
|
||||
|
||||
@@ -645,21 +692,69 @@ def _family_allowed(
|
||||
Absent counts as on when `tools` is on, so an upgrade does not silently take
|
||||
web search away from every model already set up for it.
|
||||
"""
|
||||
gate = gate_of(family)
|
||||
default = bool(capabilities.get("tools"))
|
||||
if not capabilities.get(f"tool_{family}", default):
|
||||
if not capabilities.get(f"tool_{gate}", default):
|
||||
return False
|
||||
|
||||
if family == FAMILY_SEARCH:
|
||||
if gate == FAMILY_SEARCH:
|
||||
return bool(
|
||||
allowed.get("tools.web_search")
|
||||
and config.get("enabled")
|
||||
and not search_service.availability(str(config.get("provider") or "ddgs"))
|
||||
)
|
||||
return bool(allowed.get(f"tools.{family}") and allowed.get("library.use"))
|
||||
if gate in (FAMILY_CUSTOM, FAMILY_MCP):
|
||||
# Deliberately without `library.use`: an HTTP endpoint an administrator
|
||||
# wrote has nothing to do with this person's own documents and notes,
|
||||
# and requiring the library permission for it would be a coincidence of
|
||||
# naming rather than a rule.
|
||||
return bool(allowed.get(f"tools.{gate}"))
|
||||
return bool(allowed.get(f"tools.{gate}") and allowed.get("library.use"))
|
||||
|
||||
|
||||
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
|
||||
"""The tool schemas to offer for this chat."""
|
||||
def _row_defs(db: DBSession, user: User | None, *, everything: bool = False) -> list[ToolDef]:
|
||||
"""Tool definitions built from rows, in the order they claim names.
|
||||
|
||||
Imported here rather than at the top because `custom_tools` needs `ToolDef`
|
||||
from this module.
|
||||
"""
|
||||
from lembas.services import custom_tools
|
||||
|
||||
return custom_tools.tool_defs(db, user, everything=everything)
|
||||
|
||||
|
||||
def _book(defs: list[ToolDef]) -> dict[str, ToolDef]:
|
||||
"""Keyed by name, first claim winning.
|
||||
|
||||
The built-ins are laid down first, so a row can never shadow one -- a tool
|
||||
called `notes_delete` that turns out to be somebody's HTTP endpoint is the
|
||||
kind of surprise that has no good failure mode.
|
||||
"""
|
||||
book = dict(REGISTRY)
|
||||
for tool in defs:
|
||||
book.setdefault(tool.name, tool)
|
||||
return book
|
||||
|
||||
|
||||
def registry(db: DBSession) -> dict[str, ToolDef]:
|
||||
"""Every tool that exists on this instance, keyed by name, ungated.
|
||||
|
||||
`REGISTRY` holds the built-ins alone, because it is built at import time and
|
||||
an administrator-defined tool is a row. Callers that only need to map a name
|
||||
back to a family use this; callers deciding what to *offer* use
|
||||
`resolve_tools`, which applies the gates as well.
|
||||
"""
|
||||
return _book(_row_defs(db, None, everything=True))
|
||||
|
||||
|
||||
def families(db: DBSession) -> tuple[str, ...]:
|
||||
"""Every family that exists, the built-ins in their fixed order first."""
|
||||
rows = tuple(tool.family for tool in _row_defs(db, None, everything=True))
|
||||
return (*FAMILIES, *rows)
|
||||
|
||||
|
||||
def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
"""Every tool this chat may call right now, with its runner attached."""
|
||||
from lembas.security import permissions
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
@@ -669,25 +764,47 @@ def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str
|
||||
capabilities = model.capabilities_json or {}
|
||||
|
||||
if not capabilities.get("tools"):
|
||||
return []
|
||||
return ToolSet()
|
||||
|
||||
allowed = permissions.resolve(db, user)
|
||||
config = settings_store.search(db)
|
||||
|
||||
families = {
|
||||
family
|
||||
for family in FAMILIES
|
||||
if _family_allowed(family, config=config, capabilities=capabilities, allowed=allowed)
|
||||
}
|
||||
return [tool.schema for tool in REGISTRY.values() if tool.family in families]
|
||||
# Resolved against what this reader may see, not against everything that
|
||||
# exists: a tool restricted to a group is not offered outside it.
|
||||
book = _book(_row_defs(db, user))
|
||||
return ToolSet(
|
||||
tuple(
|
||||
tool
|
||||
for tool in book.values()
|
||||
if _family_allowed(
|
||||
tool.family, config=config, capabilities=capabilities, allowed=allowed
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def context_for(db: DBSession, user: User | None, chat: Chat | None = None) -> ToolContext:
|
||||
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
|
||||
"""The tool schemas to offer for this chat.
|
||||
|
||||
The shape is unchanged on purpose: the inspector and `build_request` want
|
||||
exactly this. Anything that will also *run* a tool wants `resolve_tools`.
|
||||
"""
|
||||
return resolve_tools(db, chat, user).schemas
|
||||
|
||||
|
||||
def context_for(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
chat: Chat | None = None,
|
||||
*,
|
||||
tools: ToolSet | None = None,
|
||||
) -> ToolContext:
|
||||
"""The snapshot a running tool needs, taken while the session is open."""
|
||||
return ToolContext(
|
||||
owner_id=user.id if user else "",
|
||||
search_config=settings_store.search(db),
|
||||
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
|
||||
tools=tools.by_name if tools is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -697,8 +814,15 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco
|
||||
Never raises. A tool that fails hands the model an explanation and lets it
|
||||
carry on -- a failed lookup should produce "I could not find that" rather
|
||||
than killing the whole reply.
|
||||
|
||||
The lookup is against what was *offered*, not against everything that
|
||||
exists. Reaching for the registry directly meant a model naming a tool its
|
||||
chat was gated out of -- a family switched off for the model, a permission
|
||||
the reader does not have -- had it run anyway, because only the offer was
|
||||
ever filtered.
|
||||
"""
|
||||
tool = REGISTRY.get(name)
|
||||
book = REGISTRY if context.tools is None else context.tools
|
||||
tool = book.get(name)
|
||||
if tool is None:
|
||||
return ToolOutcome(
|
||||
f"There is no tool called {name!r}.",
|
||||
@@ -710,9 +834,12 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco
|
||||
except json.JSONDecodeError:
|
||||
# Small models emit malformed argument JSON often enough that this is a
|
||||
# normal path, not an exceptional one. Treat the whole string as the
|
||||
# first required argument rather than giving up.
|
||||
required = tool.parameters.get("required") or ["query"]
|
||||
parsed = {required[0]: arguments.strip()}
|
||||
# tool's first argument rather than giving up: what it says is required,
|
||||
# else the first thing it declares, and only then a guess -- a schema
|
||||
# somebody else wrote need not have either.
|
||||
properties = tool.parameters.get("properties") or {}
|
||||
names = tool.parameters.get("required") or list(properties) or ["query"]
|
||||
parsed = {str(names[0]): arguments.strip()}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"query": str(parsed)}
|
||||
|
||||
@@ -808,6 +935,31 @@ def tool_turn(call: dict[str, Any], content: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _row_source(db: DBSession):
|
||||
"""One harness fragment per administrator-defined tool.
|
||||
|
||||
The seam `prompts.register_source` exists for. The row supplies the default
|
||||
text and the admin page supplies the override, which is why a tool deleted
|
||||
and recreated under the same slug keeps whatever wording somebody chose for
|
||||
it -- the override outlives the row.
|
||||
|
||||
Gated on the tool's own family, so the guidance appears exactly when the
|
||||
tool it describes is offered and never otherwise.
|
||||
"""
|
||||
from lembas.db.models import CustomTool
|
||||
|
||||
for row in db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)):
|
||||
yield prompts_service.Fragment(
|
||||
key=f"tool.custom_{row.slug}",
|
||||
label=row.name or row.slug,
|
||||
group=prompts_service.GROUP_TOOLS,
|
||||
order=500 + row.position,
|
||||
families=(f"{FAMILY_CUSTOM}:{row.slug}",),
|
||||
hint=f"Appears when the {row.slug} tool is offered.",
|
||||
default=row.guidance or "",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FAMILIES",
|
||||
"MAX_ROUNDS",
|
||||
@@ -816,9 +968,19 @@ __all__ = [
|
||||
"ToolContext",
|
||||
"ToolDef",
|
||||
"ToolOutcome",
|
||||
"ToolSet",
|
||||
"assistant_turn",
|
||||
"context_for",
|
||||
"enabled_tools",
|
||||
"families",
|
||||
"registry",
|
||||
"resolve_tools",
|
||||
"run_tool",
|
||||
"tool_turn",
|
||||
]
|
||||
|
||||
|
||||
# Registered at import. `services.tools` is imported by the chat routes, the
|
||||
# generation service and the prompts admin, so the source is in place before
|
||||
# anything renders a catalogue.
|
||||
prompts_service.register_source(_row_source)
|
||||
|
||||
@@ -371,6 +371,23 @@
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* What a tool returned, verbatim. Preformatted rather than rendered: this is
|
||||
third-party text and markdown is the one path allowed to emit HTML. */
|
||||
.tool-result__text {
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--ink-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 22em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* --- Stop, notes and editing ---------------------------------------------- */
|
||||
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
|
||||
.msg__status:empty { display: none; }
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
{{ icon("globe", "icon--sm") }}
|
||||
<span class="nav-item__label">Web search</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'tools' }}" href="/admin/tools">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="nav-item__label">Tools</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'prompts' }}" href="/admin/prompts">
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
<span class="nav-item__label">Prompts</span>
|
||||
@@ -64,10 +68,6 @@
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Not yet built</div>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("gear", "icon--sm") }}
|
||||
<span class="nav-item__label">Tools</span>
|
||||
</span>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">Agents</span>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The result of calling one tool by hand.
|
||||
|
||||
What comes back is whatever the endpoint chose to send, which makes it exactly
|
||||
as untrusted as a search result or model output. It is shown escaped, inside a
|
||||
<pre>, and never rendered as Markdown.
|
||||
#}
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
|
||||
{% else %}
|
||||
<div class="alert alert--success">
|
||||
{{ icon("check", "icon--sm") }}
|
||||
<span>{{ detail or tool.slug }} answered.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if outcome %}
|
||||
<p class="field__hint">This is what the model would read back:</p>
|
||||
<pre class="tool-result__text">{{ outcome.content }}</pre>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,308 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "tools" %}
|
||||
|
||||
{% block title %}{{ "New tool" if is_new else tool.name }} - LLeMbas{% endblock %}
|
||||
{% block heading %}{{ "New tool" if is_new else tool.name }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<nav class="crumbs">
|
||||
<a class="crumbs__back" href="/admin/tools">
|
||||
{{ icon("chevron-right", "icon--sm crumbs__icon") }} All tools
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ '/admin/tools' if is_new else '/admin/tools/' ~ tool.id }}"
|
||||
class="form-grid">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What it is</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" id="name" name="name" value="{{ tool.name }}" required
|
||||
maxlength="120" placeholder="Weather">
|
||||
<p class="field__hint">Shown in the transcript when the model uses it.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="slug">Identifier</label>
|
||||
<input class="input input--mono" id="slug" name="slug" value="{{ tool.slug }}" required
|
||||
maxlength="48" placeholder="weather" pattern="[a-z0-9][a-z0-9_\-]*">
|
||||
<p class="field__hint">
|
||||
The name the model calls, and the key its guidance is stored under.
|
||||
Lowercase letters, digits, hyphens and underscores.
|
||||
{% if not is_new %}Changing it starts its guidance afresh.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">Description</label>
|
||||
<textarea class="textarea" id="description" name="description" rows="3"
|
||||
placeholder="Look up the current weather for a city."
|
||||
>{{ tool.description }}</textarea>
|
||||
<p class="field__hint">
|
||||
Sent to the model verbatim. This is the whole basis on which it decides
|
||||
whether to call this tool, so say what it does and when it is the right
|
||||
thing to use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="parameters">Parameters</label>
|
||||
<textarea class="textarea input--mono" id="parameters" name="parameters" rows="10"
|
||||
spellcheck="false">{{ parameters_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
A JSON Schema object. Each property becomes a
|
||||
<code>{{ '{{name}}' }}</code> you can use below, and a
|
||||
<code>description</code> on each one is worth writing — the model reads
|
||||
those too.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The call</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="method">Method</label>
|
||||
<select class="select" id="method" name="method">
|
||||
{% for method in methods %}
|
||||
<option value="{{ method }}" {{ 'selected' if method == tool.method }}>{{ method }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="url_template">URL</label>
|
||||
<input class="input input--mono" id="url_template" name="url_template" required
|
||||
value="{{ tool.url_template }}"
|
||||
placeholder="https://api.example.com/weather/{{ '{{city}}' }}">
|
||||
<p class="field__hint">
|
||||
Placeholders are filled from the arguments and escaped, so a value
|
||||
cannot add a path segment or a query of its own. The scheme and host
|
||||
must be written out — they cannot come from an argument.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="headers">Headers</label>
|
||||
<textarea class="textarea input--mono" id="headers" name="headers" rows="3"
|
||||
spellcheck="false"
|
||||
placeholder="Accept: application/json">{{ headers_text }}</textarea>
|
||||
<p class="field__hint">One <code>Name: value</code> per line. Placeholders work here too.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="body_template">Body</label>
|
||||
<textarea class="textarea input--mono" id="body_template" name="body_template" rows="4"
|
||||
spellcheck="false">{{ tool.body_template }}</textarea>
|
||||
<p class="field__hint">
|
||||
Ignored for GET. Placeholders are escaped for JSON, so a value cannot
|
||||
end the string it sits in and add a field.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="timeout">Timeout (seconds)</label>
|
||||
<input class="input" id="timeout" name="timeout" value="{{ tool.timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Credential</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret_placement">How it is sent</label>
|
||||
<select class="select" id="secret_placement" name="secret_placement">
|
||||
{% for value, label in secret_placements %}
|
||||
<option value="{{ value }}" {{ 'selected' if value == tool.secret_placement }}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret_name">Header or parameter name</label>
|
||||
<input class="input input--mono" id="secret_name" name="secret_name"
|
||||
value="{{ tool.secret_name }}" maxlength="120">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="secret">Secret</label>
|
||||
<input class="input input--mono" id="secret" name="secret" type="password"
|
||||
autocomplete="off" placeholder="No secret set"
|
||||
value="{{ unchanged if tool.secret_encrypted else '' }}">
|
||||
<p class="field__hint">
|
||||
{% if tool.secret_encrypted %}
|
||||
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
|
||||
or clear the field to remove it.
|
||||
{% else %}
|
||||
Encrypted at rest and never shown again. It is dropped if the endpoint
|
||||
redirects to another host.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The answer</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="response_mode">Read the response as</label>
|
||||
<select class="select" id="response_mode" name="response_mode">
|
||||
{% for value, label in response_modes %}
|
||||
<option value="{{ value }}" {{ 'selected' if value == tool.response_mode }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="response_path">Path into the JSON</label>
|
||||
<input class="input input--mono" id="response_path" name="response_path"
|
||||
value="{{ tool.response_path }}" placeholder="data.items.0.title" maxlength="300">
|
||||
<p class="field__hint">
|
||||
Dotted; a number indexes a list. Leave empty for the whole document.
|
||||
A path that leads nowhere gives the whole document rather than nothing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_chars">Most characters to keep</label>
|
||||
<input class="input" id="max_chars" name="max_chars" value="{{ tool.max_chars }}"
|
||||
inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Spent out of the context window on every call. The rest is cut off, and
|
||||
the model is told so.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Guidance</h2>
|
||||
|
||||
<div class="field">
|
||||
<textarea class="textarea" name="guidance" rows="4"
|
||||
placeholder="- Check the weather rather than guessing at it."
|
||||
>{{ tool.guidance }}</textarea>
|
||||
<p class="field__hint">
|
||||
Added to the system message whenever this tool is offered, and nowhere
|
||||
else. Start a line with <code>- </code> and it joins the list of tool
|
||||
instructions. Optional: the description above is what makes the tool
|
||||
usable, this is for habits.
|
||||
{% if prompt_overridden %}
|
||||
<br><strong>Someone has overridden this wording under
|
||||
<a href="/admin/prompts">Prompts</a></strong> — that is what the model
|
||||
sees, not this.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Availability</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if tool.enabled }}>
|
||||
<span>Enabled — offered in chats</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="allow_private" value="true"
|
||||
{{ 'checked' if tool.allow_private }}>
|
||||
<span>May reach private and loopback addresses</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
Leave the second unticked unless this tool points at something on your
|
||||
own network. It is what stops a tool being aimed at this server, a
|
||||
router, or a cloud metadata endpoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="public" value="true" {{ 'checked' if tool.public }}>
|
||||
<span>Available to everyone</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Uncheck to restrict this tool to chosen groups. Administrators always
|
||||
have access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Groups with access</span>
|
||||
{% if groups %}
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="group_ids" value="{{ group.id }}"
|
||||
{{ 'checked' if group.id in selected_groups }}>
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field__hint">Ignored while the tool is available to everyone.</p>
|
||||
{% else %}
|
||||
<p class="field__hint">
|
||||
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="position">Position</label>
|
||||
<input class="input" id="position" name="position" value="{{ tool.position }}"
|
||||
inputmode="numeric">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ "Create tool" if is_new else "Save changes" }}
|
||||
</button>
|
||||
<a class="btn btn--ghost" href="/admin/tools">Back to all tools</a>
|
||||
{% if not is_new %}
|
||||
<button class="btn btn--danger" type="submit" formnovalidate
|
||||
formaction="/admin/tools/{{ tool.id }}/delete"
|
||||
data-confirm-button="Delete the tool “{{ tool.name }}”? Chats that used it keep their transcripts.">
|
||||
Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if not is_new %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Try it</h2>
|
||||
<p class="field__hint">
|
||||
Calls the <em>saved</em> tool once, so what you see here is what a chat would
|
||||
get. Nothing is sent to a model.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="arguments">Arguments</label>
|
||||
<textarea class="textarea input--mono" id="arguments" name="arguments" rows="3"
|
||||
spellcheck="false">{"city": "Minas Tirith"}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="button"
|
||||
hx-post="/admin/tools/{{ tool.id }}/test"
|
||||
hx-include="#arguments"
|
||||
hx-target="#tool-test-result">
|
||||
{{ icon("refresh", "icon--sm") }} Run once
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tool-test-result"></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "tools" %}
|
||||
|
||||
{% block title %}Tools - LLeMbas{% endblock %}
|
||||
{% block heading %}Tools{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
HTTP calls a model can make while it answers. Each one is offered to models
|
||||
marked <strong>Custom tools</strong>, to people who have the permission, and —
|
||||
if it is restricted — only to the groups you choose. The model decides
|
||||
<em>when</em> to call it from the description you write, so write that as if
|
||||
explaining to a colleague what the tool is for.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{% if not total %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("link", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No tools yet. <a href="/admin/tools/new">Define one</a> — a name, a
|
||||
description, and a URL with <code>{{ '{{placeholders}}' }}</code> in it.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# Filters are links, so a filtered view is a real URL you can keep or share. #}
|
||||
<div class="filter-bar">
|
||||
<div class="filter-tabs">
|
||||
{% for key, label in filters.items() %}
|
||||
<a class="filter-tab {{ 'is-active' if key == active_filter }}"
|
||||
href="/admin/tools?filter={{ key }}{% if q %}&q={{ q|urlencode }}{% endif %}">
|
||||
{{ label }} <span class="filter-tab__count">{{ counts[key] }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form class="filter-form" method="get" action="/admin/tools">
|
||||
<input type="hidden" name="filter" value="{{ active_filter }}">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search tools…" aria-label="Search tools">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||
{% if q or active_filter != "all" %}
|
||||
<a class="btn btn--ghost" href="/admin/tools">Clear</a>
|
||||
{% endif %}
|
||||
<a class="btn btn--primary" href="/admin/tools/new">{{ icon("plus", "icon--sm") }} New tool</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not tools %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">Nothing matches that filter.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="model-rows">
|
||||
{% for tool in tools %}
|
||||
<div class="model-row {{ 'is-off' if not tool.enabled }}">
|
||||
<span class="model-row__pos">{{ page_start + loop.index }}</span>
|
||||
|
||||
<div class="model-row__main">
|
||||
<div class="model-row__title">
|
||||
<a class="model-row__name" href="/admin/tools/{{ tool.id }}/edit">{{ tool.name }}</a>
|
||||
{% if not tool.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
||||
{% if not tool.public %}<span class="badge">restricted</span>{% endif %}
|
||||
{% if tool.allow_private %}<span class="badge">private network</span>{% endif %}
|
||||
{% if tool.last_error %}<span class="badge badge--danger">last call failed</span>{% endif %}
|
||||
</div>
|
||||
<code class="model-row__id">
|
||||
{{ tool.slug }} · {{ tool.method }} {{ tool.url_template }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div class="model-row__actions">
|
||||
<a class="btn btn--sm" href="/admin/tools/{{ tool.id }}/edit">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="list-footer">
|
||||
<span class="text-xs faint">
|
||||
Showing {{ page_start + 1 }}–{{ page_start + tools|length }} of {{ matched }}
|
||||
{%- if matched != total %} (filtered from {{ total }}){% endif %}
|
||||
</span>
|
||||
|
||||
{% if pages > 1 %}
|
||||
{% set base = "/admin/tools?filter=" ~ active_filter ~ ("&q=" ~ q|urlencode if q else "") %}
|
||||
<div class="btn-row">
|
||||
{% if page > 1 %}
|
||||
<a class="btn btn--sm" href="{{ base }}&page={{ page - 1 }}">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Previous</span>
|
||||
{% endif %}
|
||||
<span class="text-xs faint">Page {{ page }} of {{ pages }}</span>
|
||||
{% if page < pages %}
|
||||
<a class="btn btn--sm" href="{{ base }}&page={{ page + 1 }}">Next</a>
|
||||
{% else %}
|
||||
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Next</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -6,23 +6,55 @@
|
||||
answer) and from the stored message afterwards, so the sources behind an
|
||||
answer stay in the transcript rather than vanishing when the stream ends.
|
||||
|
||||
EVERYTHING in here comes from a search provider and is untrusted, exactly as
|
||||
much as model output is. Jinja autoescaping covers the text; the URL is
|
||||
checked separately, because `is_linkable` is the only thing standing between
|
||||
a result carrying a javascript: URL and an anchor pointing at it.
|
||||
EVERYTHING in here is third-party text and is untrusted, exactly as much as
|
||||
model output is -- a search provider's results, an administrator's HTTP tool
|
||||
relaying whatever it was pointed at, an MCP server's reply. Jinja autoescaping
|
||||
covers it. Two things are handled separately: the URL, because `is_linkable`
|
||||
is the only thing standing between a result carrying a javascript: URL and an
|
||||
anchor pointing at it, and `event.text`, which is rendered as preformatted
|
||||
text and deliberately NOT through services/markdown.py -- markdown is the one
|
||||
path allowed to emit HTML, and this is the last content that should be given
|
||||
it.
|
||||
|
||||
`kind` says how to label the event. Rows written before it existed have none,
|
||||
so web_search reads as a search and everything else falls to the generic
|
||||
branch: an old notes_search event used to claim it had searched the web.
|
||||
#}
|
||||
{% for event in tool_events %}
|
||||
{% set kind = event.kind or ('search' if event.name == 'web_search' else 'tool') %}
|
||||
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}">
|
||||
<summary class="tool-activity__summary">
|
||||
{{ icon("globe", "icon--sm tool-activity__icon") }}
|
||||
{% if kind == 'search' %}
|
||||
{{ icon("globe", "icon--sm tool-activity__icon") }}
|
||||
{% elif kind == 'custom' %}
|
||||
{{ icon("link", "icon--sm tool-activity__icon") }}
|
||||
{% elif kind == 'mcp' %}
|
||||
{{ icon("server", "icon--sm tool-activity__icon") }}
|
||||
{% else %}
|
||||
{{ icon("sparkle", "icon--sm tool-activity__icon") }}
|
||||
{% endif %}
|
||||
|
||||
<span class="tool-activity__label">
|
||||
{% if event.status == "error" %}
|
||||
Web search failed
|
||||
{% elif event.query %}
|
||||
Searched the web for “{{ event.query }}”
|
||||
{% if kind == 'search' %}
|
||||
{% if event.status == "error" %}
|
||||
Web search failed
|
||||
{% elif event.query %}
|
||||
Searched the web for “{{ event.query }}”
|
||||
{% else %}
|
||||
Searched the web
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Searched the web
|
||||
{% set label = event.label or event.name %}
|
||||
{% if event.status == "error" %}
|
||||
{{ label }} failed
|
||||
{% else %}
|
||||
{{ label }}
|
||||
{% endif %}
|
||||
{% if event.query %}
|
||||
<span class="tool-activity__count">· {{ event.query }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if event.results %}
|
||||
<span class="tool-activity__count">
|
||||
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
|
||||
@@ -33,21 +65,29 @@
|
||||
</summary>
|
||||
|
||||
<div class="tool-activity__body">
|
||||
{% if event.detail and kind != 'search' %}
|
||||
<p class="tool-result__host">{{ event.detail }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if event.error %}
|
||||
<p class="tool-activity__error">{{ event.error }}</p>
|
||||
{% elif not event.results %}
|
||||
{% elif not event.results and not event.text %}
|
||||
<p class="tool-activity__error">Nothing was found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if event.text %}
|
||||
<pre class="tool-result__text">{{ event.text }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% for result in event.results %}
|
||||
<div class="tool-result">
|
||||
{% set scheme = result.url.split(":")[0] | lower %}
|
||||
{% set scheme = (result.url or "").split(":")[0] | lower %}
|
||||
{% if scheme in ("http", "https") %}
|
||||
<a class="tool-result__title" href="{{ result.url }}"
|
||||
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
|
||||
{% else %}
|
||||
{# Not a link. A search result is third-party text and its URL is not
|
||||
trusted to be safe to click. #}
|
||||
{# Not a link. A result is third-party text and its URL is not trusted to
|
||||
be safe to click. #}
|
||||
<span class="tool-result__title">{{ result.title }}</span>
|
||||
{% endif %}
|
||||
<span class="tool-result__host">{{ result.host }}</span>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""The custom-tools admin screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import CustomTool, Group, User
|
||||
from lembas.security.passwords import hash_password
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_user(client: TestClient, db, registered):
|
||||
"""A second account, which is never an administrator."""
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "sam@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _form(**overrides) -> dict:
|
||||
base = {
|
||||
"name": "Weather",
|
||||
"slug": "weather",
|
||||
"description": "Look up the weather for a city.",
|
||||
"parameters": json.dumps(
|
||||
{"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
),
|
||||
"method": "GET",
|
||||
"url_template": "https://api.test/v1/{{city}}",
|
||||
"headers": "Accept: application/json",
|
||||
"secret_placement": "bearer",
|
||||
"secret_name": "Authorization",
|
||||
"response_mode": "json",
|
||||
"max_chars": "8000",
|
||||
"timeout": "20",
|
||||
"position": "0",
|
||||
"enabled": "true",
|
||||
"public": "true",
|
||||
}
|
||||
base.update(overrides)
|
||||
return {key: value for key, value in base.items() if value is not None}
|
||||
|
||||
|
||||
def _create(client: TestClient, **overrides):
|
||||
return client.post("/admin/tools", data=_form(**overrides), follow_redirects=False)
|
||||
|
||||
|
||||
# --- Guards ------------------------------------------------------------------
|
||||
def test_the_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
|
||||
assert client.get("/admin/tools").status_code == 403
|
||||
assert client.get("/admin/tools/new").status_code == 403
|
||||
assert client.post("/admin/tools", data=_form()).status_code == 403
|
||||
|
||||
|
||||
def test_a_tool_that_does_not_exist_is_a_404(client: TestClient, registered):
|
||||
assert client.get("/admin/tools/nope/edit").status_code == 404
|
||||
|
||||
|
||||
def test_new_is_not_parsed_as_a_tool_id(client: TestClient, registered):
|
||||
"""FastAPI matches in registration order; /admin/models has been bitten by
|
||||
exactly this."""
|
||||
response = client.get("/admin/tools/new")
|
||||
assert response.status_code == 200
|
||||
assert "New tool" in response.text
|
||||
|
||||
|
||||
# --- Creating and editing ----------------------------------------------------
|
||||
def test_creating_a_tool_then_editing_it(client: TestClient, db, registered):
|
||||
_create(client, secret="s3cret")
|
||||
|
||||
tool = db.scalar(select(CustomTool))
|
||||
assert tool.slug == "weather"
|
||||
assert tool.headers_json == {"Accept": "application/json"}
|
||||
assert tool.parameters_json["properties"]["city"] == {"type": "string"}
|
||||
assert decrypt(tool.secret_encrypted) == "s3cret"
|
||||
assert tool.enabled is True
|
||||
|
||||
client.post(
|
||||
f"/admin/tools/{tool.id}",
|
||||
data=_form(name="Forecast", enabled=None, secret=UNCHANGED_SENTINEL),
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(tool)
|
||||
assert tool.name == "Forecast"
|
||||
# An unticked checkbox is simply absent from the post, which is the signal.
|
||||
assert tool.enabled is False
|
||||
assert decrypt(tool.secret_encrypted) == "s3cret", "the dots must keep the secret"
|
||||
|
||||
|
||||
def test_clearing_the_field_removes_the_secret(client: TestClient, db, registered):
|
||||
_create(client, secret="s3cret")
|
||||
tool = db.scalar(select(CustomTool))
|
||||
|
||||
client.post(f"/admin/tools/{tool.id}", data=_form(secret=""), follow_redirects=False)
|
||||
db.refresh(tool)
|
||||
assert tool.secret_encrypted == ""
|
||||
|
||||
|
||||
def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
|
||||
_create(client, secret="super-secret-value")
|
||||
tool = db.scalar(select(CustomTool))
|
||||
|
||||
page = client.get(f"/admin/tools/{tool.id}/edit").text
|
||||
assert "super-secret-value" not in page
|
||||
assert UNCHANGED_SENTINEL in page
|
||||
|
||||
|
||||
# --- Validation reports back into the form -----------------------------------
|
||||
def test_bad_parameter_json_is_reported_not_a_422(client: TestClient, db, registered):
|
||||
response = _create(client, parameters="{not json")
|
||||
assert response.status_code == 200
|
||||
assert "not valid JSON" in response.text
|
||||
assert db.scalar(select(CustomTool)) is None
|
||||
|
||||
|
||||
def test_parameters_that_are_not_an_object_are_refused(client: TestClient, db, registered):
|
||||
response = _create(client, parameters='{"type": "string"}')
|
||||
assert "must be a JSON object" in response.text
|
||||
assert db.scalar(select(CustomTool)) is None
|
||||
|
||||
|
||||
def test_a_slug_colliding_with_a_builtin_is_refused(client: TestClient, db, registered):
|
||||
response = _create(client, slug="web_search")
|
||||
assert "built-in tool" in response.text
|
||||
assert db.scalar(select(CustomTool)) is None
|
||||
|
||||
|
||||
def test_a_duplicate_slug_is_refused(client: TestClient, db, registered):
|
||||
_create(client)
|
||||
response = _create(client, name="Other")
|
||||
assert "already a tool" in response.text
|
||||
assert len(list(db.scalars(select(CustomTool)))) == 1
|
||||
|
||||
|
||||
def test_a_url_whose_host_is_a_variable_is_refused(client: TestClient, db, registered):
|
||||
response = _create(client, url_template="https://{{city}}.test/x")
|
||||
assert "literal" in response.text
|
||||
assert db.scalar(select(CustomTool)) is None
|
||||
|
||||
|
||||
def test_a_rejected_save_keeps_what_was_typed(client: TestClient, db, registered):
|
||||
_create(client)
|
||||
tool = db.scalar(select(CustomTool))
|
||||
|
||||
response = client.post(
|
||||
f"/admin/tools/{tool.id}",
|
||||
data=_form(name="Renamed", parameters="{oops"),
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "Renamed" in response.text, "the form still holds the submitted name"
|
||||
db.refresh(tool)
|
||||
assert tool.name == "Weather", "and the stored row is untouched"
|
||||
|
||||
|
||||
# --- Access ------------------------------------------------------------------
|
||||
def test_making_a_tool_public_clears_its_groups(client: TestClient, db, registered):
|
||||
group = Group(name="Council")
|
||||
db.add(group)
|
||||
db.commit()
|
||||
|
||||
_create(client, public=None, group_ids=group.id)
|
||||
tool = db.scalar(select(CustomTool))
|
||||
assert [g.name for g in tool.groups] == ["Council"]
|
||||
|
||||
client.post(
|
||||
f"/admin/tools/{tool.id}",
|
||||
data={**_form(public="true"), "group_ids": group.id},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(tool)
|
||||
assert tool.groups == []
|
||||
|
||||
|
||||
def test_a_restricted_tool_is_hidden_from_a_user_outside_its_groups(db, registered):
|
||||
from lembas.services import tool_access
|
||||
|
||||
group = Group(name="Council")
|
||||
outsider = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
|
||||
db.add_all([group, outsider])
|
||||
db.add(
|
||||
CustomTool(
|
||||
slug="weather", name="Weather", url_template="https://api.test/", public=False
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
tool = db.scalar(select(CustomTool))
|
||||
tool.groups = [group]
|
||||
db.commit()
|
||||
|
||||
assert tool_access.visible_custom_tools(db, outsider) == []
|
||||
outsider.groups = [group]
|
||||
db.commit()
|
||||
assert len(tool_access.visible_custom_tools(db, outsider)) == 1
|
||||
|
||||
|
||||
# --- Running one by hand -----------------------------------------------------
|
||||
def test_the_test_button_shows_what_the_model_would_read(
|
||||
client: TestClient, db, registered, mock_http, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
)
|
||||
mock_http(lambda _r: httpx.Response(200, json={"summary": "Sunny"}))
|
||||
_create(client, response_mode="json", response_path="summary")
|
||||
tool = db.scalar(select(CustomTool))
|
||||
|
||||
response = client.post(
|
||||
f"/admin/tools/{tool.id}/test", data={"arguments": '{"city": "Minas Tirith"}'}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Sunny" in response.text
|
||||
|
||||
|
||||
def test_a_failing_test_is_recorded_on_the_row(
|
||||
client: TestClient, db, registered, mock_http, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
)
|
||||
mock_http(lambda _r: httpx.Response(503, text="down"))
|
||||
_create(client)
|
||||
tool = db.scalar(select(CustomTool))
|
||||
|
||||
client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "{}"})
|
||||
db.refresh(tool)
|
||||
assert "503" in tool.last_error
|
||||
assert tool.last_checked_at is not None
|
||||
|
||||
|
||||
def test_bad_test_arguments_are_reported(client: TestClient, db, registered):
|
||||
_create(client)
|
||||
tool = db.scalar(select(CustomTool))
|
||||
response = client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "[1, 2]"})
|
||||
assert "JSON object" in response.text
|
||||
|
||||
|
||||
# --- The list ----------------------------------------------------------------
|
||||
def test_the_list_searches_and_filters(client: TestClient, db, registered):
|
||||
_create(client)
|
||||
_create(client, name="Tickets", slug="tickets", url_template="https://jira.test/{{city}}")
|
||||
tool = db.scalar(select(CustomTool).where(CustomTool.slug == "tickets"))
|
||||
tool.enabled = False
|
||||
db.commit()
|
||||
|
||||
page = client.get("/admin/tools").text
|
||||
assert "Weather" in page and "Tickets" in page
|
||||
|
||||
assert "Tickets" not in client.get("/admin/tools?filter=enabled").text
|
||||
assert "Weather" not in client.get("/admin/tools?q=tick").text
|
||||
|
||||
|
||||
def test_deleting_a_tool_leaves_its_prompt_override_alone(client: TestClient, db, registered):
|
||||
"""The override outlives the row, which is what lets a tool be recreated
|
||||
under the same slug without losing the wording somebody chose."""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
_create(client, guidance="- Default wording.")
|
||||
tool = db.scalar(select(CustomTool))
|
||||
prompts_service.save(db, {"tool.custom_weather": "- Edited wording."})
|
||||
|
||||
client.post(f"/admin/tools/{tool.id}/delete", follow_redirects=False)
|
||||
assert db.scalar(select(CustomTool)) is None
|
||||
assert prompts_service.stored(db)["tool.custom_weather"] == "- Edited wording."
|
||||
|
||||
|
||||
def test_the_secret_survives_a_round_trip_through_the_form(client: TestClient, db, registered):
|
||||
"""A regression guard on the sentinel: the field is rendered with dots, and
|
||||
submitting the page unchanged must not overwrite the key with them."""
|
||||
db.add(
|
||||
CustomTool(
|
||||
slug="weather",
|
||||
name="Weather",
|
||||
url_template="https://api.test/",
|
||||
secret_encrypted=encrypt("s3cret"),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
tool = db.scalar(select(CustomTool))
|
||||
|
||||
client.post(
|
||||
f"/admin/tools/{tool.id}", data=_form(secret=UNCHANGED_SENTINEL), follow_redirects=False
|
||||
)
|
||||
db.refresh(tool)
|
||||
assert decrypt(tool.secret_encrypted) == "s3cret"
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Custom HTTP tools: filling the template, and refusing to be pointed elsewhere.
|
||||
|
||||
Most of this file is about the second. The arguments come from a model, which
|
||||
can be talked into things by whatever it just read, so an argument filling a
|
||||
hole in a URL is the same kind of input as a URL typed by a stranger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from lembas.db.models import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_BEARER,
|
||||
SECRET_HEADER,
|
||||
SECRET_NONE,
|
||||
CustomTool,
|
||||
)
|
||||
from lembas.services import custom_tools
|
||||
from lembas.services.crypto import encrypt
|
||||
from lembas.services.fetch import FetchError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def dns(monkeypatch):
|
||||
"""Resolve invented hostnames to a public address.
|
||||
|
||||
`api.test` does not exist and `check_url` resolves for real, which is the
|
||||
whole point of it. A literal IP is handed back as itself, so the tests that
|
||||
check a private address are still checking one.
|
||||
"""
|
||||
import ipaddress
|
||||
import socket
|
||||
|
||||
def resolve(host, *_args, **_kwargs):
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
return [(2, 1, 6, "", (host, 80))]
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", resolve)
|
||||
|
||||
|
||||
def _spec(**overrides) -> custom_tools.HttpSpec:
|
||||
base = {
|
||||
"slug": "weather",
|
||||
"label": "Weather",
|
||||
"method": "GET",
|
||||
"url_template": "https://api.test/v1/city/{{city}}",
|
||||
"secret_placement": SECRET_NONE,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
}
|
||||
return custom_tools.HttpSpec(**{**base, **overrides})
|
||||
|
||||
|
||||
def _ok(body: str = "sunny", *, status: int = 200, content_type: str = "text/plain"):
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["request"] = request
|
||||
seen.setdefault("urls", []).append(str(request.url))
|
||||
return httpx.Response(status, text=body, headers={"content-type": content_type})
|
||||
|
||||
return handler, seen
|
||||
|
||||
|
||||
# --- Filling the URL ---------------------------------------------------------
|
||||
def test_an_argument_is_percent_encoded_into_the_url():
|
||||
url = custom_tools.fill_url(_spec(), {"city": "Minas Tirith"})
|
||||
assert url == "https://api.test/v1/city/Minas%20Tirith"
|
||||
|
||||
|
||||
def test_an_argument_cannot_add_a_path_segment_or_a_query():
|
||||
"""safe="" is the whole point. Everything structural encodes."""
|
||||
url = custom_tools.fill_url(_spec(), {"city": "../../admin?token=x#y"})
|
||||
assert "/admin" not in url
|
||||
assert "?" not in url and "#" not in url
|
||||
assert url.startswith("https://api.test/v1/city/")
|
||||
|
||||
|
||||
def test_an_argument_cannot_reach_another_host():
|
||||
url = custom_tools.fill_url(_spec(), {"city": "evil.test/steal"})
|
||||
assert url.startswith("https://api.test/")
|
||||
|
||||
|
||||
def test_an_undeclared_name_is_removed_rather_than_passed_through():
|
||||
"""Unlike the prompt fragments, where an unrecognised {{x}} is left alone.
|
||||
A literal {{x}} in a URL is not a feature."""
|
||||
spec = _spec(url_template="https://api.test/{{city}}/{{unknown}}")
|
||||
assert custom_tools.fill_url(spec, {"city": "a", "unknown": "b"}) == "https://api.test/a/"
|
||||
|
||||
|
||||
def test_an_argument_the_model_did_not_send_becomes_nothing():
|
||||
assert custom_tools.fill_url(_spec(), {}) == "https://api.test/v1/city/"
|
||||
|
||||
|
||||
def test_a_template_whose_host_is_a_variable_is_refused():
|
||||
with pytest.raises(FetchError, match="literal"):
|
||||
custom_tools.fill_url(_spec(url_template="https://{{city}}.test/x"), {"city": "a"})
|
||||
|
||||
|
||||
def test_a_template_that_is_not_http_is_refused():
|
||||
with pytest.raises(FetchError):
|
||||
custom_tools.fill_url(_spec(url_template="file:///etc/passwd"), {})
|
||||
|
||||
|
||||
# --- Reaching the network ----------------------------------------------------
|
||||
async def test_a_private_address_is_refused_unless_the_row_allows_it(mock_http):
|
||||
handler, _seen = _ok()
|
||||
mock_http(handler)
|
||||
|
||||
spec = _spec(url_template="http://127.0.0.1:11434/api/tags", parameters={})
|
||||
refused = await custom_tools.call(spec, {})
|
||||
assert refused.event["status"] == "error"
|
||||
assert "private or local" in refused.event["error"]
|
||||
|
||||
allowed = await custom_tools.call(
|
||||
_spec(
|
||||
url_template="http://127.0.0.1:11434/api/tags", parameters={}, allow_private=True
|
||||
),
|
||||
{},
|
||||
)
|
||||
assert allowed.event["status"] == "ok"
|
||||
|
||||
|
||||
async def test_a_hostname_resolving_to_loopback_is_refused(mock_http, monkeypatch):
|
||||
"""A name pointing at 127.0.0.1 walks past any check that only reads the
|
||||
text of the URL."""
|
||||
handler, _seen = _ok()
|
||||
mock_http(handler)
|
||||
monkeypatch.setattr(
|
||||
"socket.getaddrinfo",
|
||||
lambda *a, **k: [(2, 1, 6, "", ("127.0.0.1", 80))],
|
||||
)
|
||||
|
||||
outcome = await custom_tools.call(_spec(parameters={}), {})
|
||||
assert outcome.event["status"] == "error"
|
||||
assert "private or local" in outcome.event["error"]
|
||||
|
||||
|
||||
async def test_every_redirect_hop_is_checked(mock_http, monkeypatch):
|
||||
"""httpx's own following would validate the first address and then happily
|
||||
land on localhost."""
|
||||
hops = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
hops.append(str(request.url))
|
||||
if request.url.host == "api.test":
|
||||
return httpx.Response(302, headers={"location": "http://inside.test/secrets"})
|
||||
return httpx.Response(200, text="secrets")
|
||||
|
||||
mock_http(handler)
|
||||
|
||||
def resolve(host, *_args, **_kwargs):
|
||||
if host == "inside.test":
|
||||
return [(2, 1, 6, "", ("10.0.0.5", 80))]
|
||||
return [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
|
||||
monkeypatch.setattr("socket.getaddrinfo", resolve)
|
||||
|
||||
outcome = await custom_tools.call(_spec(parameters={}), {})
|
||||
assert outcome.event["status"] == "error"
|
||||
assert len(hops) == 1, "the second hop must never be requested"
|
||||
|
||||
|
||||
async def test_the_secret_is_dropped_on_a_cross_host_redirect(mock_http):
|
||||
seen = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append((request.url.host, request.headers.get("authorization")))
|
||||
if request.url.host == "api.test":
|
||||
return httpx.Response(302, headers={"location": "https://elsewhere.test/x"})
|
||||
return httpx.Response(200, text="ok")
|
||||
|
||||
mock_http(handler)
|
||||
|
||||
outcome = await custom_tools.call(
|
||||
_spec(parameters={}, secret="s3cret", secret_placement=SECRET_BEARER), {}
|
||||
)
|
||||
assert outcome.event["status"] == "ok"
|
||||
assert seen[0] == ("api.test", "Bearer s3cret")
|
||||
assert seen[1] == ("elsewhere.test", None)
|
||||
|
||||
|
||||
async def test_a_bearer_secret_reaches_the_request_and_never_the_event(mock_http):
|
||||
handler, seen = _ok()
|
||||
mock_http(handler)
|
||||
|
||||
outcome = await custom_tools.call(
|
||||
_spec(parameters={}, secret="s3cret", secret_placement=SECRET_BEARER), {}
|
||||
)
|
||||
assert seen["request"].headers["authorization"] == "Bearer s3cret"
|
||||
assert "s3cret" not in json.dumps(outcome.event)
|
||||
|
||||
|
||||
async def test_a_header_secret_uses_the_name_it_was_given(mock_http):
|
||||
handler, seen = _ok()
|
||||
mock_http(handler)
|
||||
|
||||
await custom_tools.call(
|
||||
_spec(
|
||||
parameters={},
|
||||
secret="k",
|
||||
secret_placement=SECRET_HEADER,
|
||||
secret_name="X-Api-Key",
|
||||
),
|
||||
{},
|
||||
)
|
||||
assert seen["request"].headers["x-api-key"] == "k"
|
||||
|
||||
|
||||
async def test_a_header_value_cannot_carry_a_newline(mock_http):
|
||||
handler, seen = _ok()
|
||||
mock_http(handler)
|
||||
|
||||
await custom_tools.call(
|
||||
_spec(headers={"X-Trace": "{{city}}"}), {"city": "a\r\nX-Admin: yes"}
|
||||
)
|
||||
assert "\n" not in seen["request"].headers["x-trace"]
|
||||
|
||||
|
||||
async def test_a_body_argument_cannot_end_the_json_string(mock_http):
|
||||
handler, seen = _ok()
|
||||
mock_http(handler)
|
||||
|
||||
await custom_tools.call(
|
||||
_spec(
|
||||
method="POST",
|
||||
url_template="https://api.test/v1/ask",
|
||||
body_template='{"city": "{{city}}"}',
|
||||
),
|
||||
{"city": 'x", "admin": "yes'},
|
||||
)
|
||||
body = json.loads(seen["request"].content)
|
||||
assert set(body) == {"city"}
|
||||
|
||||
|
||||
# --- Reading the response ----------------------------------------------------
|
||||
async def test_a_json_response_is_narrowed_by_the_path(mock_http):
|
||||
def handler(_request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": {"items": [{"title": "Mallorn"}, {"title": "Elanor"}]}},
|
||||
)
|
||||
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(
|
||||
_spec(parameters={}, response_mode=RESPONSE_JSON, response_path="data.items.0.title"), {}
|
||||
)
|
||||
assert outcome.content == "Mallorn"
|
||||
|
||||
|
||||
async def test_a_path_that_leads_nowhere_yields_the_whole_document(mock_http):
|
||||
mock_http(lambda _r: httpx.Response(200, json={"a": 1}))
|
||||
outcome = await custom_tools.call(
|
||||
_spec(parameters={}, response_mode=RESPONSE_JSON, response_path="nope.nope"), {}
|
||||
)
|
||||
assert json.loads(outcome.content) == {"a": 1}
|
||||
|
||||
|
||||
async def test_an_html_response_becomes_text(mock_http):
|
||||
handler, _seen = _ok(
|
||||
"<html><head><title>T</title></head><body><p>Hello</p></body></html>",
|
||||
content_type="text/html",
|
||||
)
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(parameters={}, response_mode=RESPONSE_TEXT), {})
|
||||
assert outcome.content == "Hello"
|
||||
|
||||
|
||||
async def test_a_raw_response_is_left_alone(mock_http):
|
||||
handler, _seen = _ok("<p>kept</p>", content_type="text/html")
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(parameters={}, response_mode=RESPONSE_RAW), {})
|
||||
assert outcome.content == "<p>kept</p>"
|
||||
|
||||
|
||||
async def test_the_response_is_capped_and_says_so(mock_http):
|
||||
handler, _seen = _ok("x" * 5000)
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(parameters={}, max_chars=500), {})
|
||||
assert len(outcome.content) < 600
|
||||
assert outcome.content.endswith("(truncated)")
|
||||
|
||||
|
||||
async def test_the_event_preview_is_capped_independently(mock_http):
|
||||
"""`max_chars` is spent once; the event is stored on the message row."""
|
||||
handler, _seen = _ok("x" * 30_000)
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(parameters={}, max_chars=20_000), {})
|
||||
assert len(outcome.event["text"]) <= custom_tools.MAX_EVENT_CHARS
|
||||
assert len(outcome.content) > custom_tools.MAX_EVENT_CHARS
|
||||
|
||||
|
||||
async def test_an_http_error_becomes_an_explanation_not_an_exception(mock_http):
|
||||
handler, _seen = _ok("no such city", status=404)
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(parameters={}), {})
|
||||
assert outcome.event["status"] == "error"
|
||||
assert "404" in outcome.content
|
||||
assert "no such city" in outcome.content
|
||||
|
||||
|
||||
async def test_a_transport_failure_is_reported_to_the_model(mock_http):
|
||||
def handler(request):
|
||||
raise httpx.ConnectTimeout("too slow", request=request)
|
||||
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(parameters={}), {})
|
||||
assert outcome.event["status"] == "error"
|
||||
assert "Could not reach" in outcome.content
|
||||
|
||||
|
||||
async def test_the_event_names_the_host_and_not_the_filled_url(mock_http):
|
||||
"""A path segment carries an argument, and the event is rendered and kept."""
|
||||
handler, _seen = _ok()
|
||||
mock_http(handler)
|
||||
outcome = await custom_tools.call(_spec(), {"city": "Minas Tirith"})
|
||||
assert outcome.event["detail"] == "GET api.test"
|
||||
assert "Minas" not in outcome.event["detail"]
|
||||
assert "Minas Tirith" in outcome.event["query"]
|
||||
|
||||
|
||||
# --- Turning rows into tools -------------------------------------------------
|
||||
def _row(db, **overrides) -> CustomTool:
|
||||
row = CustomTool(
|
||||
**{
|
||||
"slug": "weather",
|
||||
"name": "Weather",
|
||||
"description": "Look up the weather.",
|
||||
"url_template": "https://api.test/{{city}}",
|
||||
"parameters_json": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
**overrides,
|
||||
}
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_a_row_becomes_a_tool_definition(db, user_id):
|
||||
from lembas.db.models import User
|
||||
|
||||
_row(db)
|
||||
defs = custom_tools.tool_defs(db, db.get(User, user_id))
|
||||
assert [tool.name for tool in defs] == ["weather"]
|
||||
assert defs[0].family == "custom:weather"
|
||||
assert defs[0].schema["function"]["description"] == "Look up the weather."
|
||||
|
||||
|
||||
def test_a_disabled_row_is_not_offered(db, user_id):
|
||||
from lembas.db.models import User
|
||||
|
||||
_row(db, enabled=False)
|
||||
assert custom_tools.tool_defs(db, db.get(User, user_id)) == []
|
||||
|
||||
|
||||
def test_a_schema_that_is_not_an_object_is_replaced(db, user_id):
|
||||
"""An endpoint rejects the whole request over a malformed tools array, so
|
||||
one bad row must not take the reply with it."""
|
||||
from lembas.db.models import User
|
||||
|
||||
_row(db, parameters_json={"type": "string"})
|
||||
defs = custom_tools.tool_defs(db, db.get(User, user_id))
|
||||
assert defs[0].parameters == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_the_secret_is_decrypted_into_the_snapshot_and_nowhere_else(db):
|
||||
row = _row(db, secret_encrypted=encrypt("s3cret"))
|
||||
spec = custom_tools.spec_from(row)
|
||||
assert spec.secret == "s3cret"
|
||||
assert "s3cret" not in row.secret_encrypted
|
||||
@@ -54,6 +54,27 @@ def test_only_the_guidance_for_offered_tools_appears(db, owner):
|
||||
assert "Skills are procedures" not in text
|
||||
|
||||
|
||||
def test_a_custom_tools_guidance_appears_only_when_it_is_offered(db, owner):
|
||||
"""The row supplies the default, and the fragment is gated on the tool's own
|
||||
family -- which is why the registry had to stop being a module constant."""
|
||||
from lembas.db.models import CustomTool
|
||||
|
||||
db.add(
|
||||
CustomTool(
|
||||
slug="weather",
|
||||
name="Weather",
|
||||
description="Look up the weather.",
|
||||
url_template="https://api.test/{{city}}",
|
||||
guidance="- Check the weather rather than guessing at it.",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
offered = tools_service.registry(db)["weather"].schema
|
||||
assert "Check the weather" in harness.compose(db, owner, [offered])
|
||||
assert "Check the weather" not in harness.compose(db, owner, _tools("web_search"))
|
||||
|
||||
|
||||
def test_the_memory_block_is_included_when_memory_is_offered(db, owner):
|
||||
memories_service.add(db, owner=owner, content="Prefers metric units.")
|
||||
text = harness.compose(db, owner, _tools("memory_add"))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Rendering what a tool did.
|
||||
|
||||
The block is written from four places and read from stored rows written by
|
||||
earlier versions, so it has to render anything shaped roughly like an event --
|
||||
and everything in it is third-party text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lembas.web.templating import templates
|
||||
|
||||
|
||||
def _render(*events, live: bool = False) -> str:
|
||||
return templates.get_template("chat/_tool_activity.html").render(
|
||||
{"tool_events": list(events), "live": live}
|
||||
)
|
||||
|
||||
|
||||
def test_a_search_still_says_it_searched_the_web():
|
||||
html = _render(
|
||||
{
|
||||
"name": "web_search",
|
||||
"kind": "search",
|
||||
"query": "mallorn",
|
||||
"status": "ok",
|
||||
"results": [
|
||||
{
|
||||
"title": "Mallorn",
|
||||
"url": "https://a.test/m",
|
||||
"host": "a.test",
|
||||
"snippet": "A tree.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert "Searched the web for “mallorn”" in html
|
||||
assert '<a class="tool-result__title" href="https://a.test/m"' in html
|
||||
assert "1 result" in html
|
||||
|
||||
|
||||
def test_a_library_tool_no_longer_claims_to_have_searched_the_web():
|
||||
"""Stored rows predate `kind`, and every one of them used to render a globe
|
||||
and "Searched the web for <the note title>"."""
|
||||
html = _render({"name": "notes_search", "query": "shopping", "status": "ok", "results": []})
|
||||
assert "Searched the web" not in html
|
||||
assert "notes_search" in html
|
||||
|
||||
|
||||
def test_a_custom_tool_is_named_and_its_host_shown():
|
||||
html = _render(
|
||||
{
|
||||
"name": "weather",
|
||||
"kind": "custom",
|
||||
"label": "Weather",
|
||||
"query": "city='Minas Tirith'",
|
||||
"detail": "GET api.test",
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
"text": "Sunny.",
|
||||
}
|
||||
)
|
||||
assert "Weather" in html
|
||||
assert "GET api.test" in html
|
||||
assert "Sunny." in html
|
||||
|
||||
|
||||
def test_a_tools_own_text_is_escaped_and_never_rendered_as_markdown():
|
||||
"""Hard rule 6. A tool's reply is exactly as untrusted as a search result,
|
||||
and markdown is the one path allowed to emit HTML."""
|
||||
html = _render(
|
||||
{
|
||||
"name": "weather",
|
||||
"kind": "custom",
|
||||
"label": "Weather",
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
"text": "<img src=x onerror=alert(1)> [click](javascript:alert(1))",
|
||||
}
|
||||
)
|
||||
assert "<img" not in html
|
||||
assert "<img" in html
|
||||
# The markdown link is shown as the text it is, not turned into an anchor.
|
||||
assert "<a " not in html
|
||||
assert "[click](javascript:alert(1))" in html
|
||||
|
||||
|
||||
def test_a_result_url_that_is_not_http_never_becomes_a_link():
|
||||
html = _render(
|
||||
{
|
||||
"name": "web_search",
|
||||
"kind": "search",
|
||||
"status": "ok",
|
||||
"results": [{"title": "Bad", "url": "javascript:alert(1)", "host": "", "snippet": ""}],
|
||||
}
|
||||
)
|
||||
assert "<a " not in html
|
||||
assert '<span class="tool-result__title">Bad</span>' in html
|
||||
|
||||
|
||||
def test_a_result_with_no_url_at_all_does_not_explode():
|
||||
html = _render(
|
||||
{
|
||||
"name": "notes_search",
|
||||
"status": "ok",
|
||||
"results": [{"title": "A note", "id": "abc"}],
|
||||
}
|
||||
)
|
||||
assert "A note" in html
|
||||
|
||||
|
||||
def test_a_failure_shows_its_reason():
|
||||
html = _render(
|
||||
{
|
||||
"name": "weather",
|
||||
"kind": "custom",
|
||||
"label": "Weather",
|
||||
"status": "error",
|
||||
"error": "HTTP 503",
|
||||
"results": [],
|
||||
}
|
||||
)
|
||||
assert "tool-activity--error" in html
|
||||
assert "Weather failed" in html
|
||||
assert "HTTP 503" in html
|
||||
@@ -166,6 +166,57 @@ def _context(**kwargs):
|
||||
return tools_service.ToolContext(owner_id="someone", **kwargs)
|
||||
|
||||
|
||||
# --- The offer and the runner travel together --------------------------------
|
||||
def test_the_resolved_set_carries_the_runners_with_the_schemas(db, user_id):
|
||||
"""A tool that is a database row is not reachable through the import-time
|
||||
registry, so the resolution has to travel with the offer."""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
chat = _chat_with(db, user_id, capabilities={"tools": True})
|
||||
resolved = tools_service.resolve_tools(db, chat, _user(db, user_id))
|
||||
|
||||
assert _names(resolved.schemas) == set(resolved.by_name)
|
||||
assert all(callable(tool.run) for tool in resolved.defs)
|
||||
# The old accessor is the same set, so nothing that only wants schemas moved.
|
||||
assert resolved.schemas == tools_service.enabled_tools(db, chat, _user(db, user_id))
|
||||
|
||||
|
||||
async def test_a_tool_that_was_not_offered_is_refused(db, user_id):
|
||||
"""The lookup is against what was offered, not against everything that
|
||||
exists. A model naming a tool its chat was gated out of used to have it run,
|
||||
because only the offer was ever filtered."""
|
||||
from lembas.db.models import User
|
||||
from lembas.services.library import notes as notes_service
|
||||
|
||||
owner = db.get(User, user_id)
|
||||
note = notes_service.create(db, owner=owner, title="Keep me", body="...")
|
||||
|
||||
chat = _chat_with(db, user_id, capabilities={"tools": True, "tool_notes": False})
|
||||
resolved = tools_service.resolve_tools(db, chat, owner)
|
||||
context = tools_service.context_for(db, owner, chat, tools=resolved)
|
||||
|
||||
outcome = await tools_service.run_tool(
|
||||
context, "notes_delete", json.dumps({"id": note.id})
|
||||
)
|
||||
assert outcome.event["status"] == "error"
|
||||
assert notes_service.get(db, note.id, owner) is not None
|
||||
|
||||
|
||||
async def test_a_context_with_no_toolset_still_finds_the_builtins(monkeypatch):
|
||||
"""None means nobody resolved a set. An empty dict does not -- it means
|
||||
nothing was offered, and is authoritative."""
|
||||
|
||||
async def fake_run(_config, query, *, limit=None):
|
||||
return [SearchResult("A title", "https://a.test", "a snippet")]
|
||||
|
||||
monkeypatch.setattr("lembas.services.search.run", fake_run)
|
||||
|
||||
unresolved = await tools_service.run_tool(_context(), "web_search", '{"query": "x"}')
|
||||
assert unresolved.event["status"] == "ok"
|
||||
|
||||
empty = await tools_service.run_tool(_context(tools={}), "web_search", '{"query": "x"}')
|
||||
assert empty.event["status"] == "error"
|
||||
|
||||
|
||||
# --- Knowledge is scoped to the chat's bases ---------------------------------
|
||||
async def test_knowledge_search_is_limited_to_the_attached_bases(db, user_id):
|
||||
""""Answer from the contracts folder" is a different question from "answer
|
||||
|
||||
Reference in New Issue
Block a user