326 lines
9.9 KiB
Python
326 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Deploy Tools plugins to OpenWebUI instance.
|
|
|
|
This script deploys tool plugins to a running OpenWebUI instance.
|
|
It reads the plugin metadata and submits it to the local API.
|
|
|
|
Usage:
|
|
python deploy_tool.py # Deploy OpenWebUI Skills Manager Tool
|
|
python deploy_tool.py <tool_name> # Deploy specific tool
|
|
python deploy_tool.py --list # List available tools
|
|
"""
|
|
|
|
import requests
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any
|
|
|
|
# ─── Configuration ───────────────────────────────────────────────────────────
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
ENV_FILE = SCRIPT_DIR / ".env"
|
|
TOOLS_DIR = SCRIPT_DIR.parent / "plugins/tools"
|
|
|
|
# Default target tool
|
|
DEFAULT_TOOL = "openwebui-skills-manager"
|
|
|
|
|
|
def _load_api_key() -> str:
|
|
"""Load API key from .env file in the same directory as this script."""
|
|
env_values = {}
|
|
if ENV_FILE.exists():
|
|
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
env_values[key.strip().lower()] = value.strip().strip('"').strip("'")
|
|
|
|
api_key = (
|
|
os.getenv("OPENWEBUI_API_KEY")
|
|
or os.getenv("api_key")
|
|
or env_values.get("api_key")
|
|
or env_values.get("openwebui_api_key")
|
|
)
|
|
|
|
if not api_key:
|
|
raise ValueError(
|
|
f"Missing api_key. Please create {ENV_FILE} with: "
|
|
"api_key=sk-xxxxxxxxxxxx"
|
|
)
|
|
return api_key
|
|
|
|
|
|
def _get_base_url() -> str:
|
|
"""Load base URL from .env file or environment."""
|
|
env_values = {}
|
|
if ENV_FILE.exists():
|
|
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
env_values[key.strip().lower()] = value.strip().strip('"').strip("'")
|
|
|
|
base_url = (
|
|
os.getenv("OPENWEBUI_URL")
|
|
or os.getenv("OPENWEBUI_BASE_URL")
|
|
or os.getenv("url")
|
|
or env_values.get("url")
|
|
or env_values.get("openwebui_url")
|
|
or env_values.get("openwebui_base_url")
|
|
)
|
|
|
|
if not base_url:
|
|
raise ValueError(
|
|
f"Missing url. Please create {ENV_FILE} with: " "url=http://localhost:3000"
|
|
)
|
|
return base_url.rstrip("/")
|
|
|
|
|
|
def _find_tool_file(tool_name: str) -> Optional[Path]:
|
|
"""Find the main Python file for a tool.
|
|
|
|
Args:
|
|
tool_name: Directory name of the tool (e.g., 'openwebui-skills-manager')
|
|
|
|
Returns:
|
|
Path to the main Python file, or None if not found.
|
|
"""
|
|
tool_dir = TOOLS_DIR / tool_name
|
|
if not tool_dir.exists():
|
|
return None
|
|
|
|
# Try to find a .py file matching the tool name
|
|
py_files = list(tool_dir.glob("*.py"))
|
|
|
|
# Prefer a file with the tool name (with hyphens converted to underscores)
|
|
preferred_name = tool_name.replace("-", "_") + ".py"
|
|
for py_file in py_files:
|
|
if py_file.name == preferred_name:
|
|
return py_file
|
|
|
|
# Otherwise, return the first .py file (usually the only one)
|
|
if py_files:
|
|
return py_files[0]
|
|
|
|
return None
|
|
|
|
|
|
def _extract_metadata(content: str) -> Dict[str, Any]:
|
|
"""Extract metadata from the plugin docstring."""
|
|
metadata = {}
|
|
|
|
# Extract docstring
|
|
match = re.search(r'"""(.*?)"""', content, re.DOTALL)
|
|
if not match:
|
|
return metadata
|
|
|
|
docstring = match.group(1)
|
|
|
|
# Extract key-value pairs
|
|
for line in docstring.split("\n"):
|
|
line = line.strip()
|
|
if ":" in line and not line.startswith("#") and not line.startswith("═"):
|
|
parts = line.split(":", 1)
|
|
key = parts[0].strip().lower()
|
|
value = parts[1].strip()
|
|
metadata[key] = value
|
|
|
|
return metadata
|
|
|
|
|
|
def _build_tool_payload(
|
|
tool_name: str, file_path: Path, content: str, metadata: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Build the payload for the tool update/create API."""
|
|
tool_id = metadata.get("id", tool_name).replace("-", "_")
|
|
title = metadata.get("title", tool_name)
|
|
author = metadata.get("author", "Fu-Jie")
|
|
author_url = metadata.get(
|
|
"author_url", "https://github.com/Fu-Jie/openwebui-extensions"
|
|
)
|
|
funding_url = metadata.get("funding_url", "https://github.com/open-webui")
|
|
description = metadata.get("description", f"Tool plugin: {title}")
|
|
version = metadata.get("version", "1.0.0")
|
|
openwebui_id = metadata.get("openwebui_id", "")
|
|
|
|
payload = {
|
|
"id": tool_id,
|
|
"name": title,
|
|
"meta": {
|
|
"description": description,
|
|
"manifest": {
|
|
"title": title,
|
|
"author": author,
|
|
"author_url": author_url,
|
|
"funding_url": funding_url,
|
|
"description": description,
|
|
"version": version,
|
|
"type": "tool",
|
|
},
|
|
"type": "tool",
|
|
},
|
|
"content": content,
|
|
}
|
|
|
|
# Add openwebui_id if available
|
|
if openwebui_id:
|
|
payload["meta"]["manifest"]["openwebui_id"] = openwebui_id
|
|
|
|
return payload
|
|
|
|
|
|
def deploy_tool(tool_name: str = DEFAULT_TOOL) -> bool:
|
|
"""Deploy a tool plugin to OpenWebUI.
|
|
|
|
Args:
|
|
tool_name: Directory name of the tool to deploy
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
# 1. Load API key and base URL
|
|
try:
|
|
api_key = _load_api_key()
|
|
base_url = _get_base_url()
|
|
except ValueError as e:
|
|
print(f"[ERROR] {e}")
|
|
return False
|
|
|
|
# 2. Find tool file
|
|
file_path = _find_tool_file(tool_name)
|
|
if not file_path:
|
|
print(f"[ERROR] Tool '{tool_name}' not found in {TOOLS_DIR}")
|
|
print(f"[INFO] Available tools:")
|
|
for d in TOOLS_DIR.iterdir():
|
|
if d.is_dir() and not d.name.startswith("_"):
|
|
print(f" - {d.name}")
|
|
return False
|
|
|
|
# 3. Read local source file
|
|
if not file_path.exists():
|
|
print(f"[ERROR] Source file not found: {file_path}")
|
|
return False
|
|
|
|
content = file_path.read_text(encoding="utf-8")
|
|
metadata = _extract_metadata(content)
|
|
|
|
if not metadata:
|
|
print(f"[ERROR] Could not extract metadata from {file_path}")
|
|
return False
|
|
|
|
version = metadata.get("version", "1.0.0")
|
|
title = metadata.get("title", tool_name)
|
|
tool_id = metadata.get("id", tool_name).replace("-", "_")
|
|
|
|
# 4. Build payload
|
|
payload = _build_tool_payload(tool_name, file_path, content, metadata)
|
|
|
|
# 5. Build headers
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {api_key}",
|
|
}
|
|
|
|
# 6. Send update request through the native tool endpoints
|
|
update_url = f"{base_url}/api/v1/tools/id/{tool_id}/update"
|
|
create_url = f"{base_url}/api/v1/tools/create"
|
|
|
|
print(f"📦 Deploying tool '{title}' (version {version})...")
|
|
print(f" File: {file_path}")
|
|
|
|
try:
|
|
# Try update first
|
|
response = requests.post(
|
|
update_url,
|
|
headers=headers,
|
|
data=json.dumps(payload),
|
|
timeout=10,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
print(f"✅ Successfully updated '{title}' tool!")
|
|
return True
|
|
else:
|
|
print(
|
|
f"⚠️ Update failed with status {response.status_code}, "
|
|
"attempting to create instead..."
|
|
)
|
|
|
|
# Try create if update fails
|
|
res_create = requests.post(
|
|
create_url,
|
|
headers=headers,
|
|
data=json.dumps(payload),
|
|
timeout=10,
|
|
)
|
|
|
|
if res_create.status_code == 200:
|
|
print(f"✅ Successfully created '{title}' tool!")
|
|
return True
|
|
else:
|
|
print(
|
|
f"❌ Failed to update or create. Status: {res_create.status_code}"
|
|
)
|
|
try:
|
|
error_msg = res_create.json()
|
|
print(f" Error: {error_msg}")
|
|
except:
|
|
print(f" Response: {res_create.text[:500]}")
|
|
return False
|
|
|
|
except requests.exceptions.ConnectionError:
|
|
print("❌ Connection error: Could not reach OpenWebUI at {base_url}")
|
|
print(" Make sure OpenWebUI is running and accessible.")
|
|
return False
|
|
except requests.exceptions.Timeout:
|
|
print("❌ Request timeout: OpenWebUI took too long to respond")
|
|
return False
|
|
except Exception as e:
|
|
print(f"❌ Request error: {e}")
|
|
return False
|
|
|
|
|
|
def list_tools() -> None:
|
|
"""List all available tools."""
|
|
print("📋 Available tools:")
|
|
tools = [
|
|
d.name for d in TOOLS_DIR.iterdir() if d.is_dir() and not d.name.startswith("_")
|
|
]
|
|
|
|
if not tools:
|
|
print(" (No tools found)")
|
|
return
|
|
|
|
for tool_name in sorted(tools):
|
|
tool_dir = TOOLS_DIR / tool_name
|
|
py_file = _find_tool_file(tool_name)
|
|
|
|
if py_file:
|
|
content = py_file.read_text(encoding="utf-8")
|
|
metadata = _extract_metadata(content)
|
|
title = metadata.get("title", tool_name)
|
|
version = metadata.get("version", "?")
|
|
print(f" - {tool_name:<30} {title:<40} v{version}")
|
|
else:
|
|
print(f" - {tool_name:<30} (no Python file found)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1:
|
|
if sys.argv[1] == "--list" or sys.argv[1] == "-l":
|
|
list_tools()
|
|
else:
|
|
tool_name = sys.argv[1]
|
|
success = deploy_tool(tool_name)
|
|
sys.exit(0 if success else 1)
|
|
else:
|
|
# Deploy default tool
|
|
success = deploy_tool()
|
|
sys.exit(0 if success else 1)
|