synth-ai 0.2.16__py3-none-any.whl → 0.2.19__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 (299) hide show
  1. examples/analyze_semantic_words.sh +2 -2
  2. examples/baseline/banking77_baseline.py +204 -0
  3. examples/baseline/crafter_baseline.py +407 -0
  4. examples/baseline/pokemon_red_baseline.py +326 -0
  5. examples/baseline/simple_baseline.py +56 -0
  6. examples/baseline/warming_up_to_rl_baseline.py +239 -0
  7. examples/blog_posts/gepa/README.md +355 -0
  8. examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
  9. examples/blog_posts/gepa/configs/banking77_gepa_test.toml +82 -0
  10. examples/blog_posts/gepa/configs/banking77_mipro_local.toml +52 -0
  11. examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +59 -0
  12. examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +36 -0
  13. examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +53 -0
  14. examples/blog_posts/gepa/configs/hover_gepa_local.toml +59 -0
  15. examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +36 -0
  16. examples/blog_posts/gepa/configs/hover_mipro_local.toml +53 -0
  17. examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +59 -0
  18. examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +36 -0
  19. examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +53 -0
  20. examples/blog_posts/gepa/configs/pupa_gepa_local.toml +60 -0
  21. examples/blog_posts/gepa/configs/pupa_mipro_local.toml +54 -0
  22. examples/blog_posts/gepa/deploy_banking77_task_app.sh +41 -0
  23. examples/blog_posts/gepa/gepa_baseline.py +204 -0
  24. examples/blog_posts/gepa/query_prompts_example.py +97 -0
  25. examples/blog_posts/gepa/run_gepa_banking77.sh +87 -0
  26. examples/blog_posts/gepa/task_apps.py +105 -0
  27. examples/blog_posts/gepa/test_gepa_local.sh +67 -0
  28. examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
  29. examples/blog_posts/pokemon_vl/README.md +98 -0
  30. examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
  31. examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +27 -0
  32. examples/blog_posts/pokemon_vl/configs/eval_rl_final.toml +24 -0
  33. examples/blog_posts/pokemon_vl/configs/filter_high_reward.toml +10 -0
  34. examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +43 -0
  35. examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
  36. examples/blog_posts/pokemon_vl/extract_images.py +239 -0
  37. examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
  38. examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
  39. examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
  40. examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
  41. examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
  42. examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
  43. examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
  44. examples/blog_posts/warming_up_to_rl/README.md +158 -0
  45. examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
  46. examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
  47. examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
  48. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
  49. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -0
  50. examples/blog_posts/warming_up_to_rl/configs/eval_groq_qwen32b.toml +25 -0
  51. examples/blog_posts/warming_up_to_rl/configs/eval_openai_gpt_oss_120b.toml +29 -0
  52. examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +10 -0
  53. examples/blog_posts/warming_up_to_rl/configs/smoke_test.toml +75 -0
  54. examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +91 -0
  55. examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
  56. examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
  57. examples/dev/qwen3_32b_qlora_4xh100.toml +5 -0
  58. examples/multi_step/configs/VERILOG_REWARDS.md +4 -0
  59. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +4 -0
  60. examples/multi_step/configs/crafter_rl_outcome.toml +2 -1
  61. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +65 -107
  62. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +2 -1
  63. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +2 -1
  64. examples/multi_step/configs/crafter_rl_stepwise_simple_NEW_FORMAT.toml +105 -0
  65. examples/multi_step/configs/verilog_rl_lora.toml +80 -123
  66. examples/qwen_coder/configs/coder_lora_30b.toml +1 -3
  67. examples/qwen_coder/configs/coder_lora_4b.toml +4 -1
  68. examples/qwen_coder/configs/coder_lora_small.toml +1 -3
  69. examples/qwen_vl/README.md +10 -12
  70. examples/qwen_vl/SETUP_COMPLETE.md +7 -8
  71. examples/qwen_vl/VISION_TESTS_COMPLETE.md +2 -3
  72. examples/qwen_vl/collect_data_via_cli.md +76 -84
  73. examples/qwen_vl/collect_vision_traces.py +4 -4
  74. examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +40 -57
  75. examples/qwen_vl/configs/crafter_vlm_sft_example.toml +1 -2
  76. examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +20 -37
  77. examples/qwen_vl/configs/eval_gpt5nano_vision.toml +21 -40
  78. examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
  79. examples/qwen_vl/configs/{filter_qwen2vl_sft.toml → filter_qwen3vl_sft.toml} +4 -5
  80. examples/qwen_vl/configs/filter_vision_sft.toml +2 -3
  81. examples/qwen_vl/crafter_qwen_vl_agent.py +5 -5
  82. examples/qwen_vl/run_vision_comparison.sh +6 -7
  83. examples/rl/README.md +5 -5
  84. examples/rl/configs/rl_from_base_qwen.toml +26 -1
  85. examples/rl/configs/rl_from_base_qwen17.toml +6 -2
  86. examples/rl/task_app/README.md +1 -2
  87. examples/rl/task_app/math_single_step.py +2 -2
  88. examples/run_crafter_demo.sh +2 -2
  89. examples/sft/README.md +1 -1
  90. examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -1
  91. examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -1
  92. examples/swe/task_app/README.md +32 -2
  93. examples/swe/task_app/grpo_swe_mini.py +4 -0
  94. examples/swe/task_app/hosted/envs/crafter/react_agent.py +1 -1
  95. examples/swe/task_app/hosted/envs/mini_swe/environment.py +37 -10
  96. examples/swe/task_app/hosted/inference/openai_client.py +4 -38
  97. examples/swe/task_app/hosted/policy_routes.py +17 -0
  98. examples/swe/task_app/hosted/rollout.py +4 -2
  99. examples/swe/task_app/morph_backend.py +178 -0
  100. examples/task_apps/banking77/__init__.py +6 -0
  101. examples/task_apps/banking77/banking77_task_app.py +841 -0
  102. examples/task_apps/banking77/deploy_wrapper.py +46 -0
  103. examples/task_apps/crafter/CREATE_SFT_DATASET.md +4 -0
  104. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +4 -0
  105. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +4 -0
  106. examples/task_apps/crafter/task_app/README.md +1 -1
  107. examples/task_apps/crafter/task_app/grpo_crafter.py +90 -5
  108. examples/task_apps/crafter/task_app/grpo_crafter_task_app.py +1 -1
  109. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +4 -26
  110. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -2
  111. examples/task_apps/crafter/task_app/synth_envs_hosted/hosted_app.py +49 -0
  112. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +372 -107
  113. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +81 -12
  114. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +82 -11
  115. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +194 -1
  116. examples/task_apps/enron/task_app/grpo_enron_task_app.py +1 -1
  117. examples/task_apps/gepa_benchmarks/__init__.py +7 -0
  118. examples/task_apps/gepa_benchmarks/common.py +260 -0
  119. examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
  120. examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
  121. examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
  122. examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
  123. examples/task_apps/math/README.md +1 -2
  124. examples/task_apps/pokemon_red/README.md +3 -4
  125. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +4 -0
  126. examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +6 -5
  127. examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +1 -2
  128. examples/task_apps/pokemon_red/task_app.py +288 -39
  129. examples/task_apps/sokoban/README.md +2 -3
  130. examples/task_apps/verilog/eval_groq_qwen32b.toml +12 -14
  131. examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +1 -1
  132. examples/vlm/configs/crafter_vlm_gpt4o.toml +4 -1
  133. examples/warming_up_to_rl/configs/crafter_fft.toml +4 -1
  134. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +0 -2
  135. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +3 -2
  136. examples/warming_up_to_rl/run_local_rollout_traced.py +1 -1
  137. examples/warming_up_to_rl/task_app/README.md +1 -1
  138. examples/warming_up_to_rl/task_app/grpo_crafter.py +185 -5
  139. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +1 -1
  140. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +3 -27
  141. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -1
  142. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +49 -0
  143. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +156 -45
  144. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +37 -4
  145. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +33 -3
  146. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +67 -0
  147. examples/workflows/math_rl/configs/rl_from_base_qwen.toml +27 -0
  148. examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +6 -0
  149. synth_ai/api/train/builders.py +99 -4
  150. synth_ai/api/train/cli.py +516 -26
  151. synth_ai/api/train/config_finder.py +13 -2
  152. synth_ai/api/train/configs/__init__.py +23 -2
  153. synth_ai/api/train/configs/prompt_learning.py +442 -0
  154. synth_ai/api/train/configs/rl.py +61 -7
  155. synth_ai/api/train/configs/sft.py +6 -2
  156. synth_ai/api/train/configs/shared.py +59 -2
  157. synth_ai/api/train/task_app.py +1 -1
  158. synth_ai/api/train/validators.py +277 -0
  159. synth_ai/auth/credentials.py +119 -0
  160. synth_ai/baseline/__init__.py +25 -0
  161. synth_ai/baseline/config.py +209 -0
  162. synth_ai/baseline/discovery.py +214 -0
  163. synth_ai/baseline/execution.py +146 -0
  164. synth_ai/cli/__init__.py +94 -18
  165. synth_ai/cli/__main__.py +0 -0
  166. synth_ai/cli/claude.py +70 -0
  167. synth_ai/cli/codex.py +84 -0
  168. synth_ai/cli/commands/__init__.py +18 -0
  169. synth_ai/cli/commands/baseline/__init__.py +12 -0
  170. synth_ai/cli/commands/baseline/core.py +637 -0
  171. synth_ai/cli/commands/baseline/list.py +93 -0
  172. synth_ai/cli/commands/demo/__init__.py +6 -0
  173. synth_ai/cli/commands/demo/core.py +163 -0
  174. synth_ai/cli/commands/eval/__init__.py +19 -0
  175. synth_ai/cli/commands/eval/core.py +1112 -0
  176. synth_ai/cli/commands/eval/errors.py +81 -0
  177. synth_ai/cli/commands/eval/validation.py +133 -0
  178. synth_ai/cli/commands/filter/__init__.py +12 -0
  179. synth_ai/cli/commands/filter/core.py +424 -0
  180. synth_ai/cli/commands/filter/errors.py +55 -0
  181. synth_ai/cli/commands/filter/validation.py +77 -0
  182. synth_ai/cli/commands/help/__init__.py +177 -0
  183. synth_ai/cli/commands/help/core.py +72 -0
  184. synth_ai/cli/commands/smoke/__init__.py +7 -0
  185. synth_ai/cli/commands/smoke/core.py +1436 -0
  186. synth_ai/cli/commands/status/__init__.py +64 -0
  187. synth_ai/cli/commands/status/client.py +192 -0
  188. synth_ai/cli/commands/status/config.py +92 -0
  189. synth_ai/cli/commands/status/errors.py +20 -0
  190. synth_ai/cli/commands/status/formatters.py +164 -0
  191. synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
  192. synth_ai/cli/commands/status/subcommands/files.py +79 -0
  193. synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
  194. synth_ai/cli/commands/status/subcommands/models.py +79 -0
  195. synth_ai/cli/commands/status/subcommands/pricing.py +22 -0
  196. synth_ai/cli/commands/status/subcommands/runs.py +81 -0
  197. synth_ai/cli/commands/status/subcommands/summary.py +47 -0
  198. synth_ai/cli/commands/status/subcommands/usage.py +203 -0
  199. synth_ai/cli/commands/status/utils.py +114 -0
  200. synth_ai/cli/commands/train/__init__.py +53 -0
  201. synth_ai/cli/commands/train/core.py +21 -0
  202. synth_ai/cli/commands/train/errors.py +117 -0
  203. synth_ai/cli/commands/train/judge_schemas.py +200 -0
  204. synth_ai/cli/commands/train/judge_validation.py +305 -0
  205. synth_ai/cli/commands/train/validation.py +386 -0
  206. synth_ai/cli/demo.py +30 -158
  207. synth_ai/cli/deploy/__init__.py +43 -0
  208. synth_ai/cli/deploy.py +162 -0
  209. synth_ai/cli/eval/__init__.py +36 -0
  210. synth_ai/cli/eval/core.py +5 -0
  211. synth_ai/cli/eval/errors.py +31 -0
  212. synth_ai/cli/eval/validation.py +5 -0
  213. synth_ai/cli/filter/__init__.py +28 -0
  214. synth_ai/cli/filter/core.py +5 -0
  215. synth_ai/cli/filter/errors.py +23 -0
  216. synth_ai/cli/filter/validation.py +5 -0
  217. synth_ai/cli/legacy_root_backup.py +14 -8
  218. synth_ai/cli/modal_serve/__init__.py +12 -0
  219. synth_ai/cli/modal_serve/core.py +14 -0
  220. synth_ai/cli/modal_serve/errors.py +8 -0
  221. synth_ai/cli/modal_serve/validation.py +11 -0
  222. synth_ai/cli/opencode.py +107 -0
  223. synth_ai/cli/root.py +9 -5
  224. synth_ai/cli/serve/__init__.py +12 -0
  225. synth_ai/cli/serve/core.py +14 -0
  226. synth_ai/cli/serve/errors.py +8 -0
  227. synth_ai/cli/serve/validation.py +11 -0
  228. synth_ai/cli/setup.py +20 -265
  229. synth_ai/cli/status.py +7 -126
  230. synth_ai/cli/task_app_deploy.py +1 -10
  231. synth_ai/cli/task_app_modal_serve.py +4 -9
  232. synth_ai/cli/task_app_serve.py +4 -11
  233. synth_ai/cli/task_apps.py +51 -1480
  234. synth_ai/cli/train/__init__.py +12 -0
  235. synth_ai/cli/train/core.py +21 -0
  236. synth_ai/cli/train/errors.py +8 -0
  237. synth_ai/cli/train/validation.py +24 -0
  238. synth_ai/cli/train.py +1 -14
  239. synth_ai/demos/crafter/grpo_crafter_task_app.py +1 -1
  240. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
  241. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +7 -4
  242. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
  243. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
  244. synth_ai/environments/examples/red/engine.py +33 -12
  245. synth_ai/environments/examples/red/engine_helpers/reward_components.py +151 -179
  246. synth_ai/environments/examples/red/environment.py +26 -0
  247. synth_ai/environments/examples/red/trace_hooks_v3.py +168 -0
  248. synth_ai/http.py +12 -0
  249. synth_ai/judge_schemas.py +10 -10
  250. synth_ai/learning/__init__.py +10 -0
  251. synth_ai/learning/prompt_learning_client.py +276 -0
  252. synth_ai/learning/prompt_learning_types.py +184 -0
  253. synth_ai/learning/rl/client.py +3 -1
  254. synth_ai/pricing/__init__.py +2 -0
  255. synth_ai/pricing/model_pricing.py +57 -0
  256. synth_ai/streaming/__init__.py +29 -0
  257. synth_ai/streaming/config.py +94 -0
  258. synth_ai/streaming/handlers.py +518 -0
  259. synth_ai/streaming/streamer.py +320 -0
  260. synth_ai/streaming/types.py +95 -0
  261. synth_ai/task/apps/__init__.py +1 -0
  262. synth_ai/task/config.py +2 -0
  263. synth_ai/task/tracing_utils.py +25 -25
  264. synth_ai/task/validators.py +45 -9
  265. synth_ai/task_app_cfgs.py +21 -0
  266. synth_ai/tracing_v3/config.py +162 -19
  267. synth_ai/tracing_v3/constants.py +1 -1
  268. synth_ai/tracing_v3/db_config.py +24 -38
  269. synth_ai/tracing_v3/migration_helper.py +1 -2
  270. synth_ai/tracing_v3/storage/config.py +47 -13
  271. synth_ai/tracing_v3/storage/factory.py +3 -3
  272. synth_ai/tracing_v3/turso/daemon.py +113 -11
  273. synth_ai/tracing_v3/turso/native_manager.py +92 -16
  274. synth_ai/types.py +8 -0
  275. synth_ai/urls.py +11 -0
  276. synth_ai/utils/__init__.py +30 -1
  277. synth_ai/utils/agents.py +74 -0
  278. synth_ai/utils/bin.py +39 -0
  279. synth_ai/utils/cli.py +149 -5
  280. synth_ai/utils/env.py +40 -33
  281. synth_ai/utils/http.py +4 -1
  282. synth_ai/utils/json.py +72 -0
  283. synth_ai/utils/modal.py +285 -3
  284. synth_ai/utils/paths.py +48 -0
  285. synth_ai/utils/uvicorn.py +113 -0
  286. {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/METADATA +109 -6
  287. {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/RECORD +291 -142
  288. examples/qwen_vl/configs/eval_qwen2vl_vision.toml +0 -44
  289. synth_ai/cli/tui.py +0 -62
  290. synth_ai/tui/__init__.py +0 -5
  291. synth_ai/tui/__main__.py +0 -13
  292. synth_ai/tui/cli/__init__.py +0 -1
  293. synth_ai/tui/cli/query_experiments.py +0 -164
  294. synth_ai/tui/cli/query_experiments_v3.py +0 -164
  295. synth_ai/tui/dashboard.py +0 -911
  296. {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/WHEEL +0 -0
  297. {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/entry_points.txt +0 -0
  298. {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/licenses/LICENSE +0 -0
  299. {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/top_level.txt +0 -0
synth_ai/utils/env.py CHANGED
@@ -5,6 +5,8 @@ from pathlib import Path
5
5
 
6
6
  import click
7
7
 
8
+ from .paths import get_env_file_paths, get_home_config_file_paths
9
+
8
10
  _ENV_SAFE_CHARS = set(string.ascii_letters + string.digits + "_-./:@+=")
9
11
 
10
12
 
@@ -84,18 +86,6 @@ def mask_str(input: str, position: int = 3) -> str:
84
86
  return input[:position] + "..." + input[-position:] if len(input) > position * 2 else "***"
85
87
 
86
88
 
87
- def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
88
- base = Path(base_dir).resolve()
89
- return [path for path in base.rglob(".env*") if path.is_file()]
90
-
91
-
92
- def get_synth_config_file_paths() -> list[Path]:
93
- dir = Path.home() / ".synth-ai"
94
- if not dir.exists():
95
- return []
96
- return [path for path in dir.glob("*.json") if path.is_file()]
97
-
98
-
99
89
  def filter_env_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
100
90
  matches: list[tuple[Path, str]] = []
101
91
  for path in paths:
@@ -127,18 +117,31 @@ def filter_json_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, st
127
117
  return matches
128
118
 
129
119
 
130
- def resolve_env_var(key: str) -> None:
120
+ def ensure_env_var(key: str, expected_value: str) -> None:
121
+ actual_value = os.getenv(key)
122
+ if expected_value != actual_value:
123
+ raise ValueError(f"Expected: {key}={expected_value}\nActual: {key}={actual_value}")
124
+
125
+
126
+ def resolve_env_var(
127
+ key: str,
128
+ override_process_env: bool = False
129
+ ) -> str:
131
130
  env_value = os.getenv(key)
132
- if env_value is not None:
131
+ if env_value is not None and not override_process_env:
133
132
  click.echo(f"Using {key}={mask_str(env_value)} from process environment")
134
- return
133
+ return env_value
135
134
 
136
135
  value: str = ""
137
136
 
138
137
  env_file_paths = filter_env_files_by_key(key, get_env_file_paths())
139
- synth_file_paths = filter_json_files_by_key(key, get_synth_config_file_paths())
138
+ synth_file_paths = filter_json_files_by_key(key, get_home_config_file_paths(".synth-ai"))
140
139
 
141
140
  options: list[tuple[str, str]] = []
141
+ if env_value is not None:
142
+ if not override_process_env:
143
+ return env_value
144
+ options.append((f"(process environment) {mask_str(env_value)}", env_value))
142
145
  for path, value in env_file_paths:
143
146
  resolved_path = path.resolve()
144
147
  try:
@@ -161,14 +164,13 @@ def resolve_env_var(key: str) -> None:
161
164
  while True:
162
165
  try:
163
166
  choice = click.prompt(
164
- "Select option",
167
+ "Select an option",
165
168
  default=1,
166
169
  type=str,
167
170
  show_choices=False,
168
171
  ).strip()
169
172
  except click.Abort:
170
- return
171
-
173
+ raise
172
174
  if choice.lower() == 'm':
173
175
  value = _prompt_manual_env_value(key)
174
176
  break
@@ -186,20 +188,24 @@ def resolve_env_var(key: str) -> None:
186
188
  click.echo(f"Invalid selection. Enter a number between 1 and {len(options)} or 'm'.")
187
189
 
188
190
  else:
189
- click.echo(f"No value found for {key}")
191
+ print(f"No value found for {key}")
190
192
  value = _prompt_manual_env_value(key)
191
193
 
192
194
  os.environ[key] = value
193
- click.echo(f"Loaded {key}={mask_str(value)} into process environment")
194
- return
195
+ ensure_env_var(key, value)
196
+ print(f"Loaded {key}={mask_str(value)} into process environment")
197
+ return value
195
198
 
196
199
 
197
200
  def write_env_var_to_dotenv(
198
201
  key: str,
199
202
  value: str,
200
- output_file_path: str | Path,
203
+ output_file_path: str | Path | None = None,
204
+ print_msg: bool = True,
205
+ mask_msg: bool = True
201
206
  ) -> None:
202
- path = Path(output_file_path).expanduser()
207
+ path = Path(".env") if output_file_path is None else Path(output_file_path)
208
+ path = path.expanduser()
203
209
  path.parent.mkdir(parents=True, exist_ok=True)
204
210
 
205
211
  encoded_value = _format_env_value(value)
@@ -212,7 +218,7 @@ def write_env_var_to_dotenv(
212
218
  with path.open('r', encoding="utf-8") as handle:
213
219
  lines = handle.readlines()
214
220
  except OSError as exc:
215
- raise click.ClickException(f"Failed to read {path}: {exc}") from exc
221
+ raise RuntimeError(f"Failed to read {path}: {exc}") from exc
216
222
 
217
223
  for index, line in enumerate(lines):
218
224
  parsed = _parse_env_assignment(line)
@@ -238,9 +244,10 @@ def write_env_var_to_dotenv(
238
244
  with path.open('w', encoding="utf-8") as handle:
239
245
  handle.writelines(lines)
240
246
  except OSError as exc:
241
- raise click.ClickException(f"Failed to write {path}: {exc}") from exc
247
+ raise RuntimeError(f"Failed to write {path}: {exc}") from exc
242
248
 
243
- click.echo(f"Wrote {key}={mask_str(value)} to {path}")
249
+ if print_msg:
250
+ print(f"Wrote {key}={mask_str(value) if mask_msg else value} to {path.resolve()}")
244
251
 
245
252
 
246
253
  def write_env_var_to_json(
@@ -250,7 +257,7 @@ def write_env_var_to_json(
250
257
  ) -> None:
251
258
  path = Path(output_file_path).expanduser()
252
259
  if path.exists() and not path.is_file():
253
- raise click.ClickException(f"{path} exists and is not a file")
260
+ raise RuntimeError(f"{path} exists and is not a file")
254
261
 
255
262
  data: dict[str, str] = {}
256
263
 
@@ -259,12 +266,12 @@ def write_env_var_to_json(
259
266
  with path.open('r', encoding="utf-8") as handle:
260
267
  existing = json.load(handle)
261
268
  except json.JSONDecodeError as exc:
262
- raise click.ClickException(f"Invalid JSON in {path}: {exc}") from exc
269
+ raise RuntimeError(f"Invalid JSON in {path}: {exc}") from exc
263
270
  except OSError as exc:
264
- raise click.ClickException(f"Failed to read {path}: {exc}") from exc
271
+ raise RuntimeError(f"Failed to read {path}: {exc}") from exc
265
272
 
266
273
  if not isinstance(existing, dict):
267
- raise click.ClickException(f"Expected JSON object in {path}")
274
+ raise RuntimeError(f"Expected JSON object in {path}")
268
275
 
269
276
  for existing_key, existing_value in existing.items():
270
277
  if existing_key == key:
@@ -282,6 +289,6 @@ def write_env_var_to_json(
282
289
  json.dump(data, handle, indent=2, sort_keys=True)
283
290
  handle.write('\n')
284
291
  except OSError as exc:
285
- raise click.ClickException(f"Failed to write {path}: {exc}") from exc
292
+ raise RuntimeError(f"Failed to write {path}: {exc}") from exc
286
293
 
287
- click.echo(f"Wrote {key}={mask_str(value)} to {path}")
294
+ print(f"Wrote {key}={mask_str(value)} to {path}")
synth_ai/utils/http.py CHANGED
@@ -34,7 +34,10 @@ class AsyncHttpClient:
34
34
 
35
35
  async def __aenter__(self) -> AsyncHttpClient:
36
36
  if self._session is None:
37
- headers = {"authorization": f"Bearer {self._api_key}"}
37
+ headers = {
38
+ "authorization": f"Bearer {self._api_key}",
39
+ "accept": "application/json",
40
+ }
38
41
  user_id = os.getenv("SYNTH_USER_ID") or os.getenv("X_USER_ID") or os.getenv("USER_ID")
39
42
  if user_id:
40
43
  headers["X-User-ID"] = user_id
synth_ai/utils/json.py ADDED
@@ -0,0 +1,72 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def strip_json_comments(raw: str) -> str:
6
+ """Remove // and /* */ comments from JSONC text."""
7
+ result: list[str] = []
8
+ in_string = False
9
+ in_line_comment = False
10
+ in_block_comment = False
11
+ escape = False
12
+ i = 0
13
+ length = len(raw)
14
+ while i < length:
15
+ char = raw[i]
16
+ next_char = raw[i + 1] if i + 1 < length else ""
17
+
18
+ if in_line_comment:
19
+ if char == "\n":
20
+ in_line_comment = False
21
+ result.append(char)
22
+ i += 1
23
+ continue
24
+
25
+ if in_block_comment:
26
+ if char == "*" and next_char == "/":
27
+ in_block_comment = False
28
+ i += 2
29
+ else:
30
+ i += 1
31
+ continue
32
+
33
+ if in_string:
34
+ result.append(char)
35
+ if char == "\"" and not escape:
36
+ in_string = False
37
+ escape = (char == "\\") and not escape
38
+ i += 1
39
+ continue
40
+
41
+ if char == "/" and next_char == "/":
42
+ in_line_comment = True
43
+ i += 2
44
+ continue
45
+
46
+ if char == "/" and next_char == "*":
47
+ in_block_comment = True
48
+ i += 2
49
+ continue
50
+
51
+ if char == "\"":
52
+ in_string = True
53
+ escape = False
54
+
55
+ result.append(char)
56
+ i += 1
57
+
58
+ return "".join(result)
59
+
60
+
61
+ def create_and_write_json(path: Path, content: dict) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps(content, indent=2) + "\n")
64
+
65
+
66
+ def load_json_to_dict(path: Path) -> dict:
67
+ if not path.exists():
68
+ return {}
69
+ try:
70
+ return json.loads(strip_json_comments(path.read_text()))
71
+ except (json.JSONDecodeError, OSError):
72
+ return {}
synth_ai/utils/modal.py CHANGED
@@ -1,16 +1,25 @@
1
+ import ast
1
2
  import contextlib
2
3
  import json
3
4
  import os
5
+ import re
6
+ import shlex
4
7
  import shutil
8
+ import subprocess
5
9
  import sys
10
+ import tempfile
11
+ import textwrap
6
12
  from pathlib import Path
7
- from typing import Any, cast
13
+ from typing import Any
8
14
  from urllib.parse import urlparse, urlunparse
9
15
 
16
+ import click
17
+ from modal.config import config
10
18
  from synth_ai.demos import core as demo_core
11
19
  from synth_ai.demos.core import DEFAULT_TASK_APP_SECRET_NAME, DemoEnv
20
+ from synth_ai.task_app_cfgs import ModalTaskAppConfig
12
21
 
13
- from .env import mask_str
22
+ from .env import mask_str, resolve_env_var, write_env_var_to_dotenv
14
23
  from .http import http_request
15
24
  from .process import popen_capture
16
25
  from .user_config import load_user_config
@@ -25,6 +34,279 @@ __all__ = [
25
34
  ]
26
35
 
27
36
 
37
+ REPO_ROOT = Path(__file__).resolve().parents[2]
38
+
39
+ START_DIV = f"{'-' * 31} Modal start {'-' * 31}"
40
+ END_DIV = f"{'-' * 32} Modal end {'-' * 32}"
41
+ MODAL_URL_REGEX = re.compile(r"https?://[^\s]+modal\.run[^\s]*")
42
+
43
+
44
+ def get_default_modal_bin_path() -> Path | None:
45
+ resolved = shutil.which("modal")
46
+ return Path(resolved) if resolved else None
47
+
48
+
49
+ def ensure_py_file_defines_modal_app(file_path: Path) -> None:
50
+ if file_path.suffix != ".py":
51
+ raise TypeError()
52
+ try:
53
+ tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
54
+ except OSError as exc:
55
+ raise OSError() from exc
56
+
57
+ app_aliases: set[str] = set()
58
+ modal_aliases: set[str] = set()
59
+
60
+ def literal_name(call: ast.Call) -> str | None:
61
+ for kw in call.keywords:
62
+ if (
63
+ kw.arg in {"name", "app_name"}
64
+ and isinstance(kw.value, ast.Constant)
65
+ and isinstance(kw.value.value, str)
66
+ ):
67
+ return kw.value.value
68
+ if call.args:
69
+ first = call.args[0]
70
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
71
+ return first.value
72
+ return None
73
+
74
+ for node in ast.walk(tree):
75
+ if isinstance(node, ast.ImportFrom) and node.module == "modal":
76
+ for alias in node.names:
77
+ if alias.name == "App":
78
+ app_aliases.add(alias.asname or alias.name)
79
+ elif isinstance(node, ast.Import):
80
+ for alias in node.names:
81
+ if alias.name == "modal":
82
+ modal_aliases.add(alias.asname or alias.name)
83
+ elif isinstance(node, ast.Call):
84
+ func = node.func
85
+ if isinstance(func, ast.Name) and func.id in app_aliases:
86
+ if literal_name(node):
87
+ return None
88
+ elif (
89
+ isinstance(func, ast.Attribute)
90
+ and func.attr == "App"
91
+ and isinstance(func.value, ast.Name)
92
+ and func.value.id in modal_aliases
93
+ and literal_name(node)
94
+ ):
95
+ return None
96
+ raise ValueError()
97
+
98
+
99
+ def run_modal_setup(modal_bin_path: Path) -> None:
100
+
101
+ print("\n🌐 Connecting to your Modal account via https://modal.com")
102
+ print(START_DIV)
103
+ cmd = [str(modal_bin_path), "setup"]
104
+ try:
105
+ subprocess.run(cmd, check=True)
106
+ except subprocess.CalledProcessError as exc:
107
+ print(END_DIV)
108
+ raise RuntimeError(
109
+ f"`{' '.join(cmd)}` exited with status {exc.returncode}"
110
+ f"Run `{' '.join(cmd)} manually to inspect output"
111
+ ) from exc
112
+ print(END_DIV)
113
+ print("✅ Connected to your Modal account")
114
+
115
+
116
+ def ensure_modal_config() -> None:
117
+ token_id = os.environ.get("MODAL_TOKEN_ID") \
118
+ or config.get("token_id") \
119
+ or ''
120
+ token_secret = os.environ.get("MODAL_TOKEN_SECRET") \
121
+ or config.get("token_secret") \
122
+ or ''
123
+ if token_id and token_secret:
124
+ print(f"Found Modal token_id={mask_str(token_id)}")
125
+ print(f"Found Modal token_secret={mask_str(token_secret)}")
126
+ return
127
+
128
+ modal_bin_path = get_default_modal_bin_path()
129
+ if not modal_bin_path:
130
+ raise RuntimeError("Modal CLI not found on PATH")
131
+ run_modal_setup(modal_bin_path)
132
+
133
+
134
+ def deploy_modal_app(cfg: ModalTaskAppConfig) -> None:
135
+ ensure_py_file_defines_modal_app(cfg.modal_app_path)
136
+ ensure_modal_config()
137
+
138
+ py_paths: list[str] = []
139
+
140
+ source_dir = cfg.modal_app_path.parent.resolve()
141
+ py_paths.append(str(source_dir))
142
+ if (source_dir / "__init__.py").exists(): # if the modal app lives in a package, ensure the parent package is importable
143
+ py_paths.append(str(source_dir.parent.resolve()))
144
+
145
+ py_paths.append(str(REPO_ROOT))
146
+
147
+ env_api_key = resolve_env_var("ENVIRONMENT_API_KEY")
148
+ if not os.environ["ENVIRONMENT_API_KEY"]:
149
+ raise RuntimeError()
150
+
151
+ env_copy = os.environ.copy()
152
+ existing_python_path = env_copy.get("PYTHONPATH")
153
+ if existing_python_path:
154
+ py_paths.append(existing_python_path)
155
+ unique_python_paths = list(dict.fromkeys(py_paths))
156
+ env_copy["PYTHONPATH"] = os.pathsep.join(unique_python_paths)
157
+ if "PYTHONPATH" in env_copy: # ensure wrapper has access to synth source for intra-repo imports
158
+ env_copy["PYTHONPATH"] = os.pathsep.join(
159
+ [str(REPO_ROOT)] + env_copy["PYTHONPATH"].split(os.pathsep)
160
+ )
161
+ else:
162
+ env_copy["PYTHONPATH"] = str(REPO_ROOT)
163
+
164
+ modal_app_dir = cfg.modal_app_path.parent.resolve()
165
+ tmp_root = Path(tempfile.mkdtemp(prefix="synth_modal_app"))
166
+ wrapper_src = textwrap.dedent(f"""
167
+ from importlib import util as _util
168
+ from pathlib import Path as _Path
169
+ import sys as _sys
170
+
171
+ _source_dir = _Path({str(modal_app_dir)!r}).resolve()
172
+ _module_path = _source_dir / {cfg.modal_app_path.name!r}
173
+ _package_name = _source_dir.name
174
+ _repo_root = _Path({str(REPO_ROOT)!r}).resolve()
175
+ _synth_dir = _repo_root / "synth_ai"
176
+
177
+ for _path in (str(_source_dir), str(_source_dir.parent), str(_repo_root)):
178
+ if _path not in _sys.path:
179
+ _sys.path.insert(0, _path)
180
+
181
+ _spec = _util.spec_from_file_location("_synth_modal_target", str(_module_path))
182
+ if _spec is None or _spec.loader is None:
183
+ raise SystemExit("Unable to load modal task app from {cfg.modal_app_path}")
184
+ _module = _util.module_from_spec(_spec)
185
+ _sys.modules.setdefault("_synth_modal_target", _module)
186
+ _spec.loader.exec_module(_module)
187
+
188
+ try:
189
+ from modal import App as _ModalApp
190
+ from modal import Image as _ModalImage
191
+ except Exception:
192
+ _ModalApp = None # type: ignore[assignment]
193
+ _ModalImage = None # type: ignore[assignment]
194
+
195
+ def _apply_local_mounts(image):
196
+ if _ModalImage is None or not isinstance(image, _ModalImage):
197
+ return image
198
+ mounts = [
199
+ (str(_source_dir), f"/root/{{_package_name}}"),
200
+ (str(_synth_dir), "/root/synth_ai"),
201
+ ]
202
+ for local_path, remote_path in mounts:
203
+ try:
204
+ image = image.add_local_dir(local_path, remote_path=remote_path)
205
+ except Exception:
206
+ pass
207
+ return image
208
+
209
+ if hasattr(_module, "image"):
210
+ _module.image = _apply_local_mounts(getattr(_module, "image"))
211
+
212
+ _candidate = getattr(_module, "app", None)
213
+ if _ModalApp is None or not isinstance(_candidate, _ModalApp):
214
+ candidate_modal_app = getattr(_module, "modal_app", None)
215
+ if _ModalApp is not None and isinstance(candidate_modal_app, _ModalApp):
216
+ _candidate = candidate_modal_app
217
+ setattr(_module, "app", _candidate)
218
+
219
+ if _ModalApp is not None and not isinstance(_candidate, _ModalApp):
220
+ raise SystemExit(
221
+ "Modal task app must expose an 'app = modal.App(...)' (or modal_app) attribute."
222
+ )
223
+
224
+ try:
225
+ from modal import Secret as _Secret
226
+ except Exception:
227
+ _Secret = None
228
+
229
+ for remote_path in ("/root/synth_ai", f"/root/{{_package_name}}"):
230
+ if remote_path not in _sys.path:
231
+ _sys.path.insert(0, remote_path)
232
+
233
+ globals().update({{k: v for k, v in vars(_module).items() if not k.startswith("__")}})
234
+ app = getattr(_module, "app")
235
+ _ENVIRONMENT_API_KEY = {env_api_key!r}
236
+ if _Secret is not None and _ENVIRONMENT_API_KEY:
237
+ try:
238
+ _inline_secret = _Secret.from_dict({{"ENVIRONMENT_API_KEY": _ENVIRONMENT_API_KEY}})
239
+ except Exception:
240
+ _inline_secret = None
241
+ if _inline_secret is not None:
242
+ try:
243
+ _decorators = list(getattr(app, "_function_decorators", []))
244
+ except Exception:
245
+ _decorators = []
246
+ for _decorator in _decorators:
247
+ _existing = getattr(_decorator, "secrets", None)
248
+ if not _existing:
249
+ continue
250
+ try:
251
+ if _inline_secret not in _existing:
252
+ _decorator.secrets = list(_existing) + [_inline_secret]
253
+ except Exception:
254
+ pass
255
+ """).strip()
256
+ wrapper_path = tmp_root / "__modal_wrapper__.py"
257
+ wrapper_path.write_text(wrapper_src + '\n', encoding="utf-8")
258
+ wrapper_info = (wrapper_path, tmp_root)
259
+
260
+ cmd = [str(cfg.modal_bin_path), cfg.cmd_arg, str(wrapper_path)]
261
+ if cfg.task_app_name and cfg.cmd_arg == "deploy":
262
+ cmd.extend(["--name", cfg.task_app_name])
263
+
264
+ msg = " ".join(shlex.quote(c) for c in cmd)
265
+ if cfg.dry_run:
266
+ print("Dry run:\n", msg)
267
+ return
268
+ print(f"Running:\n{msg}")
269
+
270
+ try:
271
+ process = subprocess.Popen(
272
+ cmd,
273
+ stdout=subprocess.PIPE,
274
+ stderr=subprocess.STDOUT,
275
+ text=True,
276
+ bufsize=1,
277
+ env=env_copy
278
+ )
279
+ task_app_url = None
280
+ assert process.stdout is not None
281
+ print(START_DIV)
282
+ for line in process.stdout:
283
+ click.echo(line, nl=False)
284
+ if task_app_url is None:
285
+ match = MODAL_URL_REGEX.search(line)
286
+ if match:
287
+ task_app_url = match.group(0).rstrip(".,")
288
+ if task_app_url:
289
+ write_env_var_to_dotenv(
290
+ "TASK_APP_URL",
291
+ task_app_url,
292
+ print_msg=True,
293
+ mask_msg=False,
294
+ )
295
+ print(END_DIV)
296
+ rc = process.wait()
297
+ if rc != 0:
298
+ raise subprocess.CalledProcessError(rc, cmd)
299
+ except subprocess.CalledProcessError as exc:
300
+ raise click.ClickException(
301
+ f"modal {cfg.cmd_arg} failed with exit code: {exc.returncode}"
302
+ ) from exc
303
+ finally:
304
+ if wrapper_info is not None:
305
+ wrapper_path, tmp_root = wrapper_info
306
+ wrapper_path.unlink(missing_ok=True)
307
+ shutil.rmtree(tmp_root, ignore_errors=True)
308
+
309
+
28
310
  def is_modal_public_url(url: str | None) -> bool:
29
311
  try:
30
312
  candidate = (url or "").strip().lower()
@@ -63,7 +345,7 @@ def normalize_endpoint_url(url: str) -> str:
63
345
  creds += f":{parsed.password}"
64
346
  netloc = f"{creds}@{netloc}"
65
347
  parsed = parsed._replace(netloc=netloc)
66
- return cast(str, urlunparse(parsed))
348
+ return urlunparse(parsed)
67
349
  except Exception:
68
350
  pass
69
351
  return url
@@ -0,0 +1,48 @@
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+
5
+ def find_bin_path(name: str) -> Path | None:
6
+ path = shutil.which(name)
7
+ if not path:
8
+ return None
9
+ return Path(path)
10
+
11
+
12
+ def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
13
+ base = Path(base_dir).resolve()
14
+ return [path for path in base.rglob(".env*") if path.is_file()]
15
+
16
+
17
+ def get_home_config_file_paths(
18
+ dir_name: str,
19
+ file_extension: str = "json"
20
+ ) -> list[Path]:
21
+ dir = Path.home() / dir_name
22
+ if not dir.exists():
23
+ return []
24
+ return [path for path in dir.glob(f"*.{file_extension}") if path.is_file()]
25
+
26
+
27
+ def find_config_path(
28
+ bin_path: Path,
29
+ home_subdir: str,
30
+ filename: str,
31
+ ) -> Path | None:
32
+ """
33
+ Return a config file located in the user's home directory or alongside the binary.
34
+
35
+ Args:
36
+ bin_path: Resolved path to the executable.
37
+ home_subdir: Directory under the user's home to inspect (e.g., ".codex").
38
+ filename: Name of the config file to locate.
39
+ """
40
+ home_candidate = Path.home() / home_subdir / filename
41
+ if home_candidate.exists():
42
+ return home_candidate
43
+
44
+ local_candidate = Path(bin_path).parent / home_subdir / filename
45
+ if local_candidate.exists():
46
+ return local_candidate
47
+
48
+ return None