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.
Files changed (393) hide show
  1. praisonai/__init__.py +54 -0
  2. praisonai/__main__.py +15 -0
  3. praisonai/acp/__init__.py +54 -0
  4. praisonai/acp/config.py +159 -0
  5. praisonai/acp/server.py +587 -0
  6. praisonai/acp/session.py +219 -0
  7. praisonai/adapters/__init__.py +50 -0
  8. praisonai/adapters/readers.py +395 -0
  9. praisonai/adapters/rerankers.py +315 -0
  10. praisonai/adapters/retrievers.py +394 -0
  11. praisonai/adapters/vector_stores.py +409 -0
  12. praisonai/agent_scheduler.py +337 -0
  13. praisonai/agents_generator.py +903 -0
  14. praisonai/api/call.py +292 -0
  15. praisonai/auto.py +1197 -0
  16. praisonai/capabilities/__init__.py +275 -0
  17. praisonai/capabilities/a2a.py +140 -0
  18. praisonai/capabilities/assistants.py +283 -0
  19. praisonai/capabilities/audio.py +320 -0
  20. praisonai/capabilities/batches.py +469 -0
  21. praisonai/capabilities/completions.py +336 -0
  22. praisonai/capabilities/container_files.py +155 -0
  23. praisonai/capabilities/containers.py +93 -0
  24. praisonai/capabilities/embeddings.py +158 -0
  25. praisonai/capabilities/files.py +467 -0
  26. praisonai/capabilities/fine_tuning.py +293 -0
  27. praisonai/capabilities/guardrails.py +182 -0
  28. praisonai/capabilities/images.py +330 -0
  29. praisonai/capabilities/mcp.py +190 -0
  30. praisonai/capabilities/messages.py +270 -0
  31. praisonai/capabilities/moderations.py +154 -0
  32. praisonai/capabilities/ocr.py +217 -0
  33. praisonai/capabilities/passthrough.py +204 -0
  34. praisonai/capabilities/rag.py +207 -0
  35. praisonai/capabilities/realtime.py +160 -0
  36. praisonai/capabilities/rerank.py +165 -0
  37. praisonai/capabilities/responses.py +266 -0
  38. praisonai/capabilities/search.py +109 -0
  39. praisonai/capabilities/skills.py +133 -0
  40. praisonai/capabilities/vector_store_files.py +334 -0
  41. praisonai/capabilities/vector_stores.py +304 -0
  42. praisonai/capabilities/videos.py +141 -0
  43. praisonai/chainlit_ui.py +304 -0
  44. praisonai/chat/__init__.py +106 -0
  45. praisonai/chat/app.py +125 -0
  46. praisonai/cli/__init__.py +26 -0
  47. praisonai/cli/app.py +213 -0
  48. praisonai/cli/commands/__init__.py +75 -0
  49. praisonai/cli/commands/acp.py +70 -0
  50. praisonai/cli/commands/completion.py +333 -0
  51. praisonai/cli/commands/config.py +166 -0
  52. praisonai/cli/commands/debug.py +142 -0
  53. praisonai/cli/commands/diag.py +55 -0
  54. praisonai/cli/commands/doctor.py +166 -0
  55. praisonai/cli/commands/environment.py +179 -0
  56. praisonai/cli/commands/lsp.py +112 -0
  57. praisonai/cli/commands/mcp.py +210 -0
  58. praisonai/cli/commands/profile.py +457 -0
  59. praisonai/cli/commands/run.py +228 -0
  60. praisonai/cli/commands/schedule.py +150 -0
  61. praisonai/cli/commands/serve.py +97 -0
  62. praisonai/cli/commands/session.py +212 -0
  63. praisonai/cli/commands/traces.py +145 -0
  64. praisonai/cli/commands/version.py +101 -0
  65. praisonai/cli/configuration/__init__.py +18 -0
  66. praisonai/cli/configuration/loader.py +353 -0
  67. praisonai/cli/configuration/paths.py +114 -0
  68. praisonai/cli/configuration/schema.py +164 -0
  69. praisonai/cli/features/__init__.py +268 -0
  70. praisonai/cli/features/acp.py +236 -0
  71. praisonai/cli/features/action_orchestrator.py +546 -0
  72. praisonai/cli/features/agent_scheduler.py +773 -0
  73. praisonai/cli/features/agent_tools.py +474 -0
  74. praisonai/cli/features/agents.py +375 -0
  75. praisonai/cli/features/at_mentions.py +471 -0
  76. praisonai/cli/features/auto_memory.py +182 -0
  77. praisonai/cli/features/autonomy_mode.py +490 -0
  78. praisonai/cli/features/background.py +356 -0
  79. praisonai/cli/features/base.py +168 -0
  80. praisonai/cli/features/capabilities.py +1326 -0
  81. praisonai/cli/features/checkpoints.py +338 -0
  82. praisonai/cli/features/code_intelligence.py +652 -0
  83. praisonai/cli/features/compaction.py +294 -0
  84. praisonai/cli/features/compare.py +534 -0
  85. praisonai/cli/features/cost_tracker.py +514 -0
  86. praisonai/cli/features/debug.py +810 -0
  87. praisonai/cli/features/deploy.py +517 -0
  88. praisonai/cli/features/diag.py +289 -0
  89. praisonai/cli/features/doctor/__init__.py +63 -0
  90. praisonai/cli/features/doctor/checks/__init__.py +24 -0
  91. praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
  92. praisonai/cli/features/doctor/checks/config_checks.py +366 -0
  93. praisonai/cli/features/doctor/checks/db_checks.py +366 -0
  94. praisonai/cli/features/doctor/checks/env_checks.py +543 -0
  95. praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
  96. praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
  97. praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
  98. praisonai/cli/features/doctor/checks/network_checks.py +251 -0
  99. praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
  100. praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
  101. praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
  102. praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
  103. praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
  104. praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
  105. praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
  106. praisonai/cli/features/doctor/engine.py +266 -0
  107. praisonai/cli/features/doctor/formatters.py +310 -0
  108. praisonai/cli/features/doctor/handler.py +397 -0
  109. praisonai/cli/features/doctor/models.py +264 -0
  110. praisonai/cli/features/doctor/registry.py +239 -0
  111. praisonai/cli/features/endpoints.py +1019 -0
  112. praisonai/cli/features/eval.py +560 -0
  113. praisonai/cli/features/external_agents.py +231 -0
  114. praisonai/cli/features/fast_context.py +410 -0
  115. praisonai/cli/features/flow_display.py +566 -0
  116. praisonai/cli/features/git_integration.py +651 -0
  117. praisonai/cli/features/guardrail.py +171 -0
  118. praisonai/cli/features/handoff.py +185 -0
  119. praisonai/cli/features/hooks.py +583 -0
  120. praisonai/cli/features/image.py +384 -0
  121. praisonai/cli/features/interactive_runtime.py +585 -0
  122. praisonai/cli/features/interactive_tools.py +380 -0
  123. praisonai/cli/features/interactive_tui.py +603 -0
  124. praisonai/cli/features/jobs.py +632 -0
  125. praisonai/cli/features/knowledge.py +531 -0
  126. praisonai/cli/features/lite.py +244 -0
  127. praisonai/cli/features/lsp_cli.py +225 -0
  128. praisonai/cli/features/mcp.py +169 -0
  129. praisonai/cli/features/message_queue.py +587 -0
  130. praisonai/cli/features/metrics.py +211 -0
  131. praisonai/cli/features/n8n.py +673 -0
  132. praisonai/cli/features/observability.py +293 -0
  133. praisonai/cli/features/ollama.py +361 -0
  134. praisonai/cli/features/output_style.py +273 -0
  135. praisonai/cli/features/package.py +631 -0
  136. praisonai/cli/features/performance.py +308 -0
  137. praisonai/cli/features/persistence.py +636 -0
  138. praisonai/cli/features/profile.py +226 -0
  139. praisonai/cli/features/profiler/__init__.py +81 -0
  140. praisonai/cli/features/profiler/core.py +558 -0
  141. praisonai/cli/features/profiler/optimizations.py +652 -0
  142. praisonai/cli/features/profiler/suite.py +386 -0
  143. praisonai/cli/features/profiling.py +350 -0
  144. praisonai/cli/features/queue/__init__.py +73 -0
  145. praisonai/cli/features/queue/manager.py +395 -0
  146. praisonai/cli/features/queue/models.py +286 -0
  147. praisonai/cli/features/queue/persistence.py +564 -0
  148. praisonai/cli/features/queue/scheduler.py +484 -0
  149. praisonai/cli/features/queue/worker.py +372 -0
  150. praisonai/cli/features/recipe.py +1723 -0
  151. praisonai/cli/features/recipes.py +449 -0
  152. praisonai/cli/features/registry.py +229 -0
  153. praisonai/cli/features/repo_map.py +860 -0
  154. praisonai/cli/features/router.py +466 -0
  155. praisonai/cli/features/sandbox_executor.py +515 -0
  156. praisonai/cli/features/serve.py +829 -0
  157. praisonai/cli/features/session.py +222 -0
  158. praisonai/cli/features/skills.py +856 -0
  159. praisonai/cli/features/slash_commands.py +650 -0
  160. praisonai/cli/features/telemetry.py +179 -0
  161. praisonai/cli/features/templates.py +1384 -0
  162. praisonai/cli/features/thinking.py +305 -0
  163. praisonai/cli/features/todo.py +334 -0
  164. praisonai/cli/features/tools.py +680 -0
  165. praisonai/cli/features/tui/__init__.py +83 -0
  166. praisonai/cli/features/tui/app.py +580 -0
  167. praisonai/cli/features/tui/cli.py +566 -0
  168. praisonai/cli/features/tui/debug.py +511 -0
  169. praisonai/cli/features/tui/events.py +99 -0
  170. praisonai/cli/features/tui/mock_provider.py +328 -0
  171. praisonai/cli/features/tui/orchestrator.py +652 -0
  172. praisonai/cli/features/tui/screens/__init__.py +50 -0
  173. praisonai/cli/features/tui/screens/main.py +245 -0
  174. praisonai/cli/features/tui/screens/queue.py +174 -0
  175. praisonai/cli/features/tui/screens/session.py +124 -0
  176. praisonai/cli/features/tui/screens/settings.py +148 -0
  177. praisonai/cli/features/tui/widgets/__init__.py +56 -0
  178. praisonai/cli/features/tui/widgets/chat.py +261 -0
  179. praisonai/cli/features/tui/widgets/composer.py +224 -0
  180. praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
  181. praisonai/cli/features/tui/widgets/status.py +167 -0
  182. praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
  183. praisonai/cli/features/workflow.py +720 -0
  184. praisonai/cli/legacy.py +236 -0
  185. praisonai/cli/main.py +5559 -0
  186. praisonai/cli/schedule_cli.py +54 -0
  187. praisonai/cli/state/__init__.py +31 -0
  188. praisonai/cli/state/identifiers.py +161 -0
  189. praisonai/cli/state/sessions.py +313 -0
  190. praisonai/code/__init__.py +93 -0
  191. praisonai/code/agent_tools.py +344 -0
  192. praisonai/code/diff/__init__.py +21 -0
  193. praisonai/code/diff/diff_strategy.py +432 -0
  194. praisonai/code/tools/__init__.py +27 -0
  195. praisonai/code/tools/apply_diff.py +221 -0
  196. praisonai/code/tools/execute_command.py +275 -0
  197. praisonai/code/tools/list_files.py +274 -0
  198. praisonai/code/tools/read_file.py +206 -0
  199. praisonai/code/tools/search_replace.py +248 -0
  200. praisonai/code/tools/write_file.py +217 -0
  201. praisonai/code/utils/__init__.py +46 -0
  202. praisonai/code/utils/file_utils.py +307 -0
  203. praisonai/code/utils/ignore_utils.py +308 -0
  204. praisonai/code/utils/text_utils.py +276 -0
  205. praisonai/db/__init__.py +64 -0
  206. praisonai/db/adapter.py +531 -0
  207. praisonai/deploy/__init__.py +62 -0
  208. praisonai/deploy/api.py +231 -0
  209. praisonai/deploy/docker.py +454 -0
  210. praisonai/deploy/doctor.py +367 -0
  211. praisonai/deploy/main.py +327 -0
  212. praisonai/deploy/models.py +179 -0
  213. praisonai/deploy/providers/__init__.py +33 -0
  214. praisonai/deploy/providers/aws.py +331 -0
  215. praisonai/deploy/providers/azure.py +358 -0
  216. praisonai/deploy/providers/base.py +101 -0
  217. praisonai/deploy/providers/gcp.py +314 -0
  218. praisonai/deploy/schema.py +208 -0
  219. praisonai/deploy.py +185 -0
  220. praisonai/endpoints/__init__.py +53 -0
  221. praisonai/endpoints/a2u_server.py +410 -0
  222. praisonai/endpoints/discovery.py +165 -0
  223. praisonai/endpoints/providers/__init__.py +28 -0
  224. praisonai/endpoints/providers/a2a.py +253 -0
  225. praisonai/endpoints/providers/a2u.py +208 -0
  226. praisonai/endpoints/providers/agents_api.py +171 -0
  227. praisonai/endpoints/providers/base.py +231 -0
  228. praisonai/endpoints/providers/mcp.py +263 -0
  229. praisonai/endpoints/providers/recipe.py +206 -0
  230. praisonai/endpoints/providers/tools_mcp.py +150 -0
  231. praisonai/endpoints/registry.py +131 -0
  232. praisonai/endpoints/server.py +161 -0
  233. praisonai/inbuilt_tools/__init__.py +24 -0
  234. praisonai/inbuilt_tools/autogen_tools.py +117 -0
  235. praisonai/inc/__init__.py +2 -0
  236. praisonai/inc/config.py +96 -0
  237. praisonai/inc/models.py +155 -0
  238. praisonai/integrations/__init__.py +56 -0
  239. praisonai/integrations/base.py +303 -0
  240. praisonai/integrations/claude_code.py +270 -0
  241. praisonai/integrations/codex_cli.py +255 -0
  242. praisonai/integrations/cursor_cli.py +195 -0
  243. praisonai/integrations/gemini_cli.py +222 -0
  244. praisonai/jobs/__init__.py +67 -0
  245. praisonai/jobs/executor.py +425 -0
  246. praisonai/jobs/models.py +230 -0
  247. praisonai/jobs/router.py +314 -0
  248. praisonai/jobs/server.py +186 -0
  249. praisonai/jobs/store.py +203 -0
  250. praisonai/llm/__init__.py +66 -0
  251. praisonai/llm/registry.py +382 -0
  252. praisonai/mcp_server/__init__.py +152 -0
  253. praisonai/mcp_server/adapters/__init__.py +74 -0
  254. praisonai/mcp_server/adapters/agents.py +128 -0
  255. praisonai/mcp_server/adapters/capabilities.py +168 -0
  256. praisonai/mcp_server/adapters/cli_tools.py +568 -0
  257. praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
  258. praisonai/mcp_server/adapters/knowledge.py +93 -0
  259. praisonai/mcp_server/adapters/memory.py +104 -0
  260. praisonai/mcp_server/adapters/prompts.py +306 -0
  261. praisonai/mcp_server/adapters/resources.py +124 -0
  262. praisonai/mcp_server/adapters/tools_bridge.py +280 -0
  263. praisonai/mcp_server/auth/__init__.py +48 -0
  264. praisonai/mcp_server/auth/api_key.py +291 -0
  265. praisonai/mcp_server/auth/oauth.py +460 -0
  266. praisonai/mcp_server/auth/oidc.py +289 -0
  267. praisonai/mcp_server/auth/scopes.py +260 -0
  268. praisonai/mcp_server/cli.py +852 -0
  269. praisonai/mcp_server/elicitation.py +445 -0
  270. praisonai/mcp_server/icons.py +302 -0
  271. praisonai/mcp_server/recipe_adapter.py +573 -0
  272. praisonai/mcp_server/recipe_cli.py +824 -0
  273. praisonai/mcp_server/registry.py +703 -0
  274. praisonai/mcp_server/sampling.py +422 -0
  275. praisonai/mcp_server/server.py +490 -0
  276. praisonai/mcp_server/tasks.py +443 -0
  277. praisonai/mcp_server/transports/__init__.py +18 -0
  278. praisonai/mcp_server/transports/http_stream.py +376 -0
  279. praisonai/mcp_server/transports/stdio.py +132 -0
  280. praisonai/persistence/__init__.py +84 -0
  281. praisonai/persistence/config.py +238 -0
  282. praisonai/persistence/conversation/__init__.py +25 -0
  283. praisonai/persistence/conversation/async_mysql.py +427 -0
  284. praisonai/persistence/conversation/async_postgres.py +410 -0
  285. praisonai/persistence/conversation/async_sqlite.py +371 -0
  286. praisonai/persistence/conversation/base.py +151 -0
  287. praisonai/persistence/conversation/json_store.py +250 -0
  288. praisonai/persistence/conversation/mysql.py +387 -0
  289. praisonai/persistence/conversation/postgres.py +401 -0
  290. praisonai/persistence/conversation/singlestore.py +240 -0
  291. praisonai/persistence/conversation/sqlite.py +341 -0
  292. praisonai/persistence/conversation/supabase.py +203 -0
  293. praisonai/persistence/conversation/surrealdb.py +287 -0
  294. praisonai/persistence/factory.py +301 -0
  295. praisonai/persistence/hooks/__init__.py +18 -0
  296. praisonai/persistence/hooks/agent_hooks.py +297 -0
  297. praisonai/persistence/knowledge/__init__.py +26 -0
  298. praisonai/persistence/knowledge/base.py +144 -0
  299. praisonai/persistence/knowledge/cassandra.py +232 -0
  300. praisonai/persistence/knowledge/chroma.py +295 -0
  301. praisonai/persistence/knowledge/clickhouse.py +242 -0
  302. praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
  303. praisonai/persistence/knowledge/couchbase.py +286 -0
  304. praisonai/persistence/knowledge/lancedb.py +216 -0
  305. praisonai/persistence/knowledge/langchain_adapter.py +291 -0
  306. praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
  307. praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
  308. praisonai/persistence/knowledge/milvus.py +277 -0
  309. praisonai/persistence/knowledge/mongodb_vector.py +306 -0
  310. praisonai/persistence/knowledge/pgvector.py +335 -0
  311. praisonai/persistence/knowledge/pinecone.py +253 -0
  312. praisonai/persistence/knowledge/qdrant.py +301 -0
  313. praisonai/persistence/knowledge/redis_vector.py +291 -0
  314. praisonai/persistence/knowledge/singlestore_vector.py +299 -0
  315. praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
  316. praisonai/persistence/knowledge/upstash_vector.py +266 -0
  317. praisonai/persistence/knowledge/weaviate.py +223 -0
  318. praisonai/persistence/migrations/__init__.py +10 -0
  319. praisonai/persistence/migrations/manager.py +251 -0
  320. praisonai/persistence/orchestrator.py +406 -0
  321. praisonai/persistence/state/__init__.py +21 -0
  322. praisonai/persistence/state/async_mongodb.py +200 -0
  323. praisonai/persistence/state/base.py +107 -0
  324. praisonai/persistence/state/dynamodb.py +226 -0
  325. praisonai/persistence/state/firestore.py +175 -0
  326. praisonai/persistence/state/gcs.py +155 -0
  327. praisonai/persistence/state/memory.py +245 -0
  328. praisonai/persistence/state/mongodb.py +158 -0
  329. praisonai/persistence/state/redis.py +190 -0
  330. praisonai/persistence/state/upstash.py +144 -0
  331. praisonai/persistence/tests/__init__.py +3 -0
  332. praisonai/persistence/tests/test_all_backends.py +633 -0
  333. praisonai/profiler.py +1214 -0
  334. praisonai/recipe/__init__.py +134 -0
  335. praisonai/recipe/bridge.py +278 -0
  336. praisonai/recipe/core.py +893 -0
  337. praisonai/recipe/exceptions.py +54 -0
  338. praisonai/recipe/history.py +402 -0
  339. praisonai/recipe/models.py +266 -0
  340. praisonai/recipe/operations.py +440 -0
  341. praisonai/recipe/policy.py +422 -0
  342. praisonai/recipe/registry.py +849 -0
  343. praisonai/recipe/runtime.py +214 -0
  344. praisonai/recipe/security.py +711 -0
  345. praisonai/recipe/serve.py +859 -0
  346. praisonai/recipe/server.py +613 -0
  347. praisonai/scheduler/__init__.py +45 -0
  348. praisonai/scheduler/agent_scheduler.py +552 -0
  349. praisonai/scheduler/base.py +124 -0
  350. praisonai/scheduler/daemon_manager.py +225 -0
  351. praisonai/scheduler/state_manager.py +155 -0
  352. praisonai/scheduler/yaml_loader.py +193 -0
  353. praisonai/scheduler.py +194 -0
  354. praisonai/setup/__init__.py +1 -0
  355. praisonai/setup/build.py +21 -0
  356. praisonai/setup/post_install.py +23 -0
  357. praisonai/setup/setup_conda_env.py +25 -0
  358. praisonai/setup.py +16 -0
  359. praisonai/templates/__init__.py +116 -0
  360. praisonai/templates/cache.py +364 -0
  361. praisonai/templates/dependency_checker.py +358 -0
  362. praisonai/templates/discovery.py +391 -0
  363. praisonai/templates/loader.py +564 -0
  364. praisonai/templates/registry.py +511 -0
  365. praisonai/templates/resolver.py +206 -0
  366. praisonai/templates/security.py +327 -0
  367. praisonai/templates/tool_override.py +498 -0
  368. praisonai/templates/tools_doctor.py +256 -0
  369. praisonai/test.py +105 -0
  370. praisonai/train.py +562 -0
  371. praisonai/train_vision.py +306 -0
  372. praisonai/ui/agents.py +824 -0
  373. praisonai/ui/callbacks.py +57 -0
  374. praisonai/ui/chainlit_compat.py +246 -0
  375. praisonai/ui/chat.py +532 -0
  376. praisonai/ui/code.py +717 -0
  377. praisonai/ui/colab.py +474 -0
  378. praisonai/ui/colab_chainlit.py +81 -0
  379. praisonai/ui/components/aicoder.py +284 -0
  380. praisonai/ui/context.py +283 -0
  381. praisonai/ui/database_config.py +56 -0
  382. praisonai/ui/db.py +294 -0
  383. praisonai/ui/realtime.py +488 -0
  384. praisonai/ui/realtimeclient/__init__.py +756 -0
  385. praisonai/ui/realtimeclient/tools.py +242 -0
  386. praisonai/ui/sql_alchemy.py +710 -0
  387. praisonai/upload_vision.py +140 -0
  388. praisonai/version.py +1 -0
  389. praisonai-3.0.0.dist-info/METADATA +3493 -0
  390. praisonai-3.0.0.dist-info/RECORD +393 -0
  391. praisonai-3.0.0.dist-info/WHEEL +5 -0
  392. praisonai-3.0.0.dist-info/entry_points.txt +4 -0
  393. 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"