synth-ai 0.2.9.dev7__py3-none-any.whl → 0.2.9.dev8__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 (327) hide show
  1. examples/__init__.py +16 -0
  2. examples/crafter_debug_render.py +8 -11
  3. examples/qwen_coder/README.md +102 -0
  4. examples/qwen_coder/_shared.py +113 -0
  5. examples/qwen_coder/configs/coder_lora_30b.toml +61 -0
  6. examples/qwen_coder/configs/coder_lora_4b.toml +57 -0
  7. examples/qwen_coder/configs/coder_lora_small.toml +58 -0
  8. examples/qwen_coder/generate_dataset.py +98 -0
  9. examples/qwen_coder/infer_ft_smoke.py +64 -0
  10. examples/qwen_coder/infer_prod_proxy.py +73 -0
  11. examples/qwen_coder/infer_via_synth.py +87 -0
  12. examples/qwen_coder/scripts/infer_coder.sh +18 -0
  13. examples/qwen_coder/scripts/train_coder_30b.sh +21 -0
  14. examples/qwen_coder/sft_full_17b.py +103 -0
  15. examples/qwen_coder/sft_lora_30b.py +110 -0
  16. examples/qwen_coder/subset_jsonl.py +38 -0
  17. examples/qwen_coder/validate_jsonl.py +59 -0
  18. examples/rl/run_eval.py +36 -37
  19. examples/rl/run_rl_and_save.py +5 -5
  20. examples/rl/task_app/math_single_step.py +65 -43
  21. examples/rl/task_app/math_task_app.py +3 -3
  22. examples/sft/README.md +139 -0
  23. examples/sft/configs/crafter_fft_qwen0p6b.toml +44 -0
  24. examples/sft/configs/crafter_lora_qwen0p6b.toml +45 -0
  25. examples/sft/evaluate.py +117 -0
  26. examples/sft/export_dataset.py +117 -0
  27. examples/sft/generate_traces.py +162 -0
  28. examples/swe/__init__.py +12 -0
  29. examples/swe/task_app/README.md +105 -0
  30. examples/swe/task_app/__init__.py +2 -0
  31. examples/swe/task_app/grpo_swe_mini.py +571 -0
  32. examples/swe/task_app/grpo_swe_mini_task_app.py +136 -0
  33. examples/swe/task_app/hosted/README.md +173 -0
  34. examples/swe/task_app/hosted/__init__.py +5 -0
  35. examples/swe/task_app/hosted/branching.py +143 -0
  36. examples/swe/task_app/hosted/environment_routes.py +1289 -0
  37. examples/swe/task_app/hosted/envs/__init__.py +1 -0
  38. examples/swe/task_app/hosted/envs/crafter/__init__.py +6 -0
  39. examples/swe/task_app/hosted/envs/crafter/app.py +1 -0
  40. examples/swe/task_app/hosted/envs/crafter/environment.py +522 -0
  41. examples/swe/task_app/hosted/envs/crafter/policy.py +478 -0
  42. examples/swe/task_app/hosted/envs/crafter/react_agent.py +108 -0
  43. examples/swe/task_app/hosted/envs/crafter/shared.py +305 -0
  44. examples/swe/task_app/hosted/envs/crafter/tools.py +47 -0
  45. examples/swe/task_app/hosted/envs/mini_swe/__init__.py +8 -0
  46. examples/swe/task_app/hosted/envs/mini_swe/environment.py +1164 -0
  47. examples/swe/task_app/hosted/envs/mini_swe/policy.py +355 -0
  48. examples/swe/task_app/hosted/envs/mini_swe/shared.py +83 -0
  49. examples/swe/task_app/hosted/envs/mini_swe/tools.py +96 -0
  50. examples/swe/task_app/hosted/hosted_app.py +204 -0
  51. examples/swe/task_app/hosted/inference/__init__.py +5 -0
  52. examples/swe/task_app/hosted/inference/openai_client.py +618 -0
  53. examples/swe/task_app/hosted/main.py +100 -0
  54. examples/swe/task_app/hosted/policy_routes.py +1079 -0
  55. examples/swe/task_app/hosted/registry.py +195 -0
  56. examples/swe/task_app/hosted/rollout.py +1869 -0
  57. examples/swe/task_app/hosted/storage/__init__.py +5 -0
  58. examples/swe/task_app/hosted/storage/volume.py +211 -0
  59. examples/swe/task_app/hosted/test_agents.py +161 -0
  60. examples/swe/task_app/hosted/test_service.py +137 -0
  61. examples/swe/task_app/hosted/utils.py +62 -0
  62. examples/vlm/README.md +68 -0
  63. examples/vlm/configs/crafter_vlm_gpt4o.toml +44 -0
  64. examples/vlm/crafter_image_only_agent.py +207 -0
  65. examples/vlm/crafter_openai_vlm_agent.py +277 -0
  66. examples/vlm/filter_image_rows.py +63 -0
  67. examples/vlm/run_crafter_vlm_benchmark.py +316 -0
  68. examples/warming_up_to_rl/analyze_trace_db.py +5 -5
  69. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +11 -1
  70. examples/warming_up_to_rl/export_trace_sft.py +78 -21
  71. examples/warming_up_to_rl/groq_test.py +4 -4
  72. examples/warming_up_to_rl/manage_secrets.py +13 -18
  73. examples/warming_up_to_rl/run_eval.py +42 -44
  74. examples/warming_up_to_rl/run_fft_and_save.py +11 -16
  75. examples/warming_up_to_rl/run_local_rollout.py +1 -3
  76. examples/warming_up_to_rl/run_local_rollout_modal.py +2 -4
  77. examples/warming_up_to_rl/run_local_rollout_parallel.py +1 -4
  78. examples/warming_up_to_rl/run_local_rollout_traced.py +3 -5
  79. examples/warming_up_to_rl/run_rl_and_save.py +5 -6
  80. examples/warming_up_to_rl/run_rollout_remote.py +8 -10
  81. examples/warming_up_to_rl/task_app/README.md +6 -2
  82. examples/warming_up_to_rl/task_app/grpo_crafter.py +234 -35
  83. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +2 -3
  84. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +1 -1
  85. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +9 -11
  86. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +131 -114
  87. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +101 -41
  88. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +73 -51
  89. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +14 -6
  90. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +16 -16
  91. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +32 -34
  92. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +94 -31
  93. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +0 -2
  94. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +303 -203
  95. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +21 -23
  96. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +328 -225
  97. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +13 -13
  98. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +1 -0
  99. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +1 -0
  100. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +4 -3
  101. synth/__init__.py +14 -0
  102. synth_ai/__init__.py +26 -4
  103. synth_ai/api/models/supported.py +376 -0
  104. synth_ai/api/train/builders.py +128 -21
  105. synth_ai/api/train/cli.py +80 -64
  106. synth_ai/api/train/config_finder.py +7 -2
  107. synth_ai/api/train/env_resolver.py +1 -1
  108. synth_ai/api/train/pollers.py +2 -1
  109. synth_ai/api/train/supported_algos.py +139 -0
  110. synth_ai/api/train/task_app.py +1 -2
  111. synth_ai/api/train/utils.py +13 -44
  112. synth_ai/cli/__init__.py +8 -0
  113. synth_ai/cli/_modal_wrapper.py +28 -0
  114. synth_ai/cli/_typer_patch.py +49 -0
  115. synth_ai/cli/balance.py +1 -2
  116. synth_ai/cli/calc.py +1 -1
  117. synth_ai/cli/demo.py +2 -1
  118. synth_ai/cli/recent.py +2 -2
  119. synth_ai/cli/rl_demo.py +2 -1
  120. synth_ai/cli/root.py +11 -13
  121. synth_ai/cli/status.py +2 -2
  122. synth_ai/cli/task_apps.py +529 -179
  123. synth_ai/cli/traces.py +6 -4
  124. synth_ai/cli/watch.py +12 -18
  125. synth_ai/demo_registry.py +1 -1
  126. synth_ai/demos/core/cli.py +36 -43
  127. synth_ai/demos/demo_task_apps/__init__.py +3 -3
  128. synth_ai/demos/demo_task_apps/core.py +17 -25
  129. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +3 -4
  130. synth_ai/demos/demo_task_apps/math/app.py +2 -1
  131. synth_ai/demos/demo_task_apps/math/deploy_modal.py +3 -4
  132. synth_ai/demos/demo_task_apps/math/modal_task_app.py +16 -18
  133. synth_ai/demos/demo_task_apps/math/task_app_entry.py +0 -1
  134. synth_ai/environments/examples/crafter_classic/environment.py +76 -1
  135. synth_ai/environments/reproducibility/tree.py +2 -5
  136. synth_ai/environments/service/app.py +11 -12
  137. synth_ai/environments/service/core_routes.py +4 -7
  138. synth_ai/environments/stateful/engine.py +1 -1
  139. synth_ai/environments/tasks/core.py +1 -0
  140. synth_ai/environments/tasks/filters.py +5 -6
  141. synth_ai/environments/tasks/utils.py +4 -5
  142. synth_ai/handshake.py +9 -9
  143. synth_ai/http.py +1 -1
  144. synth_ai/http_client.py +18 -10
  145. synth_ai/inference/client.py +15 -5
  146. synth_ai/jobs/client.py +78 -83
  147. synth_ai/learning/__init__.py +41 -6
  148. synth_ai/learning/algorithms.py +14 -0
  149. synth_ai/learning/client.py +91 -24
  150. synth_ai/learning/config.py +2 -38
  151. synth_ai/learning/ft_client.py +4 -59
  152. synth_ai/learning/health.py +5 -6
  153. synth_ai/learning/jobs.py +31 -47
  154. synth_ai/{rl → learning/rl}/__init__.py +14 -4
  155. synth_ai/learning/rl/client.py +267 -0
  156. synth_ai/learning/rl/config.py +31 -0
  157. synth_ai/{rl → learning/rl}/contracts.py +5 -8
  158. synth_ai/{rl → learning/rl}/env_keys.py +39 -15
  159. synth_ai/learning/rl/secrets.py +13 -0
  160. synth_ai/learning/rl_client.py +2 -281
  161. synth_ai/learning/sft/__init__.py +29 -0
  162. synth_ai/learning/sft/client.py +68 -0
  163. synth_ai/learning/sft/config.py +270 -0
  164. synth_ai/learning/sft/data.py +295 -0
  165. synth_ai/learning/sse.py +25 -24
  166. synth_ai/learning/validators.py +25 -28
  167. synth_ai/lm/__init__.py +21 -47
  168. synth_ai/main.py +4 -0
  169. synth_ai/task/__init__.py +25 -27
  170. synth_ai/task/apps/__init__.py +7 -8
  171. synth_ai/task/auth.py +8 -8
  172. synth_ai/task/client.py +14 -14
  173. synth_ai/task/contracts.py +36 -35
  174. synth_ai/task/datasets.py +6 -5
  175. synth_ai/task/errors.py +10 -10
  176. synth_ai/task/health.py +17 -9
  177. synth_ai/task/json.py +58 -23
  178. synth_ai/task/proxy.py +13 -9
  179. synth_ai/task/rubrics.py +16 -15
  180. synth_ai/task/server.py +12 -12
  181. synth_ai/task/tracing_utils.py +4 -4
  182. synth_ai/task/vendors.py +5 -6
  183. synth_ai/tracing_v3/__init__.py +2 -0
  184. synth_ai/tracing_v3/abstractions.py +21 -4
  185. synth_ai/tracing_v3/decorators.py +18 -16
  186. synth_ai/tracing_v3/hooks.py +5 -5
  187. synth_ai/tracing_v3/llm_call_record_helpers.py +6 -6
  188. synth_ai/tracing_v3/session_tracer.py +40 -14
  189. synth_ai/tracing_v3/storage/base.py +85 -0
  190. synth_ai/tracing_v3/storage/config.py +21 -8
  191. synth_ai/tracing_v3/storage/factory.py +10 -7
  192. synth_ai/tracing_v3/storage/utils.py +4 -2
  193. synth_ai/tracing_v3/turso/daemon.py +7 -2
  194. synth_ai/tracing_v3/turso/models.py +2 -2
  195. synth_ai/tracing_v3/turso/native_manager.py +1173 -0
  196. synth_ai/tracing_v3/utils.py +4 -4
  197. synth_ai/v0/api/__init__.py +8 -0
  198. synth_ai/v0/api/models/__init__.py +8 -0
  199. synth_ai/v0/api/models/supported.py +8 -0
  200. synth_ai/v0/config/__init__.py +15 -0
  201. synth_ai/v0/config/base_url.py +12 -0
  202. synth_ai/v0/lm/__init__.py +51 -0
  203. synth_ai/{lm → v0/lm}/caching/ephemeral.py +2 -2
  204. synth_ai/{lm → v0/lm}/caching/handler.py +4 -4
  205. synth_ai/{lm → v0/lm}/caching/initialize.py +1 -1
  206. synth_ai/{lm → v0/lm}/caching/persistent.py +1 -1
  207. synth_ai/{lm → v0/lm}/config.py +6 -1
  208. synth_ai/{lm → v0/lm}/core/all.py +9 -9
  209. synth_ai/{lm → v0/lm}/core/main.py +6 -6
  210. synth_ai/{lm → v0/lm}/core/main_v3.py +10 -10
  211. synth_ai/{lm → v0/lm}/core/synth_models.py +2 -14
  212. synth_ai/{lm → v0/lm}/core/vendor_clients.py +2 -2
  213. synth_ai/{lm → v0/lm}/overrides.py +2 -2
  214. synth_ai/{lm → v0/lm}/provider_support/anthropic.py +4 -4
  215. synth_ai/{lm → v0/lm}/provider_support/openai.py +5 -5
  216. synth_ai/{lm → v0/lm}/structured_outputs/handler.py +5 -5
  217. synth_ai/{lm → v0/lm}/structured_outputs/rehabilitate.py +1 -1
  218. synth_ai/{lm → v0/lm}/vendors/core/anthropic_api.py +9 -9
  219. synth_ai/{lm → v0/lm}/vendors/core/gemini_api.py +5 -5
  220. synth_ai/{lm → v0/lm}/vendors/core/mistral_api.py +5 -5
  221. synth_ai/{lm → v0/lm}/vendors/core/openai_api.py +10 -10
  222. synth_ai/{lm → v0/lm}/vendors/openai_standard.py +8 -8
  223. synth_ai/{lm → v0/lm}/vendors/openai_standard_responses.py +2 -2
  224. synth_ai/{lm → v0/lm}/vendors/supported/custom_endpoint.py +3 -3
  225. synth_ai/{lm → v0/lm}/vendors/supported/deepseek.py +2 -2
  226. synth_ai/{lm → v0/lm}/vendors/supported/grok.py +2 -2
  227. synth_ai/{lm → v0/lm}/vendors/supported/groq.py +1 -1
  228. synth_ai/{lm → v0/lm}/vendors/supported/ollama.py +1 -1
  229. synth_ai/{lm → v0/lm}/vendors/supported/openrouter.py +3 -3
  230. synth_ai/{lm → v0/lm}/vendors/supported/together.py +1 -1
  231. synth_ai/{lm → v0/lm}/vendors/synth_client.py +1 -1
  232. synth_ai/v0/tracing_v3/__init__.py +10 -0
  233. synth_ai/v0/tracing_v3/abstractions.py +3 -0
  234. synth_ai/v0/tracing_v3/decorators.py +3 -0
  235. synth_ai/v0/tracing_v3/llm_call_record_helpers.py +3 -0
  236. synth_ai/v0/tracing_v3/session_tracer.py +3 -0
  237. synth_ai-0.2.9.dev8.dist-info/METADATA +191 -0
  238. {synth_ai-0.2.9.dev7.dist-info → synth_ai-0.2.9.dev8.dist-info}/RECORD +268 -238
  239. {synth_ai-0.2.9.dev7.dist-info → synth_ai-0.2.9.dev8.dist-info}/top_level.txt +1 -0
  240. examples/common_old/backend.py +0 -20
  241. examples/evals_old/README.md +0 -98
  242. examples/evals_old/__init__.py +0 -6
  243. examples/evals_old/compare_models.py +0 -1038
  244. examples/evals_old/example_log.md +0 -145
  245. examples/evals_old/run_demo.sh +0 -126
  246. examples/evals_old/trace_analysis.py +0 -270
  247. examples/finetuning_old/_backup_synth_qwen/config.toml +0 -29
  248. examples/finetuning_old/_backup_synth_qwen/example_log.md +0 -324
  249. examples/finetuning_old/_backup_synth_qwen/filter_traces.py +0 -60
  250. examples/finetuning_old/_backup_synth_qwen/filter_traces_achievements.py +0 -243
  251. examples/finetuning_old/_backup_synth_qwen/purge_v3_traces.py +0 -109
  252. examples/finetuning_old/_backup_synth_qwen/react_agent_lm.py +0 -1924
  253. examples/finetuning_old/_backup_synth_qwen/readme.md +0 -49
  254. examples/finetuning_old/_backup_synth_qwen/run_crafter_qwen4b.py +0 -114
  255. examples/finetuning_old/_backup_synth_qwen/run_demo.sh +0 -195
  256. examples/finetuning_old/_backup_synth_qwen/sft_kickoff.py +0 -119
  257. examples/finetuning_old/synth_qwen_v1/README.md +0 -68
  258. examples/finetuning_old/synth_qwen_v1/filter_traces.py +0 -60
  259. examples/finetuning_old/synth_qwen_v1/filter_traces_achievements.py +0 -243
  260. examples/finetuning_old/synth_qwen_v1/finetune.py +0 -46
  261. examples/finetuning_old/synth_qwen_v1/hello_ft_model.py +0 -71
  262. examples/finetuning_old/synth_qwen_v1/infer.py +0 -36
  263. examples/finetuning_old/synth_qwen_v1/poll.py +0 -46
  264. examples/finetuning_old/synth_qwen_v1/prepare_data.py +0 -35
  265. examples/finetuning_old/synth_qwen_v1/purge_v3_traces.py +0 -109
  266. examples/finetuning_old/synth_qwen_v1/react_agent_lm.py +0 -1933
  267. examples/finetuning_old/synth_qwen_v1/run_crafter_sft_job.py +0 -210
  268. examples/finetuning_old/synth_qwen_v1/run_ft_job.py +0 -237
  269. examples/finetuning_old/synth_qwen_v1/upload_data.py +0 -34
  270. examples/finetuning_old/synth_qwen_v1/util.py +0 -152
  271. examples/rl_old/task_app.py +0 -1131
  272. examples/warming_up_to_rl/old/event_rewards.md +0 -234
  273. examples/warming_up_to_rl/old/notes.md +0 -73
  274. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +0 -738
  275. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +0 -580
  276. synth_ai/experimental/synth_oss.py +0 -445
  277. synth_ai/learning/filtering.py +0 -0
  278. synth_ai/learning/offline/dpo.py +0 -0
  279. synth_ai/learning/offline/providers.py +0 -7
  280. synth_ai/learning/offline/sft.py +0 -0
  281. synth_ai/learning/offline/shared.py +0 -0
  282. synth_ai/learning/online/grpo.py +0 -0
  283. synth_ai/learning/online/irft.py +0 -0
  284. synth_ai/learning/prompts/banking77_injection_eval.py +0 -168
  285. synth_ai/learning/prompts/gepa.py +0 -0
  286. synth_ai/learning/prompts/hello_world_in_context_injection_ex.py +0 -211
  287. synth_ai/learning/prompts/mipro.py +0 -289
  288. synth_ai/learning/prompts/random_search.py +0 -249
  289. synth_ai/learning/prompts/run_mipro_banking77.py +0 -172
  290. synth_ai/learning/prompts/run_random_search_banking77.py +0 -329
  291. synth_ai/rl/secrets.py +0 -19
  292. synth_ai/scripts/verify_rewards.py +0 -100
  293. synth_ai/tracing/__init__.py +0 -30
  294. synth_ai/tracing_v1/__init__.py +0 -33
  295. synth_ai/tracing_v3/turso/__init__.py +0 -25
  296. synth_ai/tracing_v3/turso/manager.py +0 -838
  297. synth_ai/zyk/__init__.py +0 -30
  298. synth_ai-0.2.9.dev7.dist-info/METADATA +0 -131
  299. /synth_ai/{lm → v0/lm}/caching/__init__.py +0 -0
  300. /synth_ai/{lm → v0/lm}/caching/constants.py +0 -0
  301. /synth_ai/{lm → v0/lm}/caching/dbs.py +0 -0
  302. /synth_ai/{lm → v0/lm}/constants.py +0 -0
  303. /synth_ai/{lm → v0/lm}/core/__init__.py +0 -0
  304. /synth_ai/{lm → v0/lm}/core/exceptions.py +0 -0
  305. /synth_ai/{lm → v0/lm}/cost/__init__.py +0 -0
  306. /synth_ai/{lm → v0/lm}/cost/monitor.py +0 -0
  307. /synth_ai/{lm → v0/lm}/cost/statefulness.py +0 -0
  308. /synth_ai/{lm → v0/lm}/injection.py +0 -0
  309. /synth_ai/{lm → v0/lm}/provider_support/__init__.py +0 -0
  310. /synth_ai/{lm → v0/lm}/provider_support/suppress_logging.py +0 -0
  311. /synth_ai/{lm → v0/lm}/structured_outputs/__init__.py +0 -0
  312. /synth_ai/{lm → v0/lm}/structured_outputs/inject.py +0 -0
  313. /synth_ai/{lm → v0/lm}/tools/__init__.py +0 -0
  314. /synth_ai/{lm → v0/lm}/tools/base.py +0 -0
  315. /synth_ai/{lm → v0/lm}/unified_interface.py +0 -0
  316. /synth_ai/{lm → v0/lm}/vendors/__init__.py +0 -0
  317. /synth_ai/{lm → v0/lm}/vendors/base.py +0 -0
  318. /synth_ai/{lm → v0/lm}/vendors/core/__init__.py +0 -0
  319. /synth_ai/{lm → v0/lm}/vendors/core/synth_dev_api.py +0 -0
  320. /synth_ai/{lm → v0/lm}/vendors/local/__init__.py +0 -0
  321. /synth_ai/{lm → v0/lm}/vendors/local/ollama.py +0 -0
  322. /synth_ai/{lm → v0/lm}/vendors/retries.py +0 -0
  323. /synth_ai/{lm → v0/lm}/vendors/supported/__init__.py +0 -0
  324. /synth_ai/{lm → v0/lm}/warmup.py +0 -0
  325. {synth_ai-0.2.9.dev7.dist-info → synth_ai-0.2.9.dev8.dist-info}/WHEEL +0 -0
  326. {synth_ai-0.2.9.dev7.dist-info → synth_ai-0.2.9.dev8.dist-info}/entry_points.txt +0 -0
  327. {synth_ai-0.2.9.dev7.dist-info → synth_ai-0.2.9.dev8.dist-info}/licenses/LICENSE +0 -0
synth_ai/cli/task_apps.py CHANGED
@@ -1,23 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ import asyncio
4
5
  import contextlib
5
- import functools
6
6
  import hashlib
7
7
  import importlib
8
8
  import importlib.util
9
9
  import inspect
10
- import os
11
10
  import json
12
- import signal
11
+ import os
13
12
  import shutil
13
+ import signal
14
14
  import subprocess
15
15
  import sys
16
16
  import tempfile
17
+ import textwrap
18
+ import types
19
+ from collections.abc import Callable, Iterable, Iterator, Sequence
17
20
  from dataclasses import dataclass
18
21
  from pathlib import Path
19
- import types
20
- from typing import Any, Callable, Iterable, Sequence, Iterator, cast
22
+ from typing import Any, cast
21
23
 
22
24
  try: # Python 3.11+
23
25
  import tomllib as _toml
@@ -26,9 +28,10 @@ except Exception: # pragma: no cover - fallback
26
28
  import uuid
27
29
 
28
30
  import click
29
- from synth_ai.task.apps import ModalDeploymentConfig, TaskAppConfig, TaskAppEntry, registry
30
- from synth_ai.task.server import run_task_app, create_task_app
31
+
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
32
35
 
33
36
  REPO_ROOT = Path(__file__).resolve().parents[2]
34
37
 
@@ -213,7 +216,7 @@ def _discover_eval_config_paths() -> list[Path]:
213
216
  if not root.exists() or not root.is_dir():
214
217
  continue
215
218
  try:
216
- root_resolved = root.resolve()
219
+ root = root.resolve()
217
220
  except Exception:
218
221
  continue
219
222
  for path in root.rglob("*.toml"):
@@ -255,11 +258,9 @@ class _TaskAppConfigVisitor(ast.NodeVisitor):
255
258
 
256
259
  def _is_task_app_config_call(node: ast.Call) -> bool:
257
260
  func = node.func
258
- if isinstance(func, ast.Name) and func.id == "TaskAppConfig":
259
- return True
260
- if isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig":
261
- return True
262
- return False
261
+ return (isinstance(func, ast.Name) and func.id == "TaskAppConfig") or (
262
+ isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig"
263
+ )
263
264
 
264
265
 
265
266
  def _extract_app_id(node: ast.Call) -> str | None:
@@ -279,11 +280,9 @@ def _extract_app_id(node: ast.Call) -> str | None:
279
280
 
280
281
  def _is_register_task_app_call(node: ast.Call) -> bool:
281
282
  func = node.func
282
- if isinstance(func, ast.Name) and func.id == "register_task_app":
283
- return True
284
- if isinstance(func, ast.Attribute) and func.attr == "register_task_app":
285
- return True
286
- return False
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
+ )
287
286
 
288
287
 
289
288
  def _extract_register_app_id(node: ast.Call) -> str | None:
@@ -555,11 +554,7 @@ def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
555
554
  ident = identifier.strip()
556
555
  if not ident:
557
556
  return False
558
- if ident == choice.app_id or ident == choice.label:
559
- return True
560
- if ident in choice.aliases:
561
- return True
562
- return False
557
+ return ident == choice.app_id or ident == choice.label or ident in choice.aliases
563
558
 
564
559
 
565
560
  def _choice_has_modal_support(choice: AppChoice) -> bool:
@@ -581,26 +576,23 @@ def _has_modal_support_in_file(path: Path) -> bool:
581
576
 
582
577
  # Look for ModalDeploymentConfig in register_task_app calls
583
578
  for node in ast.walk(tree):
584
- if isinstance(node, ast.Call):
585
- if _is_register_task_app_call(node):
586
- # Check if the entry has modal=ModalDeploymentConfig(...)
587
- for kw in node.keywords:
588
- if kw.arg == "entry" and isinstance(kw.value, ast.Call):
589
- entry_call = kw.value
590
- if (
591
- isinstance(entry_call.func, ast.Name)
592
- and entry_call.func.id == "TaskAppEntry"
593
- ):
594
- for entry_kw in entry_call.keywords:
595
- if entry_kw.arg == "modal" and isinstance(
596
- entry_kw.value, ast.Call
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"
597
594
  ):
598
- modal_call = entry_kw.value
599
- if (
600
- isinstance(modal_call.func, ast.Name)
601
- and modal_call.func.id == "ModalDeploymentConfig"
602
- ):
603
- return True
595
+ return True
604
596
  except Exception:
605
597
  pass
606
598
  return False
@@ -614,27 +606,24 @@ def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfig | None:
614
606
 
615
607
  # Look for ModalDeploymentConfig in register_task_app calls
616
608
  for node in ast.walk(tree):
617
- if isinstance(node, ast.Call):
618
- if _is_register_task_app_call(node):
619
- # Check if the entry has modal=ModalDeploymentConfig(...)
620
- for kw in node.keywords:
621
- if kw.arg == "entry" and isinstance(kw.value, ast.Call):
622
- entry_call = kw.value
623
- if (
624
- isinstance(entry_call.func, ast.Name)
625
- and entry_call.func.id == "TaskAppEntry"
626
- ):
627
- for entry_kw in entry_call.keywords:
628
- if entry_kw.arg == "modal" and isinstance(
629
- entry_kw.value, ast.Call
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"
630
624
  ):
631
- modal_call = entry_kw.value
632
- if (
633
- isinstance(modal_call.func, ast.Name)
634
- and modal_call.func.id == "ModalDeploymentConfig"
635
- ):
636
- # Extract the arguments to ModalDeploymentConfig
637
- return _build_modal_config_from_ast(modal_call)
625
+ # Extract the arguments to ModalDeploymentConfig
626
+ return _build_modal_config_from_ast(modal_call)
638
627
  except Exception:
639
628
  pass
640
629
  return None
@@ -648,35 +637,35 @@ def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfig
648
637
  for kw in modal_call.keywords:
649
638
  if kw.arg and isinstance(kw.value, ast.Constant):
650
639
  kwargs[kw.arg] = kw.value.value
651
- elif kw.arg == "pip_packages" and isinstance(kw.value, (ast.List, ast.Tuple)):
640
+ elif kw.arg == "pip_packages" and isinstance(kw.value, ast.List | ast.Tuple):
652
641
  # Handle pip_packages list/tuple
653
642
  packages = []
654
643
  for elt in kw.value.elts:
655
644
  if isinstance(elt, ast.Constant):
656
645
  packages.append(elt.value)
657
646
  kwargs[kw.arg] = tuple(packages)
658
- elif kw.arg == "extra_local_dirs" and isinstance(kw.value, (ast.List, ast.Tuple)):
647
+ elif kw.arg == "extra_local_dirs" and isinstance(kw.value, ast.List | ast.Tuple):
659
648
  # Handle extra_local_dirs list/tuple of tuples
660
649
  dirs = []
661
650
  for elt in kw.value.elts:
662
- if isinstance(elt, (ast.List, ast.Tuple)) and len(elt.elts) == 2:
651
+ if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
663
652
  src = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
664
653
  dst = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
665
654
  if src and dst:
666
655
  dirs.append((src, dst))
667
656
  kwargs[kw.arg] = tuple(dirs)
668
- elif kw.arg == "secret_names" and isinstance(kw.value, (ast.List, ast.Tuple)):
657
+ elif kw.arg == "secret_names" and isinstance(kw.value, ast.List | ast.Tuple):
669
658
  # Handle secret_names list/tuple
670
659
  secrets = []
671
660
  for elt in kw.value.elts:
672
661
  if isinstance(elt, ast.Constant):
673
662
  secrets.append(elt.value)
674
663
  kwargs[kw.arg] = tuple(secrets)
675
- elif kw.arg == "volume_mounts" and isinstance(kw.value, (ast.List, ast.Tuple)):
664
+ elif kw.arg == "volume_mounts" and isinstance(kw.value, ast.List | ast.Tuple):
676
665
  # Handle volume_mounts list/tuple of tuples
677
666
  mounts = []
678
667
  for elt in kw.value.elts:
679
- if isinstance(elt, (ast.List, ast.Tuple)) and len(elt.elts) == 2:
668
+ if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
680
669
  name = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
681
670
  mount = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
682
671
  if name and mount:
@@ -724,8 +713,8 @@ def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
724
713
  click.echo(_format_choice(choice, idx))
725
714
  try:
726
715
  response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
727
- except (click.exceptions.Abort, EOFError, KeyboardInterrupt):
728
- raise click.ClickException("Task app selection cancelled by user")
716
+ except (click.exceptions.Abort, EOFError, KeyboardInterrupt) as exc:
717
+ raise click.ClickException("Task app selection cancelled by user") from exc
729
718
  if not response.isdigit():
730
719
  raise click.ClickException("Selection must be a number")
731
720
  index = int(response)
@@ -870,7 +859,11 @@ def _load_entry_from_path(
870
859
  continue
871
860
  if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
872
861
  config_obj = attr
873
- factory_callable = lambda cfg=attr: cfg
862
+
863
+ def _return_config(cfg: TaskAppConfig = attr) -> TaskAppConfig:
864
+ return cfg
865
+
866
+ factory_callable = _return_config
874
867
  break
875
868
 
876
869
  if factory_callable is None:
@@ -904,10 +897,12 @@ def _load_entry_from_path(
904
897
  continue
905
898
  if isinstance(result, TaskAppConfig) and result.app_id == app_id:
906
899
  # Bind attr to a local and close over it without exposing parameters
907
- _bound_func: Callable[[], TaskAppConfig] = cast(Callable[[], TaskAppConfig], attr) # type: ignore[assignment]
900
+ bound_func: Callable[[], TaskAppConfig] = cast(Callable[[], TaskAppConfig], attr) # type: ignore[assignment]
908
901
 
909
- def _factory_noargs() -> TaskAppConfig:
910
- return _bound_func()
902
+ def _factory_noargs(
903
+ func: Callable[[], TaskAppConfig] = bound_func,
904
+ ) -> TaskAppConfig:
905
+ return func()
911
906
 
912
907
  factory_callable = _factory_noargs
913
908
  config_obj = result
@@ -919,10 +914,10 @@ def _load_entry_from_path(
919
914
  # Check if the app was registered in the registry
920
915
  entry = registry.get(app_id)
921
916
  return entry
922
- except KeyError:
917
+ except KeyError as exc:
923
918
  raise click.ClickException(
924
919
  f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
925
- )
920
+ ) from exc
926
921
 
927
922
  modal_cfg: ModalDeploymentConfig | None = None
928
923
  for attr_name in dir(module):
@@ -993,6 +988,103 @@ def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) ->
993
988
  return [env_candidates[choice - 1]]
994
989
 
995
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
+
996
1088
  def _run_modal_script(
997
1089
  script_path: Path,
998
1090
  modal_cli: str,
@@ -1002,55 +1094,92 @@ def _run_modal_script(
1002
1094
  modal_name: str | None = None,
1003
1095
  dry_run: bool = False,
1004
1096
  ) -> None:
1005
- modal_path = shutil.which(modal_cli)
1006
- if modal_path is None:
1007
- raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
1008
-
1009
1097
  env_paths_list = [Path(p).resolve() for p in env_paths]
1010
1098
  path_strings = [str(p) for p in env_paths_list]
1011
1099
  _load_env_files_into_process(path_strings)
1012
1100
  _ensure_env_values(env_paths_list, script_path.parent)
1013
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))
1014
1107
 
1015
- cmd = [modal_path, command, str(script_path)]
1016
- if modal_name:
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":
1017
1139
  cmd.extend(["--name", modal_name])
1018
1140
  if dry_run:
1019
1141
  click.echo("Dry run: " + " ".join(cmd))
1020
1142
  return
1021
1143
  try:
1022
- # Capture output to extract URL
1023
- result = subprocess.run(cmd, check=True, capture_output=True, text=True)
1024
- # Print output as it would normally appear
1025
- if result.stdout:
1026
- click.echo(result.stdout, nl=False)
1027
- if result.stderr:
1028
- click.echo(result.stderr, nl=False, err=True)
1029
-
1030
- # Extract and save task app URL from output
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
+ )
1031
1153
  task_app_url = None
1032
- for line in result.stdout.splitlines():
1033
- # Look for lines containing modal.run URLs
1034
- if "modal.run" in line and "=>" in line:
1035
- # Extract URL from lines like: "└── 🔨 Created web function fastapi_app => https://...modal.run"
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):
1036
1158
  parts = line.split("=>")
1037
1159
  if len(parts) >= 2:
1038
1160
  task_app_url = parts[-1].strip()
1039
- break
1040
-
1041
- # Save URL to .env file if found
1042
- if task_app_url and env_paths_list:
1043
- env_file = env_paths_list[0] # Use the first .env file
1044
- _save_to_env_file(env_file, "TASK_APP_BASE_URL", task_app_url)
1045
- click.echo(f"\n✓ Task app URL: {task_app_url}")
1046
-
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)
1047
1168
  except subprocess.CalledProcessError as exc:
1048
1169
  raise click.ClickException(
1049
1170
  f"modal {command} failed with exit code {exc.returncode}"
1050
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)
1051
1180
 
1052
1181
 
1053
- def _preflight_env_key(crash_on_failure: bool = False) -> None:
1182
+ def _preflight_env_key(env_paths: Sequence[Path] | None = None, *, crash_on_failure: bool = False) -> None:
1054
1183
  try:
1055
1184
  raw_backend = (
1056
1185
  os.environ.get("BACKEND_BASE_URL")
@@ -1062,13 +1191,46 @@ def _preflight_env_key(crash_on_failure: bool = False) -> None:
1062
1191
  backend_base = backend_base + "/api"
1063
1192
  synth_key = os.environ.get("SYNTH_API_KEY") or ""
1064
1193
  env_api_key = (
1065
- os.environ.get("ENVIRONMENT_API_KEY")
1066
- or os.environ.get("dev_environment_api_key")
1067
- or os.environ.get("DEV_ENVIRONMENT_API_KEY")
1068
- or ""
1069
- )
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
+
1070
1231
  if synth_key and env_api_key:
1071
1232
  import base64
1233
+
1072
1234
  import httpx
1073
1235
 
1074
1236
  click.echo(f"[preflight] backend={backend_base}")
@@ -1080,10 +1242,50 @@ def _preflight_env_key(crash_on_failure: bool = False) -> None:
1080
1242
  try:
1081
1243
  from nacl.public import PublicKey, SealedBox
1082
1244
 
1083
- pub = PublicKey(base64.b64decode(pk, validate=True))
1245
+ # Decode public key and build sealed box
1246
+ pk_bytes = base64.b64decode(pk, validate=True)
1247
+ pub = PublicKey(pk_bytes)
1084
1248
  sb = SealedBox(pub)
1249
+
1250
+ # Encrypt plaintext key
1085
1251
  ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode("utf-8"))).decode()
1086
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
1087
1289
  with httpx.Client(
1088
1290
  timeout=15.0,
1089
1291
  headers={
@@ -1093,15 +1295,18 @@ def _preflight_env_key(crash_on_failure: bool = False) -> None:
1093
1295
  ) as c:
1094
1296
  click.echo("[preflight] upserting env key…")
1095
1297
  up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
1096
- click.echo(f"[preflight] upsert status={up.status_code}")
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 ''}")
1097
1304
 
1098
1305
  # If upload succeeded (2xx), consider it successful even if verification fails
1099
1306
  # This handles cases where verification endpoint has issues
1100
1307
  if 200 <= up.status_code < 300:
1101
1308
  key_preview = (
1102
- f"{env_api_key[:5]}...{env_api_key[-5:]}"
1103
- if len(env_api_key) > 10
1104
- else env_api_key
1309
+ _preview(env_api_key)
1105
1310
  )
1106
1311
  click.echo(
1107
1312
  f"✅ ENVIRONMENT_API_KEY uploaded successfully ({key_preview})"
@@ -1124,6 +1329,7 @@ def _preflight_env_key(crash_on_failure: bool = False) -> None:
1124
1329
  else:
1125
1330
  error_msg = (
1126
1331
  f"ENVIRONMENT_API_KEY upload failed with status {up.status_code}"
1332
+ + (f" body={body_snip}" if body_snip else "")
1127
1333
  )
1128
1334
  if crash_on_failure:
1129
1335
  raise click.ClickException(f"[CRITICAL] {error_msg}")
@@ -1131,12 +1337,12 @@ def _preflight_env_key(crash_on_failure: bool = False) -> None:
1131
1337
  except Exception as e:
1132
1338
  error_msg = f"Failed to encrypt/upload ENVIRONMENT_API_KEY: {e}"
1133
1339
  if crash_on_failure:
1134
- raise click.ClickException(f"[CRITICAL] {error_msg}")
1340
+ raise click.ClickException(f"[CRITICAL] {error_msg}") from e
1135
1341
  click.echo(f"[WARN] {error_msg}; proceeding anyway")
1136
1342
  except Exception as e:
1137
1343
  error_msg = f"Backend preflight for ENVIRONMENT_API_KEY failed: {e}"
1138
1344
  if crash_on_failure:
1139
- raise click.ClickException(f"[CRITICAL] {error_msg}")
1345
+ raise click.ClickException(f"[CRITICAL] {error_msg}") from e
1140
1346
  click.echo(f"[WARN] {error_msg}; proceeding anyway")
1141
1347
 
1142
1348
 
@@ -1151,17 +1357,33 @@ def _run_modal_with_entry(
1151
1357
  dry_run: bool = False,
1152
1358
  original_path: Path | None = None,
1153
1359
  ) -> None:
1154
- modal_path = shutil.which(modal_cli)
1155
- if modal_path is None:
1156
- raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
1157
-
1158
1360
  env_paths_list = [Path(p).resolve() for p in env_paths]
1159
1361
  dotenv_paths = [str(p) for p in env_paths_list]
1160
1362
  _load_env_files_into_process(dotenv_paths)
1161
1363
  fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
1162
1364
  _ensure_env_values(env_paths_list, fallback_dir)
1163
1365
  _load_env_values(env_paths_list)
1164
- _preflight_env_key(crash_on_failure=True)
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")
1165
1387
 
1166
1388
  script_path = _write_modal_entrypoint(
1167
1389
  entry,
@@ -1169,8 +1391,22 @@ def _run_modal_with_entry(
1169
1391
  modal_name,
1170
1392
  dotenv_paths=dotenv_paths,
1171
1393
  original_path=original_path,
1394
+ inline_secret_values=inline_secret_values,
1172
1395
  )
1173
- cmd = [modal_path, command, str(script_path)]
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)))
1174
1410
 
1175
1411
  if dry_run:
1176
1412
  click.echo("Dry run: " + " ".join(cmd))
@@ -1178,31 +1414,33 @@ def _run_modal_with_entry(
1178
1414
  return
1179
1415
 
1180
1416
  try:
1181
- # Capture output to extract URL
1182
- result = subprocess.run(cmd, check=True, capture_output=True, text=True)
1183
- # Print output as it would normally appear
1184
- if result.stdout:
1185
- click.echo(result.stdout, nl=False)
1186
- if result.stderr:
1187
- click.echo(result.stderr, nl=False, err=True)
1188
-
1189
- # Extract and save task app URL from output
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
+ )
1190
1426
  task_app_url = None
1191
- for line in result.stdout.splitlines():
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)
1192
1431
  # Look for lines containing modal.run URLs
1193
- if "modal.run" in line and "=>" in line:
1194
- # Extract URL from lines like: "└── 🔨 Created web function fastapi_app => https://...modal.run"
1432
+ if task_app_url is None and ("modal.run" in line and "=>" in line):
1195
1433
  parts = line.split("=>")
1196
1434
  if len(parts) >= 2:
1197
1435
  task_app_url = parts[-1].strip()
1198
- break
1199
-
1200
- # Save URL to .env file if found
1201
- if task_app_url and env_paths_list:
1202
- env_file = env_paths_list[0] # Use the first .env file
1203
- _save_to_env_file(env_file, "TASK_APP_BASE_URL", task_app_url)
1204
- click.echo(f"\n✓ Task app URL: {task_app_url}")
1205
-
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)
1206
1444
  except subprocess.CalledProcessError as exc:
1207
1445
  raise click.ClickException(
1208
1446
  f"modal {command} failed with exit code {exc.returncode}"
@@ -1485,6 +1723,76 @@ def serve_command(
1485
1723
  )
1486
1724
 
1487
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
+
1488
1796
  @task_app_group.command("serve")
1489
1797
  @click.argument("app_id", type=str, required=False)
1490
1798
  @click.option("--host", default="0.0.0.0", show_default=True)
@@ -1638,7 +1946,7 @@ def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
1638
1946
  try:
1639
1947
  os.kill(int(pid), signal.SIGTERM)
1640
1948
  except Exception as exc:
1641
- raise click.ClickException(f"Failed to terminate PID {pid}: {exc}")
1949
+ raise click.ClickException(f"Failed to terminate PID {pid}: {exc}") from exc
1642
1950
 
1643
1951
  time.sleep(0.5)
1644
1952
 
@@ -1650,7 +1958,7 @@ def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
1650
1958
  try:
1651
1959
  os.kill(int(pid), signal.SIGKILL)
1652
1960
  except Exception as exc:
1653
- raise click.ClickException(f"Failed to force terminate PID {pid}: {exc}")
1961
+ raise click.ClickException(f"Failed to force terminate PID {pid}: {exc}") from exc
1654
1962
  time.sleep(0.5)
1655
1963
 
1656
1964
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -1668,6 +1976,8 @@ def _save_to_env_file(env_path: Path, key: str, value: str) -> None:
1668
1976
  existing_lines = []
1669
1977
  if env_path.exists():
1670
1978
  existing_lines = env_path.read_text().splitlines()
1979
+ else:
1980
+ env_path.parent.mkdir(parents=True, exist_ok=True)
1671
1981
 
1672
1982
  # Check if key already exists and update it
1673
1983
  key_updated = False
@@ -1693,11 +2003,33 @@ def _save_to_env_file(env_path: Path, key: str, value: str) -> None:
1693
2003
  # Add newline before appending
1694
2004
  f.write("\n")
1695
2005
  f.write(f"{key}={value}\n")
1696
- click.echo(f"Saved {key} to {env_path}")
2006
+ click.echo(f"Saved {key} to {env_path}")
1697
2007
  except Exception as e:
1698
2008
  click.echo(f"Warning: Could not save {key} to .env: {e}", err=True)
1699
2009
 
1700
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
+
1701
2033
  def _validate_required_env_keys() -> None:
1702
2034
  """Validate required environment keys are set, prompting if missing."""
1703
2035
  # Use demo directory .env file if set, otherwise current directory
@@ -1742,17 +2074,19 @@ def _print_demo_next_steps_if_applicable() -> None:
1742
2074
  demo_dir = load_demo_dir()
1743
2075
 
1744
2076
  # Check if we're in the demo directory
1745
- if demo_dir and Path(demo_dir).resolve() == cwd:
1746
- # Check if this looks like the crafter demo (has run_local_rollout_traced.py)
1747
- if (cwd / "run_local_rollout_traced.py").exists():
1748
- click.echo("\n" + "=" * 60)
1749
- click.echo("Next step: Collect traced rollouts")
1750
- click.echo("=" * 60)
1751
- click.echo("\nIn another terminal, run:")
1752
- click.echo(f" cd {cwd}")
1753
- click.echo(" uv run python run_local_rollout_traced.py")
1754
- click.echo("\nRun this 5-10 times to collect diverse traces.")
1755
- click.echo("=" * 60 + "\n")
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")
1756
2090
  except Exception:
1757
2091
  # Silently fail - this is just a helpful printout
1758
2092
  pass
@@ -1813,7 +2147,8 @@ def _serve_entry(
1813
2147
  _ensure_port_free(port, host, force=force)
1814
2148
 
1815
2149
  _validate_required_env_keys()
1816
- _preflight_env_key()
2150
+ env_path_objs = [Path(p) for p in env_files if p]
2151
+ _preflight_env_key(env_path_objs)
1817
2152
 
1818
2153
  # Print next steps if in demo context
1819
2154
  if trace_enabled:
@@ -1912,6 +2247,7 @@ def _write_modal_entrypoint(
1912
2247
  *,
1913
2248
  dotenv_paths: Sequence[str] | None = None,
1914
2249
  original_path: Path | None = None,
2250
+ inline_secret_values: dict[str, str] | None = None,
1915
2251
  ) -> Path:
1916
2252
  modal_name = override_name or modal_cfg.app_name
1917
2253
 
@@ -1987,6 +2323,7 @@ def _write_modal_entrypoint(
1987
2323
  local_dirs.append((discovered_dir, mount_dst))
1988
2324
  secret_names = list(modal_cfg.secret_names)
1989
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}
1990
2327
 
1991
2328
  script = f"""from __future__ import annotations
1992
2329
 
@@ -2009,6 +2346,7 @@ MODAL_APP_NAME = {modal_name!r}
2009
2346
  MODULE_NAME = {module_name!r}
2010
2347
  MODULE_FILE = {guaranteed_file_str or remote_file_str!r}
2011
2348
  DOTENV_PATHS = {dotenv_paths!r}
2349
+ INLINE_SECRET_VALUES = {inline_secret_values!r}
2012
2350
 
2013
2351
  image = Image.debian_slim(python_version={modal_cfg.python_version!r})
2014
2352
 
@@ -2052,6 +2390,9 @@ for local_src, remote_dst in local_dirs:
2052
2390
  secrets = {secret_names!r}
2053
2391
  secret_objs = [Secret.from_name(name) for name in secrets]
2054
2392
 
2393
+ if INLINE_SECRET_VALUES:
2394
+ secret_objs.append(Secret.from_dict(INLINE_SECRET_VALUES))
2395
+
2055
2396
  if DOTENV_PATHS:
2056
2397
  secret_objs.extend(Secret.from_dotenv(path) for path in DOTENV_PATHS)
2057
2398
 
@@ -2119,11 +2460,11 @@ def fastapi_app():
2119
2460
  return create_task_app(config)
2120
2461
  """
2121
2462
 
2122
- tmp = tempfile.NamedTemporaryFile("w", suffix=f"_{entry.app_id}_modal.py", delete=False)
2123
- tmp.write(script)
2124
- tmp.flush()
2125
- tmp.close()
2126
- return Path(tmp.name)
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)
2127
2468
 
2128
2469
 
2129
2470
  def register(cli: click.Group) -> None:
@@ -2178,12 +2519,9 @@ def eval_command(
2178
2519
  parsed = _toml.loads(data.decode("utf-8"))
2179
2520
  if isinstance(parsed, dict):
2180
2521
  section = parsed.get("eval")
2181
- if isinstance(section, dict):
2182
- cfg = dict(section)
2183
- else:
2184
- cfg = dict(parsed)
2522
+ cfg = dict(section) if isinstance(section, dict) else dict(parsed)
2185
2523
  except Exception as exc:
2186
- raise click.ClickException(f"Failed to parse TOML '{config_path}': {exc}")
2524
+ raise click.ClickException(f"Failed to parse TOML '{config_path}': {exc}") from exc
2187
2525
 
2188
2526
  app_id = app_id or (cfg.get("app_id") if isinstance(cfg.get("app_id"), str) else None) # type: ignore
2189
2527
 
@@ -2193,10 +2531,8 @@ def eval_command(
2193
2531
  if cfg.get("seeds") and seeds == "0,1,2,3,4":
2194
2532
  val = cfg["seeds"]
2195
2533
  if isinstance(val, list):
2196
- try:
2534
+ with contextlib.suppress(Exception):
2197
2535
  seeds = ",".join(str(int(x)) for x in val)
2198
- except Exception:
2199
- pass
2200
2536
  elif isinstance(val, str):
2201
2537
  seeds = val
2202
2538
  elif isinstance(val, int):
@@ -2297,8 +2633,8 @@ def eval_command(
2297
2633
 
2298
2634
  try:
2299
2635
  seed_values = [int(s.strip()) for s in seeds.split(",") if s.strip()]
2300
- except Exception:
2301
- raise click.ClickException("Invalid --seeds; expected comma-separated integers")
2636
+ except Exception as exc:
2637
+ raise click.ClickException("Invalid --seeds; expected comma-separated integers") from exc
2302
2638
 
2303
2639
  import httpx
2304
2640
 
@@ -2324,11 +2660,9 @@ def eval_command(
2324
2660
  )
2325
2661
  else:
2326
2662
  client = httpx.Client(base_url=task_app_url, timeout=60.0, headers=headers)
2327
- with client as client:
2328
- try:
2663
+ try:
2664
+ with contextlib.suppress(Exception):
2329
2665
  client.get("/task_info")
2330
- except Exception:
2331
- pass
2332
2666
  # Precompute optional policy overrides from TOML
2333
2667
  policy_overrides: dict[str, Any] = {}
2334
2668
  try:
@@ -2406,16 +2740,32 @@ def eval_command(
2406
2740
  summary.append(f"tool_calls={len(tool_calls)}")
2407
2741
  click.echo(" ".join(summary))
2408
2742
  # Print the full response JSON (trace, trajectories, metrics)
2409
- try:
2743
+ with contextlib.suppress(Exception):
2410
2744
  click.echo(json.dumps(data, indent=2))
2411
- except Exception:
2412
- pass
2413
2745
  else:
2414
2746
  click.echo(" ".join(summary))
2415
2747
  except Exception as exc:
2416
2748
  failures += 1
2417
2749
  click.echo(f"seed={seed_val} error={exc}")
2418
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
+
2419
2769
  click.echo(
2420
2770
  f"Eval complete: {successes} ok, {failures} failed; model={selected_model}, split={split}"
2421
2771
  )