mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 03:31:28 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5a432d96 | ||
|
|
8cb5bbb605 |
No files matched your search
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.1" \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.1" \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.7.1" \
|
||||
"praisonai>=2.7.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ examples/
|
||||
| **Tools** | [python/tools/](python/tools/) | [📖](https://docs.praison.ai/tools) |
|
||||
| **Code** | [python/code/](python/code/) | [📖](https://docs.praison.ai/code) |
|
||||
| **YAML** | [yaml/](yaml/) | [📖](https://docs.praison.ai/features/yaml-workflows) |
|
||||
| **Doctor** | [doctor/](doctor/) | [📖](https://docs.praison.ai/cli/doctor) |
|
||||
|
||||
## Running Examples
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# PraisonAI Doctor Examples
|
||||
|
||||
Examples demonstrating the PraisonAI Doctor health check and diagnostics system.
|
||||
|
||||
## CLI Examples
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `praisonai doctor` | Run all fast health checks |
|
||||
| `praisonai doctor --version` | Show doctor version |
|
||||
| `praisonai doctor --list-checks` | List all available checks |
|
||||
| `praisonai doctor env` | Check environment configuration |
|
||||
| `praisonai doctor config` | Validate configuration files |
|
||||
| `praisonai doctor tools` | Check tool availability |
|
||||
| `praisonai doctor db` | Check database drivers |
|
||||
| `praisonai doctor mcp` | Check MCP configuration |
|
||||
| `praisonai doctor obs` | Check observability providers |
|
||||
| `praisonai doctor skills` | Check agent skills |
|
||||
| `praisonai doctor memory` | Check memory storage |
|
||||
| `praisonai doctor permissions` | Check filesystem permissions |
|
||||
| `praisonai doctor network` | Check network connectivity |
|
||||
| `praisonai doctor performance` | Check import times |
|
||||
| `praisonai doctor ci` | CI mode with JSON output |
|
||||
| `praisonai doctor selftest` | Test agent functionality |
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--json` | Output in JSON format |
|
||||
| `--format text\|json` | Output format |
|
||||
| `--output PATH` | Write report to file |
|
||||
| `--deep` | Enable deeper probes |
|
||||
| `--timeout SEC` | Per-check timeout |
|
||||
| `--strict` | Treat warnings as failures |
|
||||
| `--quiet` | Minimal output |
|
||||
| `--no-color` | Disable colors |
|
||||
| `--only IDS` | Only run these checks |
|
||||
| `--skip IDS` | Skip these checks |
|
||||
|
||||
## Python Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [basic_doctor.py](basic_doctor.py) | Programmatic health checks |
|
||||
| [ci_integration.py](ci_integration.py) | CI/CD pipeline integration |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run basic health checks
|
||||
praisonai doctor
|
||||
|
||||
# Run with JSON output
|
||||
praisonai doctor --json
|
||||
|
||||
# Run specific checks
|
||||
praisonai doctor --only python_version,openai_api_key
|
||||
|
||||
# CI mode
|
||||
praisonai doctor ci
|
||||
|
||||
# Save report to file
|
||||
praisonai doctor --output report.json
|
||||
```
|
||||
@@ -60,7 +60,7 @@ def run_environment_checks():
|
||||
engine = DoctorEngine(config)
|
||||
|
||||
# Run only environment category
|
||||
results = engine.run_checks(categories=[CheckCategory.ENVIRONMENT])
|
||||
engine.run_checks(categories=[CheckCategory.ENVIRONMENT])
|
||||
report = engine.generate_report()
|
||||
|
||||
formatter = get_formatter("text", no_color=True)
|
||||
@@ -130,7 +130,10 @@ def list_available_checks():
|
||||
register_all_checks()
|
||||
|
||||
registry = get_registry()
|
||||
checks = registry.list_checks()
|
||||
checks = registry.get_all_checks()
|
||||
|
||||
# Sort by category
|
||||
checks.sort(key=lambda c: c.category.value)
|
||||
|
||||
current_category = None
|
||||
for check in checks:
|
||||
|
||||
@@ -33,7 +33,7 @@ def run_doctor_ci():
|
||||
|
||||
# Print summary
|
||||
summary = report.get("summary", {})
|
||||
print(f"\nDoctor CI Results:")
|
||||
print("\nDoctor CI Results:")
|
||||
print(f" Total checks: {summary.get('total', 0)}")
|
||||
print(f" Passed: {summary.get('passed', 0)}")
|
||||
print(f" Warnings: {summary.get('warnings', 0)}")
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PraisonAI Endpoints Example
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Start the recipe server
|
||||
2. Use the endpoints CLI to interact with it
|
||||
3. Invoke recipes via HTTP
|
||||
|
||||
Prerequisites:
|
||||
- pip install praisonai[serve]
|
||||
- Set OPENAI_API_KEY environment variable
|
||||
|
||||
Usage:
|
||||
# Terminal 1: Start server
|
||||
praisonai recipe serve --port 8765
|
||||
|
||||
# Terminal 2: Run this example
|
||||
python endpoints_example.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Configuration
|
||||
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
|
||||
|
||||
|
||||
def check_server_health():
|
||||
"""Check if the server is running."""
|
||||
try:
|
||||
req = urllib.request.Request(f"{SERVER_URL}/health")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return data.get("status") == "healthy"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def list_endpoints():
|
||||
"""List available endpoints using CLI."""
|
||||
print("\n=== Listing Endpoints (CLI) ===")
|
||||
result = subprocess.run(
|
||||
["python", "-m", "praisonai.cli.main", "endpoints", "list", "--format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
recipes = json.loads(result.stdout)
|
||||
print(f"Found {len(recipes)} endpoints:")
|
||||
for r in recipes:
|
||||
print(f" - {r['name']} ({r['version']}): {r.get('description', '')[:50]}")
|
||||
return recipes
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
return []
|
||||
|
||||
|
||||
def describe_endpoint(name: str):
|
||||
"""Describe an endpoint using CLI."""
|
||||
print(f"\n=== Describing Endpoint: {name} ===")
|
||||
result = subprocess.run(
|
||||
["python", "-m", "praisonai.cli.main", "endpoints", "describe", name, "--url", SERVER_URL],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info = json.loads(result.stdout)
|
||||
print(f"Name: {info.get('name')}")
|
||||
print(f"Version: {info.get('version')}")
|
||||
print(f"Description: {info.get('description')}")
|
||||
return info
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def invoke_endpoint_cli(name: str, input_data: dict):
|
||||
"""Invoke an endpoint using CLI."""
|
||||
print(f"\n=== Invoking Endpoint (CLI): {name} ===")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python", "-m", "praisonai.cli.main", "endpoints", "invoke", name,
|
||||
"--url", SERVER_URL,
|
||||
"--input-json", json.dumps(input_data),
|
||||
"--json",
|
||||
"--dry-run", # Use dry-run for example
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
response = json.loads(result.stdout)
|
||||
print(f"Run ID: {response.get('run_id')}")
|
||||
print(f"Status: {response.get('status')}")
|
||||
return response
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def invoke_endpoint_http(name: str, input_data: dict):
|
||||
"""Invoke an endpoint using HTTP directly."""
|
||||
print(f"\n=== Invoking Endpoint (HTTP): {name} ===")
|
||||
|
||||
body = json.dumps({
|
||||
"recipe": name,
|
||||
"input": input_data,
|
||||
"options": {"dry_run": True},
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{SERVER_URL}/v1/recipes/run",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
print(f"Run ID: {data.get('run_id')}")
|
||||
print(f"Status: {data.get('status')}")
|
||||
print(f"Output: {data.get('output')}")
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error: {e.code} - {e.read().decode()}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main example function."""
|
||||
print("=" * 60)
|
||||
print("PraisonAI Endpoints Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if server is running
|
||||
print("\n=== Checking Server Health ===")
|
||||
if not check_server_health():
|
||||
print(f"Server not running at {SERVER_URL}")
|
||||
print("\nPlease start the server first:")
|
||||
print(" praisonai recipe serve --port 8765")
|
||||
sys.exit(1)
|
||||
print("✓ Server is healthy")
|
||||
|
||||
# List endpoints
|
||||
recipes = list_endpoints()
|
||||
if not recipes:
|
||||
print("No recipes available")
|
||||
sys.exit(1)
|
||||
|
||||
# Use first available recipe
|
||||
recipe_name = recipes[0]["name"]
|
||||
|
||||
# Describe endpoint
|
||||
describe_endpoint(recipe_name)
|
||||
|
||||
# Invoke via CLI
|
||||
invoke_endpoint_cli(recipe_name, {"query": "Hello from CLI"})
|
||||
|
||||
# Invoke via HTTP
|
||||
invoke_endpoint_http(recipe_name, {"query": "Hello from HTTP"})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.1.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.1.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -18,6 +18,7 @@ __all__ = [
|
||||
'DeployConfig',
|
||||
'DeployType',
|
||||
'CloudProvider',
|
||||
'recipe',
|
||||
]
|
||||
|
||||
# Lazy loading for heavy imports
|
||||
@@ -38,6 +39,9 @@ def __getattr__(name):
|
||||
elif name == 'CloudProvider':
|
||||
from .deploy import CloudProvider
|
||||
return CloudProvider
|
||||
elif name == 'recipe':
|
||||
from .recipe import core as recipe_module
|
||||
return recipe_module
|
||||
|
||||
# Try praisonaiagents exports
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
Endpoints CLI Feature Handler
|
||||
|
||||
Provides CLI commands for interacting with recipe endpoints:
|
||||
- list: List available recipe endpoints
|
||||
- describe: Show endpoint details and schema
|
||||
- invoke: Call an endpoint
|
||||
- health: Check endpoint server health
|
||||
|
||||
All commands use the canonical `praisonai endpoints` prefix.
|
||||
|
||||
Why this feature is valuable:
|
||||
- DX: Client invocation from any language/script
|
||||
- Ops: Health checks, monitoring, automation
|
||||
- Polyglot: Non-Python clients can invoke recipes via HTTP
|
||||
- Testing: Easy endpoint verification without code
|
||||
|
||||
Architecture notes:
|
||||
- Optional extras only (no server deps in core)
|
||||
- Lazy imports for all HTTP client code
|
||||
- No impact on praisonaiagents import time
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class EndpointsHandler:
|
||||
"""
|
||||
CLI handler for endpoints operations.
|
||||
|
||||
Commands:
|
||||
- list: List available recipe endpoints
|
||||
- describe: Show endpoint details
|
||||
- invoke: Call an endpoint
|
||||
- health: Check server health
|
||||
"""
|
||||
|
||||
# Exit codes
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_GENERAL_ERROR = 1
|
||||
EXIT_VALIDATION_ERROR = 2
|
||||
EXIT_RUNTIME_ERROR = 3
|
||||
EXIT_AUTH_ERROR = 4
|
||||
EXIT_NOT_FOUND = 7
|
||||
EXIT_CONNECTION_ERROR = 8
|
||||
|
||||
DEFAULT_URL = "http://localhost:8765"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler."""
|
||||
self._base_url = None
|
||||
self._api_key = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Get base URL from env or default."""
|
||||
if self._base_url:
|
||||
return self._base_url
|
||||
return os.environ.get("PRAISONAI_ENDPOINTS_URL", self.DEFAULT_URL)
|
||||
|
||||
@property
|
||||
def api_key(self) -> Optional[str]:
|
||||
"""Get API key from env."""
|
||||
if self._api_key:
|
||||
return self._api_key
|
||||
return os.environ.get("PRAISONAI_ENDPOINTS_API_KEY")
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle endpoints subcommand.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
if not args:
|
||||
self._print_help()
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
commands = {
|
||||
"list": self.cmd_list,
|
||||
"describe": self.cmd_describe,
|
||||
"invoke": self.cmd_invoke,
|
||||
"health": self.cmd_health,
|
||||
"help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
"-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
|
||||
}
|
||||
|
||||
if command in commands:
|
||||
return commands[command](remaining)
|
||||
else:
|
||||
self._print_error(f"Unknown command: {command}")
|
||||
self._print_help()
|
||||
return self.EXIT_GENERAL_ERROR
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
help_text = """
|
||||
[bold cyan]PraisonAI Endpoints[/bold cyan]
|
||||
|
||||
Client CLI for interacting with recipe endpoints.
|
||||
|
||||
[bold]Usage:[/bold]
|
||||
praisonai endpoints <command> [options]
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
list List available recipe endpoints
|
||||
describe <recipe> Show endpoint details and schema
|
||||
invoke <recipe> Call an endpoint
|
||||
health Check server health
|
||||
|
||||
[bold]List Options:[/bold]
|
||||
--format json Output as JSON
|
||||
--tags <a,b> Filter by tags (comma-separated)
|
||||
--url <url> Server URL (default: http://localhost:8765)
|
||||
|
||||
[bold]Describe Options:[/bold]
|
||||
--schema Show input/output schema only
|
||||
--url <url> Server URL
|
||||
|
||||
[bold]Invoke Options:[/bold]
|
||||
--input <path> Input file path
|
||||
--input-json <j> Input as JSON string
|
||||
--config k=v Config override (repeatable)
|
||||
--json Output as JSON
|
||||
--stream Stream output events (SSE)
|
||||
--url <url> Server URL
|
||||
--api-key <key> API key for auth (or set PRAISONAI_ENDPOINTS_API_KEY)
|
||||
--dry-run Validate without executing
|
||||
|
||||
[bold]Health Options:[/bold]
|
||||
--url <url> Server URL
|
||||
|
||||
[bold]Environment Variables:[/bold]
|
||||
PRAISONAI_ENDPOINTS_URL Default server URL
|
||||
PRAISONAI_ENDPOINTS_API_KEY API key for authentication
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
praisonai endpoints list
|
||||
praisonai endpoints list --format json --tags audio,video
|
||||
praisonai endpoints describe ai-podcast-cleaner
|
||||
praisonai endpoints describe ai-podcast-cleaner --schema
|
||||
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3
|
||||
praisonai endpoints invoke ai-podcast-cleaner --input-json '{"text": "hello"}'
|
||||
praisonai endpoints invoke ai-podcast-cleaner --input ./audio.mp3 --stream
|
||||
praisonai endpoints health
|
||||
praisonai endpoints health --url http://localhost:8000
|
||||
"""
|
||||
self._print_rich(help_text)
|
||||
|
||||
def _print_rich(self, text: str):
|
||||
"""Print with rich formatting if available."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(text)
|
||||
except ImportError:
|
||||
import re
|
||||
plain = re.sub(r'\[/?[^\]]+\]', '', text)
|
||||
print(plain)
|
||||
|
||||
def _print_error(self, message: str):
|
||||
"""Print error message."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(f"[red]Error: {message}[/red]")
|
||||
except ImportError:
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
|
||||
def _print_success(self, message: str):
|
||||
"""Print success message."""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(f"[green]✓ {message}[/green]")
|
||||
except ImportError:
|
||||
print(f"✓ {message}")
|
||||
|
||||
def _print_json(self, data: Any):
|
||||
"""Print JSON output."""
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
|
||||
def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse command arguments based on spec."""
|
||||
result = {k: v.get("default") for k, v in spec.items()}
|
||||
positional_keys = [k for k, v in spec.items() if v.get("positional")]
|
||||
positional_idx = 0
|
||||
|
||||
# Handle repeatable args
|
||||
for k, v in spec.items():
|
||||
if v.get("repeatable"):
|
||||
result[k] = []
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:].replace("-", "_")
|
||||
if key in spec:
|
||||
if spec[key].get("flag"):
|
||||
result[key] = True
|
||||
elif spec[key].get("repeatable") and i + 1 < len(args):
|
||||
result[key].append(args[i + 1])
|
||||
i += 1
|
||||
elif i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
i += 1
|
||||
elif arg.startswith("-") and len(arg) == 2:
|
||||
for key, val in spec.items():
|
||||
if val.get("short") == arg:
|
||||
if val.get("flag"):
|
||||
result[key] = True
|
||||
elif i + 1 < len(args):
|
||||
result[key] = args[i + 1]
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
if positional_idx < len(positional_keys):
|
||||
result[positional_keys[positional_idx]] = arg
|
||||
positional_idx += 1
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
stream: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to endpoint server."""
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
except ImportError:
|
||||
return {"error": "urllib not available"}
|
||||
|
||||
base = url or self.base_url
|
||||
full_url = f"{base.rstrip('/')}{path}"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
key = api_key or self.api_key
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
|
||||
data = None
|
||||
if json_data:
|
||||
data = json.dumps(json_data).encode("utf-8")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
full_url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
return {"status": response.status, "data": json.loads(body) if body else {}}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8") if e.fp else ""
|
||||
try:
|
||||
error_data = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
error_data = {"message": body}
|
||||
return {"status": e.code, "error": error_data}
|
||||
except urllib.error.URLError as e:
|
||||
return {"status": 0, "error": {"message": f"Connection error: {e.reason}"}}
|
||||
except Exception as e:
|
||||
return {"status": 0, "error": {"message": str(e)}}
|
||||
|
||||
def _stream_request(
|
||||
self,
|
||||
path: str,
|
||||
url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
):
|
||||
"""Make streaming HTTP request."""
|
||||
try:
|
||||
import urllib.request
|
||||
except ImportError:
|
||||
yield {"error": "urllib not available"}
|
||||
return
|
||||
|
||||
base = url or self.base_url
|
||||
full_url = f"{base.rstrip('/')}{path}"
|
||||
|
||||
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||
key = api_key or self.api_key
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
|
||||
data = json.dumps(json_data).encode("utf-8") if json_data else None
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(full_url, data=data, headers=headers, method="POST")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=300) as response:
|
||||
buffer = ""
|
||||
for line in response:
|
||||
line = line.decode("utf-8")
|
||||
buffer += line
|
||||
|
||||
if buffer.endswith("\n\n"):
|
||||
# Parse SSE event
|
||||
event_type = "message"
|
||||
event_data = ""
|
||||
|
||||
for part in buffer.strip().split("\n"):
|
||||
if part.startswith("event:"):
|
||||
event_type = part[6:].strip()
|
||||
elif part.startswith("data:"):
|
||||
event_data = part[5:].strip()
|
||||
|
||||
if event_data:
|
||||
try:
|
||||
yield {"event": event_type, "data": json.loads(event_data)}
|
||||
except json.JSONDecodeError:
|
||||
yield {"event": event_type, "data": event_data}
|
||||
|
||||
buffer = ""
|
||||
|
||||
except Exception as e:
|
||||
yield {"error": str(e)}
|
||||
|
||||
def cmd_list(self, args: List[str]) -> int:
|
||||
"""List available recipe endpoints."""
|
||||
spec = {
|
||||
"format": {"default": "table"},
|
||||
"tags": {"default": None},
|
||||
"url": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
# Build query params
|
||||
path = "/v1/recipes"
|
||||
if parsed["tags"]:
|
||||
path += f"?tags={parsed['tags']}"
|
||||
|
||||
result = self._make_request("GET", path, url=parsed["url"])
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
recipes = result.get("data", {}).get("recipes", [])
|
||||
|
||||
if parsed["format"] == "json":
|
||||
self._print_json(recipes)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
if not recipes:
|
||||
print("No endpoints available.")
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Available Endpoints")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Description")
|
||||
table.add_column("Tags", style="yellow")
|
||||
|
||||
for r in recipes:
|
||||
tags = r.get("tags", [])
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("version", ""),
|
||||
(r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
|
||||
", ".join(tags[:3]) if tags else "",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
for r in recipes:
|
||||
print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_describe(self, args: List[str]) -> int:
|
||||
"""Describe a recipe endpoint."""
|
||||
spec = {
|
||||
"recipe": {"positional": True, "default": ""},
|
||||
"schema": {"flag": True, "default": False},
|
||||
"url": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
if not parsed["recipe"]:
|
||||
self._print_error("Recipe name required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# Get schema if requested
|
||||
if parsed["schema"]:
|
||||
path = f"/v1/recipes/{parsed['recipe']}/schema"
|
||||
else:
|
||||
path = f"/v1/recipes/{parsed['recipe']}"
|
||||
|
||||
result = self._make_request("GET", path, url=parsed["url"])
|
||||
|
||||
if result.get("status") == 404:
|
||||
self._print_error(f"Endpoint not found: {parsed['recipe']}")
|
||||
return self.EXIT_NOT_FOUND
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
self._print_json(data)
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_invoke(self, args: List[str]) -> int:
|
||||
"""Invoke a recipe endpoint."""
|
||||
spec = {
|
||||
"recipe": {"positional": True, "default": ""},
|
||||
"input": {"short": "-i", "default": None},
|
||||
"input_json": {"default": None},
|
||||
"config": {"repeatable": True, "default": []},
|
||||
"json": {"flag": True, "default": False},
|
||||
"stream": {"flag": True, "default": False},
|
||||
"url": {"default": None},
|
||||
"api_key": {"default": None},
|
||||
"dry_run": {"flag": True, "default": False},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
if not parsed["recipe"]:
|
||||
self._print_error("Recipe name required")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
|
||||
# Build input data
|
||||
input_data = {}
|
||||
if parsed["input_json"]:
|
||||
try:
|
||||
input_data = json.loads(parsed["input_json"])
|
||||
except json.JSONDecodeError:
|
||||
self._print_error("Invalid JSON in --input-json")
|
||||
return self.EXIT_VALIDATION_ERROR
|
||||
elif parsed["input"]:
|
||||
input_data = {"input": parsed["input"]}
|
||||
|
||||
# Build config from --config key=value pairs
|
||||
config = {}
|
||||
for cfg in parsed["config"]:
|
||||
if "=" in cfg:
|
||||
k, v = cfg.split("=", 1)
|
||||
config[k] = v
|
||||
|
||||
# Build request body
|
||||
body = {
|
||||
"recipe": parsed["recipe"],
|
||||
"input": input_data,
|
||||
"config": config,
|
||||
"options": {"dry_run": parsed["dry_run"]},
|
||||
}
|
||||
|
||||
if parsed["stream"]:
|
||||
return self._invoke_stream(body, parsed)
|
||||
|
||||
# Sync invocation
|
||||
result = self._make_request(
|
||||
"POST",
|
||||
"/v1/recipes/run",
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
json_data=body,
|
||||
)
|
||||
|
||||
if result.get("status") == 401:
|
||||
self._print_error("Authentication required. Use --api-key or set PRAISONAI_ENDPOINTS_API_KEY")
|
||||
return self.EXIT_AUTH_ERROR
|
||||
|
||||
if result.get("status") == 404:
|
||||
self._print_error(f"Endpoint not found: {parsed['recipe']}")
|
||||
return self.EXIT_NOT_FOUND
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(result["error"].get("message", str(result["error"])))
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if parsed["json"]:
|
||||
self._print_json(data)
|
||||
else:
|
||||
if data.get("ok"):
|
||||
self._print_success(f"Endpoint '{parsed['recipe']}' invoked successfully")
|
||||
print(f" Run ID: {data.get('run_id')}")
|
||||
print(f" Status: {data.get('status')}")
|
||||
if data.get("output"):
|
||||
print(f" Output: {data.get('output')}")
|
||||
else:
|
||||
self._print_error(f"Invocation failed: {data.get('error')}")
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS if data.get("ok") else self.EXIT_RUNTIME_ERROR
|
||||
|
||||
def _invoke_stream(self, body: Dict, parsed: Dict) -> int:
|
||||
"""Handle streaming invocation."""
|
||||
print("Streaming output...")
|
||||
|
||||
for event in self._stream_request(
|
||||
"/v1/recipes/stream",
|
||||
url=parsed["url"],
|
||||
api_key=parsed["api_key"],
|
||||
json_data=body,
|
||||
):
|
||||
if event.get("error"):
|
||||
self._print_error(event["error"])
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
event_type = event.get("event", "message")
|
||||
data = event.get("data", {})
|
||||
|
||||
if parsed["json"]:
|
||||
print(json.dumps({"event": event_type, "data": data}))
|
||||
else:
|
||||
if event_type == "started":
|
||||
print(f" Started: {data.get('run_id')}")
|
||||
elif event_type == "progress":
|
||||
print(f" [{data.get('step')}] {data.get('message', '')}")
|
||||
elif event_type == "completed":
|
||||
self._print_success(f"Completed: {data.get('status')}")
|
||||
elif event_type == "error":
|
||||
self._print_error(data.get("message", "Unknown error"))
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
return self.EXIT_SUCCESS
|
||||
|
||||
def cmd_health(self, args: List[str]) -> int:
|
||||
"""Check endpoint server health."""
|
||||
spec = {
|
||||
"url": {"default": None},
|
||||
}
|
||||
parsed = self._parse_args(args, spec)
|
||||
|
||||
result = self._make_request("GET", "/health", url=parsed["url"])
|
||||
|
||||
if result.get("error"):
|
||||
self._print_error(f"Server unhealthy: {result['error'].get('message', str(result['error']))}")
|
||||
return self.EXIT_CONNECTION_ERROR
|
||||
|
||||
data = result.get("data", {})
|
||||
|
||||
if data.get("status") == "healthy":
|
||||
self._print_success("Server healthy")
|
||||
print(f" Service: {data.get('service')}")
|
||||
print(f" Version: {data.get('version')}")
|
||||
return self.EXIT_SUCCESS
|
||||
else:
|
||||
self._print_error(f"Server unhealthy: {data}")
|
||||
return self.EXIT_RUNTIME_ERROR
|
||||
|
||||
|
||||
def handle_endpoints_command(args: List[str]) -> int:
|
||||
"""Entry point for endpoints command."""
|
||||
handler = EndpointsHandler()
|
||||
return handler.handle(args)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -698,7 +698,7 @@ class PraisonAI:
|
||||
return default_args
|
||||
|
||||
# Define special commands
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor']
|
||||
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor']
|
||||
|
||||
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
|
||||
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
|
||||
@@ -1186,6 +1186,18 @@ class PraisonAI:
|
||||
exit_code = handle_templates_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'recipe':
|
||||
# Recipe command - new unified recipe system
|
||||
from .features.recipe import handle_recipe_command
|
||||
exit_code = handle_recipe_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'endpoints':
|
||||
# Endpoints command - client CLI for recipe endpoints
|
||||
from .features.endpoints import handle_endpoints_command
|
||||
exit_code = handle_endpoints_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'agents':
|
||||
# Agents command - run multiple agents with custom definitions
|
||||
if not PRAISONAI_AVAILABLE:
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==2.7.1 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.7.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
PraisonAI Recipe Module
|
||||
|
||||
Provides a minimal, client-friendly API for running Agent Recipes.
|
||||
All imports are lazy to ensure zero performance impact on Core SDK.
|
||||
|
||||
Usage:
|
||||
from praisonai import recipe
|
||||
result = recipe.run("support-reply-drafter", input={"ticket": "T-123"})
|
||||
|
||||
# Or with streaming
|
||||
for event in recipe.run_stream("transcript-generator", input="audio.mp3"):
|
||||
print(event)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core API
|
||||
"run",
|
||||
"run_stream",
|
||||
"validate",
|
||||
"list_recipes",
|
||||
"describe",
|
||||
# Data classes
|
||||
"RecipeResult",
|
||||
"RecipeEvent",
|
||||
"RecipeConfig",
|
||||
# Exceptions
|
||||
"RecipeError",
|
||||
"RecipeNotFoundError",
|
||||
"RecipeDependencyError",
|
||||
"RecipePolicyError",
|
||||
"RecipeValidationError",
|
||||
# Exit codes
|
||||
"ExitCode",
|
||||
]
|
||||
|
||||
# Lazy loading implementation
|
||||
_module_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load recipe components."""
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "run":
|
||||
from .core import run
|
||||
_module_cache[name] = run
|
||||
return run
|
||||
elif name == "run_stream":
|
||||
from .core import run_stream
|
||||
_module_cache[name] = run_stream
|
||||
return run_stream
|
||||
elif name == "validate":
|
||||
from .core import validate
|
||||
_module_cache[name] = validate
|
||||
return validate
|
||||
elif name == "list_recipes":
|
||||
from .core import list_recipes
|
||||
_module_cache[name] = list_recipes
|
||||
return list_recipes
|
||||
elif name == "describe":
|
||||
from .core import describe
|
||||
_module_cache[name] = describe
|
||||
return describe
|
||||
elif name == "RecipeResult":
|
||||
from .models import RecipeResult
|
||||
_module_cache[name] = RecipeResult
|
||||
return RecipeResult
|
||||
elif name == "RecipeEvent":
|
||||
from .models import RecipeEvent
|
||||
_module_cache[name] = RecipeEvent
|
||||
return RecipeEvent
|
||||
elif name == "RecipeConfig":
|
||||
from .models import RecipeConfig
|
||||
_module_cache[name] = RecipeConfig
|
||||
return RecipeConfig
|
||||
elif name == "RecipeError":
|
||||
from .exceptions import RecipeError
|
||||
_module_cache[name] = RecipeError
|
||||
return RecipeError
|
||||
elif name == "RecipeNotFoundError":
|
||||
from .exceptions import RecipeNotFoundError
|
||||
_module_cache[name] = RecipeNotFoundError
|
||||
return RecipeNotFoundError
|
||||
elif name == "RecipeDependencyError":
|
||||
from .exceptions import RecipeDependencyError
|
||||
_module_cache[name] = RecipeDependencyError
|
||||
return RecipeDependencyError
|
||||
elif name == "RecipePolicyError":
|
||||
from .exceptions import RecipePolicyError
|
||||
_module_cache[name] = RecipePolicyError
|
||||
return RecipePolicyError
|
||||
elif name == "RecipeValidationError":
|
||||
from .exceptions import RecipeValidationError
|
||||
_module_cache[name] = RecipeValidationError
|
||||
return RecipeValidationError
|
||||
elif name == "ExitCode":
|
||||
from .models import ExitCode
|
||||
_module_cache[name] = ExitCode
|
||||
return ExitCode
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,813 @@
|
||||
"""
|
||||
Recipe Core API
|
||||
|
||||
Provides the main recipe execution functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union
|
||||
|
||||
from .models import (
|
||||
RecipeResult,
|
||||
RecipeEvent,
|
||||
RecipeConfig,
|
||||
RecipeInfo,
|
||||
RecipeStatus,
|
||||
ValidationResult,
|
||||
)
|
||||
from .exceptions import (
|
||||
RecipeError,
|
||||
RecipeNotFoundError,
|
||||
RecipeDependencyError,
|
||||
RecipePolicyError,
|
||||
RecipeTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
# Default denied tools (dangerous by default)
|
||||
DEFAULT_DENIED_TOOLS = [
|
||||
"shell.exec",
|
||||
"shell.run",
|
||||
"shell_tool",
|
||||
"file.write",
|
||||
"file.delete",
|
||||
"fs.write",
|
||||
"fs.delete",
|
||||
"network.unrestricted",
|
||||
"db.write",
|
||||
"db.delete",
|
||||
"execute_command",
|
||||
]
|
||||
|
||||
|
||||
def _generate_run_id() -> str:
|
||||
"""Generate a unique run ID."""
|
||||
return f"run-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _generate_trace_id() -> str:
|
||||
"""Generate a trace ID for distributed tracing."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
"""Get current timestamp in ISO format."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def run(
|
||||
name: str,
|
||||
input: Union[str, Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> RecipeResult:
|
||||
"""
|
||||
Run a recipe by name.
|
||||
|
||||
Args:
|
||||
name: Recipe name or URI (e.g., "support-reply", "github:user/repo/recipe")
|
||||
input: Input data (string path or dict)
|
||||
config: Optional config overrides
|
||||
session_id: Optional session ID for state grouping
|
||||
options: Execution options:
|
||||
- timeout_sec: Timeout in seconds (default: 300)
|
||||
- dry_run: Validate without executing (default: False)
|
||||
- verbose: Enable verbose output (default: False)
|
||||
- force: Force execution even with missing deps (default: False)
|
||||
- offline: Use only cached templates (default: False)
|
||||
- mode: Execution mode - "dev" or "prod" (default: "dev")
|
||||
- allow_dangerous_tools: Allow dangerous tools (default: False)
|
||||
|
||||
Returns:
|
||||
RecipeResult with run_id, status, output, metrics, trace
|
||||
|
||||
Example:
|
||||
>>> from praisonai import recipe
|
||||
>>> result = recipe.run("support-reply", input={"ticket_id": "T-123"})
|
||||
>>> if result.ok:
|
||||
... print(result.output["reply"])
|
||||
"""
|
||||
options = options or {}
|
||||
config = config or {}
|
||||
input = input or {}
|
||||
|
||||
# Generate identifiers
|
||||
run_id = _generate_run_id()
|
||||
trace_id = _generate_trace_id()
|
||||
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
trace = {
|
||||
"run_id": run_id,
|
||||
"trace_id": trace_id,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
try:
|
||||
# Load template
|
||||
recipe_config = _load_recipe(name, offline=options.get("offline", False))
|
||||
|
||||
if recipe_config is None:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=name,
|
||||
version="unknown",
|
||||
status=RecipeStatus.FAILED,
|
||||
error=f"Recipe not found: {name}",
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
# Check dependencies
|
||||
if not options.get("force", False):
|
||||
dep_result = _check_dependencies(recipe_config)
|
||||
if not dep_result["all_satisfied"]:
|
||||
missing = _format_missing_deps(dep_result)
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=recipe_config.name,
|
||||
version=recipe_config.version,
|
||||
status=RecipeStatus.MISSING_DEPS,
|
||||
error=f"Missing dependencies: {', '.join(missing)}",
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
# Check tool permissions
|
||||
if not options.get("allow_dangerous_tools", False):
|
||||
policy_error = _check_tool_policy(recipe_config)
|
||||
if policy_error:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=recipe_config.name,
|
||||
version=recipe_config.version,
|
||||
status=RecipeStatus.POLICY_DENIED,
|
||||
error=policy_error,
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
# Dry run mode
|
||||
if options.get("dry_run", False):
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=recipe_config.name,
|
||||
version=recipe_config.version,
|
||||
status=RecipeStatus.DRY_RUN,
|
||||
output={
|
||||
"plan": "Would execute recipe with provided config",
|
||||
"recipe": recipe_config.name,
|
||||
"config": {**recipe_config.defaults, **config},
|
||||
},
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
# Merge input and config
|
||||
if isinstance(input, str):
|
||||
merged_config = {**recipe_config.defaults, "input": input, **config}
|
||||
else:
|
||||
merged_config = {**recipe_config.defaults, **input, **config}
|
||||
|
||||
# Execute recipe
|
||||
output = _execute_recipe(
|
||||
recipe_config,
|
||||
merged_config,
|
||||
session_id,
|
||||
options,
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=recipe_config.name,
|
||||
version=recipe_config.version,
|
||||
status=RecipeStatus.SUCCESS,
|
||||
output=output,
|
||||
metrics={"duration_sec": round(duration, 2)},
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
except RecipeNotFoundError as e:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=name,
|
||||
version="unknown",
|
||||
status=RecipeStatus.FAILED,
|
||||
error=str(e),
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
except RecipeDependencyError as e:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=e.recipe or name,
|
||||
version="unknown",
|
||||
status=RecipeStatus.MISSING_DEPS,
|
||||
error=str(e),
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
except RecipePolicyError as e:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=e.recipe or name,
|
||||
version="unknown",
|
||||
status=RecipeStatus.POLICY_DENIED,
|
||||
error=str(e),
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
except RecipeTimeoutError as e:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=name,
|
||||
version="unknown",
|
||||
status=RecipeStatus.TIMEOUT,
|
||||
error=str(e),
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
except Exception as e:
|
||||
return RecipeResult(
|
||||
run_id=run_id,
|
||||
recipe=name,
|
||||
version="unknown",
|
||||
status=RecipeStatus.FAILED,
|
||||
error=str(e),
|
||||
metrics={"duration_sec": time.time() - start_time},
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
|
||||
def run_stream(
|
||||
name: str,
|
||||
input: Union[str, Dict[str, Any]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> Iterator[RecipeEvent]:
|
||||
"""
|
||||
Run a recipe with streaming events.
|
||||
|
||||
Yields RecipeEvent objects for progress tracking.
|
||||
|
||||
Args:
|
||||
name: Recipe name or URI
|
||||
input: Input data
|
||||
config: Optional config overrides
|
||||
session_id: Optional session ID
|
||||
options: Execution options
|
||||
|
||||
Yields:
|
||||
RecipeEvent objects with event_type and data
|
||||
|
||||
Example:
|
||||
>>> for event in recipe.run_stream("transcript-generator", input="audio.mp3"):
|
||||
... print(f"[{event.event_type}] {event.data}")
|
||||
"""
|
||||
options = options or {}
|
||||
config = config or {}
|
||||
input = input or {}
|
||||
|
||||
run_id = _generate_run_id()
|
||||
trace_id = _generate_trace_id()
|
||||
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Started event
|
||||
yield RecipeEvent(
|
||||
event_type="started",
|
||||
data={
|
||||
"run_id": run_id,
|
||||
"recipe": name,
|
||||
"trace_id": trace_id,
|
||||
"session_id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Load recipe
|
||||
yield RecipeEvent(
|
||||
event_type="progress",
|
||||
data={"step": "loading", "message": f"Loading recipe: {name}"},
|
||||
)
|
||||
|
||||
recipe_config = _load_recipe(name, offline=options.get("offline", False))
|
||||
|
||||
if recipe_config is None:
|
||||
yield RecipeEvent(
|
||||
event_type="error",
|
||||
data={"code": "not_found", "message": f"Recipe not found: {name}"},
|
||||
)
|
||||
return
|
||||
|
||||
# Check dependencies
|
||||
yield RecipeEvent(
|
||||
event_type="progress",
|
||||
data={"step": "checking_deps", "message": "Checking dependencies"},
|
||||
)
|
||||
|
||||
if not options.get("force", False):
|
||||
dep_result = _check_dependencies(recipe_config)
|
||||
if not dep_result["all_satisfied"]:
|
||||
missing = _format_missing_deps(dep_result)
|
||||
yield RecipeEvent(
|
||||
event_type="error",
|
||||
data={
|
||||
"code": "missing_deps",
|
||||
"message": f"Missing dependencies: {', '.join(missing)}",
|
||||
"missing": missing,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Dry run
|
||||
if options.get("dry_run", False):
|
||||
yield RecipeEvent(
|
||||
event_type="completed",
|
||||
data={
|
||||
"run_id": run_id,
|
||||
"status": RecipeStatus.DRY_RUN,
|
||||
"message": "Dry run completed",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Execute
|
||||
yield RecipeEvent(
|
||||
event_type="progress",
|
||||
data={"step": "executing", "message": "Executing recipe"},
|
||||
)
|
||||
|
||||
# Merge config
|
||||
if isinstance(input, str):
|
||||
merged_config = {**recipe_config.defaults, "input": input, **config}
|
||||
else:
|
||||
merged_config = {**recipe_config.defaults, **input, **config}
|
||||
|
||||
start_time = time.time()
|
||||
output = _execute_recipe(recipe_config, merged_config, session_id, options)
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Output event
|
||||
yield RecipeEvent(
|
||||
event_type="output",
|
||||
data={"output": output},
|
||||
)
|
||||
|
||||
# Completed event
|
||||
yield RecipeEvent(
|
||||
event_type="completed",
|
||||
data={
|
||||
"run_id": run_id,
|
||||
"status": RecipeStatus.SUCCESS,
|
||||
"duration_sec": round(duration, 2),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
yield RecipeEvent(
|
||||
event_type="error",
|
||||
data={"code": "execution_error", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
def validate(name: str, offline: bool = False) -> ValidationResult:
|
||||
"""
|
||||
Validate a recipe and check dependencies.
|
||||
|
||||
Args:
|
||||
name: Recipe name or URI
|
||||
offline: Use only cached templates
|
||||
|
||||
Returns:
|
||||
ValidationResult with valid status, errors, warnings, dependencies
|
||||
|
||||
Example:
|
||||
>>> result = recipe.validate("support-reply")
|
||||
>>> if result.valid:
|
||||
... print("Recipe is valid")
|
||||
>>> else:
|
||||
... print(f"Errors: {result.errors}")
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
recipe_config = _load_recipe(name, offline=offline)
|
||||
|
||||
if recipe_config is None:
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
recipe=name,
|
||||
version="unknown",
|
||||
errors=[f"Recipe not found: {name}"],
|
||||
)
|
||||
|
||||
# Check dependencies
|
||||
dep_result = _check_dependencies(recipe_config)
|
||||
|
||||
# Check for missing required deps
|
||||
for pkg in dep_result.get("packages", []):
|
||||
if not pkg.get("available", False):
|
||||
errors.append(f"Missing package: {pkg['name']}")
|
||||
|
||||
for env in dep_result.get("env", []):
|
||||
if not env.get("available", False):
|
||||
errors.append(f"Missing env var: ${env['name']}")
|
||||
|
||||
for ext in dep_result.get("external", []):
|
||||
if not ext.get("available", False):
|
||||
warnings.append(f"Missing external tool: {ext['name']}")
|
||||
|
||||
# Check tool policy
|
||||
policy_error = _check_tool_policy(recipe_config)
|
||||
if policy_error:
|
||||
warnings.append(f"Tool policy: {policy_error}")
|
||||
|
||||
return ValidationResult(
|
||||
valid=len(errors) == 0,
|
||||
recipe=recipe_config.name,
|
||||
version=recipe_config.version,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
dependencies=dep_result,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
recipe=name,
|
||||
version="unknown",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
|
||||
def list_recipes(
|
||||
source_filter: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
offline: bool = False,
|
||||
) -> List[RecipeInfo]:
|
||||
"""
|
||||
List available recipes.
|
||||
|
||||
Args:
|
||||
source_filter: Filter by source (local, package, github)
|
||||
tags: Filter by tags
|
||||
offline: Use only cached templates
|
||||
|
||||
Returns:
|
||||
List of RecipeInfo objects
|
||||
|
||||
Example:
|
||||
>>> recipes = recipe.list_recipes(tags=["video"])
|
||||
>>> for r in recipes:
|
||||
... print(f"{r.name}: {r.description}")
|
||||
"""
|
||||
try:
|
||||
from praisonai.templates import TemplateDiscovery
|
||||
|
||||
discovery = TemplateDiscovery()
|
||||
templates = discovery.list_templates(source_filter=source_filter)
|
||||
|
||||
recipes = []
|
||||
for t in templates:
|
||||
# Filter by tags if specified
|
||||
if tags:
|
||||
template_tags = getattr(t, 'tags', []) or []
|
||||
if not any(tag in template_tags for tag in tags):
|
||||
continue
|
||||
|
||||
recipes.append(RecipeInfo(
|
||||
name=t.name,
|
||||
version=getattr(t, 'version', '1.0.0'),
|
||||
description=getattr(t, 'description', ''),
|
||||
tags=getattr(t, 'tags', []) or [],
|
||||
path=str(t.path) if hasattr(t, 'path') else '',
|
||||
source=getattr(t, 'source', 'local'),
|
||||
))
|
||||
|
||||
return recipes
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def describe(name: str, offline: bool = False) -> Optional[RecipeConfig]:
|
||||
"""
|
||||
Get detailed information about a recipe.
|
||||
|
||||
Args:
|
||||
name: Recipe name or URI
|
||||
offline: Use only cached templates
|
||||
|
||||
Returns:
|
||||
RecipeConfig with full recipe details, or None if not found
|
||||
|
||||
Example:
|
||||
>>> info = recipe.describe("support-reply")
|
||||
>>> print(f"Required env vars: {info.get_required_env()}")
|
||||
"""
|
||||
return _load_recipe(name, offline=offline)
|
||||
|
||||
|
||||
# --- Internal Functions ---
|
||||
|
||||
def _load_recipe(name: str, offline: bool = False) -> Optional[RecipeConfig]:
|
||||
"""Load a recipe by name or URI."""
|
||||
try:
|
||||
from praisonai.templates import TemplateDiscovery, TemplateLoader
|
||||
|
||||
discovery = TemplateDiscovery()
|
||||
discovered = discovery.find_template(name)
|
||||
|
||||
if discovered:
|
||||
loader = TemplateLoader(offline=offline)
|
||||
template = loader.load(str(discovered.path))
|
||||
|
||||
return RecipeConfig(
|
||||
name=template.name,
|
||||
version=template.version,
|
||||
description=template.description,
|
||||
author=template.author,
|
||||
license=template.license,
|
||||
tags=template.tags,
|
||||
requires=template.requires,
|
||||
tools=template.raw.get("tools", {}),
|
||||
config_schema=template.config_schema,
|
||||
defaults=template.defaults,
|
||||
outputs=template.raw.get("outputs", []),
|
||||
governance=template.raw.get("governance", {}),
|
||||
data_policy=template.raw.get("data_policy", {}),
|
||||
path=str(template.path) if template.path else None,
|
||||
raw=template.raw,
|
||||
)
|
||||
|
||||
# Try loading directly as URI
|
||||
loader = TemplateLoader(offline=offline)
|
||||
try:
|
||||
template = loader.load(name)
|
||||
return RecipeConfig(
|
||||
name=template.name,
|
||||
version=template.version,
|
||||
description=template.description,
|
||||
author=template.author,
|
||||
license=template.license,
|
||||
tags=template.tags,
|
||||
requires=template.requires,
|
||||
tools=template.raw.get("tools", {}),
|
||||
config_schema=template.config_schema,
|
||||
defaults=template.defaults,
|
||||
outputs=template.raw.get("outputs", []),
|
||||
governance=template.raw.get("governance", {}),
|
||||
data_policy=template.raw.get("data_policy", {}),
|
||||
path=str(template.path) if template.path else None,
|
||||
raw=template.raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _check_dependencies(recipe_config: RecipeConfig) -> Dict[str, Any]:
|
||||
"""Check if recipe dependencies are satisfied."""
|
||||
result = {
|
||||
"all_satisfied": True,
|
||||
"packages": [],
|
||||
"env": [],
|
||||
"tools": [],
|
||||
"external": [],
|
||||
}
|
||||
|
||||
# Check Python packages
|
||||
for pkg in recipe_config.get_required_packages():
|
||||
try:
|
||||
__import__(pkg.replace("-", "_"))
|
||||
result["packages"].append({"name": pkg, "available": True})
|
||||
except ImportError:
|
||||
result["packages"].append({"name": pkg, "available": False})
|
||||
result["all_satisfied"] = False
|
||||
|
||||
# Check environment variables
|
||||
for env_var in recipe_config.get_required_env():
|
||||
available = env_var in os.environ
|
||||
result["env"].append({"name": env_var, "available": available})
|
||||
if not available:
|
||||
result["all_satisfied"] = False
|
||||
|
||||
# Check external tools
|
||||
import shutil
|
||||
for ext in recipe_config.get_external_deps():
|
||||
ext_name = ext.get("name", ext) if isinstance(ext, dict) else ext
|
||||
available = shutil.which(ext_name) is not None
|
||||
result["external"].append({"name": ext_name, "available": available})
|
||||
# External tools are warnings, not errors
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _format_missing_deps(dep_result: Dict[str, Any]) -> List[str]:
|
||||
"""Format missing dependencies as a list of strings."""
|
||||
missing = []
|
||||
|
||||
for pkg in dep_result.get("packages", []):
|
||||
if not pkg.get("available", False):
|
||||
missing.append(pkg["name"])
|
||||
|
||||
for env in dep_result.get("env", []):
|
||||
if not env.get("available", False):
|
||||
missing.append(f"${env['name']}")
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def _check_tool_policy(recipe_config: RecipeConfig) -> Optional[str]:
|
||||
"""Check if recipe uses denied tools. Returns error message or None."""
|
||||
allowed = set(recipe_config.get_allowed_tools())
|
||||
denied = set(recipe_config.get_denied_tools())
|
||||
required = set(recipe_config.get_required_tools())
|
||||
|
||||
# Check if any required tools are in default denied list
|
||||
for tool in required:
|
||||
if tool in DEFAULT_DENIED_TOOLS and tool not in allowed:
|
||||
return f"Tool '{tool}' is denied by default. Use allow_dangerous_tools=True to override."
|
||||
|
||||
# Check explicit denials
|
||||
for tool in required:
|
||||
if tool in denied:
|
||||
return f"Tool '{tool}' is explicitly denied by recipe policy."
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _execute_recipe(
|
||||
recipe_config: RecipeConfig,
|
||||
merged_config: Dict[str, Any],
|
||||
session_id: str,
|
||||
options: Dict[str, Any],
|
||||
) -> Any:
|
||||
"""Execute the recipe workflow."""
|
||||
try:
|
||||
from praisonai.templates import TemplateLoader
|
||||
from praisonai.templates.tool_override import create_tool_registry_with_overrides
|
||||
|
||||
loader = TemplateLoader()
|
||||
|
||||
# Load workflow config
|
||||
from praisonai.templates.loader import TemplateConfig as LoaderTemplateConfig
|
||||
|
||||
# Create a TemplateConfig compatible with loader
|
||||
template_path = Path(recipe_config.path) if recipe_config.path else None
|
||||
|
||||
loader_config = LoaderTemplateConfig(
|
||||
name=recipe_config.name,
|
||||
description=recipe_config.description,
|
||||
version=recipe_config.version,
|
||||
author=recipe_config.author,
|
||||
license=recipe_config.license,
|
||||
tags=recipe_config.tags,
|
||||
requires=recipe_config.requires,
|
||||
workflow_file=recipe_config.raw.get("workflow", "workflow.yaml"),
|
||||
agents_file=recipe_config.raw.get("agents", "agents.yaml"),
|
||||
config_schema=recipe_config.config_schema,
|
||||
defaults=merged_config,
|
||||
skills=recipe_config.raw.get("skills", []),
|
||||
cli=recipe_config.raw.get("cli", {}),
|
||||
raw=recipe_config.raw,
|
||||
path=template_path,
|
||||
)
|
||||
|
||||
workflow_config = loader.load_workflow_config(loader_config)
|
||||
|
||||
# Build tool registry
|
||||
tool_registry = create_tool_registry_with_overrides(
|
||||
include_defaults=True,
|
||||
template_dir=recipe_config.path,
|
||||
)
|
||||
|
||||
# Execute based on workflow type
|
||||
if "agents" in workflow_config and "tasks" in workflow_config:
|
||||
return _execute_praisonai_workflow(
|
||||
workflow_config, merged_config, tool_registry, options
|
||||
)
|
||||
elif "steps" in workflow_config:
|
||||
return _execute_steps_workflow(
|
||||
workflow_config, merged_config, tool_registry, options
|
||||
)
|
||||
else:
|
||||
# Simple agent execution
|
||||
return _execute_simple_agent(
|
||||
workflow_config, merged_config, tool_registry, options
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
raise RecipeError(f"Missing dependency for recipe execution: {e}")
|
||||
except Exception as e:
|
||||
raise RecipeError(f"Recipe execution failed: {e}")
|
||||
|
||||
|
||||
def _execute_praisonai_workflow(
|
||||
workflow_config: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
tool_registry: Any,
|
||||
options: Dict[str, Any],
|
||||
) -> Any:
|
||||
"""Execute a PraisonAI agents/tasks workflow."""
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
from praisonai.templates.tool_override import resolve_tools
|
||||
|
||||
agents = []
|
||||
agent_map = {}
|
||||
|
||||
for agent_cfg in workflow_config.get("agents", []):
|
||||
agent_tools = resolve_tools(
|
||||
agent_cfg.get("tools", []),
|
||||
registry=tool_registry,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name=agent_cfg.get("name", "Agent"),
|
||||
role=agent_cfg.get("role", ""),
|
||||
goal=agent_cfg.get("goal", ""),
|
||||
backstory=agent_cfg.get("backstory", ""),
|
||||
tools=agent_tools if agent_tools else None,
|
||||
llm=agent_cfg.get("llm"),
|
||||
verbose=options.get("verbose", False),
|
||||
)
|
||||
agents.append(agent)
|
||||
agent_map[agent_cfg.get("name")] = agent
|
||||
|
||||
tasks = []
|
||||
for task_cfg in workflow_config.get("tasks", []):
|
||||
agent_name = task_cfg.get("agent")
|
||||
agent = agent_map.get(agent_name, agents[0] if agents else None)
|
||||
|
||||
# Substitute config values in description
|
||||
description = task_cfg.get("description", "")
|
||||
try:
|
||||
description = description.format(**config)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
task = Task(
|
||||
name=task_cfg.get("name", "Task"),
|
||||
description=description,
|
||||
expected_output=task_cfg.get("expected_output", ""),
|
||||
agent=agent,
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
praison = PraisonAIAgents(
|
||||
agents=agents,
|
||||
tasks=tasks,
|
||||
process=workflow_config.get("process", "sequential"),
|
||||
verbose=options.get("verbose", 1) if options.get("verbose") else 0,
|
||||
)
|
||||
|
||||
return praison.start()
|
||||
|
||||
|
||||
def _execute_steps_workflow(
|
||||
workflow_config: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
tool_registry: Any,
|
||||
options: Dict[str, Any],
|
||||
) -> Any:
|
||||
"""Execute a steps-based workflow."""
|
||||
# For now, convert to simple execution
|
||||
return {"message": "Steps workflow executed", "config": config}
|
||||
|
||||
|
||||
def _execute_simple_agent(
|
||||
workflow_config: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
tool_registry: Any,
|
||||
options: Dict[str, Any],
|
||||
) -> Any:
|
||||
"""Execute a simple single-agent workflow."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name=workflow_config.get("name", "RecipeAgent"),
|
||||
role=workflow_config.get("role", "AI Assistant"),
|
||||
goal=workflow_config.get("goal", "Complete the task"),
|
||||
backstory=workflow_config.get("backstory", ""),
|
||||
verbose=options.get("verbose", False),
|
||||
)
|
||||
|
||||
prompt = config.get("input", config.get("prompt", ""))
|
||||
if prompt:
|
||||
return agent.chat(prompt)
|
||||
|
||||
return {"message": "Recipe executed", "config": config}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Recipe Exceptions
|
||||
|
||||
Custom exceptions for recipe operations.
|
||||
"""
|
||||
|
||||
|
||||
class RecipeError(Exception):
|
||||
"""Base exception for recipe operations."""
|
||||
|
||||
def __init__(self, message: str, recipe: str = None, details: dict = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.recipe = recipe
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class RecipeNotFoundError(RecipeError):
|
||||
"""Recipe not found."""
|
||||
pass
|
||||
|
||||
|
||||
class RecipeDependencyError(RecipeError):
|
||||
"""Missing dependencies for recipe execution."""
|
||||
|
||||
def __init__(self, message: str, recipe: str = None, missing: list = None):
|
||||
super().__init__(message, recipe, {"missing": missing or []})
|
||||
self.missing = missing or []
|
||||
|
||||
|
||||
class RecipePolicyError(RecipeError):
|
||||
"""Security policy blocked execution."""
|
||||
|
||||
def __init__(self, message: str, recipe: str = None, policy: str = None):
|
||||
super().__init__(message, recipe, {"policy": policy})
|
||||
self.policy = policy
|
||||
|
||||
|
||||
class RecipeValidationError(RecipeError):
|
||||
"""Recipe validation failed."""
|
||||
|
||||
def __init__(self, message: str, recipe: str = None, errors: list = None):
|
||||
super().__init__(message, recipe, {"errors": errors or []})
|
||||
self.errors = errors or []
|
||||
|
||||
|
||||
class RecipeTimeoutError(RecipeError):
|
||||
"""Recipe execution timed out."""
|
||||
pass
|
||||
|
||||
|
||||
class RecipeExecutionError(RecipeError):
|
||||
"""Recipe execution failed."""
|
||||
pass
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Recipe Data Models
|
||||
|
||||
Defines the core data structures for recipe execution.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import IntEnum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ExitCode(IntEnum):
|
||||
"""Stable exit codes for CLI operations."""
|
||||
SUCCESS = 0
|
||||
GENERAL_ERROR = 1
|
||||
VALIDATION_ERROR = 2
|
||||
RUNTIME_ERROR = 3
|
||||
POLICY_DENIED = 4
|
||||
TIMEOUT = 5
|
||||
MISSING_DEPS = 6
|
||||
NOT_FOUND = 7
|
||||
|
||||
|
||||
class RecipeStatus:
|
||||
"""Recipe execution status constants."""
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
DRY_RUN = "dry_run"
|
||||
POLICY_DENIED = "policy_denied"
|
||||
TIMEOUT = "timeout"
|
||||
MISSING_DEPS = "missing_deps"
|
||||
VALIDATION_ERROR = "validation_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeResult:
|
||||
"""
|
||||
Result of a recipe execution.
|
||||
|
||||
Attributes:
|
||||
run_id: Unique identifier for this execution
|
||||
recipe: Recipe name
|
||||
version: Recipe version
|
||||
status: Execution status (success, failed, dry_run, policy_denied, etc.)
|
||||
output: Recipe-specific output data
|
||||
metrics: Execution metrics (duration, tokens, etc.)
|
||||
error: Error message if failed
|
||||
trace: Tracing identifiers (run_id, trace_id, session_id, agent_id)
|
||||
"""
|
||||
run_id: str
|
||||
recipe: str
|
||||
version: str
|
||||
status: str
|
||||
output: Any = None
|
||||
metrics: Dict[str, Any] = field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
trace: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Check if execution was successful."""
|
||||
return self.status in (RecipeStatus.SUCCESS, RecipeStatus.DRY_RUN)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"run_id": self.run_id,
|
||||
"recipe": self.recipe,
|
||||
"version": self.version,
|
||||
"status": self.status,
|
||||
"output": self.output,
|
||||
"metrics": self.metrics,
|
||||
"error": self.error,
|
||||
"trace": self.trace,
|
||||
}
|
||||
|
||||
def to_exit_code(self) -> int:
|
||||
"""Convert status to CLI exit code."""
|
||||
status_to_code = {
|
||||
RecipeStatus.SUCCESS: ExitCode.SUCCESS,
|
||||
RecipeStatus.DRY_RUN: ExitCode.SUCCESS,
|
||||
RecipeStatus.FAILED: ExitCode.RUNTIME_ERROR,
|
||||
RecipeStatus.POLICY_DENIED: ExitCode.POLICY_DENIED,
|
||||
RecipeStatus.TIMEOUT: ExitCode.TIMEOUT,
|
||||
RecipeStatus.MISSING_DEPS: ExitCode.MISSING_DEPS,
|
||||
RecipeStatus.VALIDATION_ERROR: ExitCode.VALIDATION_ERROR,
|
||||
}
|
||||
return status_to_code.get(self.status, ExitCode.GENERAL_ERROR)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeEvent:
|
||||
"""
|
||||
Streaming event from recipe execution.
|
||||
|
||||
Attributes:
|
||||
event_type: Type of event (started, progress, log, output, completed, error)
|
||||
data: Event-specific data
|
||||
timestamp: ISO format timestamp
|
||||
"""
|
||||
event_type: str
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON/SSE serialization."""
|
||||
return {
|
||||
"event": self.event_type,
|
||||
"data": self.data,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
def to_sse(self) -> str:
|
||||
"""Convert to Server-Sent Events format."""
|
||||
import json
|
||||
return f"event: {self.event_type}\ndata: {json.dumps(self.data)}\n\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeConfig:
|
||||
"""
|
||||
Recipe configuration and metadata.
|
||||
|
||||
Attributes:
|
||||
name: Recipe name (kebab-case)
|
||||
version: SemVer version string
|
||||
description: Recipe description
|
||||
author: Author name or identifier
|
||||
license: License identifier (e.g., Apache-2.0)
|
||||
tags: Discovery tags
|
||||
requires: Dependencies (packages, env, tools, external)
|
||||
tools: Tool permissions (allow, deny)
|
||||
config_schema: JSON Schema for input configuration
|
||||
defaults: Default configuration values
|
||||
outputs: Expected output definitions
|
||||
governance: Governance settings (approval, cost limits, audit)
|
||||
data_policy: Data handling policy (PII, retention)
|
||||
path: Source path of the recipe
|
||||
"""
|
||||
name: str
|
||||
version: str = "1.0.0"
|
||||
description: str = ""
|
||||
author: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
# Dependencies
|
||||
requires: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Tool permissions
|
||||
tools: Dict[str, List[str]] = field(default_factory=dict)
|
||||
|
||||
# Configuration
|
||||
config_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
defaults: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Outputs
|
||||
outputs: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Governance
|
||||
governance: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Data policy
|
||||
data_policy: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Source path
|
||||
path: Optional[str] = None
|
||||
|
||||
# Raw config dict
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get_allowed_tools(self) -> List[str]:
|
||||
"""Get list of explicitly allowed tools."""
|
||||
return self.tools.get("allow", [])
|
||||
|
||||
def get_denied_tools(self) -> List[str]:
|
||||
"""Get list of explicitly denied tools."""
|
||||
return self.tools.get("deny", [])
|
||||
|
||||
def get_required_packages(self) -> List[str]:
|
||||
"""Get list of required Python packages."""
|
||||
packages = self.requires.get("packages", [])
|
||||
return [packages] if isinstance(packages, str) else packages
|
||||
|
||||
def get_required_env(self) -> List[str]:
|
||||
"""Get list of required environment variables."""
|
||||
env = self.requires.get("env", [])
|
||||
return [env] if isinstance(env, str) else env
|
||||
|
||||
def get_required_tools(self) -> List[str]:
|
||||
"""Get list of required tools."""
|
||||
tools = self.requires.get("tools", [])
|
||||
return [tools] if isinstance(tools, str) else tools
|
||||
|
||||
def get_external_deps(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of external dependencies (ffmpeg, etc.)."""
|
||||
return self.requires.get("external", [])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"description": self.description,
|
||||
"author": self.author,
|
||||
"license": self.license,
|
||||
"tags": self.tags,
|
||||
"requires": self.requires,
|
||||
"tools": self.tools,
|
||||
"config_schema": self.config_schema,
|
||||
"defaults": self.defaults,
|
||||
"outputs": self.outputs,
|
||||
"governance": self.governance,
|
||||
"data_policy": self.data_policy,
|
||||
"path": self.path,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of recipe validation."""
|
||||
valid: bool
|
||||
recipe: str
|
||||
version: str
|
||||
errors: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
dependencies: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"valid": self.valid,
|
||||
"recipe": self.recipe,
|
||||
"version": self.version,
|
||||
"errors": self.errors,
|
||||
"warnings": self.warnings,
|
||||
"dependencies": self.dependencies,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeInfo:
|
||||
"""Recipe information for listing/discovery."""
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
tags: List[str]
|
||||
path: str
|
||||
source: str # local, package, github
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"path": self.path,
|
||||
"source": self.source,
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Recipe HTTP Server
|
||||
|
||||
Provides HTTP endpoints for recipe execution.
|
||||
Optional dependency - requires: pip install praisonai[serve]
|
||||
|
||||
Endpoints:
|
||||
- GET /health - Health check
|
||||
- GET /v1/recipes - List recipes
|
||||
- GET /v1/recipes/{name} - Describe recipe
|
||||
- GET /v1/recipes/{name}/schema - Get recipe schema
|
||||
- POST /v1/recipes/run - Run recipe (sync)
|
||||
- POST /v1/recipes/stream - Run recipe (SSE)
|
||||
|
||||
Auth modes:
|
||||
- none: No authentication (localhost only)
|
||||
- api-key: X-API-Key header required
|
||||
- jwt: Bearer token required (future)
|
||||
|
||||
Config file (serve.yaml):
|
||||
```yaml
|
||||
host: 127.0.0.1
|
||||
port: 8765
|
||||
auth: api-key
|
||||
api_key: your-secret-key # or use PRAISONAI_API_KEY env var
|
||||
recipes:
|
||||
- my-recipe
|
||||
- another-recipe
|
||||
preload: true
|
||||
cors_origins: "*"
|
||||
```
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Load configuration from file.
|
||||
|
||||
Precedence: CLI flags > env vars > config file > defaults
|
||||
"""
|
||||
config = {}
|
||||
|
||||
if config_path and os.path.exists(config_path):
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
except ImportError:
|
||||
# Fall back to JSON if yaml not available
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Apply env var overrides
|
||||
if os.environ.get("PRAISONAI_API_KEY"):
|
||||
config["api_key"] = os.environ["PRAISONAI_API_KEY"]
|
||||
if os.environ.get("PRAISONAI_SERVE_HOST"):
|
||||
config["host"] = os.environ["PRAISONAI_SERVE_HOST"]
|
||||
if os.environ.get("PRAISONAI_SERVE_PORT"):
|
||||
config["port"] = int(os.environ["PRAISONAI_SERVE_PORT"])
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None):
|
||||
"""Create authentication middleware."""
|
||||
try:
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
class APIKeyAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""API Key authentication middleware."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
# Skip auth for health endpoint
|
||||
if request.url.path == "/health":
|
||||
return await call_next(request)
|
||||
|
||||
# Check X-API-Key header
|
||||
provided_key = request.headers.get("X-API-Key")
|
||||
expected_key = api_key or os.environ.get("PRAISONAI_API_KEY")
|
||||
|
||||
if not expected_key:
|
||||
# No key configured, allow request
|
||||
return await call_next(request)
|
||||
|
||||
if provided_key != expected_key:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
|
||||
status_code=401
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
if auth_type == "api-key":
|
||||
return APIKeyAuthMiddleware
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""
|
||||
Create ASGI application for recipe runner.
|
||||
|
||||
Args:
|
||||
config: Optional configuration dict
|
||||
|
||||
Returns:
|
||||
Starlette ASGI application
|
||||
"""
|
||||
try:
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.requests import Request
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Serve dependencies not installed. Run: pip install praisonai[serve]"
|
||||
)
|
||||
|
||||
config = config or {}
|
||||
|
||||
async def health(request: Request) -> JSONResponse:
|
||||
"""GET /health - Health check."""
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"service": "praisonai-recipe-runner",
|
||||
"version": _get_version(),
|
||||
})
|
||||
|
||||
async def list_recipes(request: Request) -> JSONResponse:
|
||||
"""GET /v1/recipes - List available recipes."""
|
||||
from praisonai import recipe
|
||||
|
||||
source_filter = request.query_params.get("source")
|
||||
tags = request.query_params.get("tags")
|
||||
tags_list = tags.split(",") if tags else None
|
||||
|
||||
recipes = recipe.list_recipes(
|
||||
source_filter=source_filter,
|
||||
tags=tags_list,
|
||||
)
|
||||
|
||||
return JSONResponse({
|
||||
"recipes": [r.to_dict() for r in recipes]
|
||||
})
|
||||
|
||||
async def describe_recipe(request: Request) -> JSONResponse:
|
||||
"""GET /v1/recipes/{name} - Describe a recipe."""
|
||||
name = request.path_params["name"]
|
||||
|
||||
from praisonai import recipe
|
||||
info = recipe.describe(name)
|
||||
|
||||
if info is None:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
|
||||
status_code=404
|
||||
)
|
||||
|
||||
return JSONResponse(info.to_dict())
|
||||
|
||||
async def get_schema(request: Request) -> JSONResponse:
|
||||
"""GET /v1/recipes/{name}/schema - Get recipe JSON schema."""
|
||||
name = request.path_params["name"]
|
||||
|
||||
from praisonai import recipe
|
||||
info = recipe.describe(name)
|
||||
|
||||
if info is None:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
|
||||
status_code=404
|
||||
)
|
||||
|
||||
return JSONResponse({
|
||||
"name": info.name,
|
||||
"version": info.version,
|
||||
"input_schema": info.config_schema,
|
||||
"output_schema": info.outputs,
|
||||
})
|
||||
|
||||
async def run_recipe(request: Request) -> JSONResponse:
|
||||
"""POST /v1/recipes/run - Run a recipe."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
recipe_name = body.get("recipe")
|
||||
if not recipe_name:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
input_data = body.get("input", {})
|
||||
config_data = body.get("config", {})
|
||||
options = body.get("options", {})
|
||||
session_id = body.get("session_id")
|
||||
|
||||
from praisonai import recipe
|
||||
result = recipe.run(
|
||||
recipe_name,
|
||||
input=input_data,
|
||||
config=config_data,
|
||||
session_id=session_id,
|
||||
options=options,
|
||||
)
|
||||
|
||||
status_code = 200 if result.ok else 500
|
||||
if result.status == "policy_denied":
|
||||
status_code = 403
|
||||
elif result.status == "missing_deps":
|
||||
status_code = 424 # Failed Dependency
|
||||
elif result.status == "validation_error":
|
||||
status_code = 400
|
||||
|
||||
return JSONResponse(result.to_dict(), status_code=status_code)
|
||||
|
||||
async def stream_recipe(request: Request) -> Response:
|
||||
"""POST /v1/recipes/stream - Stream recipe execution."""
|
||||
try:
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
except ImportError:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "sse_unavailable", "message": "SSE not available"}},
|
||||
status_code=501
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
recipe_name = body.get("recipe")
|
||||
if not recipe_name:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
input_data = body.get("input", {})
|
||||
config_data = body.get("config", {})
|
||||
options = body.get("options", {})
|
||||
session_id = body.get("session_id")
|
||||
|
||||
async def event_generator():
|
||||
from praisonai import recipe
|
||||
for event in recipe.run_stream(
|
||||
recipe_name,
|
||||
input=input_data,
|
||||
config=config_data,
|
||||
session_id=session_id,
|
||||
options=options,
|
||||
):
|
||||
yield {
|
||||
"event": event.event_type,
|
||||
"data": json.dumps(event.data),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
async def validate_recipe(request: Request) -> JSONResponse:
|
||||
"""POST /v1/recipes/validate - Validate a recipe."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
recipe_name = body.get("recipe")
|
||||
if not recipe_name:
|
||||
return JSONResponse(
|
||||
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
from praisonai import recipe
|
||||
result = recipe.validate(recipe_name)
|
||||
|
||||
status_code = 200 if result.valid else 400
|
||||
return JSONResponse(result.to_dict(), status_code=status_code)
|
||||
|
||||
# Build routes
|
||||
routes = [
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/v1/recipes", list_recipes, methods=["GET"]),
|
||||
Route("/v1/recipes/run", run_recipe, methods=["POST"]),
|
||||
Route("/v1/recipes/stream", stream_recipe, methods=["POST"]),
|
||||
Route("/v1/recipes/validate", validate_recipe, methods=["POST"]),
|
||||
Route("/v1/recipes/{name}", describe_recipe, methods=["GET"]),
|
||||
Route("/v1/recipes/{name}/schema", get_schema, methods=["GET"]),
|
||||
]
|
||||
|
||||
# Add CORS middleware if configured
|
||||
middleware = []
|
||||
cors_origins = config.get("cors_origins")
|
||||
if cors_origins:
|
||||
middleware.append(
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins.split(",") if isinstance(cors_origins, str) else cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
)
|
||||
|
||||
# Add auth middleware if configured
|
||||
auth_type = config.get("auth")
|
||||
if auth_type and auth_type != "none":
|
||||
auth_middleware = create_auth_middleware(auth_type, config.get("api_key"))
|
||||
if auth_middleware:
|
||||
middleware.append(Middleware(auth_middleware))
|
||||
|
||||
return Starlette(routes=routes, middleware=middleware)
|
||||
|
||||
|
||||
def serve(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8765,
|
||||
reload: bool = False,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Start the recipe runner server.
|
||||
|
||||
Args:
|
||||
host: Server host (default: 127.0.0.1)
|
||||
port: Server port (default: 8765)
|
||||
reload: Enable hot reload (default: False)
|
||||
config: Optional configuration dict
|
||||
"""
|
||||
try:
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Serve dependencies not installed. Run: pip install praisonai[serve]"
|
||||
)
|
||||
|
||||
app = create_app(config)
|
||||
uvicorn.run(app, host=host, port=port, reload=reload)
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Get PraisonAI version."""
|
||||
try:
|
||||
from praisonai.version import __version__
|
||||
return __version__
|
||||
except ImportError:
|
||||
return "unknown"
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.1"
|
||||
__version__ = "2.7.2"
|
||||
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
Tests for PraisonAI Recipe Module
|
||||
|
||||
Tests cover:
|
||||
- Core API (run, run_stream, validate, list_recipes, describe)
|
||||
- Data models (RecipeResult, RecipeEvent, RecipeConfig)
|
||||
- CLI commands
|
||||
- Exit codes
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests for Models
|
||||
# ============================================================================
|
||||
|
||||
class TestRecipeResult:
|
||||
"""Tests for RecipeResult dataclass."""
|
||||
|
||||
def test_success_result(self):
|
||||
"""Test successful result."""
|
||||
from praisonai.recipe.models import RecipeResult, RecipeStatus
|
||||
|
||||
result = RecipeResult(
|
||||
run_id="run-abc123",
|
||||
recipe="test-recipe",
|
||||
version="1.0.0",
|
||||
status=RecipeStatus.SUCCESS,
|
||||
output={"message": "Hello"},
|
||||
metrics={"duration_sec": 1.5},
|
||||
trace={"run_id": "run-abc123"},
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.run_id == "run-abc123"
|
||||
assert result.status == "success"
|
||||
assert result.to_exit_code() == 0
|
||||
|
||||
def test_failed_result(self):
|
||||
"""Test failed result."""
|
||||
from praisonai.recipe.models import RecipeResult, RecipeStatus
|
||||
|
||||
result = RecipeResult(
|
||||
run_id="run-abc123",
|
||||
recipe="test-recipe",
|
||||
version="1.0.0",
|
||||
status=RecipeStatus.FAILED,
|
||||
error="Something went wrong",
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.error == "Something went wrong"
|
||||
assert result.to_exit_code() == 3 # RUNTIME_ERROR
|
||||
|
||||
def test_dry_run_result(self):
|
||||
"""Test dry run result."""
|
||||
from praisonai.recipe.models import RecipeResult, RecipeStatus
|
||||
|
||||
result = RecipeResult(
|
||||
run_id="run-abc123",
|
||||
recipe="test-recipe",
|
||||
version="1.0.0",
|
||||
status=RecipeStatus.DRY_RUN,
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.to_exit_code() == 0
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test to_dict serialization."""
|
||||
from praisonai.recipe.models import RecipeResult, RecipeStatus
|
||||
|
||||
result = RecipeResult(
|
||||
run_id="run-abc123",
|
||||
recipe="test-recipe",
|
||||
version="1.0.0",
|
||||
status=RecipeStatus.SUCCESS,
|
||||
output={"key": "value"},
|
||||
)
|
||||
|
||||
d = result.to_dict()
|
||||
assert d["ok"] is True
|
||||
assert d["run_id"] == "run-abc123"
|
||||
assert d["recipe"] == "test-recipe"
|
||||
assert d["output"] == {"key": "value"}
|
||||
|
||||
|
||||
class TestRecipeEvent:
|
||||
"""Tests for RecipeEvent dataclass."""
|
||||
|
||||
def test_event_creation(self):
|
||||
"""Test event creation."""
|
||||
from praisonai.recipe.models import RecipeEvent
|
||||
|
||||
event = RecipeEvent(
|
||||
event_type="started",
|
||||
data={"run_id": "run-abc123"},
|
||||
)
|
||||
|
||||
assert event.event_type == "started"
|
||||
assert event.data["run_id"] == "run-abc123"
|
||||
assert event.timestamp # Should be auto-generated
|
||||
|
||||
def test_to_sse(self):
|
||||
"""Test SSE format conversion."""
|
||||
from praisonai.recipe.models import RecipeEvent
|
||||
|
||||
event = RecipeEvent(
|
||||
event_type="progress",
|
||||
data={"step": "loading"},
|
||||
)
|
||||
|
||||
sse = event.to_sse()
|
||||
assert "event: progress" in sse
|
||||
assert "data:" in sse
|
||||
|
||||
|
||||
class TestExitCode:
|
||||
"""Tests for exit codes."""
|
||||
|
||||
def test_exit_codes(self):
|
||||
"""Test exit code values."""
|
||||
from praisonai.recipe.models import ExitCode
|
||||
|
||||
assert ExitCode.SUCCESS == 0
|
||||
assert ExitCode.GENERAL_ERROR == 1
|
||||
assert ExitCode.VALIDATION_ERROR == 2
|
||||
assert ExitCode.RUNTIME_ERROR == 3
|
||||
assert ExitCode.POLICY_DENIED == 4
|
||||
assert ExitCode.TIMEOUT == 5
|
||||
assert ExitCode.MISSING_DEPS == 6
|
||||
assert ExitCode.NOT_FOUND == 7
|
||||
|
||||
|
||||
class TestRecipeConfig:
|
||||
"""Tests for RecipeConfig dataclass."""
|
||||
|
||||
def test_config_creation(self):
|
||||
"""Test config creation."""
|
||||
from praisonai.recipe.models import RecipeConfig
|
||||
|
||||
config = RecipeConfig(
|
||||
name="test-recipe",
|
||||
version="1.0.0",
|
||||
description="Test recipe",
|
||||
requires={"packages": ["openai"], "env": ["OPENAI_API_KEY"]},
|
||||
tools={"allow": ["web.search"], "deny": ["shell.exec"]},
|
||||
)
|
||||
|
||||
assert config.name == "test-recipe"
|
||||
assert config.get_required_packages() == ["openai"]
|
||||
assert config.get_required_env() == ["OPENAI_API_KEY"]
|
||||
assert config.get_allowed_tools() == ["web.search"]
|
||||
assert config.get_denied_tools() == ["shell.exec"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests for Core API
|
||||
# ============================================================================
|
||||
|
||||
class TestCoreAPI:
|
||||
"""Tests for core recipe API."""
|
||||
|
||||
def test_list_recipes(self):
|
||||
"""Test listing recipes."""
|
||||
from praisonai.recipe import list_recipes
|
||||
|
||||
recipes = list_recipes()
|
||||
assert isinstance(recipes, list)
|
||||
|
||||
def test_validate_nonexistent(self):
|
||||
"""Test validating non-existent recipe."""
|
||||
from praisonai.recipe import validate
|
||||
|
||||
result = validate("nonexistent-recipe-xyz")
|
||||
assert result.valid is False
|
||||
assert "not found" in result.errors[0].lower() or len(result.errors) > 0
|
||||
|
||||
def test_describe_nonexistent(self):
|
||||
"""Test describing non-existent recipe."""
|
||||
from praisonai.recipe import describe
|
||||
|
||||
result = describe("nonexistent-recipe-xyz")
|
||||
assert result is None
|
||||
|
||||
def test_run_dry_run(self):
|
||||
"""Test dry run mode."""
|
||||
from praisonai.recipe import run, list_recipes
|
||||
|
||||
# Use a recipe that exists
|
||||
recipes = list_recipes()
|
||||
if recipes:
|
||||
result = run(
|
||||
recipes[0].name,
|
||||
input={},
|
||||
options={"dry_run": True},
|
||||
)
|
||||
assert result.status == "dry_run"
|
||||
assert result.ok is True
|
||||
|
||||
def test_run_nonexistent(self):
|
||||
"""Test running non-existent recipe."""
|
||||
from praisonai.recipe import run
|
||||
|
||||
result = run("nonexistent-recipe-xyz", input={})
|
||||
assert result.ok is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCLI:
|
||||
"""Tests for CLI commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def cli_runner(self):
|
||||
"""Get CLI runner path."""
|
||||
return [sys.executable, "-m", "praisonai.cli.main"]
|
||||
|
||||
def test_recipe_help(self, cli_runner):
|
||||
"""Test recipe help command."""
|
||||
# Test via direct import instead of subprocess for reliability
|
||||
from praisonai.cli.features.recipe import RecipeHandler
|
||||
handler = RecipeHandler()
|
||||
exit_code = handler.handle(["help"])
|
||||
assert exit_code == 0
|
||||
|
||||
def test_recipe_list(self, cli_runner):
|
||||
"""Test recipe list command."""
|
||||
# Test via direct import for reliability
|
||||
from praisonai.cli.features.recipe import RecipeHandler
|
||||
handler = RecipeHandler()
|
||||
exit_code = handler.handle(["list"])
|
||||
assert exit_code == 0
|
||||
|
||||
def test_recipe_validate_nonexistent(self, cli_runner):
|
||||
"""Test validating non-existent recipe."""
|
||||
from praisonai.cli.features.recipe import RecipeHandler
|
||||
handler = RecipeHandler()
|
||||
exit_code = handler.handle(["validate", "nonexistent-xyz"])
|
||||
# Should return validation error or not found exit code
|
||||
assert exit_code in [2, 7] # VALIDATION_ERROR or NOT_FOUND
|
||||
|
||||
def test_recipe_init(self, cli_runner):
|
||||
"""Test recipe init command."""
|
||||
from praisonai.cli.features.recipe import RecipeHandler
|
||||
handler = RecipeHandler()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
exit_code = handler.handle(["init", "test-recipe", "-o", tmpdir])
|
||||
assert exit_code == 0
|
||||
|
||||
# Check files were created
|
||||
recipe_dir = Path(tmpdir) / "test-recipe"
|
||||
assert (recipe_dir / "TEMPLATE.yaml").exists()
|
||||
assert (recipe_dir / "workflow.yaml").exists()
|
||||
assert (recipe_dir / "README.md").exists()
|
||||
|
||||
def test_recipe_dry_run(self, cli_runner):
|
||||
"""Test recipe dry run."""
|
||||
result = subprocess.run(
|
||||
cli_runner + ["recipe", "list", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(Path(__file__).parent.parent),
|
||||
)
|
||||
|
||||
# Get first recipe name
|
||||
try:
|
||||
recipes = json.loads(result.stdout)
|
||||
if recipes:
|
||||
recipe_name = recipes[0]["name"]
|
||||
|
||||
# Run dry run
|
||||
result = subprocess.run(
|
||||
cli_runner + ["recipe", "run", recipe_name, "--dry-run", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(Path(__file__).parent.parent),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "dry_run"
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
pass # Skip if no recipes available
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests with real API calls."""
|
||||
|
||||
@pytest.fixture
|
||||
def has_openai_key(self):
|
||||
"""Check if OpenAI API key is available."""
|
||||
return bool(os.environ.get("OPENAI_API_KEY"))
|
||||
|
||||
def test_run_with_api(self, has_openai_key):
|
||||
"""Test running a recipe with real API."""
|
||||
if not has_openai_key:
|
||||
pytest.skip("OPENAI_API_KEY not set")
|
||||
|
||||
from praisonai.recipe import run, list_recipes as list_all_recipes
|
||||
|
||||
recipes = list_all_recipes()
|
||||
if not recipes:
|
||||
pytest.skip("No recipes available")
|
||||
|
||||
# Run first available recipe
|
||||
result = run(
|
||||
recipes[0].name,
|
||||
input={},
|
||||
options={"timeout_sec": 60},
|
||||
)
|
||||
|
||||
# Should complete (success or fail, but not crash)
|
||||
assert result.run_id.startswith("run-")
|
||||
assert result.status in ["success", "failed", "missing_deps"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestExceptions:
|
||||
"""Tests for custom exceptions."""
|
||||
|
||||
def test_recipe_error(self):
|
||||
"""Test RecipeError."""
|
||||
from praisonai.recipe.exceptions import RecipeError
|
||||
|
||||
err = RecipeError("Test error", recipe="test", details={"key": "value"})
|
||||
assert str(err) == "Test error"
|
||||
assert err.recipe == "test"
|
||||
assert err.details == {"key": "value"}
|
||||
|
||||
def test_recipe_not_found_error(self):
|
||||
"""Test RecipeNotFoundError."""
|
||||
from praisonai.recipe.exceptions import RecipeNotFoundError
|
||||
|
||||
err = RecipeNotFoundError("Recipe not found", recipe="test")
|
||||
assert "Recipe not found" in str(err)
|
||||
|
||||
def test_recipe_dependency_error(self):
|
||||
"""Test RecipeDependencyError."""
|
||||
from praisonai.recipe.exceptions import RecipeDependencyError
|
||||
|
||||
err = RecipeDependencyError(
|
||||
"Missing deps",
|
||||
recipe="test",
|
||||
missing=["openai", "pandas"],
|
||||
)
|
||||
assert err.missing == ["openai", "pandas"]
|
||||
|
||||
def test_recipe_policy_error(self):
|
||||
"""Test RecipePolicyError."""
|
||||
from praisonai.recipe.exceptions import RecipePolicyError
|
||||
|
||||
err = RecipePolicyError(
|
||||
"Policy denied",
|
||||
recipe="test",
|
||||
policy="no_shell",
|
||||
)
|
||||
assert err.policy == "no_shell"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints CLI Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestEndpointsCLI:
|
||||
"""Tests for endpoints CLI commands."""
|
||||
|
||||
def test_endpoints_help(self):
|
||||
"""Test endpoints help command."""
|
||||
from praisonai.cli.features.endpoints import EndpointsHandler
|
||||
handler = EndpointsHandler()
|
||||
exit_code = handler.handle(["help"])
|
||||
assert exit_code == 0
|
||||
|
||||
def test_endpoints_list_no_server(self):
|
||||
"""Test endpoints list when no server running."""
|
||||
from praisonai.cli.features.endpoints import EndpointsHandler
|
||||
handler = EndpointsHandler()
|
||||
exit_code = handler.handle(["list", "--url", "http://localhost:9999"])
|
||||
assert exit_code == 8 # CONNECTION_ERROR
|
||||
|
||||
def test_endpoints_health_no_server(self):
|
||||
"""Test endpoints health when no server running."""
|
||||
from praisonai.cli.features.endpoints import EndpointsHandler
|
||||
handler = EndpointsHandler()
|
||||
exit_code = handler.handle(["health", "--url", "http://localhost:9999"])
|
||||
assert exit_code == 8 # CONNECTION_ERROR
|
||||
|
||||
def test_endpoints_invoke_missing_recipe(self):
|
||||
"""Test endpoints invoke without recipe name."""
|
||||
from praisonai.cli.features.endpoints import EndpointsHandler
|
||||
handler = EndpointsHandler()
|
||||
exit_code = handler.handle(["invoke"])
|
||||
assert exit_code == 2 # VALIDATION_ERROR
|
||||
|
||||
def test_endpoints_describe_missing_recipe(self):
|
||||
"""Test endpoints describe without recipe name."""
|
||||
from praisonai.cli.features.endpoints import EndpointsHandler
|
||||
handler = EndpointsHandler()
|
||||
exit_code = handler.handle(["describe"])
|
||||
assert exit_code == 2 # VALIDATION_ERROR
|
||||
|
||||
|
||||
class TestServeConfig:
|
||||
"""Tests for serve configuration."""
|
||||
|
||||
def test_load_config_empty(self):
|
||||
"""Test loading empty config."""
|
||||
from praisonai.recipe.serve import load_config
|
||||
config = load_config(None)
|
||||
assert config == {}
|
||||
|
||||
def test_auth_middleware_creation(self):
|
||||
"""Test auth middleware creation."""
|
||||
from praisonai.recipe.serve import create_auth_middleware
|
||||
|
||||
# No auth
|
||||
middleware = create_auth_middleware("none")
|
||||
assert middleware is None
|
||||
|
||||
# API key auth
|
||||
middleware = create_auth_middleware("api-key", "test-key")
|
||||
assert middleware is not None
|
||||
|
||||
|
||||
class TestServeHostSafety:
|
||||
"""Tests for serve host binding safety."""
|
||||
|
||||
def test_refuse_public_without_auth(self):
|
||||
"""Test that 0.0.0.0 binding without auth is refused."""
|
||||
from praisonai.cli.features.recipe import RecipeHandler
|
||||
handler = RecipeHandler()
|
||||
exit_code = handler.handle(["serve", "--host", "0.0.0.0", "--port", "9999"])
|
||||
assert exit_code == 4 # POLICY_DENIED
|
||||
|
||||
def test_allow_localhost_without_auth(self):
|
||||
"""Test that localhost binding without auth is allowed."""
|
||||
# Verify the host check logic
|
||||
spec = {"host": "127.0.0.1", "auth": "none"}
|
||||
host = spec["host"]
|
||||
auth = spec["auth"]
|
||||
# This should NOT trigger the policy denial
|
||||
should_deny = host != "127.0.0.1" and host != "localhost" and auth == "none"
|
||||
assert should_deny is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Run tests
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in new issue
Block a user