Files
owui-tools/app/owuitools/main.py
T
2026-09-15 15:24:03 -04:00

180 lines
7.1 KiB
Python

"""owuitools - OpenAPI tool server for Open WebUI.
Registered in Open WebUI as a single admin/global OpenAPI connection.
Everything the model can see is defined by the routers below.
Two constraints from Open WebUI 0.11.3's converter shape this whole API,
both verified against its source rather than its docs:
* The tool name the model sees is the operation's `operationId`, and an
operation without one is silently dropped from the spec. Every route
therefore sets `operation_id` explicitly.
* What the model reads as the tool's description is the operation's
`description`, which FastAPI takes from the docstring. Query parameters
get flattened to `{type, description}` - enums become prose, defaults
vanish - while a request body is `$ref`-resolved with enums, defaults
and nesting intact. So every tool is POST-with-a-body, and no tool
mixes body and query parameters (the body receives *all* parameters,
which would then fail validation).
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from . import __version__, logging_setup
from .cache import close_redis, get_redis
from .httpc import close_client, get_client
from .routers import ee, matlab, network, reference, vm, web
from .settings import settings
from .toolconfig import disabled as disabled_tools
log = logging_setup.configure("api", settings.log_level)
@asynccontextmanager
async def lifespan(app: FastAPI):
get_client()
await get_redis()
log.info("owuitools %s ready - egress via %s", __version__, settings.egress_proxy)
yield
await close_client()
await close_redis()
app = FastAPI(
title="owuitools",
version=__version__,
description=(
"Research, reference and engineering tools for the local assistant. "
"Web search and page reading, IT and networking reference lookups, and "
"deterministic electrical-engineering calculations."
),
lifespan=lifespan,
docs_url="/docs",
openapi_url="/openapi.json",
)
# vm.router is registered only when the sandbox volume is mounted. The
# schema for a tool is paid on every turn of every conversation, so a
# tool that cannot work should not be described to the model at all -
# and on a host without the VM stack installed, this one cannot.
_routers = [web.router, reference.router, network.router, ee.router, matlab.router]
if os.path.isdir(os.environ.get("OWUITOOLS_VM_JOBS_DIR", "/vmjobs")):
_routers.append(vm.router)
# Not a tool: the live-progress feed, read by an Open WebUI filter.
# It carries no operationId and is out of the schema, so the model is
# never told it exists.
_routers.append(vm.progress_router)
# Whatever configs/tools.toml switched off stops existing - not just in
# the spec the model reads, but as a route at all. See toolconfig.py for
# why this is the seam and why a missing config leaves everything on.
#
# It has to happen HERE, on the routers, rather than on app.routes after
# the fact. FastAPI 0.141 does not flatten an included router any more:
# include_router() appends one opaque _IncludedRouter to app.routes, so
# app.routes holds no APIRoute and no operation_id at all, and filtering
# it looks like it worked while removing nothing.
#
# Routes with no operation_id are never candidates: /health and the vm
# progress feed are deliberately out of the schema and are not tools.
_implemented = {
operation_id for router in _routers for route in router.routes
if (operation_id := getattr(route, "operation_id", None))
}
_switched_off = disabled_tools(_implemented)
for router in _routers:
if _switched_off:
router.routes[:] = [
route for route in router.routes
if getattr(route, "operation_id", None) not in _switched_off
]
app.include_router(router)
if _switched_off:
log.info("%d of %d tools removed by config; %d offered: %s",
len(_switched_off), len(_implemented),
len(_implemented) - len(_switched_off),
", ".join(sorted(_implemented - _switched_off)))
@app.exception_handler(RequestValidationError)
async def _validation_as_prose(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Say what was wrong with the call in words, with a 200, like every other failure.
FastAPI's default is a 422 with a JSON body of pydantic error objects.
Open WebUI does not treat a non-2xx tool response as a result; it hands
the model `{"error": "HTTP error 422: {...}"}`, which two graded rounds
show being read as the tool being broken rather than the argument being
out of range. The same information as a sentence, with the same status
as a CANNOT DO THAT, is acted on: the model fixes the one parameter.
"""
problems = []
for err in exc.errors():
loc = [str(part) for part in err.get("loc", ()) if part not in ("body",)]
field = ".".join(loc) or "the request"
sent = err.get("input")
shown = repr(sent) if not isinstance(sent, (dict, list)) else "the value given"
problems.append(f"{field}: {err.get('msg', 'invalid')} (you sent {shown})")
text = ("CANNOT DO THAT: the call was rejected before it ran - "
+ "; ".join(problems[:4])
+ ". Fix that parameter and call again; nothing else about the request needs to change.")
return JSONResponse(status_code=200, content=text)
def _lean(node) -> None:
"""Strip schema noise the model would pay for and cannot use.
Open WebUI copies each request-body property into the tool definition
verbatim, and Ollama then keeps only type, anyOf, items, enum, properties,
required and description. So `title` never reaches the model but bloats
the spec, while the `anyOf: [T, null]` pydantic emits for every optional
field *does* reach it - three tokens of type information rendered as a
dozen. Collapse it to the real type; the server accepts null regardless.
"""
if isinstance(node, dict):
node.pop("title", None)
any_of = node.get("anyOf")
if isinstance(any_of, list):
real = [alt for alt in any_of if alt != {"type": "null"}]
if len(real) == 1 and len(real) < len(any_of):
del node["anyOf"]
for key, value in real[0].items():
node.setdefault(key, value)
if "default" in node and node["default"] is None:
del node["default"]
for value in list(node.values()):
_lean(value)
elif isinstance(node, list):
for value in node:
_lean(value)
_generate_openapi = app.openapi
def _lean_openapi() -> dict:
if not app.openapi_schema:
schema = _generate_openapi()
_lean(schema.get("components", {}).get("schemas", {}))
_lean(schema.get("paths", {}))
app.openapi_schema = schema
return app.openapi_schema
app.openapi = _lean_openapi
@app.get("/health", include_in_schema=False)
async def health() -> dict:
"""Unauthenticated so Docker's healthcheck can use it. Reveals nothing."""
return {"status": "ok", "version": __version__}