PraisonAI 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- praisonai/__init__.py +54 -0
- praisonai/__main__.py +15 -0
- praisonai/acp/__init__.py +54 -0
- praisonai/acp/config.py +159 -0
- praisonai/acp/server.py +587 -0
- praisonai/acp/session.py +219 -0
- praisonai/adapters/__init__.py +50 -0
- praisonai/adapters/readers.py +395 -0
- praisonai/adapters/rerankers.py +315 -0
- praisonai/adapters/retrievers.py +394 -0
- praisonai/adapters/vector_stores.py +409 -0
- praisonai/agent_scheduler.py +337 -0
- praisonai/agents_generator.py +903 -0
- praisonai/api/call.py +292 -0
- praisonai/auto.py +1197 -0
- praisonai/capabilities/__init__.py +275 -0
- praisonai/capabilities/a2a.py +140 -0
- praisonai/capabilities/assistants.py +283 -0
- praisonai/capabilities/audio.py +320 -0
- praisonai/capabilities/batches.py +469 -0
- praisonai/capabilities/completions.py +336 -0
- praisonai/capabilities/container_files.py +155 -0
- praisonai/capabilities/containers.py +93 -0
- praisonai/capabilities/embeddings.py +158 -0
- praisonai/capabilities/files.py +467 -0
- praisonai/capabilities/fine_tuning.py +293 -0
- praisonai/capabilities/guardrails.py +182 -0
- praisonai/capabilities/images.py +330 -0
- praisonai/capabilities/mcp.py +190 -0
- praisonai/capabilities/messages.py +270 -0
- praisonai/capabilities/moderations.py +154 -0
- praisonai/capabilities/ocr.py +217 -0
- praisonai/capabilities/passthrough.py +204 -0
- praisonai/capabilities/rag.py +207 -0
- praisonai/capabilities/realtime.py +160 -0
- praisonai/capabilities/rerank.py +165 -0
- praisonai/capabilities/responses.py +266 -0
- praisonai/capabilities/search.py +109 -0
- praisonai/capabilities/skills.py +133 -0
- praisonai/capabilities/vector_store_files.py +334 -0
- praisonai/capabilities/vector_stores.py +304 -0
- praisonai/capabilities/videos.py +141 -0
- praisonai/chainlit_ui.py +304 -0
- praisonai/chat/__init__.py +106 -0
- praisonai/chat/app.py +125 -0
- praisonai/cli/__init__.py +26 -0
- praisonai/cli/app.py +213 -0
- praisonai/cli/commands/__init__.py +75 -0
- praisonai/cli/commands/acp.py +70 -0
- praisonai/cli/commands/completion.py +333 -0
- praisonai/cli/commands/config.py +166 -0
- praisonai/cli/commands/debug.py +142 -0
- praisonai/cli/commands/diag.py +55 -0
- praisonai/cli/commands/doctor.py +166 -0
- praisonai/cli/commands/environment.py +179 -0
- praisonai/cli/commands/lsp.py +112 -0
- praisonai/cli/commands/mcp.py +210 -0
- praisonai/cli/commands/profile.py +457 -0
- praisonai/cli/commands/run.py +228 -0
- praisonai/cli/commands/schedule.py +150 -0
- praisonai/cli/commands/serve.py +97 -0
- praisonai/cli/commands/session.py +212 -0
- praisonai/cli/commands/traces.py +145 -0
- praisonai/cli/commands/version.py +101 -0
- praisonai/cli/configuration/__init__.py +18 -0
- praisonai/cli/configuration/loader.py +353 -0
- praisonai/cli/configuration/paths.py +114 -0
- praisonai/cli/configuration/schema.py +164 -0
- praisonai/cli/features/__init__.py +268 -0
- praisonai/cli/features/acp.py +236 -0
- praisonai/cli/features/action_orchestrator.py +546 -0
- praisonai/cli/features/agent_scheduler.py +773 -0
- praisonai/cli/features/agent_tools.py +474 -0
- praisonai/cli/features/agents.py +375 -0
- praisonai/cli/features/at_mentions.py +471 -0
- praisonai/cli/features/auto_memory.py +182 -0
- praisonai/cli/features/autonomy_mode.py +490 -0
- praisonai/cli/features/background.py +356 -0
- praisonai/cli/features/base.py +168 -0
- praisonai/cli/features/capabilities.py +1326 -0
- praisonai/cli/features/checkpoints.py +338 -0
- praisonai/cli/features/code_intelligence.py +652 -0
- praisonai/cli/features/compaction.py +294 -0
- praisonai/cli/features/compare.py +534 -0
- praisonai/cli/features/cost_tracker.py +514 -0
- praisonai/cli/features/debug.py +810 -0
- praisonai/cli/features/deploy.py +517 -0
- praisonai/cli/features/diag.py +289 -0
- praisonai/cli/features/doctor/__init__.py +63 -0
- praisonai/cli/features/doctor/checks/__init__.py +24 -0
- praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
- praisonai/cli/features/doctor/checks/config_checks.py +366 -0
- praisonai/cli/features/doctor/checks/db_checks.py +366 -0
- praisonai/cli/features/doctor/checks/env_checks.py +543 -0
- praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
- praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
- praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
- praisonai/cli/features/doctor/checks/network_checks.py +251 -0
- praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
- praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
- praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
- praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
- praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
- praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
- praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
- praisonai/cli/features/doctor/engine.py +266 -0
- praisonai/cli/features/doctor/formatters.py +310 -0
- praisonai/cli/features/doctor/handler.py +397 -0
- praisonai/cli/features/doctor/models.py +264 -0
- praisonai/cli/features/doctor/registry.py +239 -0
- praisonai/cli/features/endpoints.py +1019 -0
- praisonai/cli/features/eval.py +560 -0
- praisonai/cli/features/external_agents.py +231 -0
- praisonai/cli/features/fast_context.py +410 -0
- praisonai/cli/features/flow_display.py +566 -0
- praisonai/cli/features/git_integration.py +651 -0
- praisonai/cli/features/guardrail.py +171 -0
- praisonai/cli/features/handoff.py +185 -0
- praisonai/cli/features/hooks.py +583 -0
- praisonai/cli/features/image.py +384 -0
- praisonai/cli/features/interactive_runtime.py +585 -0
- praisonai/cli/features/interactive_tools.py +380 -0
- praisonai/cli/features/interactive_tui.py +603 -0
- praisonai/cli/features/jobs.py +632 -0
- praisonai/cli/features/knowledge.py +531 -0
- praisonai/cli/features/lite.py +244 -0
- praisonai/cli/features/lsp_cli.py +225 -0
- praisonai/cli/features/mcp.py +169 -0
- praisonai/cli/features/message_queue.py +587 -0
- praisonai/cli/features/metrics.py +211 -0
- praisonai/cli/features/n8n.py +673 -0
- praisonai/cli/features/observability.py +293 -0
- praisonai/cli/features/ollama.py +361 -0
- praisonai/cli/features/output_style.py +273 -0
- praisonai/cli/features/package.py +631 -0
- praisonai/cli/features/performance.py +308 -0
- praisonai/cli/features/persistence.py +636 -0
- praisonai/cli/features/profile.py +226 -0
- praisonai/cli/features/profiler/__init__.py +81 -0
- praisonai/cli/features/profiler/core.py +558 -0
- praisonai/cli/features/profiler/optimizations.py +652 -0
- praisonai/cli/features/profiler/suite.py +386 -0
- praisonai/cli/features/profiling.py +350 -0
- praisonai/cli/features/queue/__init__.py +73 -0
- praisonai/cli/features/queue/manager.py +395 -0
- praisonai/cli/features/queue/models.py +286 -0
- praisonai/cli/features/queue/persistence.py +564 -0
- praisonai/cli/features/queue/scheduler.py +484 -0
- praisonai/cli/features/queue/worker.py +372 -0
- praisonai/cli/features/recipe.py +1723 -0
- praisonai/cli/features/recipes.py +449 -0
- praisonai/cli/features/registry.py +229 -0
- praisonai/cli/features/repo_map.py +860 -0
- praisonai/cli/features/router.py +466 -0
- praisonai/cli/features/sandbox_executor.py +515 -0
- praisonai/cli/features/serve.py +829 -0
- praisonai/cli/features/session.py +222 -0
- praisonai/cli/features/skills.py +856 -0
- praisonai/cli/features/slash_commands.py +650 -0
- praisonai/cli/features/telemetry.py +179 -0
- praisonai/cli/features/templates.py +1384 -0
- praisonai/cli/features/thinking.py +305 -0
- praisonai/cli/features/todo.py +334 -0
- praisonai/cli/features/tools.py +680 -0
- praisonai/cli/features/tui/__init__.py +83 -0
- praisonai/cli/features/tui/app.py +580 -0
- praisonai/cli/features/tui/cli.py +566 -0
- praisonai/cli/features/tui/debug.py +511 -0
- praisonai/cli/features/tui/events.py +99 -0
- praisonai/cli/features/tui/mock_provider.py +328 -0
- praisonai/cli/features/tui/orchestrator.py +652 -0
- praisonai/cli/features/tui/screens/__init__.py +50 -0
- praisonai/cli/features/tui/screens/main.py +245 -0
- praisonai/cli/features/tui/screens/queue.py +174 -0
- praisonai/cli/features/tui/screens/session.py +124 -0
- praisonai/cli/features/tui/screens/settings.py +148 -0
- praisonai/cli/features/tui/widgets/__init__.py +56 -0
- praisonai/cli/features/tui/widgets/chat.py +261 -0
- praisonai/cli/features/tui/widgets/composer.py +224 -0
- praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
- praisonai/cli/features/tui/widgets/status.py +167 -0
- praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
- praisonai/cli/features/workflow.py +720 -0
- praisonai/cli/legacy.py +236 -0
- praisonai/cli/main.py +5559 -0
- praisonai/cli/schedule_cli.py +54 -0
- praisonai/cli/state/__init__.py +31 -0
- praisonai/cli/state/identifiers.py +161 -0
- praisonai/cli/state/sessions.py +313 -0
- praisonai/code/__init__.py +93 -0
- praisonai/code/agent_tools.py +344 -0
- praisonai/code/diff/__init__.py +21 -0
- praisonai/code/diff/diff_strategy.py +432 -0
- praisonai/code/tools/__init__.py +27 -0
- praisonai/code/tools/apply_diff.py +221 -0
- praisonai/code/tools/execute_command.py +275 -0
- praisonai/code/tools/list_files.py +274 -0
- praisonai/code/tools/read_file.py +206 -0
- praisonai/code/tools/search_replace.py +248 -0
- praisonai/code/tools/write_file.py +217 -0
- praisonai/code/utils/__init__.py +46 -0
- praisonai/code/utils/file_utils.py +307 -0
- praisonai/code/utils/ignore_utils.py +308 -0
- praisonai/code/utils/text_utils.py +276 -0
- praisonai/db/__init__.py +64 -0
- praisonai/db/adapter.py +531 -0
- praisonai/deploy/__init__.py +62 -0
- praisonai/deploy/api.py +231 -0
- praisonai/deploy/docker.py +454 -0
- praisonai/deploy/doctor.py +367 -0
- praisonai/deploy/main.py +327 -0
- praisonai/deploy/models.py +179 -0
- praisonai/deploy/providers/__init__.py +33 -0
- praisonai/deploy/providers/aws.py +331 -0
- praisonai/deploy/providers/azure.py +358 -0
- praisonai/deploy/providers/base.py +101 -0
- praisonai/deploy/providers/gcp.py +314 -0
- praisonai/deploy/schema.py +208 -0
- praisonai/deploy.py +185 -0
- praisonai/endpoints/__init__.py +53 -0
- praisonai/endpoints/a2u_server.py +410 -0
- praisonai/endpoints/discovery.py +165 -0
- praisonai/endpoints/providers/__init__.py +28 -0
- praisonai/endpoints/providers/a2a.py +253 -0
- praisonai/endpoints/providers/a2u.py +208 -0
- praisonai/endpoints/providers/agents_api.py +171 -0
- praisonai/endpoints/providers/base.py +231 -0
- praisonai/endpoints/providers/mcp.py +263 -0
- praisonai/endpoints/providers/recipe.py +206 -0
- praisonai/endpoints/providers/tools_mcp.py +150 -0
- praisonai/endpoints/registry.py +131 -0
- praisonai/endpoints/server.py +161 -0
- praisonai/inbuilt_tools/__init__.py +24 -0
- praisonai/inbuilt_tools/autogen_tools.py +117 -0
- praisonai/inc/__init__.py +2 -0
- praisonai/inc/config.py +96 -0
- praisonai/inc/models.py +155 -0
- praisonai/integrations/__init__.py +56 -0
- praisonai/integrations/base.py +303 -0
- praisonai/integrations/claude_code.py +270 -0
- praisonai/integrations/codex_cli.py +255 -0
- praisonai/integrations/cursor_cli.py +195 -0
- praisonai/integrations/gemini_cli.py +222 -0
- praisonai/jobs/__init__.py +67 -0
- praisonai/jobs/executor.py +425 -0
- praisonai/jobs/models.py +230 -0
- praisonai/jobs/router.py +314 -0
- praisonai/jobs/server.py +186 -0
- praisonai/jobs/store.py +203 -0
- praisonai/llm/__init__.py +66 -0
- praisonai/llm/registry.py +382 -0
- praisonai/mcp_server/__init__.py +152 -0
- praisonai/mcp_server/adapters/__init__.py +74 -0
- praisonai/mcp_server/adapters/agents.py +128 -0
- praisonai/mcp_server/adapters/capabilities.py +168 -0
- praisonai/mcp_server/adapters/cli_tools.py +568 -0
- praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
- praisonai/mcp_server/adapters/knowledge.py +93 -0
- praisonai/mcp_server/adapters/memory.py +104 -0
- praisonai/mcp_server/adapters/prompts.py +306 -0
- praisonai/mcp_server/adapters/resources.py +124 -0
- praisonai/mcp_server/adapters/tools_bridge.py +280 -0
- praisonai/mcp_server/auth/__init__.py +48 -0
- praisonai/mcp_server/auth/api_key.py +291 -0
- praisonai/mcp_server/auth/oauth.py +460 -0
- praisonai/mcp_server/auth/oidc.py +289 -0
- praisonai/mcp_server/auth/scopes.py +260 -0
- praisonai/mcp_server/cli.py +852 -0
- praisonai/mcp_server/elicitation.py +445 -0
- praisonai/mcp_server/icons.py +302 -0
- praisonai/mcp_server/recipe_adapter.py +573 -0
- praisonai/mcp_server/recipe_cli.py +824 -0
- praisonai/mcp_server/registry.py +703 -0
- praisonai/mcp_server/sampling.py +422 -0
- praisonai/mcp_server/server.py +490 -0
- praisonai/mcp_server/tasks.py +443 -0
- praisonai/mcp_server/transports/__init__.py +18 -0
- praisonai/mcp_server/transports/http_stream.py +376 -0
- praisonai/mcp_server/transports/stdio.py +132 -0
- praisonai/persistence/__init__.py +84 -0
- praisonai/persistence/config.py +238 -0
- praisonai/persistence/conversation/__init__.py +25 -0
- praisonai/persistence/conversation/async_mysql.py +427 -0
- praisonai/persistence/conversation/async_postgres.py +410 -0
- praisonai/persistence/conversation/async_sqlite.py +371 -0
- praisonai/persistence/conversation/base.py +151 -0
- praisonai/persistence/conversation/json_store.py +250 -0
- praisonai/persistence/conversation/mysql.py +387 -0
- praisonai/persistence/conversation/postgres.py +401 -0
- praisonai/persistence/conversation/singlestore.py +240 -0
- praisonai/persistence/conversation/sqlite.py +341 -0
- praisonai/persistence/conversation/supabase.py +203 -0
- praisonai/persistence/conversation/surrealdb.py +287 -0
- praisonai/persistence/factory.py +301 -0
- praisonai/persistence/hooks/__init__.py +18 -0
- praisonai/persistence/hooks/agent_hooks.py +297 -0
- praisonai/persistence/knowledge/__init__.py +26 -0
- praisonai/persistence/knowledge/base.py +144 -0
- praisonai/persistence/knowledge/cassandra.py +232 -0
- praisonai/persistence/knowledge/chroma.py +295 -0
- praisonai/persistence/knowledge/clickhouse.py +242 -0
- praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
- praisonai/persistence/knowledge/couchbase.py +286 -0
- praisonai/persistence/knowledge/lancedb.py +216 -0
- praisonai/persistence/knowledge/langchain_adapter.py +291 -0
- praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
- praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
- praisonai/persistence/knowledge/milvus.py +277 -0
- praisonai/persistence/knowledge/mongodb_vector.py +306 -0
- praisonai/persistence/knowledge/pgvector.py +335 -0
- praisonai/persistence/knowledge/pinecone.py +253 -0
- praisonai/persistence/knowledge/qdrant.py +301 -0
- praisonai/persistence/knowledge/redis_vector.py +291 -0
- praisonai/persistence/knowledge/singlestore_vector.py +299 -0
- praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
- praisonai/persistence/knowledge/upstash_vector.py +266 -0
- praisonai/persistence/knowledge/weaviate.py +223 -0
- praisonai/persistence/migrations/__init__.py +10 -0
- praisonai/persistence/migrations/manager.py +251 -0
- praisonai/persistence/orchestrator.py +406 -0
- praisonai/persistence/state/__init__.py +21 -0
- praisonai/persistence/state/async_mongodb.py +200 -0
- praisonai/persistence/state/base.py +107 -0
- praisonai/persistence/state/dynamodb.py +226 -0
- praisonai/persistence/state/firestore.py +175 -0
- praisonai/persistence/state/gcs.py +155 -0
- praisonai/persistence/state/memory.py +245 -0
- praisonai/persistence/state/mongodb.py +158 -0
- praisonai/persistence/state/redis.py +190 -0
- praisonai/persistence/state/upstash.py +144 -0
- praisonai/persistence/tests/__init__.py +3 -0
- praisonai/persistence/tests/test_all_backends.py +633 -0
- praisonai/profiler.py +1214 -0
- praisonai/recipe/__init__.py +134 -0
- praisonai/recipe/bridge.py +278 -0
- praisonai/recipe/core.py +893 -0
- praisonai/recipe/exceptions.py +54 -0
- praisonai/recipe/history.py +402 -0
- praisonai/recipe/models.py +266 -0
- praisonai/recipe/operations.py +440 -0
- praisonai/recipe/policy.py +422 -0
- praisonai/recipe/registry.py +849 -0
- praisonai/recipe/runtime.py +214 -0
- praisonai/recipe/security.py +711 -0
- praisonai/recipe/serve.py +859 -0
- praisonai/recipe/server.py +613 -0
- praisonai/scheduler/__init__.py +45 -0
- praisonai/scheduler/agent_scheduler.py +552 -0
- praisonai/scheduler/base.py +124 -0
- praisonai/scheduler/daemon_manager.py +225 -0
- praisonai/scheduler/state_manager.py +155 -0
- praisonai/scheduler/yaml_loader.py +193 -0
- praisonai/scheduler.py +194 -0
- praisonai/setup/__init__.py +1 -0
- praisonai/setup/build.py +21 -0
- praisonai/setup/post_install.py +23 -0
- praisonai/setup/setup_conda_env.py +25 -0
- praisonai/setup.py +16 -0
- praisonai/templates/__init__.py +116 -0
- praisonai/templates/cache.py +364 -0
- praisonai/templates/dependency_checker.py +358 -0
- praisonai/templates/discovery.py +391 -0
- praisonai/templates/loader.py +564 -0
- praisonai/templates/registry.py +511 -0
- praisonai/templates/resolver.py +206 -0
- praisonai/templates/security.py +327 -0
- praisonai/templates/tool_override.py +498 -0
- praisonai/templates/tools_doctor.py +256 -0
- praisonai/test.py +105 -0
- praisonai/train.py +562 -0
- praisonai/train_vision.py +306 -0
- praisonai/ui/agents.py +824 -0
- praisonai/ui/callbacks.py +57 -0
- praisonai/ui/chainlit_compat.py +246 -0
- praisonai/ui/chat.py +532 -0
- praisonai/ui/code.py +717 -0
- praisonai/ui/colab.py +474 -0
- praisonai/ui/colab_chainlit.py +81 -0
- praisonai/ui/components/aicoder.py +284 -0
- praisonai/ui/context.py +283 -0
- praisonai/ui/database_config.py +56 -0
- praisonai/ui/db.py +294 -0
- praisonai/ui/realtime.py +488 -0
- praisonai/ui/realtimeclient/__init__.py +756 -0
- praisonai/ui/realtimeclient/tools.py +242 -0
- praisonai/ui/sql_alchemy.py +710 -0
- praisonai/upload_vision.py +140 -0
- praisonai/version.py +1 -0
- praisonai-3.0.0.dist-info/METADATA +3493 -0
- praisonai-3.0.0.dist-info/RECORD +393 -0
- praisonai-3.0.0.dist-info/WHEEL +5 -0
- praisonai-3.0.0.dist-info/entry_points.txt +4 -0
- praisonai-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recipe HTTP Server
|
|
3
|
+
|
|
4
|
+
Provides HTTP endpoints for recipe execution.
|
|
5
|
+
Optional dependency - requires: pip install praisonai[serve]
|
|
6
|
+
|
|
7
|
+
Endpoints:
|
|
8
|
+
- GET /health - Health check
|
|
9
|
+
- GET /v1/recipes - List recipes
|
|
10
|
+
- GET /v1/recipes/{name} - Describe recipe
|
|
11
|
+
- GET /v1/recipes/{name}/schema - Get recipe schema
|
|
12
|
+
- POST /v1/recipes/run - Run recipe (sync)
|
|
13
|
+
- POST /v1/recipes/stream - Run recipe (SSE)
|
|
14
|
+
- POST /v1/recipes/validate - Validate recipe
|
|
15
|
+
- GET /metrics - Prometheus metrics (optional)
|
|
16
|
+
- GET /openapi.json - OpenAPI specification
|
|
17
|
+
- POST /admin/reload - Hot reload registry (auth required)
|
|
18
|
+
|
|
19
|
+
Auth modes:
|
|
20
|
+
- none: No authentication (localhost only)
|
|
21
|
+
- api-key: X-API-Key header required
|
|
22
|
+
- jwt: Bearer token required
|
|
23
|
+
|
|
24
|
+
Config file (serve.yaml):
|
|
25
|
+
```yaml
|
|
26
|
+
host: 127.0.0.1
|
|
27
|
+
port: 8765
|
|
28
|
+
auth: api-key
|
|
29
|
+
api_key: your-secret-key # or use PRAISONAI_API_KEY env var
|
|
30
|
+
recipes:
|
|
31
|
+
- my-recipe
|
|
32
|
+
- another-recipe
|
|
33
|
+
preload: true
|
|
34
|
+
cors_origins: "*"
|
|
35
|
+
rate_limit: 100 # requests per minute (0 = disabled)
|
|
36
|
+
max_request_size: 10485760 # 10MB default
|
|
37
|
+
enable_metrics: false # Enable /metrics endpoint
|
|
38
|
+
enable_admin: false # Enable /admin/* endpoints
|
|
39
|
+
trace_exporter: none # none, otlp, jaeger, zipkin
|
|
40
|
+
```
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import time
|
|
46
|
+
from collections import defaultdict
|
|
47
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Load configuration from file.
|
|
53
|
+
|
|
54
|
+
Precedence: CLI flags > env vars > config file > defaults
|
|
55
|
+
"""
|
|
56
|
+
config = {}
|
|
57
|
+
|
|
58
|
+
if config_path and os.path.exists(config_path):
|
|
59
|
+
try:
|
|
60
|
+
import yaml
|
|
61
|
+
with open(config_path) as f:
|
|
62
|
+
config = yaml.safe_load(f) or {}
|
|
63
|
+
except ImportError:
|
|
64
|
+
# Fall back to JSON if yaml not available
|
|
65
|
+
with open(config_path) as f:
|
|
66
|
+
config = json.load(f)
|
|
67
|
+
|
|
68
|
+
# Apply env var overrides
|
|
69
|
+
if os.environ.get("PRAISONAI_API_KEY"):
|
|
70
|
+
config["api_key"] = os.environ["PRAISONAI_API_KEY"]
|
|
71
|
+
if os.environ.get("PRAISONAI_SERVE_HOST"):
|
|
72
|
+
config["host"] = os.environ["PRAISONAI_SERVE_HOST"]
|
|
73
|
+
if os.environ.get("PRAISONAI_SERVE_PORT"):
|
|
74
|
+
config["port"] = int(os.environ["PRAISONAI_SERVE_PORT"])
|
|
75
|
+
|
|
76
|
+
return config
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Default constants
|
|
80
|
+
DEFAULT_MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB
|
|
81
|
+
DEFAULT_RATE_LIMIT = 100 # requests per minute
|
|
82
|
+
DEFAULT_RATE_LIMIT_EXEMPT_PATHS = ["/health", "/metrics"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RateLimiter:
|
|
86
|
+
"""Simple in-memory rate limiter using sliding window."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, requests_per_minute: int = DEFAULT_RATE_LIMIT):
|
|
89
|
+
self.requests_per_minute = requests_per_minute
|
|
90
|
+
self.window_seconds = 60
|
|
91
|
+
self._requests: Dict[str, List[float]] = defaultdict(list)
|
|
92
|
+
|
|
93
|
+
def check(self, client_id: str) -> Tuple[bool, int]:
|
|
94
|
+
"""
|
|
95
|
+
Check if request is allowed.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Tuple of (allowed, retry_after_seconds)
|
|
99
|
+
"""
|
|
100
|
+
if self.requests_per_minute <= 0:
|
|
101
|
+
return True, 0
|
|
102
|
+
|
|
103
|
+
now = time.time()
|
|
104
|
+
window_start = now - self.window_seconds
|
|
105
|
+
|
|
106
|
+
# Clean old requests
|
|
107
|
+
self._requests[client_id] = [
|
|
108
|
+
t for t in self._requests[client_id] if t > window_start
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
if len(self._requests[client_id]) >= self.requests_per_minute:
|
|
112
|
+
# Calculate retry-after
|
|
113
|
+
oldest = min(self._requests[client_id])
|
|
114
|
+
retry_after = int(oldest + self.window_seconds - now) + 1
|
|
115
|
+
return False, max(1, retry_after)
|
|
116
|
+
|
|
117
|
+
self._requests[client_id].append(now)
|
|
118
|
+
return True, 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_rate_limiter(requests_per_minute: int = DEFAULT_RATE_LIMIT) -> RateLimiter:
|
|
122
|
+
"""Create a rate limiter instance."""
|
|
123
|
+
return RateLimiter(requests_per_minute=requests_per_minute)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class MetricsCollector:
|
|
127
|
+
"""Simple in-memory metrics collector for Prometheus format."""
|
|
128
|
+
|
|
129
|
+
def __init__(self):
|
|
130
|
+
self._requests_total: Dict[str, int] = defaultdict(int)
|
|
131
|
+
self._request_durations: Dict[str, List[float]] = defaultdict(list)
|
|
132
|
+
self._errors_total: Dict[str, int] = defaultdict(int)
|
|
133
|
+
|
|
134
|
+
def record_request(self, path: str, method: str, status: int, duration: float):
|
|
135
|
+
"""Record a request."""
|
|
136
|
+
# Normalize path to avoid label explosion
|
|
137
|
+
normalized_path = self._normalize_path(path)
|
|
138
|
+
key = f'{normalized_path}|{method}|{status}'
|
|
139
|
+
self._requests_total[key] += 1
|
|
140
|
+
self._request_durations[f'{normalized_path}|{method}'].append(duration)
|
|
141
|
+
|
|
142
|
+
if status >= 400:
|
|
143
|
+
error_type = "client_error" if status < 500 else "server_error"
|
|
144
|
+
error_key = f'{normalized_path}|{method}|{error_type}'
|
|
145
|
+
self._errors_total[error_key] += 1
|
|
146
|
+
|
|
147
|
+
def _normalize_path(self, path: str) -> str:
|
|
148
|
+
"""Normalize path to avoid label explosion."""
|
|
149
|
+
# Replace dynamic segments
|
|
150
|
+
parts = path.split("/")
|
|
151
|
+
normalized = []
|
|
152
|
+
for i, part in enumerate(parts):
|
|
153
|
+
if i > 0 and parts[i-1] == "recipes" and part not in ["run", "stream", "validate"]:
|
|
154
|
+
normalized.append("{name}")
|
|
155
|
+
else:
|
|
156
|
+
normalized.append(part)
|
|
157
|
+
return "/".join(normalized)
|
|
158
|
+
|
|
159
|
+
def get_prometheus_metrics(self) -> str:
|
|
160
|
+
"""Get metrics in Prometheus exposition format."""
|
|
161
|
+
lines = []
|
|
162
|
+
|
|
163
|
+
# Requests total
|
|
164
|
+
lines.append("# HELP praisonai_http_requests_total Total HTTP requests")
|
|
165
|
+
lines.append("# TYPE praisonai_http_requests_total counter")
|
|
166
|
+
for key, count in self._requests_total.items():
|
|
167
|
+
path, method, status = key.split("|")
|
|
168
|
+
lines.append(f'praisonai_http_requests_total{{path="{path}",method="{method}",status="{status}"}} {count}')
|
|
169
|
+
|
|
170
|
+
# Request duration histogram (simplified - just sum and count)
|
|
171
|
+
lines.append("# HELP praisonai_http_request_duration_seconds HTTP request duration")
|
|
172
|
+
lines.append("# TYPE praisonai_http_request_duration_seconds histogram")
|
|
173
|
+
for key, durations in self._request_durations.items():
|
|
174
|
+
path, method = key.split("|")
|
|
175
|
+
if durations:
|
|
176
|
+
total = sum(durations)
|
|
177
|
+
count = len(durations)
|
|
178
|
+
lines.append(f'praisonai_http_request_duration_seconds_sum{{path="{path}",method="{method}"}} {total:.6f}')
|
|
179
|
+
lines.append(f'praisonai_http_request_duration_seconds_count{{path="{path}",method="{method}"}} {count}')
|
|
180
|
+
# Add buckets
|
|
181
|
+
buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
|
|
182
|
+
for bucket in buckets:
|
|
183
|
+
bucket_count = sum(1 for d in durations if d <= bucket)
|
|
184
|
+
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="{bucket}"}} {bucket_count}')
|
|
185
|
+
lines.append(f'praisonai_http_request_duration_seconds_bucket{{path="{path}",method="{method}",le="+Inf"}} {count}')
|
|
186
|
+
|
|
187
|
+
# Errors total
|
|
188
|
+
lines.append("# HELP praisonai_http_errors_total Total HTTP errors")
|
|
189
|
+
lines.append("# TYPE praisonai_http_errors_total counter")
|
|
190
|
+
for key, count in self._errors_total.items():
|
|
191
|
+
path, method, error_type = key.split("|")
|
|
192
|
+
lines.append(f'praisonai_http_errors_total{{path="{path}",method="{method}",error_type="{error_type}"}} {count}')
|
|
193
|
+
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Global metrics collector (created per app instance)
|
|
198
|
+
_metrics_collector: Optional[MetricsCollector] = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_openapi_spec(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
202
|
+
"""Generate OpenAPI specification."""
|
|
203
|
+
spec = {
|
|
204
|
+
"openapi": "3.0.3",
|
|
205
|
+
"info": {
|
|
206
|
+
"title": "PraisonAI Recipe Runner API",
|
|
207
|
+
"description": "HTTP API for running PraisonAI recipes",
|
|
208
|
+
"version": _get_version(),
|
|
209
|
+
},
|
|
210
|
+
"servers": [
|
|
211
|
+
{"url": f"http://{config.get('host', '127.0.0.1')}:{config.get('port', 8765)}"}
|
|
212
|
+
],
|
|
213
|
+
"paths": {
|
|
214
|
+
"/health": {
|
|
215
|
+
"get": {
|
|
216
|
+
"summary": "Health check",
|
|
217
|
+
"responses": {"200": {"description": "Server is healthy"}}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
"/v1/recipes": {
|
|
221
|
+
"get": {
|
|
222
|
+
"summary": "List available recipes",
|
|
223
|
+
"parameters": [
|
|
224
|
+
{"name": "source", "in": "query", "schema": {"type": "string"}},
|
|
225
|
+
{"name": "tags", "in": "query", "schema": {"type": "string"}}
|
|
226
|
+
],
|
|
227
|
+
"responses": {"200": {"description": "List of recipes"}}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
"/v1/recipes/{name}": {
|
|
231
|
+
"get": {
|
|
232
|
+
"summary": "Describe a recipe",
|
|
233
|
+
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
234
|
+
"responses": {"200": {"description": "Recipe details"}, "404": {"description": "Recipe not found"}}
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
"/v1/recipes/{name}/schema": {
|
|
238
|
+
"get": {
|
|
239
|
+
"summary": "Get recipe JSON schema",
|
|
240
|
+
"parameters": [{"name": "name", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
241
|
+
"responses": {"200": {"description": "Recipe schema"}}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
"/v1/recipes/run": {
|
|
245
|
+
"post": {
|
|
246
|
+
"summary": "Run a recipe",
|
|
247
|
+
"requestBody": {
|
|
248
|
+
"required": True,
|
|
249
|
+
"content": {
|
|
250
|
+
"application/json": {
|
|
251
|
+
"schema": {
|
|
252
|
+
"type": "object",
|
|
253
|
+
"required": ["recipe"],
|
|
254
|
+
"properties": {
|
|
255
|
+
"recipe": {"type": "string"},
|
|
256
|
+
"input": {"type": "object"},
|
|
257
|
+
"config": {"type": "object"},
|
|
258
|
+
"session_id": {"type": "string"},
|
|
259
|
+
"options": {"type": "object"}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"responses": {"200": {"description": "Recipe result"}}
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
"/v1/recipes/stream": {
|
|
269
|
+
"post": {
|
|
270
|
+
"summary": "Stream recipe execution (SSE)",
|
|
271
|
+
"responses": {"200": {"description": "SSE event stream"}}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
"/v1/recipes/validate": {
|
|
275
|
+
"post": {
|
|
276
|
+
"summary": "Validate a recipe",
|
|
277
|
+
"responses": {"200": {"description": "Validation result"}}
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# Add optional endpoints
|
|
284
|
+
if config.get("enable_metrics"):
|
|
285
|
+
spec["paths"]["/metrics"] = {
|
|
286
|
+
"get": {
|
|
287
|
+
"summary": "Prometheus metrics",
|
|
288
|
+
"responses": {"200": {"description": "Metrics in Prometheus format"}}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if config.get("enable_admin"):
|
|
293
|
+
spec["paths"]["/admin/reload"] = {
|
|
294
|
+
"post": {
|
|
295
|
+
"summary": "Hot reload recipe registry",
|
|
296
|
+
"security": [{"apiKey": []}],
|
|
297
|
+
"responses": {"200": {"description": "Reload successful"}, "401": {"description": "Unauthorized"}}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
spec["paths"]["/openapi.json"] = {
|
|
302
|
+
"get": {
|
|
303
|
+
"summary": "OpenAPI specification",
|
|
304
|
+
"responses": {"200": {"description": "OpenAPI JSON"}}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return spec
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_secret: Optional[str] = None):
|
|
312
|
+
"""Create authentication middleware."""
|
|
313
|
+
try:
|
|
314
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
315
|
+
from starlette.responses import JSONResponse
|
|
316
|
+
except ImportError:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
class APIKeyAuthMiddleware(BaseHTTPMiddleware):
|
|
320
|
+
"""API Key authentication middleware."""
|
|
321
|
+
|
|
322
|
+
async def dispatch(self, request, call_next):
|
|
323
|
+
# Skip auth for health endpoint
|
|
324
|
+
if request.url.path == "/health":
|
|
325
|
+
return await call_next(request)
|
|
326
|
+
|
|
327
|
+
# Check X-API-Key header
|
|
328
|
+
provided_key = request.headers.get("X-API-Key")
|
|
329
|
+
expected_key = api_key or os.environ.get("PRAISONAI_API_KEY")
|
|
330
|
+
|
|
331
|
+
if not expected_key:
|
|
332
|
+
# No key configured, allow request
|
|
333
|
+
return await call_next(request)
|
|
334
|
+
|
|
335
|
+
if provided_key != expected_key:
|
|
336
|
+
return JSONResponse(
|
|
337
|
+
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
|
|
338
|
+
status_code=401
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return await call_next(request)
|
|
342
|
+
|
|
343
|
+
class JWTAuthMiddleware(BaseHTTPMiddleware):
|
|
344
|
+
"""JWT authentication middleware."""
|
|
345
|
+
|
|
346
|
+
async def dispatch(self, request, call_next):
|
|
347
|
+
# Skip auth for health endpoint
|
|
348
|
+
if request.url.path == "/health":
|
|
349
|
+
return await call_next(request)
|
|
350
|
+
|
|
351
|
+
# Get JWT secret
|
|
352
|
+
secret = jwt_secret or os.environ.get("PRAISONAI_JWT_SECRET")
|
|
353
|
+
if not secret:
|
|
354
|
+
return await call_next(request)
|
|
355
|
+
|
|
356
|
+
# Check Authorization header
|
|
357
|
+
auth_header = request.headers.get("Authorization", "")
|
|
358
|
+
if not auth_header.startswith("Bearer "):
|
|
359
|
+
return JSONResponse(
|
|
360
|
+
{"error": {"code": "unauthorized", "message": "Missing or invalid Authorization header"}},
|
|
361
|
+
status_code=401
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
token = auth_header[7:] # Remove "Bearer " prefix
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
# Lazy import jwt
|
|
368
|
+
import jwt as pyjwt
|
|
369
|
+
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
|
|
370
|
+
# Store user info in request state
|
|
371
|
+
request.state.user = payload
|
|
372
|
+
except ImportError:
|
|
373
|
+
return JSONResponse(
|
|
374
|
+
{"error": {"code": "server_error", "message": "JWT support not installed. Run: pip install PyJWT"}},
|
|
375
|
+
status_code=500
|
|
376
|
+
)
|
|
377
|
+
except pyjwt.ExpiredSignatureError:
|
|
378
|
+
return JSONResponse(
|
|
379
|
+
{"error": {"code": "unauthorized", "message": "Token expired"}},
|
|
380
|
+
status_code=401
|
|
381
|
+
)
|
|
382
|
+
except pyjwt.InvalidTokenError as e:
|
|
383
|
+
return JSONResponse(
|
|
384
|
+
{"error": {"code": "unauthorized", "message": f"Invalid token: {e}"}},
|
|
385
|
+
status_code=401
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return await call_next(request)
|
|
389
|
+
|
|
390
|
+
if auth_type == "api-key":
|
|
391
|
+
return APIKeyAuthMiddleware
|
|
392
|
+
elif auth_type == "jwt":
|
|
393
|
+
return JWTAuthMiddleware
|
|
394
|
+
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def create_app(config: Optional[Dict[str, Any]] = None) -> Any:
|
|
399
|
+
"""
|
|
400
|
+
Create ASGI application for recipe runner.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
config: Optional configuration dict
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Starlette ASGI application
|
|
407
|
+
"""
|
|
408
|
+
try:
|
|
409
|
+
from starlette.applications import Starlette
|
|
410
|
+
from starlette.routing import Route
|
|
411
|
+
from starlette.responses import JSONResponse, Response
|
|
412
|
+
from starlette.requests import Request
|
|
413
|
+
from starlette.middleware import Middleware
|
|
414
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
415
|
+
except ImportError:
|
|
416
|
+
raise ImportError(
|
|
417
|
+
"Serve dependencies not installed. Run: pip install praisonai[serve]"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
config = config or {}
|
|
421
|
+
|
|
422
|
+
async def health(request: Request) -> JSONResponse:
|
|
423
|
+
"""GET /health - Health check."""
|
|
424
|
+
return JSONResponse({
|
|
425
|
+
"status": "healthy",
|
|
426
|
+
"service": "praisonai-recipe-runner",
|
|
427
|
+
"version": _get_version(),
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
async def list_recipes(request: Request) -> JSONResponse:
|
|
431
|
+
"""GET /v1/recipes - List available recipes."""
|
|
432
|
+
from praisonai import recipe
|
|
433
|
+
|
|
434
|
+
source_filter = request.query_params.get("source")
|
|
435
|
+
tags = request.query_params.get("tags")
|
|
436
|
+
tags_list = tags.split(",") if tags else None
|
|
437
|
+
|
|
438
|
+
recipes = recipe.list_recipes(
|
|
439
|
+
source_filter=source_filter,
|
|
440
|
+
tags=tags_list,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return JSONResponse({
|
|
444
|
+
"recipes": [r.to_dict() for r in recipes]
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
async def describe_recipe(request: Request) -> JSONResponse:
|
|
448
|
+
"""GET /v1/recipes/{name} - Describe a recipe."""
|
|
449
|
+
name = request.path_params["name"]
|
|
450
|
+
|
|
451
|
+
from praisonai import recipe
|
|
452
|
+
info = recipe.describe(name)
|
|
453
|
+
|
|
454
|
+
if info is None:
|
|
455
|
+
return JSONResponse(
|
|
456
|
+
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
|
|
457
|
+
status_code=404
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
return JSONResponse(info.to_dict())
|
|
461
|
+
|
|
462
|
+
async def get_schema(request: Request) -> JSONResponse:
|
|
463
|
+
"""GET /v1/recipes/{name}/schema - Get recipe JSON schema."""
|
|
464
|
+
name = request.path_params["name"]
|
|
465
|
+
|
|
466
|
+
from praisonai import recipe
|
|
467
|
+
info = recipe.describe(name)
|
|
468
|
+
|
|
469
|
+
if info is None:
|
|
470
|
+
return JSONResponse(
|
|
471
|
+
{"error": {"code": "not_found", "message": f"Recipe not found: {name}"}},
|
|
472
|
+
status_code=404
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
return JSONResponse({
|
|
476
|
+
"name": info.name,
|
|
477
|
+
"version": info.version,
|
|
478
|
+
"input_schema": info.config_schema,
|
|
479
|
+
"output_schema": info.outputs,
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
async def run_recipe(request: Request) -> JSONResponse:
|
|
483
|
+
"""POST /v1/recipes/run - Run a recipe."""
|
|
484
|
+
try:
|
|
485
|
+
body = await request.json()
|
|
486
|
+
except json.JSONDecodeError:
|
|
487
|
+
return JSONResponse(
|
|
488
|
+
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
|
|
489
|
+
status_code=400
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
recipe_name = body.get("recipe")
|
|
493
|
+
if not recipe_name:
|
|
494
|
+
return JSONResponse(
|
|
495
|
+
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
|
|
496
|
+
status_code=400
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
input_data = body.get("input", {})
|
|
500
|
+
config_data = body.get("config", {})
|
|
501
|
+
options = body.get("options", {})
|
|
502
|
+
session_id = body.get("session_id")
|
|
503
|
+
|
|
504
|
+
from praisonai import recipe
|
|
505
|
+
result = recipe.run(
|
|
506
|
+
recipe_name,
|
|
507
|
+
input=input_data,
|
|
508
|
+
config=config_data,
|
|
509
|
+
session_id=session_id,
|
|
510
|
+
options=options,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
status_code = 200 if result.ok else 500
|
|
514
|
+
if result.status == "policy_denied":
|
|
515
|
+
status_code = 403
|
|
516
|
+
elif result.status == "missing_deps":
|
|
517
|
+
status_code = 424 # Failed Dependency
|
|
518
|
+
elif result.status == "validation_error":
|
|
519
|
+
status_code = 400
|
|
520
|
+
|
|
521
|
+
return JSONResponse(result.to_dict(), status_code=status_code)
|
|
522
|
+
|
|
523
|
+
async def stream_recipe(request: Request) -> Response:
|
|
524
|
+
"""POST /v1/recipes/stream - Stream recipe execution."""
|
|
525
|
+
try:
|
|
526
|
+
from sse_starlette.sse import EventSourceResponse
|
|
527
|
+
except ImportError:
|
|
528
|
+
return JSONResponse(
|
|
529
|
+
{"error": {"code": "sse_unavailable", "message": "SSE not available"}},
|
|
530
|
+
status_code=501
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
body = await request.json()
|
|
535
|
+
except json.JSONDecodeError:
|
|
536
|
+
return JSONResponse(
|
|
537
|
+
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
|
|
538
|
+
status_code=400
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
recipe_name = body.get("recipe")
|
|
542
|
+
if not recipe_name:
|
|
543
|
+
return JSONResponse(
|
|
544
|
+
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
|
|
545
|
+
status_code=400
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
input_data = body.get("input", {})
|
|
549
|
+
config_data = body.get("config", {})
|
|
550
|
+
options = body.get("options", {})
|
|
551
|
+
session_id = body.get("session_id")
|
|
552
|
+
|
|
553
|
+
async def event_generator():
|
|
554
|
+
from praisonai import recipe
|
|
555
|
+
for event in recipe.run_stream(
|
|
556
|
+
recipe_name,
|
|
557
|
+
input=input_data,
|
|
558
|
+
config=config_data,
|
|
559
|
+
session_id=session_id,
|
|
560
|
+
options=options,
|
|
561
|
+
):
|
|
562
|
+
yield {
|
|
563
|
+
"event": event.event_type,
|
|
564
|
+
"data": json.dumps(event.data),
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return EventSourceResponse(event_generator())
|
|
568
|
+
|
|
569
|
+
async def validate_recipe(request: Request) -> JSONResponse:
|
|
570
|
+
"""POST /v1/recipes/validate - Validate a recipe."""
|
|
571
|
+
try:
|
|
572
|
+
body = await request.json()
|
|
573
|
+
except json.JSONDecodeError:
|
|
574
|
+
return JSONResponse(
|
|
575
|
+
{"error": {"code": "invalid_json", "message": "Invalid JSON body"}},
|
|
576
|
+
status_code=400
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
recipe_name = body.get("recipe")
|
|
580
|
+
if not recipe_name:
|
|
581
|
+
return JSONResponse(
|
|
582
|
+
{"error": {"code": "missing_recipe", "message": "Recipe name required"}},
|
|
583
|
+
status_code=400
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
from praisonai import recipe
|
|
587
|
+
result = recipe.validate(recipe_name)
|
|
588
|
+
|
|
589
|
+
status_code = 200 if result.valid else 400
|
|
590
|
+
return JSONResponse(result.to_dict(), status_code=status_code)
|
|
591
|
+
|
|
592
|
+
async def get_metrics(request: Request) -> Response:
|
|
593
|
+
"""GET /metrics - Prometheus metrics."""
|
|
594
|
+
global _metrics_collector
|
|
595
|
+
if _metrics_collector is None:
|
|
596
|
+
_metrics_collector = MetricsCollector()
|
|
597
|
+
|
|
598
|
+
content = _metrics_collector.get_prometheus_metrics()
|
|
599
|
+
return Response(
|
|
600
|
+
content=content,
|
|
601
|
+
media_type="text/plain; version=0.0.4; charset=utf-8"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
async def admin_reload(request: Request) -> JSONResponse:
|
|
605
|
+
"""POST /admin/reload - Hot reload recipe registry."""
|
|
606
|
+
try:
|
|
607
|
+
from praisonai import recipe
|
|
608
|
+
# Clear any cached recipes and reload
|
|
609
|
+
if hasattr(recipe, '_recipe_cache'):
|
|
610
|
+
recipe._recipe_cache.clear()
|
|
611
|
+
if hasattr(recipe, 'reload_registry'):
|
|
612
|
+
recipe.reload_registry()
|
|
613
|
+
|
|
614
|
+
return JSONResponse({
|
|
615
|
+
"status": "reloaded",
|
|
616
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
617
|
+
})
|
|
618
|
+
except Exception as e:
|
|
619
|
+
return JSONResponse(
|
|
620
|
+
{"error": {"code": "reload_failed", "message": str(e)}},
|
|
621
|
+
status_code=500
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
async def get_openapi(request: Request) -> JSONResponse:
|
|
625
|
+
"""GET /openapi.json - OpenAPI specification."""
|
|
626
|
+
spec = get_openapi_spec(config)
|
|
627
|
+
return JSONResponse(spec)
|
|
628
|
+
|
|
629
|
+
# Build routes
|
|
630
|
+
routes = [
|
|
631
|
+
Route("/health", health, methods=["GET"]),
|
|
632
|
+
Route("/v1/recipes", list_recipes, methods=["GET"]),
|
|
633
|
+
Route("/v1/recipes/run", run_recipe, methods=["POST"]),
|
|
634
|
+
Route("/v1/recipes/stream", stream_recipe, methods=["POST"]),
|
|
635
|
+
Route("/v1/recipes/validate", validate_recipe, methods=["POST"]),
|
|
636
|
+
Route("/v1/recipes/{name}", describe_recipe, methods=["GET"]),
|
|
637
|
+
Route("/v1/recipes/{name}/schema", get_schema, methods=["GET"]),
|
|
638
|
+
Route("/openapi.json", get_openapi, methods=["GET"]),
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
# Add optional endpoints
|
|
642
|
+
if config.get("enable_metrics"):
|
|
643
|
+
routes.append(Route("/metrics", get_metrics, methods=["GET"]))
|
|
644
|
+
|
|
645
|
+
if config.get("enable_admin"):
|
|
646
|
+
routes.append(Route("/admin/reload", admin_reload, methods=["POST"]))
|
|
647
|
+
|
|
648
|
+
# Initialize metrics collector if enabled
|
|
649
|
+
global _metrics_collector
|
|
650
|
+
if config.get("enable_metrics"):
|
|
651
|
+
_metrics_collector = MetricsCollector()
|
|
652
|
+
|
|
653
|
+
# Create rate limiter if configured
|
|
654
|
+
rate_limit = config.get("rate_limit", 0)
|
|
655
|
+
rate_limiter = None
|
|
656
|
+
if rate_limit > 0:
|
|
657
|
+
rate_limiter = create_rate_limiter(rate_limit)
|
|
658
|
+
|
|
659
|
+
# Get max request size
|
|
660
|
+
max_request_size = config.get("max_request_size", DEFAULT_MAX_REQUEST_SIZE)
|
|
661
|
+
|
|
662
|
+
# Exempt paths for rate limiting
|
|
663
|
+
rate_limit_exempt = config.get("rate_limit_exempt_paths", DEFAULT_RATE_LIMIT_EXEMPT_PATHS)
|
|
664
|
+
|
|
665
|
+
# Add CORS middleware if configured
|
|
666
|
+
middleware = []
|
|
667
|
+
cors_origins = config.get("cors_origins")
|
|
668
|
+
if cors_origins:
|
|
669
|
+
# Parse CORS configuration
|
|
670
|
+
if isinstance(cors_origins, str):
|
|
671
|
+
origins = [o.strip() for o in cors_origins.split(",")]
|
|
672
|
+
else:
|
|
673
|
+
origins = cors_origins
|
|
674
|
+
|
|
675
|
+
middleware.append(
|
|
676
|
+
Middleware(
|
|
677
|
+
CORSMiddleware,
|
|
678
|
+
allow_origins=origins,
|
|
679
|
+
allow_methods=config.get("cors_methods", ["*"]),
|
|
680
|
+
allow_headers=config.get("cors_headers", ["*"]),
|
|
681
|
+
allow_credentials=config.get("cors_credentials", False),
|
|
682
|
+
max_age=config.get("cors_max_age", 600),
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Add auth middleware if configured
|
|
687
|
+
auth_type = config.get("auth")
|
|
688
|
+
if auth_type and auth_type != "none":
|
|
689
|
+
auth_middleware = create_auth_middleware(
|
|
690
|
+
auth_type,
|
|
691
|
+
api_key=config.get("api_key"),
|
|
692
|
+
jwt_secret=config.get("jwt_secret"),
|
|
693
|
+
)
|
|
694
|
+
if auth_middleware:
|
|
695
|
+
middleware.append(Middleware(auth_middleware))
|
|
696
|
+
|
|
697
|
+
# Create rate limit and size limit middleware
|
|
698
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
699
|
+
|
|
700
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
701
|
+
"""Rate limiting middleware."""
|
|
702
|
+
|
|
703
|
+
async def dispatch(self, request, call_next):
|
|
704
|
+
if rate_limiter is None:
|
|
705
|
+
return await call_next(request)
|
|
706
|
+
|
|
707
|
+
# Skip exempt paths
|
|
708
|
+
if request.url.path in rate_limit_exempt:
|
|
709
|
+
return await call_next(request)
|
|
710
|
+
|
|
711
|
+
# Get client identifier (IP or API key)
|
|
712
|
+
client_id = request.headers.get("X-API-Key") or request.client.host if request.client else "unknown"
|
|
713
|
+
|
|
714
|
+
allowed, retry_after = rate_limiter.check(client_id)
|
|
715
|
+
if not allowed:
|
|
716
|
+
return JSONResponse(
|
|
717
|
+
{"error": {"code": "rate_limited", "message": "Too many requests"}},
|
|
718
|
+
status_code=429,
|
|
719
|
+
headers={"Retry-After": str(retry_after)}
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
return await call_next(request)
|
|
723
|
+
|
|
724
|
+
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|
725
|
+
"""Request size limit middleware."""
|
|
726
|
+
|
|
727
|
+
async def dispatch(self, request, call_next):
|
|
728
|
+
content_length = request.headers.get("content-length")
|
|
729
|
+
if content_length and int(content_length) > max_request_size:
|
|
730
|
+
return JSONResponse(
|
|
731
|
+
{"error": {"code": "request_too_large", "message": f"Request body too large. Max: {max_request_size} bytes"}},
|
|
732
|
+
status_code=413
|
|
733
|
+
)
|
|
734
|
+
return await call_next(request)
|
|
735
|
+
|
|
736
|
+
class MetricsMiddleware(BaseHTTPMiddleware):
|
|
737
|
+
"""Metrics collection middleware."""
|
|
738
|
+
|
|
739
|
+
async def dispatch(self, request, call_next):
|
|
740
|
+
if _metrics_collector is None:
|
|
741
|
+
return await call_next(request)
|
|
742
|
+
|
|
743
|
+
start_time = time.time()
|
|
744
|
+
response = await call_next(request)
|
|
745
|
+
duration = time.time() - start_time
|
|
746
|
+
|
|
747
|
+
_metrics_collector.record_request(
|
|
748
|
+
path=request.url.path,
|
|
749
|
+
method=request.method,
|
|
750
|
+
status=response.status_code,
|
|
751
|
+
duration=duration
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
return response
|
|
755
|
+
|
|
756
|
+
# Add custom middleware (order matters - first added = outermost)
|
|
757
|
+
if config.get("enable_metrics"):
|
|
758
|
+
middleware.append(Middleware(MetricsMiddleware))
|
|
759
|
+
|
|
760
|
+
if max_request_size > 0:
|
|
761
|
+
middleware.append(Middleware(RequestSizeLimitMiddleware))
|
|
762
|
+
|
|
763
|
+
if rate_limiter is not None:
|
|
764
|
+
middleware.append(Middleware(RateLimitMiddleware))
|
|
765
|
+
|
|
766
|
+
return Starlette(routes=routes, middleware=middleware)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def serve(
|
|
770
|
+
host: str = "127.0.0.1",
|
|
771
|
+
port: int = 8765,
|
|
772
|
+
reload: bool = False,
|
|
773
|
+
config: Optional[Dict[str, Any]] = None,
|
|
774
|
+
workers: int = 1,
|
|
775
|
+
):
|
|
776
|
+
"""
|
|
777
|
+
Start the recipe runner server.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
host: Server host (default: 127.0.0.1)
|
|
781
|
+
port: Server port (default: 8765)
|
|
782
|
+
reload: Enable hot reload (default: False)
|
|
783
|
+
config: Optional configuration dict
|
|
784
|
+
workers: Number of worker processes (default: 1)
|
|
785
|
+
"""
|
|
786
|
+
try:
|
|
787
|
+
import uvicorn
|
|
788
|
+
except ImportError:
|
|
789
|
+
raise ImportError(
|
|
790
|
+
"Serve dependencies not installed. Run: pip install praisonai[serve]"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
# Initialize OpenTelemetry tracing if configured
|
|
794
|
+
trace_exporter = (config or {}).get("trace_exporter", "none")
|
|
795
|
+
if trace_exporter and trace_exporter != "none":
|
|
796
|
+
_init_tracing(trace_exporter, config or {})
|
|
797
|
+
|
|
798
|
+
app = create_app(config)
|
|
799
|
+
|
|
800
|
+
# Workers > 1 requires reload=False
|
|
801
|
+
if workers > 1 and reload:
|
|
802
|
+
import warnings
|
|
803
|
+
warnings.warn("Cannot use reload with multiple workers. Disabling reload.")
|
|
804
|
+
reload = False
|
|
805
|
+
|
|
806
|
+
uvicorn.run(app, host=host, port=port, reload=reload, workers=workers if workers > 1 else None)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _init_tracing(exporter: str, config: Dict[str, Any]):
|
|
810
|
+
"""Initialize OpenTelemetry tracing (lazy import)."""
|
|
811
|
+
try:
|
|
812
|
+
from opentelemetry import trace
|
|
813
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
814
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
815
|
+
from opentelemetry.sdk.resources import Resource
|
|
816
|
+
|
|
817
|
+
resource = Resource.create({
|
|
818
|
+
"service.name": config.get("service_name", "praisonai-recipe"),
|
|
819
|
+
"service.version": _get_version(),
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
provider = TracerProvider(resource=resource)
|
|
823
|
+
|
|
824
|
+
if exporter == "otlp":
|
|
825
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
826
|
+
endpoint = config.get("otlp_endpoint", "http://localhost:4317")
|
|
827
|
+
span_exporter = OTLPSpanExporter(endpoint=endpoint)
|
|
828
|
+
elif exporter == "jaeger":
|
|
829
|
+
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
|
|
830
|
+
span_exporter = JaegerExporter(
|
|
831
|
+
agent_host_name=config.get("jaeger_host", "localhost"),
|
|
832
|
+
agent_port=config.get("jaeger_port", 6831),
|
|
833
|
+
)
|
|
834
|
+
elif exporter == "zipkin":
|
|
835
|
+
from opentelemetry.exporter.zipkin.json import ZipkinExporter
|
|
836
|
+
span_exporter = ZipkinExporter(
|
|
837
|
+
endpoint=config.get("zipkin_endpoint", "http://localhost:9411/api/v2/spans")
|
|
838
|
+
)
|
|
839
|
+
else:
|
|
840
|
+
return # Unknown exporter
|
|
841
|
+
|
|
842
|
+
provider.add_span_processor(BatchSpanProcessor(span_exporter))
|
|
843
|
+
trace.set_tracer_provider(provider)
|
|
844
|
+
|
|
845
|
+
except ImportError:
|
|
846
|
+
import warnings
|
|
847
|
+
warnings.warn(
|
|
848
|
+
f"OpenTelemetry exporter '{exporter}' requested but dependencies not installed. "
|
|
849
|
+
"Run: pip install opentelemetry-sdk opentelemetry-exporter-otlp"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _get_version() -> str:
|
|
854
|
+
"""Get PraisonAI version."""
|
|
855
|
+
try:
|
|
856
|
+
from praisonai.version import __version__
|
|
857
|
+
return __version__
|
|
858
|
+
except ImportError:
|
|
859
|
+
return "unknown"
|