mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 11:51:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1050d66970 |
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.4" \
|
||||
"praisonai>=2.7.5" \
|
||||
"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.4" \
|
||||
"praisonai>=2.7.5" \
|
||||
"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.4" \
|
||||
"praisonai>=2.7.5" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "2.7.4"
|
||||
__version__ = "2.7.5"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user