180 lines
7.1 KiB
Python
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__}
|