AI-Dev/qortal_api_tool_function.py

217 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
title: Qortal API Toolkit
author: crowetic (with assistance from chatgpt)
git_url: https://gitea.qortal.link/crowetic/AI-Dev
version: 0.3.0
license: MIT
description: Query any Qortal Core HTTP endpoint directly from Open WebUI.
HOW-TO
------
1. Add this in 'functions' to create the tool function
2. Add the tool to your model
3. In any model/preset: *Chat → ⚙️ Tools* → enable **Qortal API Toolkit**.
4. Ask something like:
“Get the balance of address Q...”
The LLM will auto-call `qortal_get`.
________ __ .__ _____ __________.___ _____ __ .__
\_____ \ ____________/ |______ | | / _ \\______ \ |/ ____\_ __ ____ _____/ |_|__| ____ ____
/ / \ \ / _ \_ __ \ __\__ \ | | / /_\ \| ___/ \ __\ | \/ \_/ ___\ __\ |/ _ \ / \
/ \_/. ( <_> ) | \/| | / __ \| |_/ | \ | | || | | | / | \ \___| | | ( <_> ) | \
\_____\ \_/\____/|__| |__| (____ /____|____|__ /____| |___||__| |____/|___| /\___ >__| |__|\____/|___| /
\__> \/ \/ \/ \/ \/
___. __ .__
\_ |__ ___.__. /\ ___________ ______ _ __ _____/ |_|__| ____
| __ < | | \/ _/ ___\_ __ \/ _ \ \/ \/ // __ \ __\ |/ ___\
| \_\ \___ | /\ \ \___| | \( <_> ) /\ ___/| | | \ \___
|___ / ____| \/ \___ >__| \____/ \/\_/ \___ >__| |__|\___ >
\/\/ \/ \/ \/
"""
import os, json, requests, asyncio
from typing import Dict, Any, Optional, Callable
from pydantic import BaseModel, Field
# ╭─────────────────────────── helpers ───────────────────────────╮
class _EventEmitter:
def __init__(self, cb: Callable[[dict], Any] | None):
self.cb = cb
async def emit(self, description, status="in_progress", done=False):
if self.cb:
await self.cb(
{
"type": "status",
"data": {
"status": status,
"description": description,
"done": done,
},
}
)
# ╭─────────────────────────── plugin ────────────────────────────╮
class Tools:
# ─── user-visible knobs ──────────────────────────────────────
class Valves(BaseModel):
QORTAL_URL: str = Field(
default=os.getenv("QORTAL_API_URL", "https://api.qortal.org"),
description="Base URL of your Qortal node (http[s]://host:port)",
)
OPENAPI_URL: str = Field(
default=os.getenv("QORTAL_OPENAPI_URL", ""),
description="Custom location of openapi.json (leave blank for <QORTAL_URL>/openapi.json)",
)
HTTP_TIMEOUT: int = Field(default=30, description="Request timeout (seconds)")
ALLOW_POST: bool = Field(
default=False,
description="Enable POST endpoints (requires unlocked API on the node)",
)
def __init__(self):
self.valves = self.Valves()
self._openapi_cache: Optional[Dict[str, Any]] = None
self.headers = {
"User-Agent": "Open-WebUI Qortal API Toolkit (+https://qortal.org)"
}
# ─── public async methods (tool functions) ───────────────────
async def qortal_get(
self,
endpoint: str,
path_params: Dict[str, str] | None = None,
query_params: Dict[str, str] | None = None,
__event_emitter__: Callable[[dict], Any] | None = None,
) -> str:
"""
Perform a validated **GET** request to the node.
• endpoint e.g. "/addresses/balance/{address}"
• path_params dict that fills the {tokens}
• query_params optional ?key=value pairs
"""
emitter = _EventEmitter(__event_emitter__)
await emitter.emit(f"GET {endpoint}")
if not await self._is_valid_get(endpoint):
await emitter.emit(
f"Endpoint '{endpoint}' is not a valid GET per node spec", "error", True
)
return json.dumps({"error": "invalid_endpoint"})
return await self._do_request(
"GET", endpoint, path_params, query_params, None, emitter
)
async def qortal_post(
self,
endpoint: str,
path_params: Dict[str, str] | None = None,
json_body: Dict[str, Any] | None = None,
__event_emitter__: Callable[[dict], Any] | None = None,
) -> str:
"""
Perform a **POST** request (requires ALLOW_POST = true).
• endpoint e.g. "/arbitrary/publish"
• path_params dict that fills the {tokens}
• json_body payload sent as JSON
"""
emitter = _EventEmitter(__event_emitter__)
await emitter.emit(f"POST {endpoint}")
if not self.valves.ALLOW_POST:
await emitter.emit(
"POST disabled in valves; set ALLOW_POST = true to enable",
"error",
True,
)
return json.dumps({"error": "post_disabled"})
return await self._do_request(
"POST", endpoint, path_params, None, json_body, emitter
)
# ╭──────────────────── internal plumbing ───────────────────╮
async def _do_request(
self,
method: str,
endpoint: str,
path_params: Dict[str, Any] | None,
query_params: Dict[str, Any] | None,
json_body: Dict[str, Any] | None,
emitter: _EventEmitter,
) -> str:
url = self._build_url(endpoint, path_params)
try:
resp = requests.request(
method=method,
url=url,
params=query_params,
json=json_body,
headers=self.headers,
timeout=self.valves.HTTP_TIMEOUT,
)
data: Any
try:
data = resp.json()
except ValueError:
data = resp.text
await emitter.emit(
f"{method} {endpoint}{resp.status_code}", "complete", True
)
return json.dumps(
{
"success": resp.ok,
"status_code": resp.status_code,
"url": resp.url,
"data": data,
},
ensure_ascii=False,
)
except Exception as e:
await emitter.emit(f"Request error: {e}", "error", True)
return json.dumps({"success": False, "error": str(e)})
# ─── spec helpers ───────────────────────────────────────────
async def _is_valid_get(self, endpoint: str) -> bool:
endpoint = "/" + endpoint.lstrip("/")
return endpoint in await self._valid_get_list()
async def _valid_get_list(self):
spec = await self._load_openapi()
return [
p for p, verbs in spec.get("paths", {}).items() if "get" in verbs
]
async def _load_openapi(self):
if self._openapi_cache is not None:
return self._openapi_cache
url = (
self.valves.OPENAPI_URL
or f"{self.valves.QORTAL_URL.rstrip('/')}/openapi.json"
)
try:
resp = requests.get(url, headers=self.headers, timeout=self.valves.HTTP_TIMEOUT)
resp.raise_for_status()
self._openapi_cache = resp.json()
except Exception:
self._openapi_cache = {}
return self._openapi_cache
# ─── misc helpers ───────────────────────────────────────────
def _build_url(self, endpoint: str, path_params: Dict[str, Any] | None):
ep = "/" + endpoint.lstrip("/")
if path_params:
for k, v in path_params.items():
ep = ep.replace("{" + k + "}", str(v))
return self.valves.QORTAL_URL.rstrip("/") + ep