Compare commits

...
1 Commits
Author SHA1 Message Date
Mervin Praison 1050d66970 Release v2.7.5 2025-12-29 14:31:52 +00:00
12 changed files with 1468 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -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.4" \
"praisonai>=2.7.5" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -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.4" \
"praisonai>=2.7.5" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -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.4" \
"praisonai>=2.7.5" \
"praisonai[ui]" \
"praisonai[crewai]"
+42
View File
@@ -77,6 +77,48 @@ praisonai endpoints types
| `recipes` | List of recipes to serve | all |
| `cors_origins` | CORS allowed origins | - |
## Advanced Features
| Feature | CLI Flag | Description | Docs |
|---------|----------|-------------|------|
| Rate Limiting | `--rate_limit 100` | Requests per minute per client | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Request Size Limit | `--max_request_size 10485760` | Max request body (bytes) | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Metrics Endpoint | `--enable_metrics` | Prometheus /metrics endpoint | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Admin Reload | `--enable_admin` | Hot reload /admin/reload | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| Workers | `--workers 4` | Multi-process workers | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| OpenTelemetry | `--trace_exporter otlp` | Distributed tracing | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
| OpenAPI | `GET /openapi.json` | API specification | [Code](/docs/features/recipe-serve-advanced) [CLI](/docs/cli/recipe-serve-advanced) |
## Advanced Examples
| Example | Description |
|---------|-------------|
| `recipe_serve_features.py` | All advanced features demo |
## Advanced CLI Commands
```bash
# Production server with all features
praisonai recipe serve \
--host 0.0.0.0 \
--port 8765 \
--auth api-key \
--workers 4 \
--rate_limit 100 \
--enable_metrics \
--enable_admin
# Get metrics
curl http://localhost:8765/metrics
# Get OpenAPI spec
curl http://localhost:8765/openapi.json
# Hot reload recipes
curl -X POST http://localhost:8765/admin/reload \
-H "X-API-Key: your-key"
```
## CLI Commands
```bash
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
PraisonAI Recipe Server Advanced Features Example
Demonstrates:
1. Rate limiting
2. Request size limits
3. Metrics endpoint
4. Admin reload endpoint
5. OpenAPI specification
6. Workers configuration
7. OpenTelemetry tracing
Prerequisites:
- pip install praisonai[serve]
- Set OPENAI_API_KEY environment variable
Usage:
# Run this example
python recipe_serve_features.py
"""
import json
import os
import urllib.request
import urllib.error
# Configuration
SERVER_URL = os.environ.get("PRAISONAI_ENDPOINTS_URL", "http://localhost:8765")
API_KEY = os.environ.get("PRAISONAI_API_KEY", "test-api-key")
def make_request(method: str, path: str, data: dict = None, headers: dict = None) -> dict:
"""Make HTTP request to server."""
url = f"{SERVER_URL}{path}"
default_headers = {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
}
if headers:
default_headers.update(headers)
body = json.dumps(data).encode() if data else None
try:
req = urllib.request.Request(url, data=body, headers=default_headers, method=method)
with urllib.request.urlopen(req, timeout=30) as response:
return {
"status": response.status,
"data": json.loads(response.read().decode()),
"headers": dict(response.headers)
}
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
try:
error_data = json.loads(body)
except json.JSONDecodeError:
error_data = {"raw": body}
return {
"status": e.code,
"error": error_data,
"headers": dict(e.headers) if hasattr(e, 'headers') else {}
}
except urllib.error.URLError as e:
return {"status": 0, "error": str(e.reason)}
def test_health():
"""Test health endpoint."""
print("\n" + "=" * 60)
print("TEST: Health Check")
print("=" * 60)
result = make_request("GET", "/health")
if result.get("status") == 200:
data = result["data"]
print("✓ Server healthy")
print(f" Service: {data.get('service')}")
print(f" Version: {data.get('version')}")
return True
else:
print(f"✗ Server unhealthy: {result.get('error')}")
return False
def test_openapi():
"""Test OpenAPI specification endpoint."""
print("\n" + "=" * 60)
print("TEST: OpenAPI Specification")
print("=" * 60)
result = make_request("GET", "/openapi.json")
if result.get("status") == 200:
spec = result["data"]
print("✓ OpenAPI spec available")
print(f" Version: {spec.get('openapi')}")
print(f" Title: {spec.get('info', {}).get('title')}")
print(f" Paths: {list(spec.get('paths', {}).keys())[:5]}...")
return True
else:
print(f"✗ OpenAPI not available: {result.get('error')}")
return False
def test_metrics():
"""Test metrics endpoint."""
print("\n" + "=" * 60)
print("TEST: Metrics Endpoint")
print("=" * 60)
result = make_request("GET", "/metrics")
if result.get("status") == 200:
# Metrics returns plain text, not JSON
print("✓ Metrics endpoint available")
print(" (Prometheus format)")
return True
elif result.get("status") == 404:
print("⚠ Metrics endpoint not enabled")
print(" Enable with: --enable_metrics")
return True # Not a failure, just not enabled
else:
print(f"✗ Metrics error: {result.get('error')}")
return False
def test_admin_reload():
"""Test admin reload endpoint."""
print("\n" + "=" * 60)
print("TEST: Admin Reload Endpoint")
print("=" * 60)
result = make_request("POST", "/admin/reload")
if result.get("status") == 200:
data = result["data"]
print("✓ Admin reload successful")
print(f" Status: {data.get('status')}")
print(f" Timestamp: {data.get('timestamp')}")
return True
elif result.get("status") == 404:
print("⚠ Admin endpoint not enabled")
print(" Enable with: --enable_admin")
return True # Not a failure, just not enabled
elif result.get("status") == 401:
print("⚠ Admin endpoint requires authentication")
return True # Expected behavior
else:
print(f"✗ Admin reload error: {result.get('error')}")
return False
def test_rate_limiting():
"""Test rate limiting."""
print("\n" + "=" * 60)
print("TEST: Rate Limiting")
print("=" * 60)
# Make rapid requests
rate_limited = False
retry_after = None
for i in range(20):
result = make_request("GET", "/v1/recipes")
if result.get("status") == 429:
rate_limited = True
retry_after = result.get("headers", {}).get("Retry-After")
error = result.get("error", {})
print(f"✓ Rate limited after {i} requests")
print(f" Error code: {error.get('error', {}).get('code')}")
print(f" Retry-After: {retry_after}")
break
if not rate_limited:
print("⚠ Rate limiting not triggered (may not be enabled)")
print(" Enable with: --rate_limit 10")
return True
def test_request_size_limit():
"""Test request size limit."""
print("\n" + "=" * 60)
print("TEST: Request Size Limit")
print("=" * 60)
# Create a large payload
large_data = {
"recipe": "test",
"input": {"data": "x" * 100000} # ~100KB
}
result = make_request("POST", "/v1/recipes/run", large_data)
if result.get("status") == 413:
error = result.get("error", {})
print("✓ Request size limit enforced")
print(f" Error code: {error.get('error', {}).get('code')}")
return True
elif result.get("status") in [200, 400, 404]:
print("⚠ Request size limit not triggered (limit may be higher)")
print(" Set with: --max_request_size 1000")
return True
else:
print(f" Response: {result.get('status')} - {result.get('error')}")
return True
def test_list_recipes():
"""Test listing recipes."""
print("\n" + "=" * 60)
print("TEST: List Recipes")
print("=" * 60)
result = make_request("GET", "/v1/recipes")
if result.get("status") == 200:
recipes = result["data"].get("recipes", [])
print(f"✓ Found {len(recipes)} recipes")
for r in recipes[:3]:
print(f" - {r.get('name')} ({r.get('version', 'unknown')})")
if len(recipes) > 3:
print(f" ... and {len(recipes) - 3} more")
return True
elif result.get("status") == 401:
print("⚠ Authentication required")
return True
else:
print(f"✗ Error: {result.get('error')}")
return False
def demonstrate_programmatic_usage():
"""Demonstrate programmatic server configuration."""
print("\n" + "=" * 60)
print("DEMO: Programmatic Configuration")
print("=" * 60)
print("""
# Rate Limiter
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=100)
allowed, retry_after = limiter.check("client-id")
# Metrics Collector
from praisonai.recipe.serve import MetricsCollector
metrics = MetricsCollector()
metrics.record_request("/health", "GET", 200, 0.01)
print(metrics.get_prometheus_metrics())
# OpenAPI Spec
from praisonai.recipe.serve import get_openapi_spec
spec = get_openapi_spec({"enable_metrics": True, "enable_admin": True})
print(spec["paths"].keys())
# Template Search Paths
from praisonai.recipe.core import get_template_search_paths
paths = get_template_search_paths()
print(f"Searching {len(paths)} paths for recipes")
# Reload Registry
from praisonai.recipe.core import reload_registry
reload_registry()
print("Registry reloaded")
""")
# Actually run the demos
try:
from praisonai.recipe.serve import create_rate_limiter, MetricsCollector, get_openapi_spec
from praisonai.recipe.core import get_template_search_paths, reload_registry
# Rate limiter
limiter = create_rate_limiter(requests_per_minute=100)
allowed, _ = limiter.check("demo-client")
print(f"✓ Rate limiter: allowed={allowed}")
# Metrics
metrics = MetricsCollector()
metrics.record_request("/demo", "GET", 200, 0.01)
prom = metrics.get_prometheus_metrics()
print(f"✓ Metrics: {len(prom)} chars of Prometheus format")
# OpenAPI
spec = get_openapi_spec({"enable_metrics": True})
print(f"✓ OpenAPI: {len(spec['paths'])} paths defined")
# Search paths
paths = get_template_search_paths()
print(f"✓ Template paths: {len(paths)} directories")
# Reload
reload_registry()
print("✓ Registry reloaded")
except ImportError as e:
print(f"⚠ Import error: {e}")
print(" Install with: pip install praisonai[serve]")
def main():
"""Main example function."""
print("=" * 60)
print("PraisonAI Recipe Server Advanced Features")
print("=" * 60)
print(f"Server URL: {SERVER_URL}")
print(f"API Key: {'*' * 8}..." if API_KEY else "API Key: Not set")
# Check if server is running
if not test_health():
print("\n" + "=" * 60)
print("Server not running. Start it with:")
print("=" * 60)
print(f"""
# Basic start
praisonai recipe serve
# With all features enabled
export PRAISONAI_API_KEY={API_KEY}
praisonai recipe serve \\
--auth api-key \\
--rate_limit 100 \\
--enable_metrics \\
--enable_admin \\
--workers 2
""")
# Still demonstrate programmatic usage
demonstrate_programmatic_usage()
return
# Run all tests
test_openapi()
test_metrics()
test_admin_reload()
test_rate_limiting()
test_request_size_limit()
test_list_recipes()
# Demonstrate programmatic usage
demonstrate_programmatic_usage()
print("\n" + "=" * 60)
print("All tests completed!")
print("=" * 60)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -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.4.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.4.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.5.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.5.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+30 -6
View File
@@ -1040,6 +1040,12 @@ OPENAI_API_KEY=your-api-key
"recipes": {"default": None},
"config": {"default": None},
"api_key": {"default": None},
"workers": {"default": "1"},
"rate_limit": {"default": None},
"max_request_size": {"default": None},
"enable_metrics": {"flag": True, "default": False},
"enable_admin": {"flag": True, "default": False},
"trace_exporter": {"default": "none"},
}
parsed = self._parse_args(args, spec)
@@ -1057,6 +1063,7 @@ OPENAI_API_KEY=your-api-key
port = int(parsed["port"]) if parsed["port"] != "8765" or "port" not in config else config.get("port", 8765)
host = parsed["host"] if parsed["host"] != "127.0.0.1" or "host" not in config else config.get("host", "127.0.0.1")
auth = parsed["auth"] if parsed["auth"] != "none" or "auth" not in config else config.get("auth", "none")
workers = int(parsed["workers"]) if parsed["workers"] != "1" or "workers" not in config else config.get("workers", 1)
# Update config with CLI overrides
config["auth"] = auth
@@ -1064,6 +1071,16 @@ OPENAI_API_KEY=your-api-key
config["api_key"] = parsed["api_key"]
if parsed["recipes"]:
config["recipes"] = parsed["recipes"].split(",")
if parsed["rate_limit"]:
config["rate_limit"] = int(parsed["rate_limit"])
if parsed["max_request_size"]:
config["max_request_size"] = int(parsed["max_request_size"])
if parsed["enable_metrics"]:
config["enable_metrics"] = True
if parsed["enable_admin"]:
config["enable_admin"] = True
if parsed["trace_exporter"] != "none":
config["trace_exporter"] = parsed["trace_exporter"]
# Safety check: require auth for non-localhost
if host != "127.0.0.1" and host != "localhost" and auth == "none":
@@ -1074,15 +1091,22 @@ OPENAI_API_KEY=your-api-key
from praisonai.recipe.serve import serve
print(f"Starting Recipe Runner on http://{host}:{port}")
if workers > 1:
print(f"Workers: {workers}")
if auth != "none":
print(f"Auth: {auth}")
print("Press Ctrl+C to stop")
print("\nEndpoints:")
print(" GET /health - Health check")
print(" GET /v1/recipes - List recipes")
print(" GET /v1/recipes/{name} - Describe recipe")
print(" POST /v1/recipes/run - Run recipe")
print(" POST /v1/recipes/stream - Stream recipe")
print(" GET /health - Health check")
print(" GET /v1/recipes - List recipes")
print(" GET /v1/recipes/{name} - Describe recipe")
print(" POST /v1/recipes/run - Run recipe")
print(" POST /v1/recipes/stream - Stream recipe")
print(" GET /openapi.json - OpenAPI spec")
if config.get("enable_metrics"):
print(" GET /metrics - Prometheus metrics")
if config.get("enable_admin"):
print(" POST /admin/reload - Hot reload registry")
# Preload recipes if requested
if parsed["preload"]:
@@ -1091,7 +1115,7 @@ OPENAI_API_KEY=your-api-key
recipes = recipe.list_recipes()
print(f" Loaded {len(recipes)} recipes")
serve(host=host, port=port, reload=parsed["reload"], config=config)
serve(host=host, port=port, reload=parsed["reload"], config=config, workers=workers)
return self.EXIT_SUCCESS
+1 -1
View File
@@ -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.4 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.7.5 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+80
View File
@@ -59,6 +59,86 @@ def _get_timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
def get_template_search_paths() -> List[Path]:
"""
Get list of paths to search for recipe templates.
Precedence order:
1. PRAISONAI_RECIPE_PATH environment variable (colon-separated)
2. Current working directory ./recipes
3. User home ~/.praison/recipes
4. Agent-Recipes package templates (if installed)
5. Built-in templates
Returns:
List of Path objects to search for templates
"""
paths = []
# 1. Environment variable
env_path = os.environ.get("PRAISONAI_RECIPE_PATH")
if env_path:
for p in env_path.split(os.pathsep):
path = Path(p).expanduser()
if path.exists():
paths.append(path)
# 2. Current working directory
cwd_recipes = Path.cwd() / "recipes"
if cwd_recipes.exists():
paths.append(cwd_recipes)
# 3. User home
home_recipes = Path.home() / ".praison" / "recipes"
if home_recipes.exists():
paths.append(home_recipes)
# 4. Agent-Recipes package (lazy import)
try:
import agent_recipes
if hasattr(agent_recipes, 'get_template_path'):
agent_path = Path(agent_recipes.get_template_path(""))
if agent_path.parent.exists():
paths.append(agent_path.parent)
elif hasattr(agent_recipes, '__file__'):
agent_templates = Path(agent_recipes.__file__).parent / "templates"
if agent_templates.exists():
paths.append(agent_templates)
except ImportError:
pass
# 5. Built-in templates (relative to this package)
builtin = Path(__file__).parent.parent / "templates"
if builtin.exists():
paths.append(builtin)
return paths
def reload_registry():
"""
Reload the recipe registry, clearing any cached templates.
This is useful for hot-reloading recipes during development
or when using the /admin/reload endpoint.
"""
global _recipe_cache
if '_recipe_cache' in globals():
_recipe_cache.clear()
# Also clear any module-level caches
import importlib
try:
from praisonai import recipe as recipe_module
importlib.reload(recipe_module)
except Exception:
pass
# Recipe cache for performance
_recipe_cache: Dict[str, Any] = {}
def run(
name: str,
input: Union[str, Dict[str, Any]] = None,
+440 -8
View File
@@ -5,17 +5,21 @@ 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 /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)
- POST /v1/recipes/run - Run recipe (sync)
- POST /v1/recipes/stream - Run recipe (SSE)
- POST /v1/recipes/validate - Validate recipe
- GET /metrics - Prometheus metrics (optional)
- GET /openapi.json - OpenAPI specification
- POST /admin/reload - Hot reload registry (auth required)
Auth modes:
- none: No authentication (localhost only)
- api-key: X-API-Key header required
- jwt: Bearer token required (future)
- jwt: Bearer token required
Config file (serve.yaml):
```yaml
@@ -28,12 +32,19 @@ recipes:
- another-recipe
preload: true
cors_origins: "*"
rate_limit: 100 # requests per minute (0 = disabled)
max_request_size: 10485760 # 10MB default
enable_metrics: false # Enable /metrics endpoint
enable_admin: false # Enable /admin/* endpoints
trace_exporter: none # none, otlp, jaeger, zipkin
```
"""
import json
import os
from typing import Any, Dict, Optional
import time
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
@@ -65,6 +76,238 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
return config
# Default constants
DEFAULT_MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB
DEFAULT_RATE_LIMIT = 100 # requests per minute
DEFAULT_RATE_LIMIT_EXEMPT_PATHS = ["/health", "/metrics"]
class RateLimiter:
"""Simple in-memory rate limiter using sliding window."""
def __init__(self, requests_per_minute: int = DEFAULT_RATE_LIMIT):
self.requests_per_minute = requests_per_minute
self.window_seconds = 60
self._requests: Dict[str, List[float]] = defaultdict(list)
def check(self, client_id: str) -> Tuple[bool, int]:
"""
Check if request is allowed.
Returns:
Tuple of (allowed, retry_after_seconds)
"""
if self.requests_per_minute <= 0:
return True, 0
now = time.time()
window_start = now - self.window_seconds
# Clean old requests
self._requests[client_id] = [
t for t in self._requests[client_id] if t > window_start
]
if len(self._requests[client_id]) >= self.requests_per_minute:
# Calculate retry-after
oldest = min(self._requests[client_id])
retry_after = int(oldest + self.window_seconds - now) + 1
return False, max(1, retry_after)
self._requests[client_id].append(now)
return True, 0
def create_rate_limiter(requests_per_minute: int = DEFAULT_RATE_LIMIT) -> RateLimiter:
"""Create a rate limiter instance."""
return RateLimiter(requests_per_minute=requests_per_minute)
class MetricsCollector:
"""Simple in-memory metrics collector for Prometheus format."""
def __init__(self):
self._requests_total: Dict[str, int] = defaultdict(int)
self._request_durations: Dict[str, List[float]] = defaultdict(list)
self._errors_total: Dict[str, int] = defaultdict(int)
def record_request(self, path: str, method: str, status: int, duration: float):
"""Record a request."""
# Normalize path to avoid label explosion
normalized_path = self._normalize_path(path)
key = f'{normalized_path}|{method}|{status}'
self._requests_total[key] += 1
self._request_durations[f'{normalized_path}|{method}'].append(duration)
if status >= 400:
error_type = "client_error" if status < 500 else "server_error"
error_key = f'{normalized_path}|{method}|{error_type}'
self._errors_total[error_key] += 1
def _normalize_path(self, path: str) -> str:
"""Normalize path to avoid label explosion."""
# Replace dynamic segments
parts = path.split("/")
normalized = []
for i, part in enumerate(parts):
if i > 0 and parts[i-1] == "recipes" and part not in ["run", "stream", "validate"]:
normalized.append("{name}")
else:
normalized.append(part)
return "/".join(normalized)
def get_prometheus_metrics(self) -> str:
"""Get metrics in Prometheus exposition format."""
lines = []
# Requests total
lines.append("# HELP praisonai_http_requests_total Total HTTP requests")
lines.append("# TYPE praisonai_http_requests_total counter")
for key, count in self._requests_total.items():
path, method, status = key.split("|")
lines.append(f'praisonai_http_requests_total{{path="{path}",method="{method}",status="{status}"}} {count}')
# Request duration histogram (simplified - just sum and count)
lines.append("# HELP praisonai_http_request_duration_seconds HTTP request duration")
lines.append("# TYPE praisonai_http_request_duration_seconds histogram")
for key, durations in self._request_durations.items():
path, method = key.split("|")
if durations:
total = sum(durations)
count = len(durations)
lines.append(f'praisonai_http_request_duration_seconds_sum{{path="{path}",method="{method}"}} {total:.6f}')
lines.append(f'praisonai_http_request_duration_seconds_count{{path="{path}",method="{method}"}} {count}')
# Add buckets
buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
for bucket in buckets:
bucket_count = sum(1 for d in durations if d <= bucket)
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="{bucket}"}} {bucket_count}')
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="+Inf"}} {count}')
# Errors total
lines.append("# HELP praisonai_http_errors_total Total HTTP errors")
lines.append("# TYPE praisonai_http_errors_total counter")
for key, count in self._errors_total.items():
path, method, error_type = key.split("|")
lines.append(f'praisonai_http_errors_total{{path="{path}",method="{method}",error_type="{error_type}"}} {count}')
return "\n".join(lines)
# Global metrics collector (created per app instance)
_metrics_collector: Optional[MetricsCollector] = None
def get_openapi_spec(config: Dict[str, Any]) -> Dict[str, Any]:
"""Generate OpenAPI specification."""
spec = {
"openapi": "3.0.3",
"info": {
"title": "PraisonAI Recipe Runner API",
"description": "HTTP API for running PraisonAI recipes",
"version": _get_version(),
},
"servers": [
{"url": f"http://{config.get('host', '127.0.0.1')}:{config.get('port', 8765)}"}
],
"paths": {
"/health": {
"get": {
"summary": "Health check",
"responses": {"200": {"description": "Server is healthy"}}
}
},
"/v1/recipes": {
"get": {
"summary": "List available recipes",
"parameters": [
{"name": "source", "in": "query", "schema": {"type": "string"}},
{"name": "tags", "in": "query", "schema": {"type": "string"}}
],
"responses": {"200": {"description": "List of recipes"}}
}
},
"/v1/recipes/{name}": {
"get": {
"summary": "Describe a recipe",
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
"responses": {"200": {"description": "Recipe details"}, "404": {"description": "Recipe not found"}}
}
},
"/v1/recipes/{name}/schema": {
"get": {
"summary": "Get recipe JSON schema",
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
"responses": {"200": {"description": "Recipe schema"}}
}
},
"/v1/recipes/run": {
"post": {
"summary": "Run a recipe",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["recipe"],
"properties": {
"recipe": {"type": "string"},
"input": {"type": "object"},
"config": {"type": "object"},
"session_id": {"type": "string"},
"options": {"type": "object"}
}
}
}
}
},
"responses": {"200": {"description": "Recipe result"}}
}
},
"/v1/recipes/stream": {
"post": {
"summary": "Stream recipe execution (SSE)",
"responses": {"200": {"description": "SSE event stream"}}
}
},
"/v1/recipes/validate": {
"post": {
"summary": "Validate a recipe",
"responses": {"200": {"description": "Validation result"}}
}
},
}
}
# Add optional endpoints
if config.get("enable_metrics"):
spec["paths"]["/metrics"] = {
"get": {
"summary": "Prometheus metrics",
"responses": {"200": {"description": "Metrics in Prometheus format"}}
}
}
if config.get("enable_admin"):
spec["paths"]["/admin/reload"] = {
"post": {
"summary": "Hot reload recipe registry",
"security": [{"apiKey": []}],
"responses": {"200": {"description": "Reload successful"}, "401": {"description": "Unauthorized"}}
}
}
spec["paths"]["/openapi.json"] = {
"get": {
"summary": "OpenAPI specification",
"responses": {"200": {"description": "OpenAPI JSON"}}
}
}
return spec
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_secret: Optional[str] = None):
"""Create authentication middleware."""
try:
@@ -346,6 +589,43 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
status_code = 200 if result.valid else 400
return JSONResponse(result.to_dict(), status_code=status_code)
async def get_metrics(request: Request) -> Response:
"""GET /metrics - Prometheus metrics."""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = MetricsCollector()
content = _metrics_collector.get_prometheus_metrics()
return Response(
content=content,
media_type="text/plain; version=0.0.4; charset=utf-8"
)
async def admin_reload(request: Request) -> JSONResponse:
"""POST /admin/reload - Hot reload recipe registry."""
try:
from praisonai import recipe
# Clear any cached recipes and reload
if hasattr(recipe, '_recipe_cache'):
recipe._recipe_cache.clear()
if hasattr(recipe, 'reload_registry'):
recipe.reload_registry()
return JSONResponse({
"status": "reloaded",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
})
except Exception as e:
return JSONResponse(
{"error": {"code": "reload_failed", "message": str(e)}},
status_code=500
)
async def get_openapi(request: Request) -> JSONResponse:
"""GET /openapi.json - OpenAPI specification."""
spec = get_openapi_spec(config)
return JSONResponse(spec)
# Build routes
routes = [
Route("/health", health, methods=["GET"]),
@@ -355,8 +635,33 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
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"]),
Route("/openapi.json", get_openapi, methods=["GET"]),
]
# Add optional endpoints
if config.get("enable_metrics"):
routes.append(Route("/metrics", get_metrics, methods=["GET"]))
if config.get("enable_admin"):
routes.append(Route("/admin/reload", admin_reload, methods=["POST"]))
# Initialize metrics collector if enabled
global _metrics_collector
if config.get("enable_metrics"):
_metrics_collector = MetricsCollector()
# Create rate limiter if configured
rate_limit = config.get("rate_limit", 0)
rate_limiter = None
if rate_limit > 0:
rate_limiter = create_rate_limiter(rate_limit)
# Get max request size
max_request_size = config.get("max_request_size", DEFAULT_MAX_REQUEST_SIZE)
# Exempt paths for rate limiting
rate_limit_exempt = config.get("rate_limit_exempt_paths", DEFAULT_RATE_LIMIT_EXEMPT_PATHS)
# Add CORS middleware if configured
middleware = []
cors_origins = config.get("cors_origins")
@@ -389,6 +694,75 @@ def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
if auth_middleware:
middleware.append(Middleware(auth_middleware))
# Create rate limit and size limit middleware
from starlette.middleware.base import BaseHTTPMiddleware
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Rate limiting middleware."""
async def dispatch(self, request, call_next):
if rate_limiter is None:
return await call_next(request)
# Skip exempt paths
if request.url.path in rate_limit_exempt:
return await call_next(request)
# Get client identifier (IP or API key)
client_id = request.headers.get("X-API-Key") or request.client.host if request.client else "unknown"
allowed, retry_after = rate_limiter.check(client_id)
if not allowed:
return JSONResponse(
{"error": {"code": "rate_limited", "message": "Too many requests"}},
status_code=429,
headers={"Retry-After": str(retry_after)}
)
return await call_next(request)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""Request size limit middleware."""
async def dispatch(self, request, call_next):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > max_request_size:
return JSONResponse(
{"error": {"code": "request_too_large", "message": f"Request body too large. Max: {max_request_size} bytes"}},
status_code=413
)
return await call_next(request)
class MetricsMiddleware(BaseHTTPMiddleware):
"""Metrics collection middleware."""
async def dispatch(self, request, call_next):
if _metrics_collector is None:
return await call_next(request)
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
_metrics_collector.record_request(
path=request.url.path,
method=request.method,
status=response.status_code,
duration=duration
)
return response
# Add custom middleware (order matters - first added = outermost)
if config.get("enable_metrics"):
middleware.append(Middleware(MetricsMiddleware))
if max_request_size > 0:
middleware.append(Middleware(RequestSizeLimitMiddleware))
if rate_limiter is not None:
middleware.append(Middleware(RateLimitMiddleware))
return Starlette(routes=routes, middleware=middleware)
@@ -397,6 +771,7 @@ def serve(
port: int = 8765,
reload: bool = False,
config: Optional[Dict[str, Any]] = None,
workers: int = 1,
):
"""
Start the recipe runner server.
@@ -406,6 +781,7 @@ def serve(
port: Server port (default: 8765)
reload: Enable hot reload (default: False)
config: Optional configuration dict
workers: Number of worker processes (default: 1)
"""
try:
import uvicorn
@@ -414,8 +790,64 @@ def serve(
"Serve dependencies not installed. Run: pip install praisonai[serve]"
)
# Initialize OpenTelemetry tracing if configured
trace_exporter = (config or {}).get("trace_exporter", "none")
if trace_exporter and trace_exporter != "none":
_init_tracing(trace_exporter, config or {})
app = create_app(config)
uvicorn.run(app, host=host, port=port, reload=reload)
# Workers > 1 requires reload=False
if workers > 1 and reload:
import warnings
warnings.warn("Cannot use reload with multiple workers. Disabling reload.")
reload = False
uvicorn.run(app, host=host, port=port, reload=reload, workers=workers if workers > 1 else None)
def _init_tracing(exporter: str, config: Dict[str, Any]):
"""Initialize OpenTelemetry tracing (lazy import)."""
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
resource = Resource.create({
"service.name": config.get("service_name", "praisonai-recipe"),
"service.version": _get_version(),
})
provider = TracerProvider(resource=resource)
if exporter == "otlp":
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
endpoint = config.get("otlp_endpoint", "http://localhost:4317")
span_exporter = OTLPSpanExporter(endpoint=endpoint)
elif exporter == "jaeger":
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
span_exporter = JaegerExporter(
agent_host_name=config.get("jaeger_host", "localhost"),
agent_port=config.get("jaeger_port", 6831),
)
elif exporter == "zipkin":
from opentelemetry.exporter.zipkin.json import ZipkinExporter
span_exporter = ZipkinExporter(
endpoint=config.get("zipkin_endpoint", "http://localhost:9411/api/v2/spans")
)
else:
return # Unknown exporter
provider.add_span_processor(BatchSpanProcessor(span_exporter))
trace.set_tracer_provider(provider)
except ImportError:
import warnings
warnings.warn(
f"OpenTelemetry exporter '{exporter}' requested but dependencies not installed. "
"Run: pip install opentelemetry-sdk opentelemetry-exporter-otlp"
)
def _get_version() -> str:
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7.4"
__version__ = "2.7.5"
+519
View File
@@ -0,0 +1,519 @@
"""
Tests for Recipe Serve Features - Rate Limiting, Size Limits, Metrics, Admin, OpenTelemetry
TDD tests for gap implementation:
- Rate limiting middleware
- Request size limits
- GET /metrics endpoint (Prometheus format)
- POST /admin/reload endpoint
- OpenTelemetry export (lazy import)
- --workers CLI flag
- OpenAPI export
"""
import json
import os
import sys
import time
from unittest.mock import MagicMock, patch
import pytest
# ============================================================================
# Rate Limiting Tests
# ============================================================================
class TestRateLimiting:
"""Tests for rate limiting middleware."""
def test_rate_limiter_creation(self):
"""Test rate limiter can be created with config."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=100)
assert limiter is not None
def test_rate_limiter_allows_requests_under_limit(self):
"""Test rate limiter allows requests under limit."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=10)
client_ip = "127.0.0.1"
# Should allow 10 requests
for _ in range(10):
allowed, _ = limiter.check(client_ip)
assert allowed is True
def test_rate_limiter_blocks_over_limit(self):
"""Test rate limiter blocks requests over limit."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=5)
client_ip = "127.0.0.1"
# Use up the limit
for _ in range(5):
limiter.check(client_ip)
# Next request should be blocked
allowed, retry_after = limiter.check(client_ip)
assert allowed is False
assert retry_after > 0
def test_rate_limiter_per_client(self):
"""Test rate limiter tracks per-client."""
from praisonai.recipe.serve import create_rate_limiter
limiter = create_rate_limiter(requests_per_minute=2)
# Client 1 uses up limit
for _ in range(2):
limiter.check("client1")
# Client 2 should still be allowed
allowed, _ = limiter.check("client2")
assert allowed is True
def test_rate_limit_middleware_returns_429(self):
"""Test rate limit middleware returns 429 with proper error."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {
"rate_limit": 2, # 2 requests per minute
"rate_limit_exempt_paths": ["/health"],
}
app = create_app(config)
client = TestClient(app)
# First 2 requests should succeed
for _ in range(2):
response = client.get("/v1/recipes")
assert response.status_code in [200, 404] # OK or no recipes
# Third request should be rate limited
response = client.get("/v1/recipes")
assert response.status_code == 429
data = response.json()
assert "error" in data
assert data["error"]["code"] == "rate_limited"
assert "Retry-After" in response.headers
def test_rate_limit_exempts_health(self):
"""Test /health is exempt from rate limiting by default."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"rate_limit": 1}
app = create_app(config)
client = TestClient(app)
# Use up rate limit
client.get("/v1/recipes")
# Health should still work
response = client.get("/health")
assert response.status_code == 200
# ============================================================================
# Request Size Limit Tests
# ============================================================================
class TestRequestSizeLimits:
"""Tests for request size limits."""
def test_size_limit_allows_small_requests(self):
"""Test small requests are allowed."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"max_request_size": 1024 * 1024} # 1MB
app = create_app(config)
client = TestClient(app)
# Small request should work
response = client.post(
"/v1/recipes/run",
json={"recipe": "test", "input": "small"}
)
# May return 404 if recipe doesn't exist, but not 413
assert response.status_code != 413
def test_size_limit_blocks_large_requests(self):
"""Test large requests are blocked with 413."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"max_request_size": 100} # 100 bytes
app = create_app(config)
client = TestClient(app)
# Large request should be blocked
large_data = {"recipe": "test", "input": "x" * 200}
response = client.post(
"/v1/recipes/run",
json=large_data
)
assert response.status_code == 413
data = response.json()
assert "error" in data
assert data["error"]["code"] == "request_too_large"
def test_default_size_limit_is_10mb(self):
"""Test default size limit is 10MB."""
from praisonai.recipe.serve import DEFAULT_MAX_REQUEST_SIZE
assert DEFAULT_MAX_REQUEST_SIZE == 10 * 1024 * 1024
# ============================================================================
# Metrics Endpoint Tests
# ============================================================================
class TestMetricsEndpoint:
"""Tests for /metrics endpoint."""
def test_metrics_endpoint_exists_when_enabled(self):
"""Test /metrics endpoint exists when enabled."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True}
app = create_app(config)
client = TestClient(app)
response = client.get("/metrics")
assert response.status_code == 200
def test_metrics_endpoint_disabled_by_default_on_public(self):
"""Test /metrics is disabled by default on non-localhost."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
# Simulate non-localhost config
config = {"host": "0.0.0.0", "enable_metrics": False}
app = create_app(config)
client = TestClient(app)
response = client.get("/metrics")
assert response.status_code == 404
def test_metrics_prometheus_format(self):
"""Test metrics are in Prometheus exposition format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True}
app = create_app(config)
client = TestClient(app)
# Make some requests to generate metrics
client.get("/health")
client.get("/v1/recipes")
response = client.get("/metrics")
assert response.status_code == 200
content = response.text
# Check for Prometheus format markers
assert "praisonai_http_requests_total" in content
assert "praisonai_http_request_duration_seconds" in content
def test_metrics_include_required_labels(self):
"""Test metrics include path, method, status labels."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True}
app = create_app(config)
client = TestClient(app)
client.get("/health")
response = client.get("/metrics")
content = response.text
# Should have labels
assert 'path="/health"' in content or 'path="health"' in content
assert 'method="GET"' in content
assert 'status="200"' in content
# ============================================================================
# Admin Reload Endpoint Tests
# ============================================================================
class TestAdminReload:
"""Tests for /admin/reload endpoint."""
def test_admin_reload_requires_auth(self):
"""Test /admin/reload requires authentication."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_admin": True, "auth": "api-key", "api_key": "secret"}
app = create_app(config)
client = TestClient(app)
# Without auth should fail
response = client.post("/admin/reload")
assert response.status_code == 401
def test_admin_reload_with_auth(self):
"""Test /admin/reload works with authentication."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_admin": True, "auth": "api-key", "api_key": "secret"}
app = create_app(config)
client = TestClient(app)
response = client.post(
"/admin/reload",
headers={"X-API-Key": "secret"}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "reloaded"
def test_admin_reload_disabled_by_default(self):
"""Test /admin/reload is disabled by default."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {} # No enable_admin
app = create_app(config)
client = TestClient(app)
response = client.post("/admin/reload")
assert response.status_code == 404
# ============================================================================
# OpenAPI Export Tests
# ============================================================================
class TestOpenAPIExport:
"""Tests for OpenAPI spec export."""
def test_openapi_endpoint_exists(self):
"""Test /openapi.json endpoint exists."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {}
app = create_app(config)
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert "openapi" in data
assert "paths" in data
def test_openapi_includes_all_endpoints(self):
"""Test OpenAPI spec includes all endpoints."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"enable_metrics": True, "enable_admin": True}
app = create_app(config)
client = TestClient(app)
response = client.get("/openapi.json")
data = response.json()
paths = data["paths"]
assert "/health" in paths
assert "/v1/recipes" in paths
assert "/v1/recipes/run" in paths
assert "/metrics" in paths
assert "/admin/reload" in paths
# ============================================================================
# Workers CLI Flag Tests
# ============================================================================
class TestWorkersCLI:
"""Tests for --workers CLI flag."""
def test_serve_command_accepts_workers_flag(self):
"""Test serve command accepts --workers flag."""
from praisonai.cli.features.recipe import RecipeHandler
handler = RecipeHandler()
# Parse args with workers flag
spec = {
"workers": {"default": "1"},
"port": {"default": "8765"},
"host": {"default": "127.0.0.1"},
}
args = ["--workers", "4", "--port", "8000"]
parsed = handler._parse_args(args, spec)
assert parsed["workers"] == "4"
def test_workers_passed_to_uvicorn(self):
"""Test workers count is passed to uvicorn."""
# Test that serve function accepts workers parameter
from praisonai.recipe.serve import serve
import inspect
sig = inspect.signature(serve)
params = list(sig.parameters.keys())
assert "workers" in params, "serve() should accept workers parameter"
# Check default value
workers_param = sig.parameters["workers"]
assert workers_param.default == 1, "workers should default to 1"
# ============================================================================
# OpenTelemetry Lazy Import Tests
# ============================================================================
class TestOpenTelemetryLazyImport:
"""Tests for OpenTelemetry lazy import behavior."""
def test_no_crash_without_otel_installed(self):
"""Test server works without OpenTelemetry installed."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
# Should not crash even without otel
config = {"trace_exporter": "none"}
app = create_app(config)
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
def test_otel_config_options(self):
"""Test OpenTelemetry config options are recognized."""
from praisonai.recipe.serve import load_config
# These should be valid config keys
config = {
"trace_exporter": "otlp",
"otlp_endpoint": "http://localhost:4317",
"service_name": "praisonai-recipe",
}
# Should not raise
assert config["trace_exporter"] == "otlp"
# ============================================================================
# Agent-Recipes Integration Tests
# ============================================================================
class TestAgentRecipesIntegration:
"""Tests for Agent-Recipes template integration."""
def test_template_discovery_precedence(self):
"""Test template discovery follows precedence order."""
from praisonai.recipe.core import get_template_search_paths
paths = get_template_search_paths()
# Should include standard paths
assert any("praison" in str(p).lower() for p in paths)
def test_agent_recipes_path_included(self):
"""Test Agent-Recipes path is in search paths."""
from praisonai.recipe.core import get_template_search_paths
paths = get_template_search_paths()
path_strs = [str(p) for p in paths]
# Should include Agent-Recipes or similar
# The exact path depends on installation
assert len(paths) > 0
# ============================================================================
# Structured Error Tests
# ============================================================================
class TestStructuredErrors:
"""Tests for consistent structured error responses."""
def test_401_error_format(self):
"""Test 401 errors have correct format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"auth": "api-key", "api_key": "secret"}
app = create_app(config)
client = TestClient(app)
response = client.get("/v1/recipes")
assert response.status_code == 401
data = response.json()
assert "error" in data
assert "code" in data["error"]
assert "message" in data["error"]
def test_429_error_format(self):
"""Test 429 errors have correct format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"rate_limit": 1}
app = create_app(config)
client = TestClient(app)
# Use up limit
client.get("/v1/recipes")
response = client.get("/v1/recipes")
if response.status_code == 429:
data = response.json()
assert "error" in data
assert data["error"]["code"] == "rate_limited"
def test_413_error_format(self):
"""Test 413 errors have correct format."""
pytest.importorskip("starlette")
from starlette.testclient import TestClient
from praisonai.recipe.serve import create_app
config = {"max_request_size": 10} # Very small
app = create_app(config)
client = TestClient(app)
response = client.post(
"/v1/recipes/run",
json={"recipe": "test", "input": "x" * 100}
)
if response.status_code == 413:
data = response.json()
assert "error" in data
assert data["error"]["code"] == "request_too_large"