synth-ai 0.2.9.dev11__py3-none-any.whl → 0.4.1__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.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

Files changed (909) hide show
  1. synth_ai/__init__.py +44 -45
  2. synth_ai/__main__.py +30 -3
  3. synth_ai/cli/__init__.py +104 -78
  4. synth_ai/cli/__main__.py +42 -0
  5. synth_ai/cli/_internal/__init__.py +5 -0
  6. synth_ai/cli/_internal/modal_wrapper.py +31 -0
  7. synth_ai/cli/_internal/storage.py +20 -0
  8. synth_ai/cli/_internal/typer_patch.py +47 -0
  9. synth_ai/cli/_internal/validate_task_app.py +29 -0
  10. synth_ai/cli/agents/__init__.py +17 -0
  11. synth_ai/cli/agents/claude.py +77 -0
  12. synth_ai/cli/agents/codex.py +265 -0
  13. synth_ai/cli/agents/opencode.py +253 -0
  14. synth_ai/cli/commands/__init__.py +18 -0
  15. synth_ai/cli/commands/artifacts/__init__.py +13 -0
  16. synth_ai/cli/commands/artifacts/client.py +119 -0
  17. synth_ai/cli/commands/artifacts/config.py +57 -0
  18. synth_ai/cli/commands/artifacts/core.py +24 -0
  19. synth_ai/cli/commands/artifacts/download.py +188 -0
  20. synth_ai/cli/commands/artifacts/export.py +186 -0
  21. synth_ai/cli/commands/artifacts/list.py +156 -0
  22. synth_ai/cli/commands/artifacts/parsing.py +250 -0
  23. synth_ai/cli/commands/artifacts/show.py +336 -0
  24. synth_ai/cli/commands/baseline/__init__.py +12 -0
  25. synth_ai/cli/commands/baseline/core.py +636 -0
  26. synth_ai/cli/commands/baseline/list.py +94 -0
  27. synth_ai/cli/commands/demo/__init__.py +3 -0
  28. synth_ai/cli/commands/demo/core.py +153 -0
  29. synth_ai/cli/commands/eval/__init__.py +19 -0
  30. synth_ai/cli/commands/eval/core.py +1113 -0
  31. synth_ai/cli/commands/eval/errors.py +81 -0
  32. synth_ai/cli/commands/eval/validation.py +133 -0
  33. synth_ai/cli/commands/filter/__init__.py +12 -0
  34. synth_ai/cli/commands/filter/core.py +424 -0
  35. synth_ai/cli/commands/filter/errors.py +55 -0
  36. synth_ai/cli/commands/filter/validation.py +77 -0
  37. synth_ai/cli/commands/help/__init__.py +185 -0
  38. synth_ai/cli/commands/help/core.py +72 -0
  39. synth_ai/cli/commands/scan/__init__.py +19 -0
  40. synth_ai/cli/commands/scan/cloudflare_scanner.py +403 -0
  41. synth_ai/cli/commands/scan/core.py +344 -0
  42. synth_ai/cli/commands/scan/health_checker.py +242 -0
  43. synth_ai/cli/commands/scan/local_scanner.py +278 -0
  44. synth_ai/cli/commands/scan/models.py +83 -0
  45. synth_ai/cli/commands/smoke/__init__.py +7 -0
  46. synth_ai/cli/commands/smoke/core.py +1438 -0
  47. synth_ai/cli/commands/status/__init__.py +66 -0
  48. synth_ai/cli/commands/status/client.py +192 -0
  49. synth_ai/cli/commands/status/config.py +92 -0
  50. synth_ai/cli/commands/status/errors.py +20 -0
  51. synth_ai/cli/commands/status/formatters.py +164 -0
  52. synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
  53. synth_ai/cli/commands/status/subcommands/files.py +79 -0
  54. synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
  55. synth_ai/cli/commands/status/subcommands/models.py +79 -0
  56. synth_ai/cli/commands/status/subcommands/pricing.py +23 -0
  57. synth_ai/cli/commands/status/subcommands/runs.py +81 -0
  58. synth_ai/cli/commands/status/subcommands/session.py +182 -0
  59. synth_ai/cli/commands/status/subcommands/summary.py +47 -0
  60. synth_ai/cli/commands/status/subcommands/usage.py +203 -0
  61. synth_ai/cli/commands/status/utils.py +114 -0
  62. synth_ai/cli/commands/train/__init__.py +53 -0
  63. synth_ai/cli/commands/train/core.py +22 -0
  64. synth_ai/cli/commands/train/errors.py +117 -0
  65. synth_ai/cli/commands/train/judge_schemas.py +201 -0
  66. synth_ai/cli/commands/train/judge_validation.py +305 -0
  67. synth_ai/cli/commands/train/prompt_learning_validation.py +633 -0
  68. synth_ai/cli/commands/train/validation.py +392 -0
  69. synth_ai/cli/demo_apps/__init__.py +10 -0
  70. synth_ai/cli/demo_apps/core/__init__.py +28 -0
  71. synth_ai/cli/demo_apps/core/cli.py +1735 -0
  72. synth_ai/cli/demo_apps/crafter/crafter_fft_4b.toml +55 -0
  73. synth_ai/cli/demo_apps/crafter/grpo_crafter_task_app.py +186 -0
  74. synth_ai/cli/demo_apps/crafter/rl_from_base_qwen4b.toml +74 -0
  75. synth_ai/cli/demo_apps/demo_registry.py +176 -0
  76. synth_ai/cli/demo_apps/demo_task_apps/core.py +440 -0
  77. synth_ai/cli/demo_apps/demo_task_apps/crafter/__init__.py +1 -0
  78. synth_ai/cli/demo_apps/demo_task_apps/crafter/grpo_crafter_task_app.py +185 -0
  79. synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +742 -0
  80. synth_ai/cli/demo_apps/demo_task_apps/math/task_app_entry.py +39 -0
  81. synth_ai/cli/demo_apps/math/__init__.py +1 -0
  82. synth_ai/cli/demo_apps/math/_common.py +16 -0
  83. synth_ai/cli/demo_apps/math/app.py +38 -0
  84. synth_ai/cli/demo_apps/math/config.toml +76 -0
  85. synth_ai/cli/demo_apps/math/deploy_modal.py +54 -0
  86. synth_ai/cli/demo_apps/math/modal_task_app.py +702 -0
  87. synth_ai/cli/demo_apps/math/task_app_entry.py +53 -0
  88. synth_ai/cli/demo_apps/mipro/main.py +271 -0
  89. synth_ai/cli/demo_apps/mipro/task_app.py +933 -0
  90. synth_ai/cli/demo_apps/mipro/train_cfg.toml +92 -0
  91. synth_ai/cli/demos/__init__.py +12 -0
  92. synth_ai/cli/demos/demo.py +32 -0
  93. synth_ai/cli/demos/rl_demo.py +254 -0
  94. synth_ai/cli/deploy.py +216 -0
  95. synth_ai/cli/infra/__init__.py +14 -0
  96. synth_ai/cli/infra/balance.py +216 -0
  97. synth_ai/cli/infra/mcp.py +35 -0
  98. synth_ai/cli/infra/modal_app.py +36 -0
  99. synth_ai/cli/infra/setup.py +69 -0
  100. synth_ai/cli/infra/status.py +16 -0
  101. synth_ai/cli/infra/turso.py +77 -0
  102. synth_ai/cli/lib/__init__.py +10 -0
  103. synth_ai/cli/lib/agents.py +76 -0
  104. synth_ai/cli/lib/apps/modal_app.py +101 -0
  105. synth_ai/cli/lib/apps/task_app.py +643 -0
  106. synth_ai/cli/lib/bin.py +39 -0
  107. synth_ai/cli/lib/env.py +375 -0
  108. synth_ai/cli/lib/errors.py +85 -0
  109. synth_ai/cli/lib/modal.py +315 -0
  110. synth_ai/cli/lib/plotting.py +126 -0
  111. synth_ai/cli/lib/prompt_args.py +39 -0
  112. synth_ai/cli/lib/prompts.py +284 -0
  113. synth_ai/cli/lib/sqld.py +122 -0
  114. synth_ai/cli/lib/task_app_discovery.py +884 -0
  115. synth_ai/cli/lib/task_app_env.py +295 -0
  116. synth_ai/cli/lib/train_cfgs.py +300 -0
  117. synth_ai/cli/lib/tunnel_records.py +207 -0
  118. synth_ai/cli/local/__init__.py +14 -0
  119. synth_ai/cli/local/experiment_queue/__init__.py +72 -0
  120. synth_ai/cli/local/experiment_queue/api_schemas.py +221 -0
  121. synth_ai/cli/local/experiment_queue/celery_app.py +208 -0
  122. synth_ai/cli/local/experiment_queue/config.py +128 -0
  123. synth_ai/cli/local/experiment_queue/config_utils.py +272 -0
  124. synth_ai/cli/local/experiment_queue/database.py +175 -0
  125. synth_ai/cli/local/experiment_queue/dispatcher.py +119 -0
  126. synth_ai/cli/local/experiment_queue/models.py +231 -0
  127. synth_ai/cli/local/experiment_queue/progress_info.py +160 -0
  128. synth_ai/cli/local/experiment_queue/results.py +373 -0
  129. synth_ai/cli/local/experiment_queue/schemas.py +131 -0
  130. synth_ai/cli/local/experiment_queue/service.py +344 -0
  131. synth_ai/cli/local/experiment_queue/status.py +372 -0
  132. synth_ai/cli/local/experiment_queue/status_tracker.py +360 -0
  133. synth_ai/cli/local/experiment_queue/tasks.py +1984 -0
  134. synth_ai/cli/local/experiment_queue/trace_storage.py +65 -0
  135. synth_ai/cli/local/experiment_queue/validation.py +157 -0
  136. synth_ai/cli/local/session/__init__.py +92 -0
  137. synth_ai/cli/local/session/client.py +383 -0
  138. synth_ai/cli/local/session/constants.py +63 -0
  139. synth_ai/cli/local/session/exceptions.py +105 -0
  140. synth_ai/cli/local/session/manager.py +139 -0
  141. synth_ai/cli/local/session/models.py +89 -0
  142. synth_ai/cli/local/session/query.py +110 -0
  143. synth_ai/cli/root.py +30 -103
  144. synth_ai/cli/task_apps/__init__.py +26 -0
  145. synth_ai/cli/task_apps/commands.py +3153 -0
  146. synth_ai/cli/task_apps/deploy.py +7 -0
  147. synth_ai/cli/task_apps/list.py +26 -0
  148. synth_ai/cli/task_apps/main.py +36 -0
  149. synth_ai/cli/task_apps/modal_serve.py +11 -0
  150. synth_ai/cli/task_apps/serve.py +11 -0
  151. synth_ai/cli/training/__init__.py +8 -0
  152. synth_ai/cli/training/train.py +5 -0
  153. synth_ai/cli/training/train_cfg.py +34 -0
  154. synth_ai/cli/training/watch.py +506 -0
  155. synth_ai/cli/turso.py +34 -55
  156. synth_ai/cli/usage.py +159 -0
  157. synth_ai/cli/utils/__init__.py +8 -0
  158. synth_ai/cli/utils/experiments.py +235 -0
  159. synth_ai/cli/utils/queue.py +504 -0
  160. synth_ai/cli/utils/recent.py +133 -0
  161. synth_ai/cli/utils/traces.py +164 -0
  162. synth_ai/contracts/__init__.py +67 -0
  163. synth_ai/core/__init__.py +100 -0
  164. synth_ai/core/_utils/__init__.py +54 -0
  165. synth_ai/core/_utils/base_url.py +10 -0
  166. synth_ai/core/_utils/http.py +10 -0
  167. synth_ai/core/_utils/prompts.py +14 -0
  168. synth_ai/core/_utils/task_app_state.py +12 -0
  169. synth_ai/core/_utils/user_config.py +10 -0
  170. synth_ai/core/apps/common.py +116 -0
  171. synth_ai/core/auth.py +95 -0
  172. synth_ai/core/cfgs.py +240 -0
  173. synth_ai/core/config/__init__.py +16 -0
  174. synth_ai/core/config/base.py +168 -0
  175. synth_ai/core/config/resolver.py +89 -0
  176. synth_ai/core/env.py +220 -0
  177. synth_ai/core/errors.py +126 -0
  178. synth_ai/core/http.py +230 -0
  179. synth_ai/core/integrations/__init__.py +11 -0
  180. synth_ai/core/integrations/cloudflare.py +1710 -0
  181. synth_ai/core/integrations/mcp/__init__.py +6 -0
  182. synth_ai/core/integrations/mcp/__main__.py +8 -0
  183. synth_ai/core/integrations/mcp/claude.py +36 -0
  184. synth_ai/core/integrations/mcp/main.py +254 -0
  185. synth_ai/core/integrations/mcp/setup.py +100 -0
  186. synth_ai/core/integrations/modal.py +277 -0
  187. synth_ai/core/json.py +72 -0
  188. synth_ai/core/log_filter.py +99 -0
  189. synth_ai/core/logging.py +82 -0
  190. synth_ai/core/paths.py +107 -0
  191. synth_ai/core/pricing.py +109 -0
  192. synth_ai/core/process.py +233 -0
  193. synth_ai/core/ssl.py +25 -0
  194. synth_ai/core/storage/__init__.py +71 -0
  195. synth_ai/core/task_app_state.py +318 -0
  196. synth_ai/core/telemetry.py +282 -0
  197. synth_ai/core/tracing_v3/__init__.py +99 -0
  198. synth_ai/core/tracing_v3/config.py +229 -0
  199. synth_ai/core/tracing_v3/constants.py +21 -0
  200. synth_ai/core/tracing_v3/db_config.py +182 -0
  201. synth_ai/core/tracing_v3/decorators.py +401 -0
  202. synth_ai/core/tracing_v3/examples/basic_usage.py +194 -0
  203. synth_ai/core/tracing_v3/llm_call_record_helpers.py +437 -0
  204. synth_ai/core/tracing_v3/migration_helper.py +119 -0
  205. synth_ai/core/tracing_v3/replica_sync.py +262 -0
  206. synth_ai/core/tracing_v3/serialization.py +130 -0
  207. synth_ai/core/tracing_v3/session_tracer.py +542 -0
  208. synth_ai/core/tracing_v3/storage/base.py +211 -0
  209. synth_ai/core/tracing_v3/storage/config.py +109 -0
  210. synth_ai/core/tracing_v3/storage/factory.py +39 -0
  211. synth_ai/core/tracing_v3/storage/utils.py +206 -0
  212. synth_ai/core/tracing_v3/trace_utils.py +326 -0
  213. synth_ai/core/tracing_v3/turso/__init__.py +12 -0
  214. synth_ai/core/tracing_v3/turso/daemon.py +278 -0
  215. synth_ai/core/tracing_v3/turso/models.py +470 -0
  216. synth_ai/core/tracing_v3/turso/native_manager.py +1385 -0
  217. synth_ai/core/tracing_v3/utils.py +108 -0
  218. synth_ai/core/urls.py +18 -0
  219. synth_ai/core/user_config.py +137 -0
  220. synth_ai/core/uvicorn.py +222 -0
  221. synth_ai/data/__init__.py +110 -0
  222. synth_ai/data/enums.py +141 -0
  223. synth_ai/data/rewards.py +152 -0
  224. synth_ai/data/specs.py +36 -0
  225. synth_ai/data/traces.py +35 -0
  226. synth_ai/products/__init__.py +6 -0
  227. synth_ai/products/graph_evolve/__init__.py +46 -0
  228. synth_ai/products/graph_evolve/client.py +226 -0
  229. synth_ai/products/graph_evolve/config.py +591 -0
  230. synth_ai/products/graph_evolve/converters/__init__.py +42 -0
  231. synth_ai/products/graph_evolve/converters/openai_sft.py +484 -0
  232. synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +109 -0
  233. synth_ai/products/graph_evolve/run.py +222 -0
  234. synth_ai/sdk/__init__.py +119 -0
  235. synth_ai/sdk/api/__init__.py +1 -0
  236. synth_ai/sdk/api/models/supported.py +514 -0
  237. synth_ai/sdk/api/research_agent/__init__.py +86 -0
  238. synth_ai/sdk/api/research_agent/cli.py +428 -0
  239. synth_ai/sdk/api/research_agent/config.py +357 -0
  240. synth_ai/sdk/api/research_agent/job.py +717 -0
  241. synth_ai/sdk/api/train/__init__.py +85 -0
  242. synth_ai/sdk/api/train/builders.py +895 -0
  243. synth_ai/sdk/api/train/cli.py +2188 -0
  244. synth_ai/sdk/api/train/config_finder.py +267 -0
  245. synth_ai/sdk/api/train/configs/__init__.py +65 -0
  246. synth_ai/sdk/api/train/configs/prompt_learning.py +1706 -0
  247. synth_ai/sdk/api/train/configs/rl.py +188 -0
  248. synth_ai/sdk/api/train/configs/sft.py +99 -0
  249. synth_ai/sdk/api/train/configs/shared.py +81 -0
  250. synth_ai/sdk/api/train/context_learning.py +312 -0
  251. synth_ai/sdk/api/train/env_resolver.py +418 -0
  252. synth_ai/sdk/api/train/graph_validators.py +216 -0
  253. synth_ai/sdk/api/train/graphgen.py +984 -0
  254. synth_ai/sdk/api/train/graphgen_models.py +823 -0
  255. synth_ai/sdk/api/train/graphgen_validators.py +109 -0
  256. synth_ai/sdk/api/train/pollers.py +124 -0
  257. synth_ai/sdk/api/train/progress/__init__.py +97 -0
  258. synth_ai/sdk/api/train/progress/dataclasses.py +569 -0
  259. synth_ai/sdk/api/train/progress/events.py +326 -0
  260. synth_ai/sdk/api/train/progress/results.py +428 -0
  261. synth_ai/sdk/api/train/progress/tracker.py +641 -0
  262. synth_ai/sdk/api/train/prompt_learning.py +470 -0
  263. synth_ai/sdk/api/train/rl.py +442 -0
  264. synth_ai/sdk/api/train/sft.py +396 -0
  265. synth_ai/sdk/api/train/summary.py +522 -0
  266. synth_ai/sdk/api/train/supported_algos.py +147 -0
  267. synth_ai/sdk/api/train/task_app.py +331 -0
  268. synth_ai/sdk/api/train/utils.py +279 -0
  269. synth_ai/sdk/api/train/validators.py +2424 -0
  270. synth_ai/sdk/baseline/__init__.py +25 -0
  271. synth_ai/sdk/baseline/config.py +209 -0
  272. synth_ai/sdk/baseline/discovery.py +216 -0
  273. synth_ai/sdk/baseline/execution.py +154 -0
  274. synth_ai/sdk/graphs/__init__.py +15 -0
  275. synth_ai/sdk/graphs/completions.py +570 -0
  276. synth_ai/sdk/inference/__init__.py +6 -0
  277. synth_ai/sdk/inference/client.py +128 -0
  278. synth_ai/sdk/jobs/__init__.py +16 -0
  279. synth_ai/sdk/jobs/client.py +371 -0
  280. synth_ai/sdk/judging/__init__.py +15 -0
  281. synth_ai/sdk/judging/base.py +24 -0
  282. synth_ai/sdk/judging/client.py +191 -0
  283. synth_ai/sdk/judging/schemas.py +222 -0
  284. synth_ai/sdk/judging/types.py +42 -0
  285. synth_ai/sdk/learning/__init__.py +69 -0
  286. synth_ai/sdk/learning/client.py +240 -0
  287. synth_ai/sdk/learning/ft_client.py +7 -0
  288. synth_ai/sdk/learning/health.py +49 -0
  289. synth_ai/sdk/learning/jobs.py +202 -0
  290. synth_ai/sdk/learning/prompt_extraction.py +334 -0
  291. synth_ai/sdk/learning/prompt_learning_client.py +455 -0
  292. synth_ai/sdk/learning/prompt_learning_types.py +185 -0
  293. synth_ai/sdk/learning/rl/client.py +268 -0
  294. synth_ai/sdk/learning/rl/contracts.py +27 -0
  295. synth_ai/sdk/learning/rl/env_keys.py +166 -0
  296. synth_ai/sdk/learning/rl/secrets.py +13 -0
  297. synth_ai/sdk/learning/sft/client.py +95 -0
  298. synth_ai/sdk/learning/sft/config.py +270 -0
  299. synth_ai/sdk/learning/sft/data.py +698 -0
  300. synth_ai/sdk/learning/validators.py +52 -0
  301. synth_ai/sdk/research_agent/__init__.py +34 -0
  302. synth_ai/sdk/research_agent/container_builder.py +328 -0
  303. synth_ai/sdk/research_agent/container_spec.py +198 -0
  304. synth_ai/sdk/research_agent/defaults.py +34 -0
  305. synth_ai/sdk/research_agent/results_collector.py +69 -0
  306. synth_ai/sdk/specs/__init__.py +46 -0
  307. synth_ai/sdk/specs/dataclasses.py +149 -0
  308. synth_ai/sdk/specs/loader.py +144 -0
  309. synth_ai/sdk/specs/serializer.py +199 -0
  310. synth_ai/sdk/specs/validation.py +250 -0
  311. synth_ai/sdk/streaming/__init__.py +35 -0
  312. synth_ai/sdk/streaming/config.py +94 -0
  313. synth_ai/sdk/streaming/handlers.py +1997 -0
  314. synth_ai/sdk/streaming/streamer.py +704 -0
  315. synth_ai/sdk/streaming/types.py +112 -0
  316. synth_ai/sdk/task/__init__.py +151 -0
  317. synth_ai/sdk/task/apps/__init__.py +133 -0
  318. synth_ai/sdk/task/config.py +261 -0
  319. synth_ai/sdk/task/contracts.py +298 -0
  320. synth_ai/sdk/task/datasets.py +108 -0
  321. synth_ai/sdk/task/in_process.py +1190 -0
  322. synth_ai/sdk/task/in_process_runner.py +309 -0
  323. synth_ai/sdk/task/inference_api.py +299 -0
  324. synth_ai/sdk/task/proxy.py +287 -0
  325. synth_ai/sdk/task/rubrics/__init__.py +55 -0
  326. synth_ai/sdk/task/rubrics/loaders.py +156 -0
  327. synth_ai/sdk/task/rubrics/models.py +57 -0
  328. synth_ai/sdk/task/rubrics/scoring.py +116 -0
  329. synth_ai/sdk/task/rubrics/strict.py +149 -0
  330. synth_ai/sdk/task/server.py +580 -0
  331. synth_ai/sdk/task/trace_correlation_helpers.py +506 -0
  332. synth_ai/sdk/task/tracing_utils.py +95 -0
  333. synth_ai/sdk/task/validators.py +456 -0
  334. synth_ai/sdk/tracing/__init__.py +39 -0
  335. synth_ai/sdk/training/__init__.py +102 -0
  336. synth_ai/sdk/usage/__init__.py +37 -0
  337. synth_ai/sdk/usage/client.py +171 -0
  338. synth_ai/sdk/usage/models.py +261 -0
  339. synth_ai/utils/__init__.py +213 -0
  340. synth_ai-0.4.1.dist-info/METADATA +195 -0
  341. synth_ai-0.4.1.dist-info/RECORD +379 -0
  342. synth_ai-0.4.1.dist-info/entry_points.txt +2 -0
  343. synth_ai-0.4.1.dist-info/top_level.txt +1 -0
  344. examples/__init__.py +0 -16
  345. examples/analyze_semantic_words.sh +0 -17
  346. examples/crafter_debug_render.py +0 -186
  347. examples/qwen_coder/README.md +0 -102
  348. examples/qwen_coder/_shared.py +0 -113
  349. examples/qwen_coder/configs/coder_lora_30b.toml +0 -61
  350. examples/qwen_coder/configs/coder_lora_4b.toml +0 -57
  351. examples/qwen_coder/configs/coder_lora_small.toml +0 -58
  352. examples/qwen_coder/generate_dataset.py +0 -98
  353. examples/qwen_coder/infer_ft_smoke.py +0 -64
  354. examples/qwen_coder/infer_prod_proxy.py +0 -73
  355. examples/qwen_coder/infer_via_synth.py +0 -87
  356. examples/qwen_coder/scripts/infer_coder.sh +0 -18
  357. examples/qwen_coder/scripts/train_coder_30b.sh +0 -21
  358. examples/qwen_coder/sft_full_17b.py +0 -103
  359. examples/qwen_coder/sft_lora_30b.py +0 -110
  360. examples/qwen_coder/subset_jsonl.py +0 -38
  361. examples/qwen_coder/validate_jsonl.py +0 -59
  362. examples/rl/README.md +0 -169
  363. examples/rl/configs/eval_base_qwen.toml +0 -15
  364. examples/rl/configs/eval_rl_qwen.toml +0 -11
  365. examples/rl/configs/rl_from_base_qwen.toml +0 -35
  366. examples/rl/configs/rl_from_base_qwen17.toml +0 -74
  367. examples/rl/configs/rl_from_ft_qwen.toml +0 -35
  368. examples/rl/download_dataset.py +0 -80
  369. examples/rl/run_eval.py +0 -436
  370. examples/rl/run_rl_and_save.py +0 -111
  371. examples/rl/task_app/README.md +0 -22
  372. examples/rl/task_app/math_single_step.py +0 -991
  373. examples/rl/task_app/math_task_app.py +0 -115
  374. examples/run_crafter_demo.sh +0 -10
  375. examples/sft/README.md +0 -139
  376. examples/sft/configs/crafter_fft_qwen0p6b.toml +0 -44
  377. examples/sft/configs/crafter_lora_qwen0p6b.toml +0 -45
  378. examples/sft/evaluate.py +0 -117
  379. examples/sft/export_dataset.py +0 -117
  380. examples/sft/generate_traces.py +0 -162
  381. examples/swe/__init__.py +0 -12
  382. examples/swe/task_app/README.md +0 -105
  383. examples/swe/task_app/__init__.py +0 -2
  384. examples/swe/task_app/grpo_swe_mini.py +0 -571
  385. examples/swe/task_app/grpo_swe_mini_task_app.py +0 -136
  386. examples/swe/task_app/hosted/README.md +0 -173
  387. examples/swe/task_app/hosted/__init__.py +0 -5
  388. examples/swe/task_app/hosted/branching.py +0 -143
  389. examples/swe/task_app/hosted/environment_routes.py +0 -1289
  390. examples/swe/task_app/hosted/envs/__init__.py +0 -1
  391. examples/swe/task_app/hosted/envs/crafter/__init__.py +0 -6
  392. examples/swe/task_app/hosted/envs/crafter/app.py +0 -1
  393. examples/swe/task_app/hosted/envs/crafter/environment.py +0 -522
  394. examples/swe/task_app/hosted/envs/crafter/policy.py +0 -478
  395. examples/swe/task_app/hosted/envs/crafter/react_agent.py +0 -108
  396. examples/swe/task_app/hosted/envs/crafter/shared.py +0 -305
  397. examples/swe/task_app/hosted/envs/crafter/tools.py +0 -47
  398. examples/swe/task_app/hosted/envs/mini_swe/__init__.py +0 -8
  399. examples/swe/task_app/hosted/envs/mini_swe/environment.py +0 -1164
  400. examples/swe/task_app/hosted/envs/mini_swe/policy.py +0 -355
  401. examples/swe/task_app/hosted/envs/mini_swe/shared.py +0 -83
  402. examples/swe/task_app/hosted/envs/mini_swe/tools.py +0 -96
  403. examples/swe/task_app/hosted/hosted_app.py +0 -204
  404. examples/swe/task_app/hosted/inference/__init__.py +0 -5
  405. examples/swe/task_app/hosted/inference/openai_client.py +0 -618
  406. examples/swe/task_app/hosted/main.py +0 -100
  407. examples/swe/task_app/hosted/policy_routes.py +0 -1079
  408. examples/swe/task_app/hosted/registry.py +0 -195
  409. examples/swe/task_app/hosted/rollout.py +0 -1869
  410. examples/swe/task_app/hosted/storage/__init__.py +0 -5
  411. examples/swe/task_app/hosted/storage/volume.py +0 -211
  412. examples/swe/task_app/hosted/test_agents.py +0 -161
  413. examples/swe/task_app/hosted/test_service.py +0 -137
  414. examples/swe/task_app/hosted/utils.py +0 -62
  415. examples/vlm/README.md +0 -68
  416. examples/vlm/configs/crafter_vlm_gpt4o.toml +0 -44
  417. examples/vlm/crafter_image_only_agent.py +0 -207
  418. examples/vlm/crafter_openai_vlm_agent.py +0 -277
  419. examples/vlm/filter_image_rows.py +0 -63
  420. examples/vlm/run_crafter_vlm_benchmark.py +0 -316
  421. examples/warming_up_to_rl/analyze_trace_db.py +0 -422
  422. examples/warming_up_to_rl/configs/crafter_fft.toml +0 -48
  423. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +0 -54
  424. examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +0 -20
  425. examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +0 -13
  426. examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +0 -23
  427. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +0 -83
  428. examples/warming_up_to_rl/configs/rl_from_ft.toml +0 -56
  429. examples/warming_up_to_rl/export_trace_sft.py +0 -723
  430. examples/warming_up_to_rl/groq_test.py +0 -95
  431. examples/warming_up_to_rl/manage_secrets.py +0 -131
  432. examples/warming_up_to_rl/readme.md +0 -179
  433. examples/warming_up_to_rl/run_eval.py +0 -510
  434. examples/warming_up_to_rl/run_fft_and_save.py +0 -380
  435. examples/warming_up_to_rl/run_local_rollout.py +0 -237
  436. examples/warming_up_to_rl/run_local_rollout_modal.py +0 -246
  437. examples/warming_up_to_rl/run_local_rollout_parallel.py +0 -403
  438. examples/warming_up_to_rl/run_local_rollout_traced.py +0 -475
  439. examples/warming_up_to_rl/run_rl_and_save.py +0 -124
  440. examples/warming_up_to_rl/run_rollout_remote.py +0 -154
  441. examples/warming_up_to_rl/task_app/README.md +0 -42
  442. examples/warming_up_to_rl/task_app/grpo_crafter.py +0 -700
  443. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +0 -146
  444. examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +0 -173
  445. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +0 -5
  446. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +0 -143
  447. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +0 -1226
  448. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +0 -1
  449. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -6
  450. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +0 -1
  451. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +0 -522
  452. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +0 -478
  453. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +0 -108
  454. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -305
  455. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -47
  456. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +0 -204
  457. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +0 -5
  458. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +0 -618
  459. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +0 -100
  460. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +0 -1083
  461. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +0 -195
  462. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +0 -1869
  463. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +0 -5
  464. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +0 -211
  465. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +0 -161
  466. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +0 -137
  467. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +0 -62
  468. synth/__init__.py +0 -14
  469. synth_ai/api/models/supported.py +0 -376
  470. synth_ai/api/train/__init__.py +0 -5
  471. synth_ai/api/train/builders.py +0 -296
  472. synth_ai/api/train/cli.py +0 -606
  473. synth_ai/api/train/config_finder.py +0 -228
  474. synth_ai/api/train/env_resolver.py +0 -347
  475. synth_ai/api/train/pollers.py +0 -75
  476. synth_ai/api/train/supported_algos.py +0 -139
  477. synth_ai/api/train/task_app.py +0 -195
  478. synth_ai/api/train/utils.py +0 -217
  479. synth_ai/cli/_modal_wrapper.py +0 -28
  480. synth_ai/cli/_typer_patch.py +0 -49
  481. synth_ai/cli/balance.py +0 -203
  482. synth_ai/cli/calc.py +0 -69
  483. synth_ai/cli/demo.py +0 -159
  484. synth_ai/cli/legacy_root_backup.py +0 -470
  485. synth_ai/cli/man.py +0 -106
  486. synth_ai/cli/recent.py +0 -127
  487. synth_ai/cli/rl_demo.py +0 -274
  488. synth_ai/cli/status.py +0 -133
  489. synth_ai/cli/task_apps.py +0 -2782
  490. synth_ai/cli/traces.py +0 -163
  491. synth_ai/cli/watch.py +0 -505
  492. synth_ai/config/base_url.py +0 -107
  493. synth_ai/core/experiment.py +0 -13
  494. synth_ai/core/system.py +0 -15
  495. synth_ai/demo_registry.py +0 -295
  496. synth_ai/demos/core/__init__.py +0 -1
  497. synth_ai/demos/core/cli.py +0 -1756
  498. synth_ai/demos/demo_task_apps/core.py +0 -440
  499. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +0 -172
  500. synth_ai/demos/demo_task_apps/math/deploy_task_app.sh +0 -22
  501. synth_ai/demos/demo_task_apps/math/modal_task_app.py +0 -739
  502. synth_ai/demos/demo_task_apps/math/task_app_entry.py +0 -37
  503. synth_ai/environments/__init__.py +0 -31
  504. synth_ai/environments/environment/__init__.py +0 -1
  505. synth_ai/environments/environment/artifacts/__init__.py +0 -1
  506. synth_ai/environments/environment/artifacts/base.py +0 -52
  507. synth_ai/environments/environment/core.py +0 -67
  508. synth_ai/environments/environment/db/__init__.py +0 -1
  509. synth_ai/environments/environment/db/sqlite.py +0 -45
  510. synth_ai/environments/environment/registry.py +0 -233
  511. synth_ai/environments/environment/resources/sqlite.py +0 -45
  512. synth_ai/environments/environment/results.py +0 -1
  513. synth_ai/environments/environment/rewards/__init__.py +0 -1
  514. synth_ai/environments/environment/rewards/core.py +0 -29
  515. synth_ai/environments/environment/shared_engine.py +0 -26
  516. synth_ai/environments/environment/tools/__init__.py +0 -200
  517. synth_ai/environments/examples/__init__.py +0 -1
  518. synth_ai/environments/examples/bandit/__init__.py +0 -33
  519. synth_ai/environments/examples/bandit/engine.py +0 -302
  520. synth_ai/environments/examples/bandit/environment.py +0 -194
  521. synth_ai/environments/examples/bandit/taskset.py +0 -200
  522. synth_ai/environments/examples/crafter_classic/__init__.py +0 -8
  523. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +0 -250
  524. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_comprehensive_evaluation.py +0 -59
  525. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_browser.py +0 -152
  526. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_config.toml +0 -24
  527. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_framework.py +0 -1194
  528. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/crafter_synth_config.toml +0 -56
  529. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_config_modal.toml +0 -32
  530. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_modal.py +0 -384
  531. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_action_results.py +0 -53
  532. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_agent_actions.py +0 -178
  533. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_latest_run.py +0 -222
  534. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_lm_traces.py +0 -183
  535. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_no_rewards.py +0 -210
  536. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_trace_issue.py +0 -206
  537. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_db_schema.py +0 -49
  538. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_latest_results.py +0 -64
  539. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/debug_agent_responses.py +0 -88
  540. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/quick_trace_check.py +0 -77
  541. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/compare_experiments.py +0 -324
  542. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/kick_off_ft_oai.py +0 -362
  543. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/multi_model_config.toml +0 -49
  544. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_enhanced_hooks.py +0 -332
  545. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_events.py +0 -97
  546. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_results.py +0 -217
  547. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_hook_storage.py +0 -87
  548. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_seeds.py +0 -88
  549. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/compare_seed_performance.py +0 -195
  550. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/custom_eval_pipelines.py +0 -400
  551. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/plot_hook_frequency.py +0 -195
  552. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/seed_analysis_summary.py +0 -56
  553. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v3.py +0 -858
  554. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_quick_evaluation.py +0 -52
  555. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_react_agent.py +0 -874
  556. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +0 -1412
  557. synth_ai/environments/examples/crafter_classic/agent_demos/example_v3_usage.py +0 -216
  558. synth_ai/environments/examples/crafter_classic/agent_demos/old/compare_traces.py +0 -296
  559. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_comprehensive_evaluation.py +0 -58
  560. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_env_serialization.py +0 -464
  561. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_evaluation_browser.py +0 -152
  562. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_quick_evaluation.py +0 -51
  563. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_trace_evaluation.py +0 -1412
  564. synth_ai/environments/examples/crafter_classic/agent_demos/old/debug_player_loss.py +0 -112
  565. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_service.py +0 -203
  566. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_slowness.py +0 -305
  567. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_by_difficulty.py +0 -126
  568. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_example.py +0 -94
  569. synth_ai/environments/examples/crafter_classic/agent_demos/old/explore_saved_states.py +0 -142
  570. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft.py +0 -26
  571. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft_OLD.py +0 -984
  572. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_gemini.py +0 -724
  573. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_modal.py +0 -386
  574. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_metadata.py +0 -205
  575. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_gemini.py +0 -150
  576. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_modal.py +0 -283
  577. synth_ai/environments/examples/crafter_classic/agent_demos/old/prepare_vertex_ft.py +0 -280
  578. synth_ai/environments/examples/crafter_classic/agent_demos/old/profile_env_slowness.py +0 -456
  579. synth_ai/environments/examples/crafter_classic/agent_demos/old/replicate_issue.py +0 -166
  580. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_and_eval.py +0 -102
  581. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_comparison.py +0 -128
  582. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_qwen_rollouts.py +0 -655
  583. synth_ai/environments/examples/crafter_classic/agent_demos/old/trace_eval_OLD.py +0 -202
  584. synth_ai/environments/examples/crafter_classic/agent_demos/old/validate_openai_format.py +0 -166
  585. synth_ai/environments/examples/crafter_classic/config_logging.py +0 -111
  586. synth_ai/environments/examples/crafter_classic/debug_translation.py +0 -0
  587. synth_ai/environments/examples/crafter_classic/engine.py +0 -579
  588. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +0 -64
  589. synth_ai/environments/examples/crafter_classic/engine_helpers/action_map.py +0 -6
  590. synth_ai/environments/examples/crafter_classic/engine_helpers/serialization.py +0 -75
  591. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +0 -267
  592. synth_ai/environments/examples/crafter_classic/environment.py +0 -479
  593. synth_ai/environments/examples/crafter_classic/taskset.py +0 -233
  594. synth_ai/environments/examples/crafter_classic/trace_hooks_v3.py +0 -228
  595. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +0 -299
  596. synth_ai/environments/examples/crafter_custom/__init__.py +0 -4
  597. synth_ai/environments/examples/crafter_custom/agent_demos/__init__.py +0 -1
  598. synth_ai/environments/examples/crafter_custom/agent_demos/trace_eval.py +0 -202
  599. synth_ai/environments/examples/crafter_custom/crafter/__init__.py +0 -7
  600. synth_ai/environments/examples/crafter_custom/crafter/config.py +0 -182
  601. synth_ai/environments/examples/crafter_custom/crafter/constants.py +0 -8
  602. synth_ai/environments/examples/crafter_custom/crafter/engine.py +0 -269
  603. synth_ai/environments/examples/crafter_custom/crafter/env.py +0 -262
  604. synth_ai/environments/examples/crafter_custom/crafter/objects.py +0 -417
  605. synth_ai/environments/examples/crafter_custom/crafter/recorder.py +0 -187
  606. synth_ai/environments/examples/crafter_custom/crafter/worldgen.py +0 -118
  607. synth_ai/environments/examples/crafter_custom/dataset_builder.py +0 -373
  608. synth_ai/environments/examples/crafter_custom/environment.py +0 -312
  609. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_issue.py +0 -159
  610. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_spawning.py +0 -158
  611. synth_ai/environments/examples/crafter_custom/old/compare_worlds.py +0 -71
  612. synth_ai/environments/examples/crafter_custom/old/dataset_stats.py +0 -105
  613. synth_ai/environments/examples/crafter_custom/old/diamond_spawning_summary.py +0 -119
  614. synth_ai/environments/examples/crafter_custom/old/example_dataset_usage.py +0 -52
  615. synth_ai/environments/examples/crafter_custom/run_dataset.py +0 -305
  616. synth_ai/environments/examples/enron/art_helpers/email_search_tools.py +0 -156
  617. synth_ai/environments/examples/enron/art_helpers/local_email_db.py +0 -281
  618. synth_ai/environments/examples/enron/art_helpers/types_enron.py +0 -25
  619. synth_ai/environments/examples/enron/engine.py +0 -295
  620. synth_ai/environments/examples/enron/environment.py +0 -166
  621. synth_ai/environments/examples/enron/taskset.py +0 -112
  622. synth_ai/environments/examples/enron/units/keyword_stats.py +0 -112
  623. synth_ai/environments/examples/minigrid/__init__.py +0 -48
  624. synth_ai/environments/examples/minigrid/agent_demos/minigrid_evaluation_framework.py +0 -1188
  625. synth_ai/environments/examples/minigrid/agent_demos/minigrid_quick_evaluation.py +0 -48
  626. synth_ai/environments/examples/minigrid/agent_demos/minigrid_react_agent.py +0 -562
  627. synth_ai/environments/examples/minigrid/agent_demos/minigrid_trace_evaluation.py +0 -221
  628. synth_ai/environments/examples/minigrid/engine.py +0 -589
  629. synth_ai/environments/examples/minigrid/environment.py +0 -274
  630. synth_ai/environments/examples/minigrid/environment_mapping.py +0 -242
  631. synth_ai/environments/examples/minigrid/puzzle_loader.py +0 -417
  632. synth_ai/environments/examples/minigrid/taskset.py +0 -583
  633. synth_ai/environments/examples/nethack/__init__.py +0 -7
  634. synth_ai/environments/examples/nethack/achievements.py +0 -337
  635. synth_ai/environments/examples/nethack/agent_demos/nethack_evaluation_framework.py +0 -981
  636. synth_ai/environments/examples/nethack/agent_demos/nethack_quick_evaluation.py +0 -74
  637. synth_ai/environments/examples/nethack/agent_demos/nethack_react_agent.py +0 -831
  638. synth_ai/environments/examples/nethack/engine.py +0 -739
  639. synth_ai/environments/examples/nethack/environment.py +0 -256
  640. synth_ai/environments/examples/nethack/helpers/__init__.py +0 -41
  641. synth_ai/environments/examples/nethack/helpers/action_mapping.py +0 -301
  642. synth_ai/environments/examples/nethack/helpers/nle_wrapper.py +0 -402
  643. synth_ai/environments/examples/nethack/helpers/observation_utils.py +0 -433
  644. synth_ai/environments/examples/nethack/helpers/recording_wrapper.py +0 -200
  645. synth_ai/environments/examples/nethack/helpers/trajectory_recorder.py +0 -269
  646. synth_ai/environments/examples/nethack/helpers/visualization/replay_viewer.py +0 -308
  647. synth_ai/environments/examples/nethack/helpers/visualization/visualizer.py +0 -431
  648. synth_ai/environments/examples/nethack/taskset.py +0 -323
  649. synth_ai/environments/examples/red/__init__.py +0 -7
  650. synth_ai/environments/examples/red/agent_demos/__init__.py +0 -1
  651. synth_ai/environments/examples/red/config_logging.py +0 -110
  652. synth_ai/environments/examples/red/engine.py +0 -694
  653. synth_ai/environments/examples/red/engine_helpers/__init__.py +0 -1
  654. synth_ai/environments/examples/red/engine_helpers/memory_map.py +0 -28
  655. synth_ai/environments/examples/red/engine_helpers/reward_components.py +0 -276
  656. synth_ai/environments/examples/red/engine_helpers/reward_library/__init__.py +0 -142
  657. synth_ai/environments/examples/red/engine_helpers/reward_library/adaptive_rewards.py +0 -57
  658. synth_ai/environments/examples/red/engine_helpers/reward_library/battle_rewards.py +0 -284
  659. synth_ai/environments/examples/red/engine_helpers/reward_library/composite_rewards.py +0 -150
  660. synth_ai/environments/examples/red/engine_helpers/reward_library/economy_rewards.py +0 -138
  661. synth_ai/environments/examples/red/engine_helpers/reward_library/efficiency_rewards.py +0 -57
  662. synth_ai/environments/examples/red/engine_helpers/reward_library/exploration_rewards.py +0 -331
  663. synth_ai/environments/examples/red/engine_helpers/reward_library/novelty_rewards.py +0 -121
  664. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_rewards.py +0 -559
  665. synth_ai/environments/examples/red/engine_helpers/reward_library/pokemon_rewards.py +0 -313
  666. synth_ai/environments/examples/red/engine_helpers/reward_library/social_rewards.py +0 -148
  667. synth_ai/environments/examples/red/engine_helpers/reward_library/story_rewards.py +0 -247
  668. synth_ai/environments/examples/red/engine_helpers/screen_analysis.py +0 -368
  669. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +0 -140
  670. synth_ai/environments/examples/red/environment.py +0 -238
  671. synth_ai/environments/examples/red/taskset.py +0 -79
  672. synth_ai/environments/examples/red/units/__init__.py +0 -1
  673. synth_ai/environments/examples/sokoban/__init__.py +0 -1
  674. synth_ai/environments/examples/sokoban/agent_demos/sokoban_full_eval.py +0 -899
  675. synth_ai/environments/examples/sokoban/engine.py +0 -678
  676. synth_ai/environments/examples/sokoban/engine_helpers/__init__.py +0 -1
  677. synth_ai/environments/examples/sokoban/engine_helpers/room_utils.py +0 -657
  678. synth_ai/environments/examples/sokoban/engine_helpers/vendored/__init__.py +0 -18
  679. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/__init__.py +0 -3
  680. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/boxoban_env.py +0 -131
  681. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/render_utils.py +0 -370
  682. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/room_utils.py +0 -332
  683. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env.py +0 -306
  684. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_fixed_targets.py +0 -67
  685. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_pull.py +0 -115
  686. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_two_player.py +0 -123
  687. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_variations.py +0 -394
  688. synth_ai/environments/examples/sokoban/environment.py +0 -229
  689. synth_ai/environments/examples/sokoban/generate_verified_puzzles.py +0 -440
  690. synth_ai/environments/examples/sokoban/puzzle_loader.py +0 -312
  691. synth_ai/environments/examples/sokoban/taskset.py +0 -428
  692. synth_ai/environments/examples/tictactoe/__init__.py +0 -1
  693. synth_ai/environments/examples/tictactoe/engine.py +0 -368
  694. synth_ai/environments/examples/tictactoe/environment.py +0 -240
  695. synth_ai/environments/examples/tictactoe/taskset.py +0 -215
  696. synth_ai/environments/examples/verilog/__init__.py +0 -10
  697. synth_ai/environments/examples/verilog/engine.py +0 -329
  698. synth_ai/environments/examples/verilog/environment.py +0 -350
  699. synth_ai/environments/examples/verilog/taskset.py +0 -420
  700. synth_ai/environments/examples/wordle/__init__.py +0 -29
  701. synth_ai/environments/examples/wordle/engine.py +0 -398
  702. synth_ai/environments/examples/wordle/environment.py +0 -159
  703. synth_ai/environments/examples/wordle/helpers/generate_instances_wordfreq.py +0 -75
  704. synth_ai/environments/examples/wordle/taskset.py +0 -230
  705. synth_ai/environments/reproducibility/core.py +0 -42
  706. synth_ai/environments/reproducibility/helpers.py +0 -0
  707. synth_ai/environments/reproducibility/tree.py +0 -363
  708. synth_ai/environments/service/app.py +0 -97
  709. synth_ai/environments/service/core_routes.py +0 -1021
  710. synth_ai/environments/service/external_registry.py +0 -56
  711. synth_ai/environments/service/registry.py +0 -9
  712. synth_ai/environments/stateful/__init__.py +0 -1
  713. synth_ai/environments/stateful/core.py +0 -163
  714. synth_ai/environments/stateful/engine.py +0 -21
  715. synth_ai/environments/stateful/state.py +0 -7
  716. synth_ai/environments/tasks/api.py +0 -19
  717. synth_ai/environments/tasks/core.py +0 -81
  718. synth_ai/environments/tasks/filters.py +0 -40
  719. synth_ai/environments/tasks/utils.py +0 -90
  720. synth_ai/environments/v0_observability/history.py +0 -3
  721. synth_ai/environments/v0_observability/log.py +0 -2
  722. synth_ai/evals/base.py +0 -13
  723. synth_ai/handshake.py +0 -109
  724. synth_ai/http.py +0 -26
  725. synth_ai/http_client.py +0 -136
  726. synth_ai/inference/__init__.py +0 -5
  727. synth_ai/inference/client.py +0 -34
  728. synth_ai/jobs/client.py +0 -271
  729. synth_ai/learning/__init__.py +0 -59
  730. synth_ai/learning/client.py +0 -241
  731. synth_ai/learning/ft_client.py +0 -7
  732. synth_ai/learning/health.py +0 -49
  733. synth_ai/learning/jobs.py +0 -201
  734. synth_ai/learning/rl/client.py +0 -267
  735. synth_ai/learning/rl/contracts.py +0 -27
  736. synth_ai/learning/rl/env_keys.py +0 -166
  737. synth_ai/learning/rl/secrets.py +0 -13
  738. synth_ai/learning/sft/client.py +0 -68
  739. synth_ai/learning/sft/config.py +0 -270
  740. synth_ai/learning/sft/data.py +0 -295
  741. synth_ai/learning/validators.py +0 -49
  742. synth_ai/lm/__init__.py +0 -25
  743. synth_ai/main.py +0 -6
  744. synth_ai/task/__init__.py +0 -102
  745. synth_ai/task/apps/__init__.py +0 -128
  746. synth_ai/task/contracts.py +0 -137
  747. synth_ai/task/datasets.py +0 -108
  748. synth_ai/task/proxy.py +0 -259
  749. synth_ai/task/server.py +0 -424
  750. synth_ai/task/tracing_utils.py +0 -84
  751. synth_ai/task/validators.py +0 -11
  752. synth_ai/tracing_v3/__init__.py +0 -97
  753. synth_ai/tracing_v3/config.py +0 -84
  754. synth_ai/tracing_v3/db_config.py +0 -194
  755. synth_ai/tracing_v3/decorators.py +0 -369
  756. synth_ai/tracing_v3/examples/basic_usage.py +0 -189
  757. synth_ai/tracing_v3/llm_call_record_helpers.py +0 -337
  758. synth_ai/tracing_v3/migration_helper.py +0 -120
  759. synth_ai/tracing_v3/replica_sync.py +0 -258
  760. synth_ai/tracing_v3/session_tracer.py +0 -530
  761. synth_ai/tracing_v3/storage/base.py +0 -210
  762. synth_ai/tracing_v3/storage/config.py +0 -75
  763. synth_ai/tracing_v3/storage/factory.py +0 -39
  764. synth_ai/tracing_v3/storage/utils.py +0 -204
  765. synth_ai/tracing_v3/turso/daemon.py +0 -149
  766. synth_ai/tracing_v3/turso/models.py +0 -469
  767. synth_ai/tracing_v3/turso/native_manager.py +0 -1173
  768. synth_ai/tracing_v3/utils.py +0 -108
  769. synth_ai/v0/api/__init__.py +0 -8
  770. synth_ai/v0/api/models/__init__.py +0 -8
  771. synth_ai/v0/api/models/supported.py +0 -8
  772. synth_ai/v0/config/__init__.py +0 -15
  773. synth_ai/v0/config/base_url.py +0 -12
  774. synth_ai/v0/lm/__init__.py +0 -51
  775. synth_ai/v0/lm/caching/constants.py +0 -6
  776. synth_ai/v0/lm/caching/dbs.py +0 -0
  777. synth_ai/v0/lm/caching/ephemeral.py +0 -100
  778. synth_ai/v0/lm/caching/handler.py +0 -137
  779. synth_ai/v0/lm/caching/initialize.py +0 -11
  780. synth_ai/v0/lm/caching/persistent.py +0 -114
  781. synth_ai/v0/lm/config.py +0 -115
  782. synth_ai/v0/lm/constants.py +0 -32
  783. synth_ai/v0/lm/core/__init__.py +0 -8
  784. synth_ai/v0/lm/core/all.py +0 -73
  785. synth_ai/v0/lm/core/exceptions.py +0 -5
  786. synth_ai/v0/lm/core/main.py +0 -331
  787. synth_ai/v0/lm/core/main_v3.py +0 -594
  788. synth_ai/v0/lm/core/synth_models.py +0 -35
  789. synth_ai/v0/lm/core/vendor_clients.py +0 -190
  790. synth_ai/v0/lm/cost/__init__.py +0 -0
  791. synth_ai/v0/lm/cost/monitor.py +0 -1
  792. synth_ai/v0/lm/cost/statefulness.py +0 -1
  793. synth_ai/v0/lm/injection.py +0 -80
  794. synth_ai/v0/lm/overrides.py +0 -206
  795. synth_ai/v0/lm/provider_support/__init__.py +0 -8
  796. synth_ai/v0/lm/provider_support/anthropic.py +0 -972
  797. synth_ai/v0/lm/provider_support/openai.py +0 -1139
  798. synth_ai/v0/lm/provider_support/suppress_logging.py +0 -31
  799. synth_ai/v0/lm/structured_outputs/__init__.py +0 -0
  800. synth_ai/v0/lm/structured_outputs/handler.py +0 -440
  801. synth_ai/v0/lm/structured_outputs/inject.py +0 -297
  802. synth_ai/v0/lm/structured_outputs/rehabilitate.py +0 -185
  803. synth_ai/v0/lm/tools/__init__.py +0 -3
  804. synth_ai/v0/lm/tools/base.py +0 -172
  805. synth_ai/v0/lm/unified_interface.py +0 -202
  806. synth_ai/v0/lm/vendors/__init__.py +0 -0
  807. synth_ai/v0/lm/vendors/base.py +0 -81
  808. synth_ai/v0/lm/vendors/core/__init__.py +0 -0
  809. synth_ai/v0/lm/vendors/core/anthropic_api.py +0 -387
  810. synth_ai/v0/lm/vendors/core/gemini_api.py +0 -292
  811. synth_ai/v0/lm/vendors/core/mistral_api.py +0 -322
  812. synth_ai/v0/lm/vendors/core/openai_api.py +0 -227
  813. synth_ai/v0/lm/vendors/core/synth_dev_api.py +0 -0
  814. synth_ai/v0/lm/vendors/local/__init__.py +0 -0
  815. synth_ai/v0/lm/vendors/local/ollama.py +0 -0
  816. synth_ai/v0/lm/vendors/openai_standard.py +0 -782
  817. synth_ai/v0/lm/vendors/openai_standard_responses.py +0 -259
  818. synth_ai/v0/lm/vendors/retries.py +0 -22
  819. synth_ai/v0/lm/vendors/supported/__init__.py +0 -0
  820. synth_ai/v0/lm/vendors/supported/custom_endpoint.py +0 -415
  821. synth_ai/v0/lm/vendors/supported/deepseek.py +0 -69
  822. synth_ai/v0/lm/vendors/supported/grok.py +0 -75
  823. synth_ai/v0/lm/vendors/supported/groq.py +0 -16
  824. synth_ai/v0/lm/vendors/supported/ollama.py +0 -15
  825. synth_ai/v0/lm/vendors/supported/openrouter.py +0 -74
  826. synth_ai/v0/lm/vendors/supported/together.py +0 -11
  827. synth_ai/v0/lm/vendors/synth_client.py +0 -835
  828. synth_ai/v0/lm/warmup.py +0 -186
  829. synth_ai/v0/tracing/__init__.py +0 -0
  830. synth_ai/v0/tracing/abstractions.py +0 -224
  831. synth_ai/v0/tracing/base_client.py +0 -91
  832. synth_ai/v0/tracing/client_manager.py +0 -131
  833. synth_ai/v0/tracing/config.py +0 -142
  834. synth_ai/v0/tracing/context.py +0 -146
  835. synth_ai/v0/tracing/decorators.py +0 -682
  836. synth_ai/v0/tracing/events/__init__.py +0 -0
  837. synth_ai/v0/tracing/events/manage.py +0 -147
  838. synth_ai/v0/tracing/events/scope.py +0 -86
  839. synth_ai/v0/tracing/events/store.py +0 -228
  840. synth_ai/v0/tracing/immediate_client.py +0 -151
  841. synth_ai/v0/tracing/local.py +0 -18
  842. synth_ai/v0/tracing/log_client_base.py +0 -73
  843. synth_ai/v0/tracing/retry_queue.py +0 -186
  844. synth_ai/v0/tracing/trackers.py +0 -515
  845. synth_ai/v0/tracing/upload.py +0 -409
  846. synth_ai/v0/tracing/utils.py +0 -9
  847. synth_ai/v0/tracing_v1/__init__.py +0 -16
  848. synth_ai/v0/tracing_v1/abstractions.py +0 -224
  849. synth_ai/v0/tracing_v1/base_client.py +0 -91
  850. synth_ai/v0/tracing_v1/client_manager.py +0 -131
  851. synth_ai/v0/tracing_v1/config.py +0 -142
  852. synth_ai/v0/tracing_v1/context.py +0 -146
  853. synth_ai/v0/tracing_v1/decorators.py +0 -703
  854. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  855. synth_ai/v0/tracing_v1/events/manage.py +0 -147
  856. synth_ai/v0/tracing_v1/events/scope.py +0 -86
  857. synth_ai/v0/tracing_v1/events/store.py +0 -228
  858. synth_ai/v0/tracing_v1/immediate_client.py +0 -151
  859. synth_ai/v0/tracing_v1/local.py +0 -18
  860. synth_ai/v0/tracing_v1/log_client_base.py +0 -73
  861. synth_ai/v0/tracing_v1/retry_queue.py +0 -186
  862. synth_ai/v0/tracing_v1/trackers.py +0 -515
  863. synth_ai/v0/tracing_v1/upload.py +0 -527
  864. synth_ai/v0/tracing_v1/utils.py +0 -9
  865. synth_ai/v0/tracing_v3/__init__.py +0 -10
  866. synth_ai/v0/tracing_v3/abstractions.py +0 -3
  867. synth_ai/v0/tracing_v3/decorators.py +0 -3
  868. synth_ai/v0/tracing_v3/llm_call_record_helpers.py +0 -3
  869. synth_ai/v0/tracing_v3/session_tracer.py +0 -3
  870. synth_ai-0.2.9.dev11.dist-info/METADATA +0 -191
  871. synth_ai-0.2.9.dev11.dist-info/RECORD +0 -571
  872. synth_ai-0.2.9.dev11.dist-info/entry_points.txt +0 -3
  873. synth_ai-0.2.9.dev11.dist-info/top_level.txt +0 -3
  874. /synth_ai/{demos/demo_task_apps → cli/demo_apps}/crafter/__init__.py +0 -0
  875. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/__init__.py +0 -0
  876. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/crafter/configs/crafter_fft_4b.toml +0 -0
  877. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/crafter/configs/rl_from_base_qwen4b.toml +0 -0
  878. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/__init__.py +0 -0
  879. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/_common.py +0 -0
  880. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/app.py +0 -0
  881. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/config.toml +0 -0
  882. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/deploy_modal.py +0 -0
  883. /synth_ai/{v0/lm/caching → core/apps}/__init__.py +0 -0
  884. /synth_ai/{tracing_v3 → core/tracing_v3}/abstractions.py +0 -0
  885. /synth_ai/{tracing_v3 → core/tracing_v3}/hooks.py +0 -0
  886. /synth_ai/{tracing_v3 → core/tracing_v3}/lm_call_record_abstractions.py +0 -0
  887. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/__init__.py +0 -0
  888. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/exceptions.py +0 -0
  889. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/types.py +0 -0
  890. /synth_ai/{compound/cais.py → py.typed} +0 -0
  891. /synth_ai/{learning → sdk/learning}/algorithms.py +0 -0
  892. /synth_ai/{learning → sdk/learning}/config.py +0 -0
  893. /synth_ai/{learning → sdk/learning}/constants.py +0 -0
  894. /synth_ai/{learning → sdk/learning}/core.py +0 -0
  895. /synth_ai/{learning → sdk/learning}/gateway.py +0 -0
  896. /synth_ai/{learning → sdk/learning}/rl/__init__.py +0 -0
  897. /synth_ai/{learning → sdk/learning}/rl/config.py +0 -0
  898. /synth_ai/{learning → sdk/learning}/rl_client.py +0 -0
  899. /synth_ai/{learning → sdk/learning}/sft/__init__.py +0 -0
  900. /synth_ai/{learning → sdk/learning}/sse.py +0 -0
  901. /synth_ai/{task → sdk/task}/auth.py +0 -0
  902. /synth_ai/{task → sdk/task}/client.py +0 -0
  903. /synth_ai/{task → sdk/task}/errors.py +0 -0
  904. /synth_ai/{task → sdk/task}/health.py +0 -0
  905. /synth_ai/{task → sdk/task}/json.py +0 -0
  906. /synth_ai/{task → sdk/task}/rubrics.py +0 -0
  907. /synth_ai/{task → sdk/task}/vendors.py +0 -0
  908. {synth_ai-0.2.9.dev11.dist-info → synth_ai-0.4.1.dist-info}/WHEEL +0 -0
  909. {synth_ai-0.2.9.dev11.dist-info → synth_ai-0.4.1.dist-info}/licenses/LICENSE +0 -0
synth_ai/cli/task_apps.py DELETED
@@ -1,2782 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import ast
4
- import asyncio
5
- import contextlib
6
- import hashlib
7
- import importlib
8
- import importlib.util
9
- import inspect
10
- import json
11
- import os
12
- import shutil
13
- import signal
14
- import subprocess
15
- import sys
16
- import tempfile
17
- import textwrap
18
- import types
19
- from collections.abc import Callable, Iterable, Iterator, Sequence
20
- from dataclasses import dataclass
21
- from pathlib import Path
22
- from typing import Any, cast
23
-
24
- try: # Python 3.11+
25
- import tomllib as _toml
26
- except Exception: # pragma: no cover - fallback
27
- _toml = None # type: ignore
28
- import uuid
29
-
30
- import click
31
-
32
- from synth_ai.config.base_url import PROD_BASE_URL_DEFAULT
33
- from synth_ai.task.apps import ModalDeploymentConfig, TaskAppConfig, TaskAppEntry, registry
34
- from synth_ai.task.server import create_task_app, run_task_app
35
-
36
- REPO_ROOT = Path(__file__).resolve().parents[2]
37
-
38
- DEFAULT_IGNORE_DIRS = {
39
- ".git",
40
- "__pycache__",
41
- "node_modules",
42
- "venv",
43
- ".venv",
44
- "build",
45
- "dist",
46
- ".mypy_cache",
47
- ".pytest_cache",
48
- }
49
-
50
- DEFAULT_SEARCH_RELATIVE = (
51
- Path("."),
52
- Path("examples"),
53
- Path("synth_ai"),
54
- )
55
-
56
-
57
- @dataclass
58
- class AppChoice:
59
- app_id: str
60
- label: str
61
- path: Path
62
- source: str
63
- description: str | None = None
64
- aliases: tuple[str, ...] = ()
65
- entry: TaskAppEntry | None = None
66
- entry_loader: Callable[[], TaskAppEntry] | None = None
67
- modal_script: Path | None = None
68
- lineno: int | None = None
69
-
70
- def ensure_entry(self) -> TaskAppEntry:
71
- if self.entry is not None:
72
- return self.entry
73
- if self.entry_loader is None:
74
- raise click.ClickException(f"Unable to load task app '{self.app_id}' from {self.path}")
75
- entry = self.entry_loader()
76
- self.entry = entry
77
- return entry
78
-
79
-
80
- def _temporary_sys_path(paths: Sequence[Path]):
81
- """Context manager to prepend entries to sys.path temporarily."""
82
-
83
- @contextlib.contextmanager
84
- def _manager() -> Iterator[None]:
85
- added: list[str] = []
86
- for p in paths:
87
- try:
88
- resolved = str(p.resolve())
89
- except Exception:
90
- continue
91
- if resolved in sys.path:
92
- continue
93
- sys.path.insert(0, resolved)
94
- added.append(resolved)
95
- try:
96
- yield None
97
- finally:
98
- for entry in added:
99
- with contextlib.suppress(ValueError):
100
- sys.path.remove(entry)
101
-
102
- return _manager()
103
-
104
-
105
- def _possible_module_names(
106
- path: Path, module_search_roots: Sequence[Path]
107
- ) -> list[tuple[str, Path]]:
108
- """Return potential module names based on candidate roots."""
109
-
110
- candidates: list[tuple[str, Path]] = []
111
- for root in module_search_roots:
112
- try:
113
- resolved_root = root.resolve()
114
- except Exception:
115
- continue
116
- if not resolved_root.exists() or not path.is_relative_to(resolved_root):
117
- continue
118
- relative = path.relative_to(resolved_root)
119
- stem = relative.with_suffix("")
120
- parts = list(stem.parts)
121
- if not parts:
122
- continue
123
- module_name = ".".join(parts)
124
- if module_name:
125
- candidates.append((module_name, resolved_root))
126
- return candidates
127
-
128
-
129
- def _ensure_parent_namespace(module_name: str, search_root: Path) -> None:
130
- """Ensure namespace packages exist for dotted module names."""
131
-
132
- parts = module_name.split(".")
133
- for depth in range(1, len(parts)):
134
- parent_name = ".".join(parts[:depth])
135
- if parent_name in sys.modules:
136
- continue
137
- parent_module = types.ModuleType(parent_name)
138
- candidate_dir = search_root.joinpath(*parts[:depth])
139
- try:
140
- resolved = candidate_dir.resolve()
141
- except Exception:
142
- resolved = search_root.resolve()
143
- parent_module.__path__ = [str(resolved)] # type: ignore[attr-defined]
144
- sys.modules[parent_name] = parent_module
145
-
146
-
147
- def _should_ignore_path(path: Path) -> bool:
148
- return any(part in DEFAULT_IGNORE_DIRS for part in path.parts)
149
-
150
-
151
- def _candidate_search_roots() -> list[Path]:
152
- """Only search for task apps in the current working directory and subdirectories."""
153
- roots: list[Path] = []
154
-
155
- # Prioritize demo directory if it exists
156
- try:
157
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
158
-
159
- demo_dir = load_demo_dir()
160
- if demo_dir:
161
- demo_path = Path(demo_dir)
162
- if demo_path.exists() and demo_path.is_dir():
163
- roots.append(demo_path.resolve())
164
- except Exception:
165
- pass
166
-
167
- # Allow explicit search paths via environment variable
168
- env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
169
- if env_paths:
170
- for chunk in env_paths.split(os.pathsep):
171
- if chunk:
172
- roots.append(Path(chunk).expanduser())
173
-
174
- # Always include current working directory
175
- cwd = Path.cwd().resolve()
176
- roots.append(cwd)
177
-
178
- for rel in DEFAULT_SEARCH_RELATIVE:
179
- try:
180
- candidate = (cwd / rel).resolve()
181
- except Exception:
182
- continue
183
- roots.append(candidate)
184
-
185
- # Remove duplicates while preserving order
186
- seen: set[Path] = set()
187
- ordered: list[Path] = []
188
- for root in roots:
189
- try:
190
- resolved = root.resolve()
191
- except Exception:
192
- continue
193
- if resolved in seen or not resolved.exists():
194
- continue
195
- seen.add(resolved)
196
- ordered.append(resolved)
197
- return ordered
198
-
199
-
200
- def _eval_config_sort_key(path: Path) -> tuple[int, int, int, str]:
201
- name = path.name.lower()
202
- parent_names = {p.name.lower() for p in path.parents}
203
- in_configs = 0 if "configs" in parent_names else 1
204
- in_examples = 0 if "examples" in parent_names else 1
205
- starts_eval = 0 if name.startswith("eval") else 1
206
- return (in_configs, in_examples, starts_eval, str(path))
207
-
208
-
209
- def _discover_eval_config_paths() -> list[Path]:
210
- """Find candidate eval TOML files near the current working directory."""
211
-
212
- candidates: list[Path] = []
213
- seen: set[Path] = set()
214
- search_roots = _candidate_search_roots()
215
- for root in search_roots:
216
- if not root.exists() or not root.is_dir():
217
- continue
218
- try:
219
- root = root.resolve()
220
- except Exception:
221
- continue
222
- for path in root.rglob("*.toml"):
223
- if not path.is_file():
224
- continue
225
- if _should_ignore_path(path):
226
- continue
227
- name_lower = path.name.lower()
228
- if "eval" not in name_lower and "evaluation" not in name_lower:
229
- continue
230
- try:
231
- resolved = path.resolve()
232
- except Exception:
233
- continue
234
- if resolved in seen:
235
- continue
236
- seen.add(resolved)
237
- candidates.append(resolved)
238
-
239
- candidates.sort(key=_eval_config_sort_key)
240
- return candidates
241
-
242
-
243
- class _TaskAppConfigVisitor(ast.NodeVisitor):
244
- def __init__(self) -> None:
245
- self.matches: list[tuple[str, int]] = []
246
-
247
- def visit_Call(self, node: ast.Call) -> None: # noqa: D401
248
- if _is_task_app_config_call(node):
249
- app_id = _extract_app_id(node)
250
- if app_id:
251
- self.matches.append((app_id, getattr(node, "lineno", 0)))
252
- elif _is_register_task_app_call(node):
253
- app_id = _extract_register_app_id(node)
254
- if app_id:
255
- self.matches.append((app_id, getattr(node, "lineno", 0)))
256
- self.generic_visit(node)
257
-
258
-
259
- def _is_task_app_config_call(node: ast.Call) -> bool:
260
- func = node.func
261
- return (isinstance(func, ast.Name) and func.id == "TaskAppConfig") or (
262
- isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig"
263
- )
264
-
265
-
266
- def _extract_app_id(node: ast.Call) -> str | None:
267
- for kw in node.keywords:
268
- if (
269
- kw.arg == "app_id"
270
- and isinstance(kw.value, ast.Constant)
271
- and isinstance(kw.value.value, str)
272
- ):
273
- return kw.value.value
274
- if node.args:
275
- first = node.args[0]
276
- if isinstance(first, ast.Constant) and isinstance(first.value, str):
277
- return first.value
278
- return None
279
-
280
-
281
- def _is_register_task_app_call(node: ast.Call) -> bool:
282
- func = node.func
283
- return (isinstance(func, ast.Name) and func.id == "register_task_app") or (
284
- isinstance(func, ast.Attribute) and func.attr == "register_task_app"
285
- )
286
-
287
-
288
- def _extract_register_app_id(node: ast.Call) -> str | None:
289
- # Look for entry=TaskAppEntry(app_id="...", ...)
290
- for kw in node.keywords:
291
- if kw.arg == "entry" and isinstance(kw.value, ast.Call):
292
- entry_call = kw.value
293
- if isinstance(entry_call.func, ast.Name) and entry_call.func.id == "TaskAppEntry":
294
- for entry_kw in entry_call.keywords:
295
- if (
296
- entry_kw.arg == "app_id"
297
- and isinstance(entry_kw.value, ast.Constant)
298
- and isinstance(entry_kw.value.value, str)
299
- ):
300
- return entry_kw.value.value
301
- return None
302
-
303
-
304
- class _ModalAppVisitor(ast.NodeVisitor):
305
- def __init__(self) -> None:
306
- self.app_aliases: set[str] = set()
307
- self.modal_aliases: set[str] = set()
308
- self.matches: list[tuple[str, int]] = []
309
-
310
- def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: D401
311
- if node.module == "modal":
312
- for alias in node.names:
313
- if alias.name == "App":
314
- self.app_aliases.add(alias.asname or alias.name)
315
- self.generic_visit(node)
316
-
317
- def visit_Import(self, node: ast.Import) -> None: # noqa: D401
318
- for alias in node.names:
319
- if alias.name == "modal":
320
- self.modal_aliases.add(alias.asname or alias.name)
321
- self.generic_visit(node)
322
-
323
- def visit_Call(self, node: ast.Call) -> None: # noqa: D401
324
- func = node.func
325
- if isinstance(func, ast.Name) and func.id in self.app_aliases:
326
- name = _extract_modal_app_name(node)
327
- if name:
328
- self.matches.append((name, getattr(node, "lineno", 0)))
329
- elif isinstance(func, ast.Attribute):
330
- if (
331
- isinstance(func.value, ast.Name)
332
- and func.value.id in self.modal_aliases
333
- and func.attr == "App"
334
- ):
335
- name = _extract_modal_app_name(node)
336
- if name:
337
- self.matches.append((name, getattr(node, "lineno", 0)))
338
- self.generic_visit(node)
339
-
340
-
341
- def _extract_modal_app_name(node: ast.Call) -> str | None:
342
- for kw in node.keywords:
343
- if (
344
- kw.arg in {"name", "app_name"}
345
- and isinstance(kw.value, ast.Constant)
346
- and isinstance(kw.value.value, str)
347
- ):
348
- return kw.value.value
349
- if node.args:
350
- first = node.args[0]
351
- if isinstance(first, ast.Constant) and isinstance(first.value, str):
352
- return first.value
353
- return None
354
-
355
-
356
- def _collect_task_app_choices() -> list[AppChoice]:
357
- # Clear registry to avoid duplicate registration errors
358
- registry.clear()
359
-
360
- choices: list[AppChoice] = []
361
- with contextlib.suppress(Exception):
362
- import synth_ai.demos.demo_task_apps # noqa: F401
363
- # Only use discovered task apps, not registered ones (since we moved them to examples)
364
- choices.extend(_collect_scanned_task_configs())
365
- choices.extend(_collect_modal_scripts())
366
-
367
- unique: dict[tuple[str, Path], AppChoice] = {}
368
- ordered: list[AppChoice] = []
369
- for choice in choices:
370
- key = (choice.app_id, choice.path.resolve())
371
- if key in unique:
372
- existing = unique[key]
373
- if existing.source == "registered" and choice.source != "registered":
374
- continue
375
- if choice.source == "registered" and existing.source != "registered":
376
- unique[key] = choice
377
- idx = ordered.index(existing)
378
- ordered[idx] = choice
379
- continue
380
- unique[key] = choice
381
- ordered.append(choice)
382
- ordered.sort(key=_app_choice_sort_key)
383
- return ordered
384
-
385
-
386
- def _collect_registered_choices() -> list[AppChoice]:
387
- result: list[AppChoice] = []
388
- for entry in registry.list():
389
- module_name = entry.config_factory.__module__
390
- module = sys.modules.get(module_name)
391
- if module is None:
392
- module = importlib.import_module(module_name)
393
- module_file = getattr(module, "__file__", None)
394
- path = Path(module_file).resolve() if module_file else REPO_ROOT
395
- result.append(
396
- AppChoice(
397
- app_id=entry.app_id,
398
- label=entry.app_id,
399
- path=path,
400
- source="registered",
401
- description=entry.description,
402
- aliases=tuple(entry.aliases),
403
- entry=entry,
404
- )
405
- )
406
- return result
407
-
408
-
409
- def _collect_scanned_task_configs() -> list[AppChoice]:
410
- results: list[AppChoice] = []
411
- seen: set[tuple[str, Path]] = set()
412
- for root in _candidate_search_roots():
413
- try:
414
- root_resolved = root.resolve()
415
- except Exception:
416
- continue
417
- if not root.exists() or not root.is_dir():
418
- continue
419
- for path in root.rglob("*.py"):
420
- if not path.is_file():
421
- continue
422
- if _should_ignore_path(path):
423
- continue
424
- try:
425
- source = path.read_text(encoding="utf-8")
426
- except Exception:
427
- continue
428
- try:
429
- tree = ast.parse(source, filename=str(path))
430
- except SyntaxError:
431
- continue
432
- visitor = _TaskAppConfigVisitor()
433
- visitor.visit(tree)
434
- for app_id, lineno in visitor.matches:
435
- key = (app_id, path.resolve())
436
- if key in seen:
437
- continue
438
- seen.add(key)
439
- results.append(
440
- AppChoice(
441
- app_id=app_id,
442
- label=app_id,
443
- path=path.resolve(),
444
- source="discovered",
445
- description=f"TaskAppConfig in {path.name} (line {lineno})",
446
- entry_loader=lambda p=path.resolve(),
447
- a=app_id,
448
- roots=(root_resolved,): _load_entry_from_path(
449
- p, a, module_search_roots=roots
450
- ),
451
- lineno=lineno,
452
- )
453
- )
454
- return results
455
-
456
-
457
- def _collect_modal_scripts() -> list[AppChoice]:
458
- results: list[AppChoice] = []
459
- seen: set[tuple[str, Path]] = set()
460
- for root in _candidate_search_roots():
461
- if not root.exists() or not root.is_dir():
462
- continue
463
- for path in root.rglob("*.py"):
464
- if not path.is_file():
465
- continue
466
- if _should_ignore_path(path):
467
- continue
468
- try:
469
- source = path.read_text(encoding="utf-8")
470
- except Exception:
471
- continue
472
- try:
473
- tree = ast.parse(source, filename=str(path))
474
- except SyntaxError:
475
- continue
476
- visitor = _ModalAppVisitor()
477
- visitor.visit(tree)
478
- for app_name, lineno in visitor.matches:
479
- key = (app_name, path.resolve())
480
- if key in seen:
481
- continue
482
- seen.add(key)
483
- results.append(
484
- AppChoice(
485
- app_id=app_name,
486
- label=app_name,
487
- path=path.resolve(),
488
- source="modal-script",
489
- description=f"Modal App '{app_name}' in {path.name} (line {lineno})",
490
- modal_script=path.resolve(),
491
- lineno=lineno,
492
- )
493
- )
494
- return results
495
-
496
-
497
- def _app_choice_sort_key(choice: AppChoice) -> tuple[int, int, int, int, int, str, str]:
498
- """Ranking heuristic so wrapper-style task apps surface first."""
499
-
500
- # Prioritize apps in the current working directory (demo or otherwise)
501
- cwd_rank = 1
502
- try:
503
- cwd = Path.cwd().resolve()
504
- if choice.path.is_relative_to(cwd):
505
- # Check if this is directly in CWD (not in subdirectories like examples/)
506
- try:
507
- rel_path = choice.path.relative_to(cwd)
508
- # If it's in the immediate directory or one level deep, prioritize it
509
- if len(rel_path.parts) <= 2:
510
- cwd_rank = 0
511
- except Exception:
512
- pass
513
- except Exception:
514
- pass
515
-
516
- # Further prioritize apps in the demo directory if one is set
517
- demo_rank = 1
518
- try:
519
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
520
-
521
- demo_dir = load_demo_dir()
522
- if demo_dir:
523
- demo_path = Path(demo_dir).resolve()
524
- if choice.path.is_relative_to(demo_path):
525
- demo_rank = 0
526
- except Exception:
527
- pass
528
-
529
- modal_rank = 1 if choice.modal_script else 0
530
-
531
- name = choice.path.name.lower()
532
- file_rank = 3
533
- if name.endswith("_task_app.py") or name.endswith("task_app.py"):
534
- file_rank = 0
535
- elif name.endswith("_app.py") or "task_app" in name:
536
- file_rank = 1
537
- elif name.endswith(".py"):
538
- file_rank = 2
539
-
540
- directory_rank = 0 if choice.path.parent.name.lower() in {"task_app", "task_apps"} else 1
541
-
542
- return (
543
- demo_rank,
544
- cwd_rank,
545
- modal_rank,
546
- file_rank,
547
- directory_rank,
548
- choice.app_id,
549
- str(choice.path),
550
- )
551
-
552
-
553
- def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
554
- ident = identifier.strip()
555
- if not ident:
556
- return False
557
- return ident == choice.app_id or ident == choice.label or ident in choice.aliases
558
-
559
-
560
- def _choice_has_modal_support(choice: AppChoice) -> bool:
561
- if choice.modal_script:
562
- return True
563
- try:
564
- entry = choice.ensure_entry()
565
- except click.ClickException:
566
- # If we can't load the entry, try to detect Modal support via AST parsing
567
- return _has_modal_support_in_file(choice.path)
568
- return entry.modal is not None
569
-
570
-
571
- def _has_modal_support_in_file(path: Path) -> bool:
572
- """Detect if a file has Modal deployment support by parsing the AST."""
573
- try:
574
- source = path.read_text(encoding="utf-8")
575
- tree = ast.parse(source, filename=str(path))
576
-
577
- # Look for ModalDeploymentConfig in register_task_app calls
578
- for node in ast.walk(tree):
579
- if isinstance(node, ast.Call) and _is_register_task_app_call(node):
580
- # Check if the entry has modal=ModalDeploymentConfig(...)
581
- for kw in node.keywords:
582
- if kw.arg == "entry" and isinstance(kw.value, ast.Call):
583
- entry_call = kw.value
584
- if (
585
- isinstance(entry_call.func, ast.Name)
586
- and entry_call.func.id == "TaskAppEntry"
587
- ):
588
- for entry_kw in entry_call.keywords:
589
- if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
590
- modal_call = entry_kw.value
591
- if (
592
- isinstance(modal_call.func, ast.Name)
593
- and modal_call.func.id == "ModalDeploymentConfig"
594
- ):
595
- return True
596
- except Exception:
597
- pass
598
- return False
599
-
600
-
601
- def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfig | None:
602
- """Extract ModalDeploymentConfig from a file by parsing the AST."""
603
- try:
604
- source = path.read_text(encoding="utf-8")
605
- tree = ast.parse(source, filename=str(path))
606
-
607
- # Look for ModalDeploymentConfig in register_task_app calls
608
- for node in ast.walk(tree):
609
- if isinstance(node, ast.Call) and _is_register_task_app_call(node):
610
- # Check if the entry has modal=ModalDeploymentConfig(...)
611
- for kw in node.keywords:
612
- if kw.arg == "entry" and isinstance(kw.value, ast.Call):
613
- entry_call = kw.value
614
- if (
615
- isinstance(entry_call.func, ast.Name)
616
- and entry_call.func.id == "TaskAppEntry"
617
- ):
618
- for entry_kw in entry_call.keywords:
619
- if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
620
- modal_call = entry_kw.value
621
- if (
622
- isinstance(modal_call.func, ast.Name)
623
- and modal_call.func.id == "ModalDeploymentConfig"
624
- ):
625
- # Extract the arguments to ModalDeploymentConfig
626
- return _build_modal_config_from_ast(modal_call)
627
- except Exception:
628
- pass
629
- return None
630
-
631
-
632
- def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfig | None:
633
- """Build a ModalDeploymentConfig from an AST Call node."""
634
- try:
635
- # Extract keyword arguments
636
- kwargs = {}
637
- for kw in modal_call.keywords:
638
- if kw.arg and isinstance(kw.value, ast.Constant):
639
- kwargs[kw.arg] = kw.value.value
640
- elif kw.arg == "pip_packages" and isinstance(kw.value, ast.List | ast.Tuple):
641
- # Handle pip_packages list/tuple
642
- packages = []
643
- for elt in kw.value.elts:
644
- if isinstance(elt, ast.Constant):
645
- packages.append(elt.value)
646
- kwargs[kw.arg] = tuple(packages)
647
- elif kw.arg == "extra_local_dirs" and isinstance(kw.value, ast.List | ast.Tuple):
648
- # Handle extra_local_dirs list/tuple of tuples
649
- dirs = []
650
- for elt in kw.value.elts:
651
- if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
652
- src = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
653
- dst = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
654
- if src and dst:
655
- dirs.append((src, dst))
656
- kwargs[kw.arg] = tuple(dirs)
657
- elif kw.arg == "secret_names" and isinstance(kw.value, ast.List | ast.Tuple):
658
- # Handle secret_names list/tuple
659
- secrets = []
660
- for elt in kw.value.elts:
661
- if isinstance(elt, ast.Constant):
662
- secrets.append(elt.value)
663
- kwargs[kw.arg] = tuple(secrets)
664
- elif kw.arg == "volume_mounts" and isinstance(kw.value, ast.List | ast.Tuple):
665
- # Handle volume_mounts list/tuple of tuples
666
- mounts = []
667
- for elt in kw.value.elts:
668
- if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
669
- name = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
670
- mount = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
671
- if name and mount:
672
- mounts.append((name, mount))
673
- kwargs[kw.arg] = tuple(mounts)
674
-
675
- # Create ModalDeploymentConfig with extracted arguments
676
- from synth_ai.task.apps import ModalDeploymentConfig
677
-
678
- return ModalDeploymentConfig(**kwargs)
679
- except Exception:
680
- return None
681
-
682
-
683
- def _choice_has_local_support(choice: AppChoice) -> bool:
684
- if choice.modal_script:
685
- return False
686
- try:
687
- choice.ensure_entry()
688
- except click.ClickException:
689
- return False
690
- return True
691
-
692
-
693
- def _format_choice(choice: AppChoice, index: int | None = None) -> str:
694
- prefix = f"[{index}] " if index is not None else ""
695
- # Get file modification timestamp
696
- try:
697
- from datetime import datetime
698
-
699
- mtime = choice.path.stat().st_mtime
700
- modified_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
701
- details = f"Modified: {modified_str}"
702
- except Exception:
703
- # Fallback if timestamp unavailable
704
- details = choice.description or "No timestamp available"
705
- # Format: single line with timestamp
706
- main_line = f"{prefix}{choice.app_id} ({choice.source}) – {details}"
707
- return main_line
708
-
709
-
710
- def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
711
- click.echo("Select a task app:")
712
- for idx, choice in enumerate(choices, start=1):
713
- click.echo(_format_choice(choice, idx))
714
- try:
715
- response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
716
- except (click.exceptions.Abort, EOFError, KeyboardInterrupt) as exc:
717
- raise click.ClickException("Task app selection cancelled by user") from exc
718
- if not response.isdigit():
719
- raise click.ClickException("Selection must be a number")
720
- index = int(response)
721
- if not 1 <= index <= len(choices):
722
- raise click.ClickException("Selection out of range")
723
- return choices[index - 1]
724
-
725
-
726
- def _select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
727
- choices = _collect_task_app_choices()
728
- if purpose in {"serve", "eval"}:
729
- filtered = [c for c in choices if not c.modal_script]
730
- elif purpose in {"deploy", "modal-serve"}:
731
- filtered = []
732
- for choice in choices:
733
- if choice.modal_script or _choice_has_modal_support(choice):
734
- filtered.append(choice)
735
- else:
736
- filtered = choices
737
-
738
- filtered.sort(key=_app_choice_sort_key)
739
-
740
- if not filtered:
741
- raise click.ClickException("No task apps discovered for this command.")
742
-
743
- if app_id:
744
- matches = [c for c in filtered if _choice_matches_identifier(c, app_id)]
745
- if not matches:
746
- available = ", ".join(sorted({c.app_id for c in filtered}))
747
- raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
748
- if len(matches) == 1:
749
- return matches[0]
750
- # Prefer entries with modal support when required
751
- if purpose in {"deploy", "modal-serve"}:
752
- modal_matches = [c for c in matches if _choice_has_modal_support(c)]
753
- if len(modal_matches) == 1:
754
- return modal_matches[0]
755
- if modal_matches:
756
- matches = modal_matches
757
- return _prompt_user_for_choice(matches)
758
-
759
- if len(filtered) == 1:
760
- choice = filtered[0]
761
- click.echo(_format_choice(choice))
762
- return choice
763
-
764
- return _prompt_user_for_choice(filtered)
765
-
766
-
767
- def _import_task_app_module(
768
- resolved: Path,
769
- module_name: str,
770
- *,
771
- namespace_root: Path | None,
772
- sys_path_roots: Sequence[Path],
773
- ensure_namespace: bool = True,
774
- ) -> types.ModuleType:
775
- spec = importlib.util.spec_from_file_location(module_name, str(resolved))
776
- if spec is None or spec.loader is None:
777
- raise click.ClickException(f"Unable to load Python module from {resolved}")
778
-
779
- module = importlib.util.module_from_spec(spec)
780
- sys.modules[module_name] = module
781
-
782
- with _temporary_sys_path(sys_path_roots):
783
- if ensure_namespace and namespace_root is not None and "." in module_name:
784
- _ensure_parent_namespace(module_name, namespace_root)
785
-
786
- # Clear registry before importing to avoid duplicate registration errors
787
- registry.clear()
788
-
789
- try:
790
- spec.loader.exec_module(module)
791
- except Exception:
792
- # Remove partially-imported module to avoid reuse
793
- sys.modules.pop(module_name, None)
794
- raise
795
-
796
- return module
797
-
798
-
799
- def _load_entry_from_path(
800
- path: Path, app_id: str, module_search_roots: Sequence[Path] | None = None
801
- ) -> TaskAppEntry:
802
- resolved = path.resolve()
803
- search_roots: list[Path] = []
804
- seen_roots: set[Path] = set()
805
-
806
- def _append_root(candidate: Path) -> None:
807
- try:
808
- resolved_root = candidate.resolve()
809
- except Exception:
810
- return
811
- if resolved_root in seen_roots:
812
- return
813
- seen_roots.add(resolved_root)
814
- search_roots.append(resolved_root)
815
-
816
- for root in module_search_roots or []:
817
- _append_root(root)
818
- _append_root(resolved.parent)
819
- _append_root(REPO_ROOT)
820
-
821
- last_error: Exception | None = None
822
- module: types.ModuleType | None = None
823
-
824
- for module_name, namespace_root in _possible_module_names(resolved, search_roots):
825
- try:
826
- module = _import_task_app_module(
827
- resolved,
828
- module_name,
829
- namespace_root=namespace_root,
830
- sys_path_roots=search_roots,
831
- ensure_namespace=True,
832
- )
833
- break
834
- except Exception as exc: # pragma: no cover - best-effort fallbacks
835
- last_error = exc
836
- continue
837
-
838
- if module is None:
839
- hashed_name = f"_synth_task_app_{hashlib.md5(str(resolved).encode(), usedforsecurity=False).hexdigest()}"
840
- try:
841
- module = _import_task_app_module(
842
- resolved,
843
- hashed_name,
844
- namespace_root=None,
845
- sys_path_roots=search_roots,
846
- ensure_namespace=False,
847
- )
848
- except Exception as exc: # pragma: no cover - propagate meaningful error
849
- detail = last_error or exc
850
- raise click.ClickException(f"Failed to import {resolved}: {detail}") from detail
851
-
852
- config_obj: TaskAppConfig | None = None
853
- factory_callable: Callable[[], TaskAppConfig] | None = None
854
-
855
- for attr_name in dir(module):
856
- try:
857
- attr = getattr(module, attr_name)
858
- except Exception:
859
- continue
860
- if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
861
- config_obj = attr
862
-
863
- def _return_config(cfg: TaskAppConfig = attr) -> TaskAppConfig:
864
- return cfg
865
-
866
- factory_callable = _return_config
867
- break
868
-
869
- if factory_callable is None:
870
- for attr_name in dir(module):
871
- if attr_name.startswith("_"):
872
- continue
873
- try:
874
- attr = getattr(module, attr_name)
875
- except Exception:
876
- continue
877
- if not callable(attr):
878
- continue
879
- try:
880
- sig = inspect.signature(attr)
881
- except (TypeError, ValueError):
882
- continue
883
- has_required = False
884
- for param in sig.parameters.values():
885
- if (
886
- param.kind
887
- in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
888
- and param.default is inspect._empty
889
- ):
890
- has_required = True
891
- break
892
- if has_required:
893
- continue
894
- try:
895
- result = attr()
896
- except Exception:
897
- continue
898
- if isinstance(result, TaskAppConfig) and result.app_id == app_id:
899
- # Bind attr to a local and close over it without exposing parameters
900
- bound_func: Callable[[], TaskAppConfig] = cast(Callable[[], TaskAppConfig], attr) # type: ignore[assignment]
901
-
902
- def _factory_noargs(
903
- func: Callable[[], TaskAppConfig] = bound_func,
904
- ) -> TaskAppConfig:
905
- return func()
906
-
907
- factory_callable = _factory_noargs
908
- config_obj = result
909
- break
910
-
911
- # If no TaskAppConfig found directly, check if it was registered via register_task_app
912
- if factory_callable is None or config_obj is None:
913
- try:
914
- # Check if the app was registered in the registry
915
- entry = registry.get(app_id)
916
- return entry
917
- except KeyError as exc:
918
- raise click.ClickException(
919
- f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
920
- ) from exc
921
-
922
- modal_cfg: ModalDeploymentConfig | None = None
923
- for attr_name in dir(module):
924
- try:
925
- attr = getattr(module, attr_name)
926
- except Exception:
927
- continue
928
- if isinstance(attr, ModalDeploymentConfig):
929
- modal_cfg = attr
930
- break
931
-
932
- # If no ModalDeploymentConfig found, try to detect it via AST parsing
933
- if modal_cfg is None:
934
- modal_cfg = _extract_modal_config_from_file(resolved)
935
-
936
- description = inspect.getdoc(module) or f"Discovered task app in {resolved.name}"
937
- env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
938
-
939
- entry = TaskAppEntry(
940
- app_id=app_id,
941
- description=description,
942
- config_factory=factory_callable,
943
- aliases=(),
944
- env_files=tuple(str(Path(p)) for p in env_files if p),
945
- modal=modal_cfg,
946
- )
947
- return entry
948
-
949
-
950
- def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) -> list[Path]:
951
- if explicit:
952
- resolved: list[Path] = []
953
- for candidate in explicit:
954
- p = Path(candidate).expanduser()
955
- if not p.exists():
956
- raise click.ClickException(f"Env file not found: {p}")
957
- resolved.append(p)
958
- return resolved
959
-
960
- # Always prompt for env file selection instead of auto-loading defaults
961
- script_dir = script_path.parent.resolve()
962
- cwd = Path.cwd()
963
-
964
- # Look for env files in current working directory first, then repo root
965
- env_candidates = []
966
-
967
- # Add CWD env files first (prioritized)
968
- cwd_env_files = sorted(cwd.glob("**/*.env"))
969
- env_candidates.extend(cwd_env_files)
970
-
971
- # Add repo root env files
972
- repo_env_files = sorted(REPO_ROOT.glob("**/*.env"))
973
- # Avoid duplicates
974
- for repo_file in repo_env_files:
975
- if repo_file not in env_candidates:
976
- env_candidates.append(repo_file)
977
-
978
- if not env_candidates:
979
- created = _interactive_create_env(script_dir)
980
- if created is None:
981
- raise click.ClickException("Env file required (--env-file) for this task app")
982
- return [created]
983
-
984
- click.echo("Select env file to load:")
985
- for idx, path in enumerate(env_candidates, start=1):
986
- click.echo(f" {idx}) {path.resolve()}")
987
- choice = click.prompt("Enter choice", type=click.IntRange(1, len(env_candidates)), default=1)
988
- return [env_candidates[choice - 1]]
989
-
990
-
991
- def _modal_command_prefix(modal_cli: str) -> list[str]:
992
- """Resolve a command prefix for invoking the Modal CLI within the active environment."""
993
- if modal_cli == "modal" and importlib.util.find_spec("modal") is not None:
994
- return [sys.executable, "-m", "synth_ai.cli._modal_wrapper"]
995
-
996
- modal_path = shutil.which(modal_cli)
997
- if modal_path is not None:
998
- return [modal_path]
999
-
1000
- if modal_cli == "modal":
1001
- raise click.ClickException(
1002
- "Modal CLI not found. Install the 'modal' package in this environment or pass "
1003
- "--modal-cli with an explicit path."
1004
- )
1005
- raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
1006
-
1007
-
1008
- def _build_modal_app_wrapper(original_script: Path) -> tuple[Path, Path]:
1009
- source_dir = original_script.parent.resolve()
1010
- repo_root = REPO_ROOT
1011
- synth_src = (repo_root / "synth_ai").resolve()
1012
- temp_root = Path(tempfile.mkdtemp(prefix="synth_modal_app_"))
1013
-
1014
- wrapper_source = textwrap.dedent(
1015
- f"""
1016
- from importlib import util as _util
1017
- from pathlib import Path as _Path
1018
- import sys as _sys
1019
-
1020
- _source_dir = _Path({str(source_dir)!r}).resolve()
1021
- _module_path = _source_dir / {original_script.name!r}
1022
- _package_name = _source_dir.name
1023
- _repo_root = _Path({str(repo_root)!r}).resolve()
1024
- _synth_dir = _repo_root / "synth_ai"
1025
-
1026
- for _path in (str(_source_dir), str(_source_dir.parent), str(_repo_root)):
1027
- if _path not in _sys.path:
1028
- _sys.path.insert(0, _path)
1029
-
1030
- _spec = _util.spec_from_file_location("_synth_modal_target", str(_module_path))
1031
- if _spec is None or _spec.loader is None:
1032
- raise SystemExit("Unable to load modal task app from {original_script}")
1033
- _module = _util.module_from_spec(_spec)
1034
- _sys.modules.setdefault("_synth_modal_target", _module)
1035
- _spec.loader.exec_module(_module)
1036
-
1037
- try:
1038
- from modal import App as _ModalApp
1039
- from modal import Image as _ModalImage
1040
- except Exception:
1041
- _ModalApp = None # type: ignore[assignment]
1042
- _ModalImage = None # type: ignore[assignment]
1043
-
1044
- def _apply_local_mounts(image):
1045
- if _ModalImage is None or not isinstance(image, _ModalImage):
1046
- return image
1047
- mounts = [
1048
- (str(_source_dir), f"/root/{{_package_name}}"),
1049
- (str(_synth_dir), "/root/synth_ai"),
1050
- ]
1051
- for local_path, remote_path in mounts:
1052
- try:
1053
- image = image.add_local_dir(local_path, remote_path=remote_path)
1054
- except Exception:
1055
- pass
1056
- return image
1057
-
1058
- if hasattr(_module, "image"):
1059
- _module.image = _apply_local_mounts(getattr(_module, "image"))
1060
-
1061
- _candidate = getattr(_module, "app", None)
1062
- if _ModalApp is None or not isinstance(_candidate, _ModalApp):
1063
- candidate_modal_app = getattr(_module, "modal_app", None)
1064
- if _ModalApp is not None and isinstance(candidate_modal_app, _ModalApp):
1065
- _candidate = candidate_modal_app
1066
- setattr(_module, "app", _candidate)
1067
-
1068
- if _ModalApp is not None and not isinstance(_candidate, _ModalApp):
1069
- raise SystemExit(
1070
- "Modal task app must expose an 'app = modal.App(...)' (or modal_app) attribute."
1071
- )
1072
-
1073
- for remote_path in ("/root/synth_ai", f"/root/{{_package_name}}"):
1074
- if remote_path not in _sys.path:
1075
- _sys.path.insert(0, remote_path)
1076
-
1077
- globals().update({{k: v for k, v in vars(_module).items() if not k.startswith("__")}})
1078
- app = getattr(_module, "app")
1079
- """
1080
- ).strip()
1081
-
1082
- wrapper_path = temp_root / "__modal_wrapper__.py"
1083
- wrapper_path.write_text(wrapper_source + "\n", encoding="utf-8")
1084
- return wrapper_path, temp_root
1085
-
1086
-
1087
-
1088
- def _run_modal_script(
1089
- script_path: Path,
1090
- modal_cli: str,
1091
- command: str,
1092
- env_paths: Sequence[Path],
1093
- *,
1094
- modal_name: str | None = None,
1095
- dry_run: bool = False,
1096
- ) -> None:
1097
- env_paths_list = [Path(p).resolve() for p in env_paths]
1098
- path_strings = [str(p) for p in env_paths_list]
1099
- _load_env_files_into_process(path_strings)
1100
- _ensure_env_values(env_paths_list, script_path.parent)
1101
- _load_env_values(env_paths_list)
1102
- # Ensure ENVIRONMENT_API_KEY is uploaded to backend for this org (matches registry path behavior)
1103
- try:
1104
- _preflight_env_key(env_paths_list, crash_on_failure=True)
1105
- except Exception as _pf_err:
1106
- raise click.ClickException(str(_pf_err))
1107
-
1108
- proc_env = os.environ.copy()
1109
- pythonpath_entries: list[str] = []
1110
- script_dir = script_path.parent.resolve()
1111
- pythonpath_entries.append(str(script_dir))
1112
- if (script_dir / "__init__.py").exists():
1113
- # Script lives inside a package; ensure the parent package directory is importable.
1114
- pythonpath_entries.append(str(script_dir.parent.resolve()))
1115
- pythonpath_entries.append(str(REPO_ROOT))
1116
- existing_pp = proc_env.get("PYTHONPATH")
1117
- if existing_pp:
1118
- pythonpath_entries.append(existing_pp)
1119
- unique_paths = list(dict.fromkeys(pythonpath_entries))
1120
- proc_env["PYTHONPATH"] = os.pathsep.join(unique_paths)
1121
-
1122
- wrapper_info: tuple[Path, Path] | None = None
1123
- target_script = script_path
1124
- if command in {"serve", "deploy"}:
1125
- wrapper_path, temp_root = _build_modal_app_wrapper(script_path)
1126
- wrapper_info = (wrapper_path, temp_root)
1127
- target_script = wrapper_path
1128
-
1129
- # Ensure the wrapper has access to the Synth AI source for intra-repo imports
1130
- if "PYTHONPATH" in proc_env:
1131
- proc_env["PYTHONPATH"] = os.pathsep.join(
1132
- [str(REPO_ROOT)] + proc_env["PYTHONPATH"].split(os.pathsep)
1133
- )
1134
- else:
1135
- proc_env["PYTHONPATH"] = str(REPO_ROOT)
1136
-
1137
- cmd = [*_modal_command_prefix(modal_cli), command, str(target_script)]
1138
- if modal_name and command == "deploy":
1139
- cmd.extend(["--name", modal_name])
1140
- if dry_run:
1141
- click.echo("Dry run: " + " ".join(cmd))
1142
- return
1143
- try:
1144
- # Stream output live for better diagnostics
1145
- proc = subprocess.Popen(
1146
- cmd,
1147
- stdout=subprocess.PIPE,
1148
- stderr=subprocess.STDOUT,
1149
- text=True,
1150
- bufsize=1,
1151
- env=proc_env,
1152
- )
1153
- task_app_url = None
1154
- assert proc.stdout is not None
1155
- for line in proc.stdout:
1156
- click.echo(line, nl=False)
1157
- if task_app_url is None and ("modal.run" in line and "=>" in line):
1158
- parts = line.split("=>")
1159
- if len(parts) >= 2:
1160
- task_app_url = parts[-1].strip()
1161
- if task_app_url and env_paths_list:
1162
- env_file = env_paths_list[0]
1163
- _save_to_env_file(env_file, "TASK_APP_BASE_URL", task_app_url)
1164
- click.echo(f"\n✓ Task app URL: {task_app_url}\n")
1165
- rc = proc.wait()
1166
- if rc != 0:
1167
- raise subprocess.CalledProcessError(rc, cmd)
1168
- except subprocess.CalledProcessError as exc:
1169
- raise click.ClickException(
1170
- f"modal {command} failed with exit code {exc.returncode}"
1171
- ) from exc
1172
- finally:
1173
- if wrapper_info is not None:
1174
- wrapper_path, temp_root = wrapper_info
1175
- try:
1176
- wrapper_path.unlink(missing_ok=True)
1177
- except Exception:
1178
- pass
1179
- shutil.rmtree(temp_root, ignore_errors=True)
1180
-
1181
-
1182
- def _preflight_env_key(env_paths: Sequence[Path] | None = None, *, crash_on_failure: bool = False) -> None:
1183
- try:
1184
- raw_backend = (
1185
- os.environ.get("BACKEND_BASE_URL")
1186
- or os.environ.get("SYNTH_BASE_URL")
1187
- or f"{PROD_BASE_URL_DEFAULT}/api"
1188
- )
1189
- backend_base = raw_backend.rstrip("/")
1190
- if not backend_base.endswith("/api"):
1191
- backend_base = backend_base + "/api"
1192
- synth_key = os.environ.get("SYNTH_API_KEY") or ""
1193
- env_api_key = (
1194
- os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
1195
- ).strip()
1196
-
1197
- def _preview(value: str) -> str:
1198
- if len(value) <= 10:
1199
- return value
1200
- return f"{value[:6]}...{value[-4:]}"
1201
-
1202
- minted = False
1203
- if not env_api_key:
1204
- try:
1205
- from synth_ai.learning.rl.secrets import mint_environment_api_key
1206
-
1207
- env_api_key = mint_environment_api_key()
1208
- os.environ["ENVIRONMENT_API_KEY"] = env_api_key
1209
- os.environ.setdefault("DEV_ENVIRONMENT_API_KEY", env_api_key)
1210
- minted = True
1211
- click.echo(
1212
- f"[preflight] minted ENVIRONMENT_API_KEY ({_preview(env_api_key)})"
1213
- )
1214
- except Exception as mint_err:
1215
- if crash_on_failure:
1216
- raise click.ClickException(
1217
- f"[CRITICAL] Failed to mint ENVIRONMENT_API_KEY: {mint_err}"
1218
- ) from mint_err
1219
- click.echo(
1220
- f"[WARN] Failed to mint ENVIRONMENT_API_KEY automatically ({mint_err}); proceeding without upload"
1221
- )
1222
-
1223
- if env_api_key and not os.environ.get("ENVIRONMENT_API_KEY"):
1224
- os.environ["ENVIRONMENT_API_KEY"] = env_api_key
1225
- if env_api_key and not os.environ.get("DEV_ENVIRONMENT_API_KEY"):
1226
- os.environ["DEV_ENVIRONMENT_API_KEY"] = env_api_key
1227
-
1228
- if minted:
1229
- _persist_env_api_key(env_api_key, env_paths)
1230
-
1231
- if synth_key and env_api_key:
1232
- import base64
1233
-
1234
- import httpx
1235
-
1236
- click.echo(f"[preflight] backend={backend_base}")
1237
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
1238
- click.echo("[preflight] fetching public key…")
1239
- rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
1240
- pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
1241
- if pk:
1242
- try:
1243
- from nacl.public import PublicKey, SealedBox
1244
-
1245
- # Decode public key and build sealed box
1246
- pk_bytes = base64.b64decode(pk, validate=True)
1247
- pub = PublicKey(pk_bytes)
1248
- sb = SealedBox(pub)
1249
-
1250
- # Encrypt plaintext key
1251
- ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode("utf-8"))).decode()
1252
- payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
1253
-
1254
- # Emit diagnostic logging (safe previews + hashes only)
1255
- try:
1256
- import hashlib as _hash
1257
-
1258
- # Backend URL context
1259
- click.echo(f"[preflight] posting to {backend_base.rstrip('/')}/v1/env-keys")
1260
-
1261
- # Public key diagnostics
1262
- pk_sha256 = _hash.sha256(pk_bytes).hexdigest()
1263
- click.echo(
1264
- f"[preflight] public_key: b64_len={len(pk)} sha256={pk_sha256} head={pk[:16]} tail={pk[-16:]}"
1265
- )
1266
-
1267
- # Plaintext diagnostics (never print full secret)
1268
- _plain = env_api_key
1269
- _plen = len(_plain)
1270
- _ppref = (_plain[:6] + "…") if _plen > 10 else _plain
1271
- _psuf = ("…" + _plain[-4:]) if _plen > 10 else ""
1272
- _has_ws = any(ch.isspace() for ch in _plain)
1273
- click.echo(
1274
- f"[preflight] plaintext: len={_plen} preview={_ppref}{_psuf} has_ws={bool(_has_ws)}"
1275
- )
1276
-
1277
- # Ciphertext diagnostics
1278
- try:
1279
- _ct_bytes = base64.b64decode(ct_b64, validate=True)
1280
- _ct_sha256 = _hash.sha256(_ct_bytes).hexdigest()
1281
- click.echo(
1282
- f"[preflight] ciphertext: b64_len={len(ct_b64)} sha256={_ct_sha256} head={ct_b64[:16]} tail={ct_b64[-16:]}"
1283
- )
1284
- except Exception:
1285
- click.echo("[preflight] ciphertext: invalid base64 (unexpected)")
1286
- except Exception:
1287
- # Best-effort logging only
1288
- pass
1289
- with httpx.Client(
1290
- timeout=15.0,
1291
- headers={
1292
- "Authorization": f"Bearer {synth_key}",
1293
- "Content-Type": "application/json",
1294
- },
1295
- ) as c:
1296
- click.echo("[preflight] upserting env key…")
1297
- up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
1298
- body_snip = ""
1299
- try:
1300
- body_snip = up.text[:400] if up.text else ""
1301
- except Exception:
1302
- body_snip = ""
1303
- click.echo(f"[preflight] upsert status={up.status_code}{(' body='+body_snip) if body_snip else ''}")
1304
-
1305
- # If upload succeeded (2xx), consider it successful even if verification fails
1306
- # This handles cases where verification endpoint has issues
1307
- if 200 <= up.status_code < 300:
1308
- key_preview = (
1309
- _preview(env_api_key)
1310
- )
1311
- click.echo(
1312
- f"✅ ENVIRONMENT_API_KEY uploaded successfully ({key_preview})"
1313
- )
1314
-
1315
- # Try verification, but don't fail if it doesn't work
1316
- click.echo("[preflight] verifying env key presence…")
1317
- try:
1318
- ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
1319
- if ver.status_code == 200 and (ver.json() or {}).get("present"):
1320
- click.echo("✅ Key verified in backend")
1321
- else:
1322
- click.echo(
1323
- f"⚠️ Verification returned {ver.status_code}, but upload succeeded - proceeding"
1324
- )
1325
- except Exception as verify_err:
1326
- click.echo(
1327
- f"⚠️ Verification check failed ({verify_err}), but upload succeeded - proceeding"
1328
- )
1329
- else:
1330
- error_msg = (
1331
- f"ENVIRONMENT_API_KEY upload failed with status {up.status_code}"
1332
- + (f" body={body_snip}" if body_snip else "")
1333
- )
1334
- if crash_on_failure:
1335
- raise click.ClickException(f"[CRITICAL] {error_msg}")
1336
- click.echo(f"[WARN] {error_msg}; proceeding anyway")
1337
- except Exception as e:
1338
- error_msg = f"Failed to encrypt/upload ENVIRONMENT_API_KEY: {e}"
1339
- if crash_on_failure:
1340
- raise click.ClickException(f"[CRITICAL] {error_msg}") from e
1341
- click.echo(f"[WARN] {error_msg}; proceeding anyway")
1342
- except Exception as e:
1343
- error_msg = f"Backend preflight for ENVIRONMENT_API_KEY failed: {e}"
1344
- if crash_on_failure:
1345
- raise click.ClickException(f"[CRITICAL] {error_msg}") from e
1346
- click.echo(f"[WARN] {error_msg}; proceeding anyway")
1347
-
1348
-
1349
- def _run_modal_with_entry(
1350
- entry: TaskAppEntry,
1351
- modal_cfg: ModalDeploymentConfig,
1352
- modal_cli: str,
1353
- modal_name: str | None,
1354
- env_paths: list[Path],
1355
- command: str,
1356
- *,
1357
- dry_run: bool = False,
1358
- original_path: Path | None = None,
1359
- ) -> None:
1360
- env_paths_list = [Path(p).resolve() for p in env_paths]
1361
- dotenv_paths = [str(p) for p in env_paths_list]
1362
- _load_env_files_into_process(dotenv_paths)
1363
- fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
1364
- _ensure_env_values(env_paths_list, fallback_dir)
1365
- _load_env_values(env_paths_list)
1366
- _preflight_env_key(env_paths_list, crash_on_failure=True)
1367
-
1368
- inline_secret_values: dict[str, str] = {}
1369
- env_key = os.environ.get("ENVIRONMENT_API_KEY", "").strip()
1370
- if env_key:
1371
- inline_secret_values["ENVIRONMENT_API_KEY"] = env_key
1372
- inline_secret_values.setdefault("DEV_ENVIRONMENT_API_KEY", env_key)
1373
- aliases = os.environ.get("ENVIRONMENT_API_KEY_ALIASES", "").strip()
1374
- if aliases:
1375
- inline_secret_values["ENVIRONMENT_API_KEY_ALIASES"] = aliases
1376
- for vendor_key in ("GROQ_API_KEY", "OPENAI_API_KEY"):
1377
- val = os.environ.get(vendor_key, "").strip()
1378
- if val:
1379
- inline_secret_values[vendor_key] = val
1380
-
1381
- if inline_secret_values:
1382
- preview = inline_secret_values.get("ENVIRONMENT_API_KEY", "")
1383
- shown = f"{preview[:6]}...{preview[-4:]}" if preview and len(preview) > 10 else preview
1384
- click.echo(f"[deploy] inline ENVIRONMENT_API_KEY prepared ({shown})")
1385
- else:
1386
- click.echo("[deploy] no inline ENVIRONMENT_API_KEY found; relying on Modal secrets/dotenv")
1387
-
1388
- script_path = _write_modal_entrypoint(
1389
- entry,
1390
- modal_cfg,
1391
- modal_name,
1392
- dotenv_paths=dotenv_paths,
1393
- original_path=original_path,
1394
- inline_secret_values=inline_secret_values,
1395
- )
1396
- cmd = [*_modal_command_prefix(modal_cli), command, str(script_path)]
1397
-
1398
- if modal_name and command == "deploy":
1399
- cmd.extend(["--name", modal_name])
1400
-
1401
- proc_env = os.environ.copy()
1402
- pythonpath_entries: list[str] = [str(REPO_ROOT)]
1403
- if original_path is not None:
1404
- source_dir = Path(original_path).resolve().parent
1405
- pythonpath_entries.insert(0, str(source_dir))
1406
- existing_pp = proc_env.get("PYTHONPATH")
1407
- if existing_pp:
1408
- pythonpath_entries.append(existing_pp)
1409
- proc_env["PYTHONPATH"] = os.pathsep.join(list(dict.fromkeys(pythonpath_entries)))
1410
-
1411
- if dry_run:
1412
- click.echo("Dry run: " + " ".join(cmd))
1413
- script_path.unlink(missing_ok=True)
1414
- return
1415
-
1416
- try:
1417
- # Stream output live for better diagnostics
1418
- proc = subprocess.Popen(
1419
- cmd,
1420
- stdout=subprocess.PIPE,
1421
- stderr=subprocess.STDOUT,
1422
- text=True,
1423
- bufsize=1,
1424
- env=proc_env,
1425
- )
1426
- task_app_url = None
1427
- assert proc.stdout is not None
1428
- for line in proc.stdout:
1429
- # Echo lines as they arrive
1430
- click.echo(line, nl=False)
1431
- # Look for lines containing modal.run URLs
1432
- if task_app_url is None and ("modal.run" in line and "=>" in line):
1433
- parts = line.split("=>")
1434
- if len(parts) >= 2:
1435
- task_app_url = parts[-1].strip()
1436
- # Save URL immediately for convenience
1437
- if task_app_url and env_paths_list:
1438
- env_file = env_paths_list[0]
1439
- _save_to_env_file(env_file, "TASK_APP_BASE_URL", task_app_url)
1440
- click.echo(f"\n✓ Task app URL: {task_app_url}\n")
1441
- rc = proc.wait()
1442
- if rc != 0:
1443
- raise subprocess.CalledProcessError(rc, cmd)
1444
- except subprocess.CalledProcessError as exc:
1445
- raise click.ClickException(
1446
- f"modal {command} failed with exit code {exc.returncode}"
1447
- ) from exc
1448
- finally:
1449
- script_path.unlink(missing_ok=True)
1450
-
1451
-
1452
- def _load_env_values(paths: list[Path], *, allow_empty: bool = False) -> dict[str, str]:
1453
- values: dict[str, str] = {}
1454
- for p in paths:
1455
- try:
1456
- content = p.read_text(encoding="utf-8")
1457
- except FileNotFoundError:
1458
- continue
1459
- for line in content.splitlines():
1460
- if not line or line.lstrip().startswith("#") or "=" not in line:
1461
- continue
1462
- key, value = line.split("=", 1)
1463
- if key and key not in values:
1464
- values[key.strip()] = value.strip()
1465
- if not allow_empty and not values:
1466
- raise click.ClickException("No environment values found")
1467
- os.environ.update({k: v for k, v in values.items() if k and v})
1468
- return values
1469
-
1470
-
1471
- def _interactive_create_env(target_dir: Path) -> Path | None:
1472
- env_path = (target_dir / ".env").resolve()
1473
- if env_path.exists():
1474
- existing = _parse_env_file(env_path)
1475
- env_api = (existing.get("ENVIRONMENT_API_KEY") or "").strip()
1476
- if env_api:
1477
- return env_path
1478
- click.echo(f"Existing {env_path} is missing ENVIRONMENT_API_KEY. Let's update it.")
1479
- return _interactive_fill_env(env_path)
1480
-
1481
- click.echo("No .env found for this task app. Let's create one.")
1482
- return _interactive_fill_env(env_path)
1483
-
1484
-
1485
- def _parse_env_file(path: Path) -> dict[str, str]:
1486
- data: dict[str, str] = {}
1487
- try:
1488
- for line in path.read_text(encoding="utf-8").splitlines():
1489
- if not line or line.lstrip().startswith("#") or "=" not in line:
1490
- continue
1491
- key, value = line.split("=", 1)
1492
- data[key.strip()] = value.strip()
1493
- except FileNotFoundError:
1494
- pass
1495
- return data
1496
-
1497
-
1498
- def _interactive_fill_env(env_path: Path) -> Path | None:
1499
- existing = _parse_env_file(env_path) if env_path.exists() else {}
1500
-
1501
- def _prompt(label: str, *, default: str = "", required: bool) -> str | None:
1502
- while True:
1503
- try:
1504
- value = click.prompt(
1505
- label, default=default, show_default=bool(default) or not required
1506
- ).strip()
1507
- except (click.exceptions.Abort, EOFError, KeyboardInterrupt):
1508
- click.echo("Aborted env creation.")
1509
- return None
1510
- if value or not required:
1511
- return value
1512
- click.echo("This field is required.")
1513
-
1514
- env_default = existing.get("ENVIRONMENT_API_KEY", "").strip()
1515
- env_api_key = _prompt("ENVIRONMENT_API_KEY", default=env_default, required=True)
1516
- if env_api_key is None:
1517
- return None
1518
- synth_default = existing.get("SYNTH_API_KEY", "").strip()
1519
- openai_default = existing.get("OPENAI_API_KEY", "").strip()
1520
- synth_key = _prompt("SYNTH_API_KEY (optional)", default=synth_default, required=False) or ""
1521
- openai_key = _prompt("OPENAI_API_KEY (optional)", default=openai_default, required=False) or ""
1522
-
1523
- lines = [
1524
- f"ENVIRONMENT_API_KEY={env_api_key}",
1525
- f"SYNTH_API_KEY={synth_key}",
1526
- f"OPENAI_API_KEY={openai_key}",
1527
- ]
1528
- env_path.parent.mkdir(parents=True, exist_ok=True)
1529
- env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1530
- click.echo(f"Wrote credentials to {env_path}")
1531
- return env_path
1532
-
1533
-
1534
- def _ensure_env_values(env_paths: list[Path], fallback_dir: Path) -> None:
1535
- if (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
1536
- return
1537
- target = env_paths[0] if env_paths else (fallback_dir / ".env").resolve()
1538
- result = _interactive_fill_env(target)
1539
- if result is None:
1540
- raise click.ClickException("ENVIRONMENT_API_KEY required to continue")
1541
- # After generating .env, load it and override any previously-empty values
1542
- _load_env_values([result])
1543
- if not (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
1544
- raise click.ClickException("Failed to load ENVIRONMENT_API_KEY from generated .env")
1545
-
1546
-
1547
- def _deploy_entry(
1548
- entry: TaskAppEntry,
1549
- modal_name: str | None,
1550
- dry_run: bool,
1551
- modal_cli: str,
1552
- env_file: Sequence[str],
1553
- original_path: Path | None = None,
1554
- ) -> None:
1555
- modal_cfg = entry.modal
1556
- if modal_cfg is None:
1557
- raise click.ClickException(
1558
- f"Task app '{entry.app_id}' does not define Modal deployment settings"
1559
- )
1560
-
1561
- env_paths = _determine_env_files(entry, env_file)
1562
- click.echo("Using env file(s): " + ", ".join(str(p.resolve()) for p in env_paths))
1563
- _run_modal_with_entry(
1564
- entry,
1565
- modal_cfg,
1566
- modal_cli,
1567
- modal_name,
1568
- env_paths,
1569
- command="deploy",
1570
- dry_run=dry_run,
1571
- original_path=original_path,
1572
- )
1573
-
1574
-
1575
- def _modal_serve_entry(
1576
- entry: TaskAppEntry,
1577
- modal_name: str | None,
1578
- modal_cli: str,
1579
- env_file: Sequence[str],
1580
- original_path: Path | None = None,
1581
- ) -> None:
1582
- modal_cfg = entry.modal
1583
- if modal_cfg is None:
1584
- raise click.ClickException(
1585
- f"Task app '{entry.app_id}' does not define Modal deployment settings"
1586
- )
1587
-
1588
- env_paths = _determine_env_files(entry, env_file)
1589
- click.echo("Using env file(s): " + ", ".join(str(p.resolve()) for p in env_paths))
1590
- _run_modal_with_entry(
1591
- entry,
1592
- modal_cfg,
1593
- modal_cli,
1594
- modal_name,
1595
- env_paths,
1596
- command="serve",
1597
- original_path=original_path,
1598
- )
1599
-
1600
-
1601
- @click.group(name="task-app", help="Utilities for serving and deploying Synth task apps.")
1602
- def task_app_group() -> None:
1603
- pass
1604
-
1605
-
1606
- @task_app_group.command("list")
1607
- def list_apps() -> None:
1608
- """List registered task apps."""
1609
-
1610
- entries = registry.list()
1611
- if not entries:
1612
- click.echo("No task apps registered.")
1613
- return
1614
- for entry in entries:
1615
- aliases = f" (aliases: {', '.join(entry.aliases)})" if entry.aliases else ""
1616
- click.echo(f"- {entry.app_id}{aliases}: {entry.description}")
1617
-
1618
-
1619
- def _load_env_files_into_process(paths: Sequence[str]) -> None:
1620
- for p in paths:
1621
- try:
1622
- txt = Path(p).expanduser().read_text()
1623
- except Exception:
1624
- continue
1625
- for line in txt.splitlines():
1626
- if not line or line.startswith("#") or "=" not in line:
1627
- continue
1628
- k, v = line.split("=", 1)
1629
- key = k.strip()
1630
- val = v.strip().strip('"').strip("'")
1631
- # Load into process, but allow overriding if the current value is empty
1632
- if key:
1633
- current = os.environ.get(key)
1634
- if current is None or not str(current).strip():
1635
- os.environ[key] = val
1636
-
1637
-
1638
- @click.command("serve")
1639
- @click.argument("app_id", type=str, required=False)
1640
- @click.option("--host", default="0.0.0.0", show_default=True)
1641
- @click.option("--port", default=None, type=int, help="Port to serve on (default: 8001)")
1642
- @click.option("--env-file", multiple=True, type=click.Path(), help="Extra .env files to load")
1643
- @click.option(
1644
- "--reload/--no-reload", "reload_flag", default=False, help="Enable uvicorn auto-reload"
1645
- )
1646
- @click.option(
1647
- "--force/--no-force",
1648
- "force",
1649
- default=False,
1650
- help="Kill any process already bound to the selected port before starting",
1651
- )
1652
- @click.option(
1653
- "--trace",
1654
- "trace_dir",
1655
- type=click.Path(),
1656
- default=None,
1657
- help="Enable tracing and write SFT JSONL files to this directory (default: traces/v3)",
1658
- )
1659
- @click.option(
1660
- "--trace-db",
1661
- "trace_db",
1662
- type=click.Path(),
1663
- default=None,
1664
- help="Override local trace DB path (default: traces/v3/synth_ai.db)",
1665
- )
1666
- def serve_command(
1667
- app_id: str | None,
1668
- host: str,
1669
- port: int | None,
1670
- env_file: Sequence[str],
1671
- reload_flag: bool,
1672
- force: bool,
1673
- trace_dir: str | None,
1674
- trace_db: str | None,
1675
- ) -> None:
1676
- # Change to demo directory if stored (REQUIRED for demo isolation)
1677
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
1678
-
1679
- demo_dir = load_demo_dir()
1680
- if demo_dir:
1681
- demo_path = Path(demo_dir)
1682
- if not demo_path.is_dir():
1683
- raise click.ClickException(
1684
- f"Demo directory not found: {demo_dir}\nRun 'synth-ai setup' to create a demo."
1685
- )
1686
- os.chdir(demo_dir)
1687
- click.echo(f"Using demo directory: {demo_dir}\n")
1688
- # Store demo directory for path resolution
1689
- os.environ["SYNTH_DEMO_DIR"] = str(demo_path.resolve())
1690
-
1691
- # Prompt for port if not provided
1692
- if port is None:
1693
- port = click.prompt("Port to serve on", type=int, default=8001)
1694
-
1695
- # Prompt for trace directory if not provided
1696
- if trace_dir is None:
1697
- click.echo(
1698
- "\nTracing captures rollout data (actions, rewards, model outputs) to a local SQLite DB."
1699
- )
1700
- click.echo("This data can be exported to JSONL for supervised fine-tuning (SFT).")
1701
- enable_tracing = click.confirm("Enable tracing?", default=True)
1702
- if enable_tracing:
1703
- demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
1704
- default_trace_dir = str((demo_base / "traces/v3").resolve())
1705
- trace_dir = click.prompt(
1706
- "Trace directory", type=str, default=default_trace_dir, show_default=True
1707
- )
1708
- else:
1709
- trace_dir = None
1710
-
1711
- # Prompt for trace DB if not provided and tracing is enabled
1712
- if trace_dir and trace_db is None:
1713
- demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
1714
- default_trace_db = str((demo_base / "traces/v3/synth_ai.db").resolve())
1715
- trace_db = click.prompt(
1716
- "Trace DB path", type=str, default=default_trace_db, show_default=True
1717
- )
1718
-
1719
- choice = _select_app_choice(app_id, purpose="serve")
1720
- entry = choice.ensure_entry()
1721
- _serve_entry(
1722
- entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db
1723
- )
1724
-
1725
-
1726
- @task_app_group.command("info")
1727
- @click.option(
1728
- "--base",
1729
- "base_url",
1730
- default=None,
1731
- help="Task app base URL (default: TASK_APP_BASE_URL or http://127.0.0.1:8001)",
1732
- )
1733
- @click.option(
1734
- "--api-key",
1735
- default=None,
1736
- help="Environment API key (default: ENVIRONMENT_API_KEY or dev fallbacks)",
1737
- )
1738
- @click.option(
1739
- "--seed",
1740
- "seeds",
1741
- multiple=True,
1742
- type=int,
1743
- help="Optional seed(s) to request specific instances (repeatable)",
1744
- )
1745
- def info_command(base_url: str | None, api_key: str | None, seeds: tuple[int, ...]) -> None:
1746
- """Fetch Task App /task_info with authentication and print JSON."""
1747
- import json as _json
1748
- import os as _os
1749
-
1750
- import requests as _requests
1751
-
1752
- base = (base_url or _os.getenv("TASK_APP_BASE_URL") or "http://127.0.0.1:8001").rstrip("/")
1753
-
1754
- # Resolve API key, permitting dev fallbacks
1755
- try:
1756
- from synth_ai.task.auth import normalize_environment_api_key as _norm_key
1757
- except Exception:
1758
- _norm_key = lambda: _os.getenv("ENVIRONMENT_API_KEY") # noqa: E731
1759
- key = (api_key or _norm_key() or "").strip()
1760
- if not key:
1761
- raise click.ClickException("Missing API key. Provide --api-key or set ENVIRONMENT_API_KEY.")
1762
-
1763
- headers: dict[str, str] = {"X-API-Key": key, "Authorization": f"Bearer {key}"}
1764
- aliases = (_os.getenv("ENVIRONMENT_API_KEY_ALIASES") or "").strip()
1765
- keys_csv = (
1766
- ",".join([key] + [p.strip() for p in aliases.split(",") if p.strip()]) if aliases else key
1767
- )
1768
- if keys_csv:
1769
- headers["X-API-Keys"] = keys_csv
1770
-
1771
- params: list[tuple[str, str]] = []
1772
- for s in seeds:
1773
- params.append(("seed", str(int(s))))
1774
-
1775
- url = f"{base}/task_info"
1776
- try:
1777
- r = _requests.get(url, headers=headers, params=params or None, timeout=30)
1778
- except Exception as exc:
1779
- raise click.ClickException(f"Request failed: {exc}") from exc
1780
- if not (200 <= r.status_code < 300):
1781
- ct = r.headers.get("content-type", "")
1782
- detail = r.text
1783
- if ct.startswith("application/json"):
1784
- with contextlib.suppress(Exception):
1785
- detail = _json.dumps(r.json(), indent=2)
1786
- raise click.ClickException(f"{url} returned {r.status_code}:\n{detail}")
1787
-
1788
- data = (
1789
- r.json()
1790
- if r.headers.get("content-type", "").startswith("application/json")
1791
- else {"raw": r.text}
1792
- )
1793
- click.echo(_json.dumps(data, indent=2, sort_keys=True))
1794
-
1795
-
1796
- @task_app_group.command("serve")
1797
- @click.argument("app_id", type=str, required=False)
1798
- @click.option("--host", default="0.0.0.0", show_default=True)
1799
- @click.option("--port", default=None, type=int, help="Port to serve on (default: 8001)")
1800
- @click.option("--env-file", multiple=True, type=click.Path(), help="Extra .env files to load")
1801
- @click.option(
1802
- "--reload/--no-reload", "reload_flag", default=False, help="Enable uvicorn auto-reload"
1803
- )
1804
- @click.option(
1805
- "--force/--no-force",
1806
- "force",
1807
- default=False,
1808
- help="Kill any process already bound to the selected port before starting",
1809
- )
1810
- @click.option(
1811
- "--trace",
1812
- "trace_dir",
1813
- type=click.Path(),
1814
- default=None,
1815
- help="Enable tracing and write SFT JSONL files to this directory (default: traces/v3)",
1816
- )
1817
- @click.option(
1818
- "--trace-db",
1819
- "trace_db",
1820
- type=click.Path(),
1821
- default=None,
1822
- help="Override local trace DB path (default: traces/v3/synth_ai.db)",
1823
- )
1824
- def serve_task_group(
1825
- app_id: str | None,
1826
- host: str,
1827
- port: int | None,
1828
- env_file: Sequence[str],
1829
- reload_flag: bool,
1830
- force: bool,
1831
- trace_dir: str | None,
1832
- trace_db: str | None,
1833
- ) -> None:
1834
- # Change to demo directory if stored (REQUIRED for demo isolation)
1835
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
1836
-
1837
- demo_dir = load_demo_dir()
1838
- if demo_dir:
1839
- demo_path = Path(demo_dir)
1840
- if not demo_path.is_dir():
1841
- raise click.ClickException(
1842
- f"Demo directory not found: {demo_dir}\nRun 'synth-ai setup' to create a demo."
1843
- )
1844
- os.chdir(demo_dir)
1845
- click.echo(f"Using demo directory: {demo_dir}\n")
1846
- # Store demo directory for path resolution
1847
- os.environ["SYNTH_DEMO_DIR"] = str(demo_path.resolve())
1848
-
1849
- # Prompt for port if not provided
1850
- if port is None:
1851
- port = click.prompt("Port to serve on", type=int, default=8001)
1852
-
1853
- # Prompt for trace directory if not provided
1854
- if trace_dir is None:
1855
- click.echo(
1856
- "\nTracing captures rollout data (actions, rewards, model outputs) to a local SQLite DB."
1857
- )
1858
- click.echo("This data can be exported to JSONL for supervised fine-tuning (SFT).")
1859
- enable_tracing = click.confirm("Enable tracing?", default=True)
1860
- if enable_tracing:
1861
- demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
1862
- default_trace_dir = str((demo_base / "traces/v3").resolve())
1863
- trace_dir = click.prompt(
1864
- "Trace directory", type=str, default=default_trace_dir, show_default=True
1865
- )
1866
- else:
1867
- trace_dir = None
1868
-
1869
- # Prompt for trace DB if not provided and tracing is enabled
1870
- if trace_dir and trace_db is None:
1871
- demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
1872
- default_trace_db = str((demo_base / "traces/v3/synth_ai.db").resolve())
1873
- trace_db = click.prompt(
1874
- "Trace DB path", type=str, default=default_trace_db, show_default=True
1875
- )
1876
-
1877
- choice = _select_app_choice(app_id, purpose="serve")
1878
- entry = choice.ensure_entry()
1879
- _serve_entry(
1880
- entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db
1881
- )
1882
-
1883
-
1884
- def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) -> list[Path]:
1885
- resolved: list[Path] = []
1886
- for candidate in user_env_files:
1887
- p = Path(candidate).expanduser()
1888
- if not p.exists():
1889
- raise click.ClickException(f"Env file not found: {p}")
1890
- resolved.append(p)
1891
- if resolved:
1892
- return resolved
1893
-
1894
- # Always prompt for env file selection instead of auto-loading defaults
1895
- # Look for env files in current working directory first, then repo root
1896
- cwd = Path.cwd()
1897
- env_candidates = []
1898
-
1899
- # Add CWD env files first (prioritized)
1900
- cwd_env_files = sorted(cwd.glob("**/*.env"))
1901
- env_candidates.extend(cwd_env_files)
1902
-
1903
- # Add repo root env files
1904
- repo_env_files = sorted(REPO_ROOT.glob("**/*.env"))
1905
- # Avoid duplicates
1906
- for repo_file in repo_env_files:
1907
- if repo_file not in env_candidates:
1908
- env_candidates.append(repo_file)
1909
-
1910
- if not env_candidates:
1911
- raise click.ClickException("No env file found. Pass --env-file explicitly.")
1912
-
1913
- click.echo("Select env file to load:")
1914
- for idx, path in enumerate(env_candidates, start=1):
1915
- click.echo(f" {idx}) {path.resolve()}")
1916
- choice = click.prompt("Enter choice", type=click.IntRange(1, len(env_candidates)), default=1)
1917
- return [env_candidates[choice - 1]]
1918
-
1919
-
1920
- def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
1921
- import os
1922
- import socket
1923
- import subprocess
1924
- import time
1925
-
1926
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1927
- in_use = s.connect_ex((host, port)) == 0
1928
- if not in_use:
1929
- return
1930
-
1931
- try:
1932
- out = subprocess.run(
1933
- ["lsof", "-ti", f"TCP:{port}"], capture_output=True, text=True, check=False
1934
- )
1935
- pids = [pid for pid in out.stdout.strip().splitlines() if pid]
1936
- except FileNotFoundError:
1937
- pids = []
1938
-
1939
- if not force:
1940
- message = f"Port {port} appears to be in use"
1941
- if pids:
1942
- message += f" (PIDs: {', '.join(pids)})"
1943
- raise click.ClickException(message)
1944
-
1945
- for pid in pids:
1946
- try:
1947
- os.kill(int(pid), signal.SIGTERM)
1948
- except Exception as exc:
1949
- raise click.ClickException(f"Failed to terminate PID {pid}: {exc}") from exc
1950
-
1951
- time.sleep(0.5)
1952
-
1953
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1954
- still_in_use = s.connect_ex((host, port)) == 0
1955
-
1956
- if still_in_use:
1957
- for pid in pids:
1958
- try:
1959
- os.kill(int(pid), signal.SIGKILL)
1960
- except Exception as exc:
1961
- raise click.ClickException(f"Failed to force terminate PID {pid}: {exc}") from exc
1962
- time.sleep(0.5)
1963
-
1964
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1965
- in_use_after = s.connect_ex((host, port)) == 0
1966
- if in_use_after:
1967
- raise click.ClickException(
1968
- f"Port {port} is still in use after attempting to terminate processes."
1969
- )
1970
-
1971
-
1972
- def _save_to_env_file(env_path: Path, key: str, value: str) -> None:
1973
- """Save or update a key-value pair in the .env file."""
1974
- try:
1975
- # Read existing .env
1976
- existing_lines = []
1977
- if env_path.exists():
1978
- existing_lines = env_path.read_text().splitlines()
1979
- else:
1980
- env_path.parent.mkdir(parents=True, exist_ok=True)
1981
-
1982
- # Check if key already exists and update it
1983
- key_updated = False
1984
- new_lines = []
1985
- for line in existing_lines:
1986
- if line.strip().startswith(f"{key}="):
1987
- new_lines.append(f"{key}={value}")
1988
- key_updated = True
1989
- else:
1990
- new_lines.append(line)
1991
-
1992
- if key_updated:
1993
- # Write updated lines back
1994
- env_path.write_text("\n".join(new_lines) + "\n")
1995
- click.echo(f"Updated {key} in {env_path}")
1996
- else:
1997
- # Append to .env
1998
- with open(env_path, "a") as f:
1999
- if existing_lines and not existing_lines[-1].strip():
2000
- # File exists and last line is not empty
2001
- pass
2002
- elif existing_lines:
2003
- # Add newline before appending
2004
- f.write("\n")
2005
- f.write(f"{key}={value}\n")
2006
- click.echo(f"Saved {key} to {env_path}")
2007
- except Exception as e:
2008
- click.echo(f"Warning: Could not save {key} to .env: {e}", err=True)
2009
-
2010
-
2011
- def _persist_env_api_key(env_api_key: str, env_paths: Sequence[Path] | None) -> None:
2012
- """Persist ENVIRONMENT_API_KEY to provided env files (or default .env)."""
2013
- targets: list[Path] = []
2014
- seen: set[Path] = set()
2015
- for path in env_paths or ():
2016
- try:
2017
- resolved = Path(path).resolve()
2018
- except Exception:
2019
- continue
2020
- if resolved in seen:
2021
- continue
2022
- seen.add(resolved)
2023
- targets.append(resolved)
2024
-
2025
- if not targets:
2026
- demo_dir = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2027
- targets.append((demo_dir / ".env").resolve())
2028
-
2029
- for target in targets:
2030
- _save_to_env_file(target, "ENVIRONMENT_API_KEY", env_api_key)
2031
-
2032
-
2033
- def _validate_required_env_keys() -> None:
2034
- """Validate required environment keys are set, prompting if missing."""
2035
- # Use demo directory .env file if set, otherwise current directory
2036
- demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2037
- env_file = demo_base / ".env"
2038
-
2039
- if env_file.exists():
2040
- try:
2041
- from dotenv import load_dotenv
2042
-
2043
- load_dotenv(env_file, override=False)
2044
- except Exception:
2045
- pass # Best effort
2046
-
2047
- env_api_key = os.environ.get("ENVIRONMENT_API_KEY", "").strip()
2048
-
2049
- if not env_api_key:
2050
- env_api_key = input("Please enter your RL Environment API key:\n> ").strip()
2051
- if not env_api_key:
2052
- raise click.ClickException("RL Environment API key is required to start the server")
2053
- os.environ["ENVIRONMENT_API_KEY"] = env_api_key
2054
- _save_to_env_file(env_file, "ENVIRONMENT_API_KEY", env_api_key)
2055
-
2056
- # Check for Groq API key
2057
- groq_api_key = os.environ.get("GROQ_API_KEY", "").strip()
2058
-
2059
- if not groq_api_key:
2060
- click.echo("\nInference API key configuration:")
2061
- click.echo("This workflow requires a Groq API key.")
2062
- groq_api_key = input("Groq API key (or press Enter to skip): ").strip()
2063
- if groq_api_key:
2064
- os.environ["GROQ_API_KEY"] = groq_api_key
2065
- _save_to_env_file(env_file, "GROQ_API_KEY", groq_api_key)
2066
-
2067
-
2068
- def _print_demo_next_steps_if_applicable() -> None:
2069
- """Print next steps if currently in a demo directory."""
2070
- try:
2071
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
2072
-
2073
- cwd = Path.cwd().resolve()
2074
- demo_dir = load_demo_dir()
2075
-
2076
- # Check if we're in the demo directory
2077
- if (
2078
- demo_dir
2079
- and Path(demo_dir).resolve() == cwd
2080
- and (cwd / "run_local_rollout_traced.py").exists()
2081
- ):
2082
- click.echo("\n" + "=" * 60)
2083
- click.echo("Next step: Collect traced rollouts")
2084
- click.echo("=" * 60)
2085
- click.echo("\nIn another terminal, run:")
2086
- click.echo(f" cd {cwd}")
2087
- click.echo(" uv run python run_local_rollout_traced.py")
2088
- click.echo("\nRun this 5-10 times to collect diverse traces.")
2089
- click.echo("=" * 60 + "\n")
2090
- except Exception:
2091
- # Silently fail - this is just a helpful printout
2092
- pass
2093
-
2094
-
2095
- def _serve_entry(
2096
- entry: TaskAppEntry,
2097
- host: str,
2098
- port: int,
2099
- env_file: Sequence[str],
2100
- reload_flag: bool,
2101
- force: bool,
2102
- *,
2103
- trace_dir: str | None = None,
2104
- trace_db: str | None = None,
2105
- ) -> None:
2106
- env_files = list(entry.env_files)
2107
- env_files.extend(env_file)
2108
-
2109
- trace_enabled = trace_dir is not None or trace_db is not None
2110
- if trace_enabled:
2111
- os.environ["TASKAPP_TRACING_ENABLED"] = "1"
2112
-
2113
- # Ensure paths are absolute relative to demo directory
2114
- demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2115
-
2116
- if trace_dir is not None:
2117
- dir_path = Path(trace_dir).expanduser()
2118
- if not dir_path.is_absolute():
2119
- dir_path = (demo_base / dir_path).resolve()
2120
- try:
2121
- dir_path.mkdir(parents=True, exist_ok=True)
2122
- except Exception as exc:
2123
- raise click.ClickException(
2124
- f"Failed to create trace directory {dir_path}: {exc}"
2125
- ) from exc
2126
- os.environ["TASKAPP_SFT_OUTPUT_DIR"] = str(dir_path)
2127
- click.echo(f"Tracing enabled. SFT JSONL will be written to {dir_path}")
2128
- if trace_db is not None:
2129
- db_path = Path(trace_db).expanduser()
2130
- if not db_path.is_absolute():
2131
- db_path = (demo_base / db_path).resolve()
2132
- # Construct the sqlite URL from the absolute path
2133
- db_url = f"sqlite+aiosqlite:///{db_path}"
2134
- os.environ["SQLD_DB_PATH"] = str(db_path)
2135
- os.environ["TURSO_LOCAL_DB_URL"] = db_url
2136
- click.echo(f"Tracing DB path set to {db_path}")
2137
- from synth_ai.tracing_v3.config import CONFIG as TRACE_CONFIG
2138
-
2139
- # Use the explicitly set URL if available
2140
- new_db_url = os.getenv("TURSO_LOCAL_DB_URL") or TRACE_CONFIG.db_url
2141
- TRACE_CONFIG.db_url = new_db_url
2142
- if new_db_url:
2143
- click.echo(f"Tracing DB URL resolved to {new_db_url}")
2144
- elif os.getenv("TASKAPP_TRACING_ENABLED"):
2145
- click.echo("Tracing enabled via environment variables")
2146
-
2147
- _ensure_port_free(port, host, force=force)
2148
-
2149
- _validate_required_env_keys()
2150
- env_path_objs = [Path(p) for p in env_files if p]
2151
- _preflight_env_key(env_path_objs)
2152
-
2153
- # Print next steps if in demo context
2154
- if trace_enabled:
2155
- _print_demo_next_steps_if_applicable()
2156
-
2157
- run_task_app(
2158
- entry.config_factory,
2159
- host=host,
2160
- port=port,
2161
- reload=reload_flag,
2162
- env_files=env_files,
2163
- )
2164
-
2165
-
2166
- @task_app_group.command("deploy")
2167
- @click.argument("app_id", type=str, required=False)
2168
- @click.option("--name", "modal_name", default=None, help="Override Modal app name")
2169
- @click.option("--dry-run", is_flag=True, help="Print modal deploy command without executing")
2170
- @click.option("--modal-cli", default="modal", help="Path to modal CLI executable")
2171
- @click.option(
2172
- "--env-file",
2173
- multiple=True,
2174
- type=click.Path(),
2175
- help="Env file to load into the container (can be repeated)",
2176
- )
2177
- def deploy_app(
2178
- app_id: str | None,
2179
- modal_name: str | None,
2180
- dry_run: bool,
2181
- modal_cli: str,
2182
- env_file: Sequence[str],
2183
- ) -> None:
2184
- """Deploy a task app to Modal."""
2185
-
2186
- # Change to demo directory if stored (for consistent discovery)
2187
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
2188
-
2189
- demo_dir = load_demo_dir()
2190
- if demo_dir:
2191
- demo_path = Path(demo_dir)
2192
- if not demo_path.is_dir():
2193
- raise click.ClickException(
2194
- f"Demo directory not found: {demo_dir}\nRun 'synth-ai demo' to create a demo."
2195
- )
2196
- os.chdir(demo_dir)
2197
- click.echo(f"Using demo directory: {demo_dir}\n")
2198
-
2199
- choice = _select_app_choice(app_id, purpose="deploy")
2200
-
2201
- if choice.modal_script:
2202
- env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
2203
- click.echo("Using env file(s): " + ", ".join(str(p.resolve()) for p in env_paths))
2204
- _run_modal_script(
2205
- choice.modal_script,
2206
- modal_cli,
2207
- "deploy",
2208
- env_paths,
2209
- modal_name=modal_name,
2210
- dry_run=dry_run,
2211
- )
2212
- return
2213
-
2214
- entry = choice.ensure_entry()
2215
- _deploy_entry(entry, modal_name, dry_run, modal_cli, env_file, original_path=choice.path)
2216
-
2217
-
2218
- @task_app_group.command("modal-serve")
2219
- @click.argument("app_id", type=str, required=False)
2220
- @click.option("--modal-cli", default="modal", help="Path to modal CLI executable")
2221
- @click.option("--name", "modal_name", default=None, help="Override Modal app name (optional)")
2222
- @click.option(
2223
- "--env-file",
2224
- multiple=True,
2225
- type=click.Path(),
2226
- help="Env file to load into the container (can be repeated)",
2227
- )
2228
- def modal_serve_app(
2229
- app_id: str | None, modal_cli: str, modal_name: str | None, env_file: Sequence[str]
2230
- ) -> None:
2231
- choice = _select_app_choice(app_id, purpose="modal-serve")
2232
-
2233
- if choice.modal_script:
2234
- env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
2235
- click.echo("Using env file(s): " + ", ".join(str(p.resolve()) for p in env_paths))
2236
- _run_modal_script(choice.modal_script, modal_cli, "serve", env_paths, modal_name=modal_name)
2237
- return
2238
-
2239
- entry = choice.ensure_entry()
2240
- _modal_serve_entry(entry, modal_name, modal_cli, env_file, original_path=choice.path)
2241
-
2242
-
2243
- def _write_modal_entrypoint(
2244
- entry: TaskAppEntry,
2245
- modal_cfg: ModalDeploymentConfig,
2246
- override_name: str | None,
2247
- *,
2248
- dotenv_paths: Sequence[str] | None = None,
2249
- original_path: Path | None = None,
2250
- inline_secret_values: dict[str, str] | None = None,
2251
- ) -> Path:
2252
- modal_name = override_name or modal_cfg.app_name
2253
-
2254
- # For dynamically discovered apps, import the module by its package path
2255
- # Compute the module name relative to the mounted repo root (/opt/synth_ai_repo)
2256
- remote_file_str: str | None = None
2257
- if original_path:
2258
- try:
2259
- # Build lookup of local->remote mounts
2260
- mount_map: list[tuple[Path, Path]] = [
2261
- (Path(local).resolve(), Path(remote))
2262
- for (local, remote) in modal_cfg.extra_local_dirs
2263
- ]
2264
- orig = Path(original_path).resolve()
2265
- for local_src, remote_dst in mount_map:
2266
- with contextlib.suppress(Exception):
2267
- if orig.is_relative_to(local_src): # py311+
2268
- remote_file_str = str((remote_dst / orig.relative_to(local_src)).resolve())
2269
- break
2270
- try:
2271
- rel = orig.relative_to(local_src)
2272
- remote_file_str = str((remote_dst / rel).resolve())
2273
- break
2274
- except Exception:
2275
- pass
2276
- except Exception:
2277
- remote_file_str = None
2278
- module_name = entry.config_factory.__module__
2279
-
2280
- # Prefer a guaranteed mount for the discovered file to avoid package import issues
2281
- guaranteed_file_str: str | None = None
2282
- if original_path:
2283
- guaranteed_file_str = str(
2284
- (Path("/opt/synth_ai_repo/__local_task_app__") / Path(original_path).stem).with_suffix(
2285
- ".py"
2286
- )
2287
- )
2288
-
2289
- dotenv_paths = [str(Path(path)) for path in (dotenv_paths or [])]
2290
-
2291
- pip_packages = list(modal_cfg.pip_packages)
2292
- # Ensure synth-ai (matching host version if available) is installed in the container
2293
- synth_pkg = "synth-ai"
2294
- try:
2295
- import synth_ai as _host_synth
2296
-
2297
- host_ver = getattr(_host_synth, "__version__", None)
2298
- if host_ver:
2299
- synth_pkg = f"synth-ai=={host_ver}"
2300
- except Exception:
2301
- pass
2302
- if not any(str(p).startswith("synth-ai") for p in pip_packages):
2303
- pip_packages.insert(0, synth_pkg)
2304
-
2305
- local_dirs = [(str(Path(src)), dst) for src, dst in modal_cfg.extra_local_dirs]
2306
- # Also mount the host synth_ai source if available to ensure latest code is used
2307
- try:
2308
- import synth_ai as _host_synth
2309
-
2310
- host_synth_dir = Path(_host_synth.__file__).resolve().parent
2311
- # host_synth_dir points to .../synth_ai; mount that directory
2312
- sy_dst = "/opt/synth_ai_repo/synth_ai"
2313
- candidate = (str(host_synth_dir), sy_dst)
2314
- if candidate not in local_dirs:
2315
- local_dirs.insert(0, candidate)
2316
- except Exception:
2317
- pass
2318
- # Ensure the discovered app directory is mounted, regardless of modal_cfg
2319
- if original_path:
2320
- discovered_dir = str(Path(original_path).resolve().parent)
2321
- mount_dst = "/opt/synth_ai_repo/__local_task_app__"
2322
- if (discovered_dir, mount_dst) not in local_dirs:
2323
- local_dirs.append((discovered_dir, mount_dst))
2324
- secret_names = list(modal_cfg.secret_names)
2325
- volume_mounts = [(name, mount) for name, mount in modal_cfg.volume_mounts]
2326
- inline_secret_values = {k: v for k, v in (inline_secret_values or {}).items() if v}
2327
-
2328
- script = f"""from __future__ import annotations
2329
-
2330
- import importlib
2331
- import importlib.util
2332
- import sys
2333
- import os
2334
- import shutil
2335
- import tempfile
2336
- from pathlib import Path as _Path
2337
- import fnmatch
2338
- sys.path.insert(0, '/opt/synth_ai_repo')
2339
-
2340
- from modal import App, Image, Secret, Volume, asgi_app
2341
-
2342
- # Defer importing synth_ai until inside fastapi_app to avoid local import errors
2343
-
2344
- ENTRY_ID = {entry.app_id!r}
2345
- MODAL_APP_NAME = {modal_name!r}
2346
- MODULE_NAME = {module_name!r}
2347
- MODULE_FILE = {guaranteed_file_str or remote_file_str!r}
2348
- DOTENV_PATHS = {dotenv_paths!r}
2349
- INLINE_SECRET_VALUES = {inline_secret_values!r}
2350
-
2351
- image = Image.debian_slim(python_version={modal_cfg.python_version!r})
2352
-
2353
- pip_packages = {pip_packages!r}
2354
- if pip_packages:
2355
- image = image.pip_install(*pip_packages)
2356
-
2357
- local_dirs = {local_dirs!r}
2358
-
2359
- def _copy_tree_filtered(src_dir: str) -> str:
2360
- src = _Path(src_dir)
2361
- temp_dir = _Path(tempfile.mkdtemp(prefix='synth_mount_'))
2362
-
2363
- exclude_dirs = {".cache", ".git", "__pycache__"}
2364
- exclude_globs = ['*.db', '*.db-journal', '*-wal', '*-shm']
2365
-
2366
- for root, dirs, files in os.walk(src):
2367
- rel_root = _Path(root).relative_to(src)
2368
- # filter dirs in-place
2369
- dirs[:] = [d for d in dirs if d not in exclude_dirs]
2370
- # ensure target directory exists
2371
- target_dir = (temp_dir / rel_root)
2372
- target_dir.mkdir(parents=True, exist_ok=True)
2373
- # copy files with filtering
2374
- for name in files:
2375
- if any(fnmatch.fnmatch(name, pat) for pat in exclude_globs):
2376
- continue
2377
- src_file = _Path(root) / name
2378
- dst_file = target_dir / name
2379
- try:
2380
- shutil.copy2(src_file, dst_file)
2381
- except Exception:
2382
- # ignore problematic files
2383
- continue
2384
- return str(temp_dir)
2385
-
2386
- for local_src, remote_dst in local_dirs:
2387
- safe_src = _copy_tree_filtered(local_src)
2388
- image = image.add_local_dir(safe_src, remote_dst)
2389
-
2390
- secrets = {secret_names!r}
2391
- secret_objs = [Secret.from_name(name) for name in secrets]
2392
-
2393
- if INLINE_SECRET_VALUES:
2394
- secret_objs.append(Secret.from_dict(INLINE_SECRET_VALUES))
2395
-
2396
- if DOTENV_PATHS:
2397
- secret_objs.extend(Secret.from_dotenv(path) for path in DOTENV_PATHS)
2398
-
2399
- volume_mounts = {volume_mounts!r}
2400
- volume_map = {{}}
2401
- for vol_name, mount_path in volume_mounts:
2402
- volume_map[mount_path] = Volume.from_name(vol_name, create_if_missing=True)
2403
-
2404
- app = App(MODAL_APP_NAME)
2405
-
2406
- @app.function(
2407
- image=image,
2408
- timeout={modal_cfg.timeout},
2409
- memory={modal_cfg.memory},
2410
- cpu={modal_cfg.cpu},
2411
- min_containers={modal_cfg.min_containers},
2412
- max_containers={modal_cfg.max_containers},
2413
- secrets=secret_objs,
2414
- volumes=volume_map,
2415
- )
2416
- @asgi_app()
2417
- def fastapi_app():
2418
- # Import the module to trigger registration (inside container)
2419
- import os
2420
- # Prefer mounted source over any preinstalled site-packages version
2421
- import sys as _sys
2422
- for k in list(_sys.modules.keys()):
2423
- if k == 'synth_ai' or k.startswith('synth_ai.'):
2424
- _sys.modules.pop(k, None)
2425
- import importlib as _importlib
2426
- _importlib.invalidate_caches()
2427
- try:
2428
- if MODULE_FILE and os.path.exists(MODULE_FILE):
2429
- spec = importlib.util.spec_from_file_location(MODULE_NAME or 'task_app_module', MODULE_FILE)
2430
- if not spec or not spec.loader:
2431
- raise RuntimeError("Failed to prepare spec for: " + str(MODULE_FILE))
2432
- mod = importlib.util.module_from_spec(spec)
2433
- sys.modules[MODULE_NAME or 'task_app_module'] = mod
2434
- spec.loader.exec_module(mod)
2435
- else:
2436
- try:
2437
- importlib.import_module(MODULE_NAME)
2438
- except Exception:
2439
- fallback_file = '/opt/synth_ai_repo/__local_task_app__/' + (MODULE_NAME.split('.')[-1] if MODULE_NAME else 'task_app') + '.py'
2440
- if os.path.exists(fallback_file):
2441
- spec = importlib.util.spec_from_file_location(MODULE_NAME or 'task_app_module', fallback_file)
2442
- if not spec or not spec.loader:
2443
- raise RuntimeError("Failed to prepare fallback spec for: " + str(fallback_file))
2444
- mod = importlib.util.module_from_spec(spec)
2445
- sys.modules[MODULE_NAME or 'task_app_module'] = mod
2446
- spec.loader.exec_module(mod)
2447
- else:
2448
- raise
2449
- except Exception as e:
2450
- raise RuntimeError("Task app import failed: " + str(e))
2451
-
2452
- # Get the entry from registry (now that it's registered)
2453
- from synth_ai.task.apps import registry
2454
- from synth_ai.task.server import create_task_app
2455
- entry = registry.get(ENTRY_ID)
2456
- cfg = entry.modal
2457
- if cfg is None:
2458
- raise RuntimeError("Modal configuration missing for task app " + ENTRY_ID)
2459
- config = entry.config_factory()
2460
- return create_task_app(config)
2461
- """
2462
-
2463
- with tempfile.NamedTemporaryFile("w", suffix=f"_{entry.app_id}_modal.py", delete=False) as tmp:
2464
- tmp.write(script)
2465
- tmp.flush()
2466
- name = tmp.name
2467
- return Path(name)
2468
-
2469
-
2470
- def register(cli: click.Group) -> None:
2471
- cli.add_command(serve_command)
2472
- cli.add_command(task_app_group)
2473
- cli.add_command(eval_command)
2474
-
2475
-
2476
- @click.command("eval")
2477
- @click.argument("app_id", type=str, required=False)
2478
- @click.option("--config", type=click.Path(), default=None, help="Path to eval TOML (short schema)")
2479
- @click.option(
2480
- "--url",
2481
- "task_app_url",
2482
- type=str,
2483
- default=None,
2484
- help="Base URL of a running task app (skip in-process server)",
2485
- )
2486
- @click.option("--seeds", default="0,1,2,3,4", help="Comma-separated seeds/indices to evaluate")
2487
- @click.option("--split", default="train", show_default=True, help="Dataset split to use")
2488
- @click.option("--model", default=None, help="Model identifier (prompted if omitted)")
2489
- @click.option("--env-file", multiple=True, type=click.Path(), help="Env file(s) for keys")
2490
- def eval_command(
2491
- app_id: str | None,
2492
- config: str | None,
2493
- task_app_url: str | None,
2494
- seeds: str,
2495
- split: str,
2496
- model: str | None,
2497
- env_file: Sequence[str],
2498
- ) -> None:
2499
- """Run local rollouts against a task app using in-process ASGI and summarize results."""
2500
- cfg: dict[str, Any] = {}
2501
- config_path: Path | None = None
2502
- if config:
2503
- config_path = Path(config)
2504
- else:
2505
- auto_configs = _discover_eval_config_paths()
2506
- if auto_configs:
2507
- config_path = auto_configs[0]
2508
- click.echo(f"Using eval config: {config_path}")
2509
-
2510
- if config_path:
2511
- if _toml is None:
2512
- raise click.ClickException(
2513
- "TOML parser not available; use Python 3.11+ or install tomli"
2514
- )
2515
- if not config_path.exists():
2516
- raise click.ClickException(f"Eval config not found: {config_path}")
2517
- try:
2518
- data = config_path.read_bytes()
2519
- parsed = _toml.loads(data.decode("utf-8"))
2520
- if isinstance(parsed, dict):
2521
- section = parsed.get("eval")
2522
- cfg = dict(section) if isinstance(section, dict) else dict(parsed)
2523
- except Exception as exc:
2524
- raise click.ClickException(f"Failed to parse TOML '{config_path}': {exc}") from exc
2525
-
2526
- app_id = app_id or (cfg.get("app_id") if isinstance(cfg.get("app_id"), str) else None) # type: ignore
2527
-
2528
- # Determine selection params (CLI takes precedence; TOML only fills unset model/seeds/env)
2529
- if cfg.get("model") and not model:
2530
- model = str(cfg["model"]) # type: ignore[index]
2531
- if cfg.get("seeds") and seeds == "0,1,2,3,4":
2532
- val = cfg["seeds"]
2533
- if isinstance(val, list):
2534
- with contextlib.suppress(Exception):
2535
- seeds = ",".join(str(int(x)) for x in val)
2536
- elif isinstance(val, str):
2537
- seeds = val
2538
- elif isinstance(val, int):
2539
- seeds = str(val)
2540
- if cfg.get("env_file") and not env_file:
2541
- ef = cfg["env_file"]
2542
- if isinstance(ef, str):
2543
- env_file = (ef,) # type: ignore[assignment]
2544
- elif isinstance(ef, list):
2545
- env_file = tuple(str(x) for x in ef) # type: ignore[assignment]
2546
-
2547
- entry: TaskAppEntry | None = None
2548
- if task_app_url is None:
2549
- choice = _select_app_choice(app_id, purpose="eval")
2550
- entry = choice.ensure_entry()
2551
-
2552
- env_paths: list[Path] = []
2553
- if entry is not None:
2554
- env_paths = _determine_env_files(entry, env_file)
2555
- else:
2556
- if not env_file:
2557
- raise click.ClickException("--env-file is required when using --url")
2558
- for candidate in env_file:
2559
- p = Path(candidate).expanduser()
2560
- if not p.exists():
2561
- raise click.ClickException(f"Env file not found: {p}")
2562
- env_paths.append(p)
2563
-
2564
- click.echo("Using env file(s): " + ", ".join(str(p) for p in env_paths))
2565
- _load_env_files_into_process([str(Path(p)) for p in env_paths])
2566
-
2567
- if task_app_url is None:
2568
- config = entry.config_factory() # type: ignore[union-attr]
2569
- # Help the type checker; runtime check also enforced in server.run_task_app
2570
- if not isinstance(config, TaskAppConfig):
2571
- raise click.ClickException(
2572
- "Invalid task app: config_factory did not return TaskAppConfig"
2573
- )
2574
- app = create_task_app(config)
2575
-
2576
- # Determine supported models
2577
- supported: list[str] = []
2578
- if task_app_url is None:
2579
- try:
2580
- supported = list((config.base_task_info.inference or {}).get("models") or []) # type: ignore[union-attr]
2581
- except Exception:
2582
- supported = []
2583
- else:
2584
- try:
2585
- import httpx as _hx
2586
-
2587
- headers = {}
2588
- api_key = (os.environ.get("ENVIRONMENT_API_KEY") or "").strip()
2589
- if api_key:
2590
- headers["X-API-Key"] = api_key
2591
- with _hx.Client(base_url=task_app_url, headers=headers, timeout=15.0) as c:
2592
- info = c.get("/info").json()
2593
- inf = info.get("inference") if isinstance(info, dict) else None
2594
- if isinstance(inf, dict):
2595
- m = inf.get("models")
2596
- if isinstance(m, list):
2597
- supported = [str(x) for x in m]
2598
- if not supported:
2599
- providers = inf.get("providers")
2600
- if isinstance(providers, list):
2601
- if "openai" in providers:
2602
- supported.append("gpt-5")
2603
- if "groq" in providers:
2604
- supported.append("groq:llama-3.1-70b-versatile")
2605
- supported.append("synth:qwen-0.6b")
2606
- except Exception:
2607
- supported = []
2608
- if not supported:
2609
- # Only fall back to local config-derived providers when running in-process
2610
- if task_app_url is None:
2611
- try:
2612
- providers = list((config.base_task_info.inference or {}).get("providers") or []) # type: ignore[union-attr]
2613
- except Exception:
2614
- providers = []
2615
- if "openai" in providers:
2616
- supported.append("gpt-5")
2617
- if "groq" in providers:
2618
- supported.append("groq:llama-3.1-70b-versatile")
2619
- # Always include a local synth model option for smoke tests
2620
- supported.append("synth:qwen-0.6b")
2621
-
2622
- selected_model = model
2623
- if not selected_model:
2624
- if not supported:
2625
- raise click.ClickException(
2626
- "No supported models; supply --model or add base_task_info.inference.models"
2627
- )
2628
- click.echo("Select model to evaluate:")
2629
- for idx, m in enumerate(supported, start=1):
2630
- click.echo(f" {idx}) {m}")
2631
- choice_idx = click.prompt("Enter choice", type=click.IntRange(1, len(supported)))
2632
- selected_model = supported[choice_idx - 1]
2633
-
2634
- try:
2635
- seed_values = [int(s.strip()) for s in seeds.split(",") if s.strip()]
2636
- except Exception as exc:
2637
- raise click.ClickException("Invalid --seeds; expected comma-separated integers") from exc
2638
-
2639
- import httpx
2640
-
2641
- headers = {}
2642
- api_key = (os.environ.get("ENVIRONMENT_API_KEY") or "").strip()
2643
- if api_key:
2644
- headers["X-API-Key"] = api_key
2645
-
2646
- successes = 0
2647
- failures = 0
2648
- # Aggregate outcome stats across successful seeds
2649
- outcome_sum: float = 0.0
2650
- outcome_count: int = 0
2651
- outcome_correct: int = 0
2652
- if task_app_url is None:
2653
- transport = httpx.ASGITransport(app=app) # type: ignore[name-defined]
2654
- # Newer httpx types consider ASGITransport under httpx._transports; cast to satisfy type checker
2655
- client = httpx.Client(
2656
- transport=cast(Any, transport),
2657
- base_url="http://eval.local",
2658
- timeout=60.0,
2659
- headers=headers,
2660
- )
2661
- else:
2662
- client = httpx.Client(base_url=task_app_url, timeout=60.0, headers=headers)
2663
- try:
2664
- with contextlib.suppress(Exception):
2665
- client.get("/task_info")
2666
- # Precompute optional policy overrides from TOML
2667
- policy_overrides: dict[str, Any] = {}
2668
- try:
2669
- # Accept [eval.policy] table or top-level keys for convenience
2670
- if isinstance(cfg.get("policy"), dict):
2671
- policy_overrides.update(dict(cfg["policy"]))
2672
- # Back-compat: allow temperature/max_tokens at top level
2673
- for k in (
2674
- "temperature",
2675
- "max_tokens",
2676
- "reasoning_effort",
2677
- "system_hint",
2678
- "tool_choice",
2679
- ):
2680
- if k in cfg and k not in policy_overrides:
2681
- policy_overrides[k] = cfg.get(k)
2682
- except Exception:
2683
- policy_overrides = {}
2684
-
2685
- for seed_val in seed_values:
2686
- body = {
2687
- "run_id": str(uuid.uuid4()),
2688
- "env": {"config": {"split": split, "index": seed_val}, "seed": seed_val},
2689
- "policy": {
2690
- "policy_name": selected_model,
2691
- "config": {"model": selected_model, **policy_overrides},
2692
- },
2693
- "ops": [],
2694
- }
2695
- try:
2696
- resp = client.post("/rollout", json=body)
2697
- ok = 200 <= resp.status_code < 300
2698
- if ok:
2699
- successes += 1
2700
- else:
2701
- failures += 1
2702
-
2703
- # Print summary with any available metrics/tool calls
2704
- summary = [f"seed={seed_val}", f"status={resp.status_code}"]
2705
- try:
2706
- data = resp.json()
2707
- except Exception:
2708
- data = None
2709
- if isinstance(data, dict):
2710
- metrics = data.get("metrics") if isinstance(data.get("metrics"), dict) else None
2711
- if metrics:
2712
- mean_return = metrics.get("mean_return") or metrics.get("total_reward")
2713
- outcome = metrics.get("outcome_score")
2714
- if mean_return is not None:
2715
- summary.append(f"mean_return={mean_return}")
2716
- if outcome is not None:
2717
- summary.append(f"outcome={outcome}")
2718
- # Aggregate outcome stats
2719
- try:
2720
- val = float(outcome)
2721
- outcome_sum += val
2722
- outcome_count += 1
2723
- if val >= 0.5:
2724
- outcome_correct += 1
2725
- except Exception:
2726
- pass
2727
- # Try to infer tool call count from first trajectory step
2728
- trajs = (
2729
- data.get("trajectories")
2730
- if isinstance(data.get("trajectories"), list)
2731
- else None
2732
- )
2733
- if trajs:
2734
- first = trajs[0] if trajs else None
2735
- steps = first.get("steps") if isinstance(first, dict) else None
2736
- if isinstance(steps, list) and steps:
2737
- step0 = steps[0]
2738
- tool_calls = step0.get("tool_calls") or step0.get("tools") or []
2739
- if isinstance(tool_calls, list):
2740
- summary.append(f"tool_calls={len(tool_calls)}")
2741
- click.echo(" ".join(summary))
2742
- # Print the full response JSON (trace, trajectories, metrics)
2743
- with contextlib.suppress(Exception):
2744
- click.echo(json.dumps(data, indent=2))
2745
- else:
2746
- click.echo(" ".join(summary))
2747
- except Exception as exc:
2748
- failures += 1
2749
- click.echo(f"seed={seed_val} error={exc}")
2750
-
2751
- finally:
2752
- try:
2753
- client.close()
2754
- except AttributeError:
2755
- transport_obj = getattr(client, "_transport", None)
2756
- if transport_obj and hasattr(transport_obj, "aclose"):
2757
- try:
2758
- asyncio.run(transport_obj.aclose())
2759
- except RuntimeError:
2760
- # Fallback when already inside a running loop (rare for CLI).
2761
- new_loop = asyncio.new_event_loop()
2762
- try:
2763
- new_loop.run_until_complete(transport_obj.aclose())
2764
- finally:
2765
- new_loop.close()
2766
- except Exception:
2767
- pass
2768
-
2769
- click.echo(
2770
- f"Eval complete: {successes} ok, {failures} failed; model={selected_model}, split={split}"
2771
- )
2772
- # Print outcome summary if any successes
2773
- if outcome_count > 0:
2774
- mean_outcome = outcome_sum / float(outcome_count)
2775
- frac_right = outcome_correct / float(outcome_count)
2776
- click.echo(
2777
- f"Outcome summary: correct={outcome_correct}/{outcome_count} ({frac_right:.2%}), mean_outcome={mean_outcome:.3f}"
2778
- )
2779
-
2780
-
2781
- def register_eval(cli: click.Group) -> None:
2782
- cli.add_command(eval_command)