Initial Commit
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""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__}
|
||||
Reference in New Issue
Block a user