synth-ai 0.2.13.dev2__py3-none-any.whl → 0.2.16__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 (293) hide show
  1. examples/README.md +1 -0
  2. examples/multi_step/SFT_README.md +147 -0
  3. examples/multi_step/configs/README_verilog_rl.md +77 -0
  4. examples/multi_step/configs/VERILOG_REWARDS.md +90 -0
  5. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +183 -0
  6. examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
  7. examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
  8. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +12 -11
  9. examples/multi_step/configs/crafter_sft_qwen30b_lora.toml +62 -0
  10. examples/multi_step/configs/crafter_synth_backend.md +40 -0
  11. examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
  12. examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
  13. examples/multi_step/configs/verilog_rl_lora.toml +190 -0
  14. examples/multi_step/convert_traces_to_sft.py +84 -0
  15. examples/multi_step/judges/crafter_backend_judge.py +220 -0
  16. examples/multi_step/judges/verilog_backend_judge.py +234 -0
  17. examples/multi_step/readme.md +48 -0
  18. examples/multi_step/run_sft_qwen30b.sh +45 -0
  19. examples/multi_step/verilog_rl_lora.md +218 -0
  20. examples/qwen_coder/configs/coder_lora_30b.toml +3 -2
  21. examples/qwen_coder/configs/coder_lora_4b.toml +2 -1
  22. examples/qwen_coder/configs/coder_lora_small.toml +2 -1
  23. examples/qwen_vl/BUGS_AND_FIXES.md +232 -0
  24. examples/qwen_vl/IMAGE_VALIDATION_COMPLETE.md +271 -0
  25. examples/qwen_vl/IMAGE_VALIDATION_SUMMARY.md +260 -0
  26. examples/qwen_vl/INFERENCE_SFT_TESTS.md +412 -0
  27. examples/qwen_vl/NEXT_STEPS_2B.md +325 -0
  28. examples/qwen_vl/QUICKSTART.md +327 -0
  29. examples/qwen_vl/QUICKSTART_RL_VISION.md +110 -0
  30. examples/qwen_vl/README.md +154 -0
  31. examples/qwen_vl/RL_VISION_COMPLETE.md +475 -0
  32. examples/qwen_vl/RL_VISION_TESTING.md +333 -0
  33. examples/qwen_vl/SDK_VISION_INTEGRATION.md +328 -0
  34. examples/qwen_vl/SETUP_COMPLETE.md +275 -0
  35. examples/qwen_vl/VISION_TESTS_COMPLETE.md +490 -0
  36. examples/qwen_vl/VLM_PIPELINE_COMPLETE.md +242 -0
  37. examples/qwen_vl/__init__.py +2 -0
  38. examples/qwen_vl/collect_data_via_cli.md +423 -0
  39. examples/qwen_vl/collect_vision_traces.py +368 -0
  40. examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +127 -0
  41. examples/qwen_vl/configs/crafter_vlm_sft_example.toml +60 -0
  42. examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +43 -0
  43. examples/qwen_vl/configs/eval_gpt4o_vision_proper.toml +29 -0
  44. examples/qwen_vl/configs/eval_gpt5nano_vision.toml +45 -0
  45. examples/qwen_vl/configs/eval_qwen2vl_vision.toml +44 -0
  46. examples/qwen_vl/configs/filter_qwen2vl_sft.toml +50 -0
  47. examples/qwen_vl/configs/filter_vision_sft.toml +53 -0
  48. examples/qwen_vl/configs/filter_vision_test.toml +8 -0
  49. examples/qwen_vl/configs/sft_qwen3_vl_2b_test.toml +54 -0
  50. examples/qwen_vl/crafter_gpt5nano_agent.py +308 -0
  51. examples/qwen_vl/crafter_qwen_vl_agent.py +300 -0
  52. examples/qwen_vl/run_vision_comparison.sh +62 -0
  53. examples/qwen_vl/run_vision_sft_pipeline.sh +175 -0
  54. examples/qwen_vl/test_image_validation.py +201 -0
  55. examples/qwen_vl/test_sft_vision_data.py +110 -0
  56. examples/rl/README.md +1 -1
  57. examples/rl/configs/eval_base_qwen.toml +17 -0
  58. examples/rl/configs/eval_rl_qwen.toml +13 -0
  59. examples/rl/configs/rl_from_base_qwen.toml +37 -0
  60. examples/rl/configs/rl_from_base_qwen17.toml +76 -0
  61. examples/rl/configs/rl_from_ft_qwen.toml +37 -0
  62. examples/rl/run_eval.py +436 -0
  63. examples/rl/run_rl_and_save.py +111 -0
  64. examples/rl/task_app/README.md +22 -0
  65. examples/rl/task_app/math_single_step.py +990 -0
  66. examples/rl/task_app/math_task_app.py +111 -0
  67. examples/sft/README.md +5 -5
  68. examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -2
  69. examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -3
  70. examples/sft/evaluate.py +4 -4
  71. examples/sft/export_dataset.py +7 -4
  72. examples/sft/generate_traces.py +2 -0
  73. examples/swe/task_app/README.md +1 -1
  74. examples/swe/task_app/grpo_swe_mini.py +1 -1
  75. examples/swe/task_app/grpo_swe_mini_task_app.py +0 -12
  76. examples/swe/task_app/hosted/envs/mini_swe/environment.py +13 -13
  77. examples/swe/task_app/hosted/policy_routes.py +0 -2
  78. examples/swe/task_app/hosted/rollout.py +2 -8
  79. examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
  80. examples/task_apps/crafter/CREATE_SFT_DATASET.md +273 -0
  81. examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
  82. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +174 -0
  83. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +268 -0
  84. examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
  85. examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
  86. examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
  87. examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
  88. examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
  89. examples/task_apps/crafter/task_app/__init__.py +3 -0
  90. examples/task_apps/crafter/task_app/grpo_crafter.py +309 -14
  91. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/environment.py +10 -0
  92. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +75 -4
  93. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +17 -2
  94. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +55 -3
  95. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +114 -32
  96. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +127 -27
  97. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +156 -0
  98. examples/task_apps/enron/__init__.py +1 -0
  99. examples/task_apps/enron/filter_sft.toml +5 -0
  100. examples/task_apps/enron/tests/__init__.py +2 -0
  101. examples/task_apps/enron/tests/integration/__init__.py +2 -0
  102. examples/task_apps/enron/tests/integration/test_enron_eval.py +2 -0
  103. examples/task_apps/enron/tests/unit/__init__.py +2 -0
  104. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
  105. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
  106. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +415 -0
  107. examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +29 -0
  108. examples/task_apps/pokemon_red/pallet_town_rl_config.toml +2 -0
  109. examples/task_apps/pokemon_red/task_app.py +199 -6
  110. examples/task_apps/pokemon_red/test_pallet_town_rewards.py +2 -0
  111. examples/task_apps/sokoban/filter_sft.toml +5 -0
  112. examples/task_apps/sokoban/tests/__init__.py +2 -0
  113. examples/task_apps/sokoban/tests/integration/__init__.py +2 -0
  114. examples/task_apps/sokoban/tests/unit/__init__.py +2 -0
  115. examples/task_apps/verilog/eval_groq_qwen32b.toml +8 -4
  116. examples/task_apps/verilog/filter_sft.toml +5 -0
  117. examples/task_apps/verilog/task_app/grpo_verilog.py +258 -23
  118. examples/task_apps/verilog/tests/__init__.py +2 -0
  119. examples/task_apps/verilog/tests/integration/__init__.py +2 -0
  120. examples/task_apps/verilog/tests/integration/test_verilog_eval.py +2 -0
  121. examples/task_apps/verilog/tests/unit/__init__.py +2 -0
  122. examples/vlm/README.md +3 -3
  123. examples/vlm/configs/crafter_vlm_gpt4o.toml +2 -0
  124. examples/vlm/crafter_openai_vlm_agent.py +3 -5
  125. examples/vlm/filter_image_rows.py +1 -1
  126. examples/vlm/run_crafter_vlm_benchmark.py +2 -2
  127. examples/warming_up_to_rl/_utils.py +92 -0
  128. examples/warming_up_to_rl/analyze_trace_db.py +1 -1
  129. examples/warming_up_to_rl/configs/crafter_fft.toml +2 -0
  130. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +2 -0
  131. examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +2 -0
  132. examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +2 -0
  133. examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +2 -1
  134. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +2 -1
  135. examples/warming_up_to_rl/configs/rl_from_ft.toml +2 -0
  136. examples/warming_up_to_rl/export_trace_sft.py +174 -60
  137. examples/warming_up_to_rl/groq_test.py +2 -0
  138. examples/warming_up_to_rl/readme.md +63 -132
  139. examples/warming_up_to_rl/run_fft_and_save.py +1 -1
  140. examples/warming_up_to_rl/run_local_rollout.py +2 -0
  141. examples/warming_up_to_rl/run_local_rollout_modal.py +2 -0
  142. examples/warming_up_to_rl/run_local_rollout_parallel.py +2 -0
  143. examples/warming_up_to_rl/run_local_rollout_traced.py +2 -0
  144. examples/warming_up_to_rl/run_rl_and_save.py +1 -1
  145. examples/warming_up_to_rl/run_rollout_remote.py +2 -0
  146. examples/warming_up_to_rl/task_app/README.md +42 -0
  147. examples/warming_up_to_rl/task_app/grpo_crafter.py +696 -0
  148. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +135 -0
  149. examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
  150. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
  151. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +143 -0
  152. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1226 -0
  153. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
  154. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
  155. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
  156. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +522 -0
  157. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +478 -0
  158. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +108 -0
  159. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +305 -0
  160. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
  161. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +204 -0
  162. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
  163. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +618 -0
  164. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +100 -0
  165. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +1081 -0
  166. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +195 -0
  167. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1861 -0
  168. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
  169. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +211 -0
  170. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +161 -0
  171. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +137 -0
  172. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +62 -0
  173. synth_ai/__init__.py +44 -30
  174. synth_ai/_utils/__init__.py +47 -0
  175. synth_ai/_utils/base_url.py +10 -0
  176. synth_ai/_utils/http.py +10 -0
  177. synth_ai/_utils/prompts.py +10 -0
  178. synth_ai/_utils/task_app_state.py +12 -0
  179. synth_ai/_utils/user_config.py +10 -0
  180. synth_ai/api/models/supported.py +145 -7
  181. synth_ai/api/train/__init__.py +13 -1
  182. synth_ai/api/train/cli.py +30 -7
  183. synth_ai/api/train/config_finder.py +18 -11
  184. synth_ai/api/train/env_resolver.py +13 -10
  185. synth_ai/cli/__init__.py +66 -49
  186. synth_ai/cli/_modal_wrapper.py +9 -6
  187. synth_ai/cli/_typer_patch.py +0 -2
  188. synth_ai/cli/_validate_task_app.py +22 -4
  189. synth_ai/cli/legacy_root_backup.py +3 -1
  190. synth_ai/cli/lib/__init__.py +10 -0
  191. synth_ai/cli/lib/task_app_discovery.py +7 -0
  192. synth_ai/cli/lib/task_app_env.py +518 -0
  193. synth_ai/cli/recent.py +1 -0
  194. synth_ai/cli/setup.py +266 -0
  195. synth_ai/cli/task_app_deploy.py +16 -0
  196. synth_ai/cli/task_app_list.py +25 -0
  197. synth_ai/cli/task_app_modal_serve.py +16 -0
  198. synth_ai/cli/task_app_serve.py +18 -0
  199. synth_ai/cli/task_apps.py +392 -141
  200. synth_ai/cli/train.py +18 -0
  201. synth_ai/cli/tui.py +62 -0
  202. synth_ai/demos/__init__.py +10 -0
  203. synth_ai/demos/core/__init__.py +28 -1
  204. synth_ai/demos/crafter/__init__.py +1 -0
  205. synth_ai/demos/crafter/crafter_fft_4b.toml +55 -0
  206. synth_ai/demos/crafter/grpo_crafter_task_app.py +185 -0
  207. synth_ai/demos/crafter/rl_from_base_qwen4b.toml +74 -0
  208. synth_ai/demos/demo_registry.py +176 -0
  209. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
  210. synth_ai/demos/math/__init__.py +1 -0
  211. synth_ai/demos/math/_common.py +16 -0
  212. synth_ai/demos/math/app.py +38 -0
  213. synth_ai/demos/math/config.toml +76 -0
  214. synth_ai/demos/math/deploy_modal.py +54 -0
  215. synth_ai/demos/math/modal_task_app.py +702 -0
  216. synth_ai/demos/math/task_app_entry.py +51 -0
  217. synth_ai/environments/environment/core.py +7 -1
  218. synth_ai/environments/examples/bandit/engine.py +0 -1
  219. synth_ai/environments/examples/bandit/environment.py +0 -1
  220. synth_ai/environments/examples/crafter_classic/environment.py +1 -1
  221. synth_ai/environments/examples/verilog/engine.py +76 -10
  222. synth_ai/environments/examples/wordle/environment.py +0 -1
  223. synth_ai/evals/base.py +16 -5
  224. synth_ai/evals/client.py +1 -1
  225. synth_ai/inference/client.py +1 -1
  226. synth_ai/learning/client.py +1 -1
  227. synth_ai/learning/health.py +1 -1
  228. synth_ai/learning/jobs.py +1 -1
  229. synth_ai/learning/rl/client.py +1 -1
  230. synth_ai/learning/rl/env_keys.py +1 -1
  231. synth_ai/learning/rl/secrets.py +1 -1
  232. synth_ai/learning/sft/client.py +1 -1
  233. synth_ai/learning/sft/data.py +407 -4
  234. synth_ai/learning/validators.py +4 -1
  235. synth_ai/task/__init__.py +11 -1
  236. synth_ai/task/apps/__init__.py +5 -2
  237. synth_ai/task/config.py +259 -0
  238. synth_ai/task/contracts.py +15 -2
  239. synth_ai/task/rubrics/__init__.py +4 -2
  240. synth_ai/task/rubrics/loaders.py +27 -4
  241. synth_ai/task/rubrics/scoring.py +3 -0
  242. synth_ai/task/rubrics.py +219 -0
  243. synth_ai/task/trace_correlation_helpers.py +328 -0
  244. synth_ai/task/tracing_utils.py +14 -3
  245. synth_ai/task/validators.py +145 -2
  246. synth_ai/tracing_v3/config.py +15 -13
  247. synth_ai/tracing_v3/constants.py +21 -0
  248. synth_ai/tracing_v3/db_config.py +3 -1
  249. synth_ai/tracing_v3/decorators.py +10 -7
  250. synth_ai/tracing_v3/session_tracer.py +10 -0
  251. synth_ai/tracing_v3/turso/daemon.py +2 -2
  252. synth_ai/tracing_v3/turso/native_manager.py +108 -77
  253. synth_ai/tracing_v3/utils.py +1 -1
  254. synth_ai/tui/__init__.py +5 -0
  255. synth_ai/tui/__main__.py +13 -0
  256. synth_ai/tui/cli/__init__.py +1 -0
  257. synth_ai/tui/cli/query_experiments.py +164 -0
  258. synth_ai/tui/cli/query_experiments_v3.py +164 -0
  259. synth_ai/tui/dashboard.py +911 -0
  260. synth_ai/utils/__init__.py +101 -0
  261. synth_ai/utils/base_url.py +94 -0
  262. synth_ai/utils/cli.py +131 -0
  263. synth_ai/utils/env.py +287 -0
  264. synth_ai/utils/http.py +169 -0
  265. synth_ai/utils/modal.py +308 -0
  266. synth_ai/utils/process.py +212 -0
  267. synth_ai/utils/prompts.py +39 -0
  268. synth_ai/utils/sqld.py +122 -0
  269. synth_ai/utils/task_app_discovery.py +882 -0
  270. synth_ai/utils/task_app_env.py +186 -0
  271. synth_ai/utils/task_app_state.py +318 -0
  272. synth_ai/utils/user_config.py +137 -0
  273. synth_ai/v0/config/__init__.py +1 -5
  274. synth_ai/v0/config/base_url.py +1 -7
  275. synth_ai/v0/tracing/config.py +1 -1
  276. synth_ai/v0/tracing/decorators.py +1 -1
  277. synth_ai/v0/tracing/upload.py +1 -1
  278. synth_ai/v0/tracing_v1/config.py +1 -1
  279. synth_ai/v0/tracing_v1/decorators.py +1 -1
  280. synth_ai/v0/tracing_v1/upload.py +1 -1
  281. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/METADATA +85 -31
  282. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/RECORD +286 -135
  283. synth_ai/cli/man.py +0 -106
  284. synth_ai/compound/cais.py +0 -0
  285. synth_ai/core/experiment.py +0 -13
  286. synth_ai/core/system.py +0 -15
  287. synth_ai/demo_registry.py +0 -295
  288. synth_ai/handshake.py +0 -109
  289. synth_ai/http.py +0 -26
  290. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/WHEEL +0 -0
  291. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/entry_points.txt +0 -0
  292. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/licenses/LICENSE +0 -0
  293. {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/top_level.txt +0 -0
synth_ai/utils/http.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import aiohttp
9
+
10
+ __all__ = ["HTTPError", "AsyncHttpClient", "http_request", "sleep"]
11
+
12
+
13
+ @dataclass
14
+ class HTTPError(Exception):
15
+ status: int
16
+ url: str
17
+ message: str
18
+ body_snippet: str | None = None
19
+ detail: Any | None = None
20
+
21
+ def __str__(self) -> str: # pragma: no cover - trivial
22
+ base = f"HTTP {self.status} for {self.url}: {self.message}"
23
+ if self.body_snippet:
24
+ base += f" | body[0:200]={self.body_snippet[:200]}"
25
+ return base
26
+
27
+
28
+ class AsyncHttpClient:
29
+ def __init__(self, base_url: str, api_key: str, timeout: float = 30.0) -> None:
30
+ self._base_url = base_url.rstrip("/")
31
+ self._api_key = api_key
32
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
33
+ self._session: aiohttp.ClientSession | None = None
34
+
35
+ async def __aenter__(self) -> AsyncHttpClient:
36
+ if self._session is None:
37
+ headers = {"authorization": f"Bearer {self._api_key}"}
38
+ user_id = os.getenv("SYNTH_USER_ID") or os.getenv("X_USER_ID") or os.getenv("USER_ID")
39
+ if user_id:
40
+ headers["X-User-ID"] = user_id
41
+ org_id = os.getenv("SYNTH_ORG_ID") or os.getenv("X_ORG_ID") or os.getenv("ORG_ID")
42
+ if org_id:
43
+ headers["X-Org-ID"] = org_id
44
+ self._session = aiohttp.ClientSession(headers=headers, timeout=self._timeout)
45
+ return self
46
+
47
+ async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
48
+ if self._session is not None:
49
+ await self._session.close()
50
+ self._session = None
51
+
52
+ def _abs(self, path: str) -> str:
53
+ if path.startswith("http://") or path.startswith("https://"):
54
+ return path
55
+ if self._base_url.endswith("/api") and path.startswith("/api"):
56
+ path = path[4:]
57
+ return f"{self._base_url}/{path.lstrip('/')}"
58
+
59
+ async def get(
60
+ self,
61
+ path: str,
62
+ *,
63
+ params: dict[str, Any] | None = None,
64
+ headers: dict[str, str] | None = None,
65
+ ) -> Any:
66
+ url = self._abs(path)
67
+ assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
68
+ async with self._session.get(url, params=params, headers=headers) as resp:
69
+ return await self._handle_response(resp, url)
70
+
71
+ async def post_json(
72
+ self, path: str, *, json: dict[str, Any], headers: dict[str, str] | None = None
73
+ ) -> Any:
74
+ url = self._abs(path)
75
+ assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
76
+ async with self._session.post(url, json=json, headers=headers) as resp:
77
+ return await self._handle_response(resp, url)
78
+
79
+ async def post_multipart(
80
+ self,
81
+ path: str,
82
+ *,
83
+ data: dict[str, Any],
84
+ files: dict[str, tuple[str, bytes, str | None]],
85
+ headers: dict[str, str] | None = None,
86
+ ) -> Any:
87
+ url = self._abs(path)
88
+ assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
89
+ form = aiohttp.FormData()
90
+ for k, v in data.items():
91
+ form.add_field(k, str(v))
92
+ for field, (filename, content, content_type) in files.items():
93
+ form.add_field(
94
+ field,
95
+ content,
96
+ filename=filename,
97
+ content_type=content_type or "application/octet-stream",
98
+ )
99
+ async with self._session.post(url, data=form, headers=headers) as resp:
100
+ return await self._handle_response(resp, url)
101
+
102
+ async def delete(self, path: str, *, headers: dict[str, str] | None = None) -> Any:
103
+ url = self._abs(path)
104
+ assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
105
+ async with self._session.delete(url, headers=headers) as resp:
106
+ return await self._handle_response(resp, url)
107
+
108
+ async def _handle_response(self, resp: aiohttp.ClientResponse, url: str) -> Any:
109
+ text = await resp.text()
110
+ body_snippet = text[:200] if text else None
111
+ if 200 <= resp.status < 300:
112
+ ctype = resp.headers.get("content-type", "")
113
+ if "application/json" in ctype:
114
+ try:
115
+ return await resp.json()
116
+ except Exception:
117
+ return text
118
+ return text
119
+ detail: Any | None = None
120
+ try:
121
+ detail = await resp.json()
122
+ except Exception:
123
+ detail = None
124
+ raise HTTPError(
125
+ status=resp.status,
126
+ url=url,
127
+ message="request_failed",
128
+ body_snippet=body_snippet,
129
+ detail=detail,
130
+ )
131
+
132
+
133
+ def http_request(
134
+ method: str, url: str, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None
135
+ ) -> tuple[int, dict[str, Any] | str]:
136
+ import json as _json
137
+ import ssl
138
+ import urllib.error
139
+ import urllib.request
140
+
141
+ data = None
142
+ if body is not None:
143
+ data = _json.dumps(body).encode("utf-8")
144
+ req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
145
+ try:
146
+ ctx = ssl._create_unverified_context()
147
+ if os.getenv("SYNTH_SSL_VERIFY", "0") == "1":
148
+ ctx = None
149
+ with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
150
+ code = getattr(resp, "status", 200)
151
+ txt = resp.read().decode("utf-8", errors="ignore")
152
+ try:
153
+ return int(code), _json.loads(txt)
154
+ except Exception:
155
+ return int(code), txt
156
+ except urllib.error.HTTPError as exc: # Capture 4xx/5xx bodies
157
+ txt = exc.read().decode("utf-8", errors="ignore")
158
+ try:
159
+ return int(exc.code or 0), _json.loads(txt)
160
+ except Exception:
161
+ return int(exc.code or 0), txt
162
+ except Exception as exc:
163
+ return 0, str(exc)
164
+
165
+
166
+ async def sleep(seconds: float) -> None:
167
+ """Small async sleep helper preserved for backwards compatibility."""
168
+
169
+ await asyncio.sleep(max(float(seconds or 0.0), 0.0))
@@ -0,0 +1,308 @@
1
+ import contextlib
2
+ import json
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+ from urllib.parse import urlparse, urlunparse
9
+
10
+ from synth_ai.demos import core as demo_core
11
+ from synth_ai.demos.core import DEFAULT_TASK_APP_SECRET_NAME, DemoEnv
12
+
13
+ from .env import mask_str
14
+ from .http import http_request
15
+ from .process import popen_capture
16
+ from .user_config import load_user_config
17
+
18
+ __all__ = [
19
+ "ensure_modal_installed",
20
+ "ensure_task_app_ready",
21
+ "find_asgi_apps",
22
+ "is_local_demo_url",
23
+ "is_modal_public_url",
24
+ "normalize_endpoint_url",
25
+ ]
26
+
27
+
28
+ def is_modal_public_url(url: str | None) -> bool:
29
+ try:
30
+ candidate = (url or "").strip().lower()
31
+ if not candidate or not (candidate.startswith("http://") or candidate.startswith("https://")):
32
+ return False
33
+ return (".modal.run" in candidate) and ("modal.local" not in candidate) and ("pypi-mirror" not in candidate)
34
+ except Exception:
35
+ return False
36
+
37
+
38
+ def is_local_demo_url(url: str | None) -> bool:
39
+ try:
40
+ candidate = (url or "").strip().lower()
41
+ if not candidate:
42
+ return False
43
+ return candidate.startswith("http://127.0.0.1") or candidate.startswith("http://localhost")
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ def normalize_endpoint_url(url: str) -> str:
49
+ """Convert loopback URLs to forms accepted by the backend."""
50
+ if not url:
51
+ return url
52
+ try:
53
+ parsed = urlparse(url)
54
+ host = parsed.hostname or ""
55
+ if host in {"127.0.0.1", "::1"}:
56
+ new_host = "localhost"
57
+ netloc = new_host
58
+ if parsed.port:
59
+ netloc = f"{new_host}:{parsed.port}"
60
+ if parsed.username:
61
+ creds = parsed.username
62
+ if parsed.password:
63
+ creds += f":{parsed.password}"
64
+ netloc = f"{creds}@{netloc}"
65
+ parsed = parsed._replace(netloc=netloc)
66
+ return cast(str, urlunparse(parsed))
67
+ except Exception:
68
+ pass
69
+ return url
70
+
71
+
72
+ def find_asgi_apps(root: Path) -> list[Path]:
73
+ """Recursively search for Python files that declare a Modal ASGI app."""
74
+ results: list[Path] = []
75
+ skip_dirs = {
76
+ ".git",
77
+ ".hg",
78
+ ".svn",
79
+ "node_modules",
80
+ "dist",
81
+ "build",
82
+ "__pycache__",
83
+ ".ruff_cache",
84
+ ".mypy_cache",
85
+ "venv",
86
+ ".venv",
87
+ }
88
+ for dirpath, dirnames, filenames in os.walk(root):
89
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
90
+ for name in filenames:
91
+ if not name.endswith(".py"):
92
+ continue
93
+ path = Path(dirpath) / name
94
+ try:
95
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
96
+ txt = fh.read()
97
+ if ("@asgi_app()" in txt) or ("@modal.asgi_app()" in txt):
98
+ results.append(path)
99
+ except Exception:
100
+ continue
101
+
102
+ def _priority(path: Path) -> tuple[int, str]:
103
+ rel = str(path.resolve())
104
+ in_demo = "/synth_demo/" in rel or rel.endswith("/synth_demo/task_app.py")
105
+ return (0 if in_demo else 1, rel)
106
+
107
+ results.sort(key=_priority)
108
+ return results
109
+
110
+
111
+ def ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoEnv:
112
+ persist_path = demo_core.load_demo_dir() or os.getcwd()
113
+ user_config_map = load_user_config()
114
+
115
+ env_key = (env.env_api_key or "").strip()
116
+ if not env_key:
117
+ raise RuntimeError(
118
+ f"[{label}] ENVIRONMENT_API_KEY missing. Run `uvx synth-ai demo deploy` first."
119
+ )
120
+
121
+ template_id = demo_core.load_template_id()
122
+ allow_local = template_id == "crafter-local"
123
+
124
+ task_url = env.task_app_base_url
125
+ url_ok = is_modal_public_url(task_url) or (allow_local and is_local_demo_url(task_url or ""))
126
+ if not task_url or not url_ok:
127
+ resolved = task_url or ""
128
+ dynamic_lookup_allowed = env.task_app_name and not (
129
+ allow_local and is_local_demo_url(task_url or "")
130
+ )
131
+ if dynamic_lookup_allowed and not is_modal_public_url(resolved):
132
+ code, out = popen_capture(
133
+ [
134
+ "uv",
135
+ "run",
136
+ "python",
137
+ "-m",
138
+ "modal",
139
+ "app",
140
+ "url",
141
+ env.task_app_name,
142
+ ]
143
+ )
144
+ if code == 0 and out:
145
+ for token in out.split():
146
+ if is_modal_public_url(token):
147
+ resolved = token.strip().rstrip("/")
148
+ break
149
+ if dynamic_lookup_allowed and not is_modal_public_url(resolved):
150
+ try:
151
+ choice = (
152
+ input(
153
+ f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: "
154
+ ).strip().lower()
155
+ or "y"
156
+ )
157
+ except Exception:
158
+ choice = "y"
159
+ if choice.startswith("y"):
160
+ code, out = popen_capture(
161
+ [
162
+ "uv",
163
+ "run",
164
+ "python",
165
+ "-m",
166
+ "modal",
167
+ "app",
168
+ "url",
169
+ env.task_app_name,
170
+ ]
171
+ )
172
+ if code == 0 and out:
173
+ for token in out.split():
174
+ if is_modal_public_url(token):
175
+ resolved = token.strip().rstrip("/")
176
+ break
177
+ if not is_modal_public_url(resolved):
178
+ hint = "Examples: https://<app-name>-fastapi-app.modal.run"
179
+ if allow_local:
180
+ hint += " or http://127.0.0.1:8001"
181
+ print(f"[{label}] Task app URL not configured or not a valid target.")
182
+ print(hint)
183
+ entered = input(
184
+ "Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: "
185
+ ).strip()
186
+ if not entered:
187
+ raise RuntimeError(f"[{label}] Task App URL is required.")
188
+ entered_clean = entered.rstrip("/")
189
+ if not (
190
+ is_modal_public_url(entered_clean)
191
+ or (allow_local and is_local_demo_url(entered_clean))
192
+ ):
193
+ raise RuntimeError(f"[{label}] Valid Task App URL is required.")
194
+ task_url = entered_clean
195
+ else:
196
+ task_url = resolved
197
+ demo_core.persist_task_url(task_url, name=(env.task_app_name or None), path=persist_path)
198
+
199
+ app_name = (env.task_app_name or "").strip()
200
+ requires_modal_name = is_modal_public_url(task_url)
201
+ if requires_modal_name and not app_name:
202
+ fallback = input("Enter Modal app name for the task app (required): ").strip()
203
+ if not fallback:
204
+ raise RuntimeError(f"[{label}] Task app name is required.")
205
+ app_name = fallback
206
+ demo_core.persist_task_url(task_url, name=app_name, path=persist_path)
207
+
208
+ demo_core.persist_task_url(task_url, name=app_name if requires_modal_name else None, path=persist_path)
209
+ if synth_key:
210
+ os.environ["SYNTH_API_KEY"] = synth_key
211
+
212
+ openai_key = (
213
+ os.environ.get("OPENAI_API_KEY")
214
+ or str(user_config_map.get("OPENAI_API_KEY") or "")
215
+ ).strip()
216
+ if openai_key:
217
+ os.environ["OPENAI_API_KEY"] = openai_key
218
+
219
+ print(f"[{label}] Verifying rollout health:")
220
+ try:
221
+ preview = mask_str(env_key)
222
+ print(f"[{label}] {preview}")
223
+ except Exception:
224
+ pass
225
+ health_base = task_url.rstrip("/")
226
+ health_urls = [f"{health_base}/health/rollout", f"{health_base}/health"]
227
+ rc = 0
228
+ body: Any = ""
229
+ for h in health_urls:
230
+ print(f"[{label}] GET", h)
231
+ rc, body = http_request("GET", h, headers={"X-API-Key": env_key})
232
+ if rc == 200:
233
+ break
234
+ print(f"[{label}] status: {rc}")
235
+ try:
236
+ preview_body = json.dumps(body)[:800] if isinstance(body, dict) else str(body)[:800]
237
+ except Exception:
238
+ preview_body = str(body)[:800]
239
+ print(f"[{label}] body:", preview_body)
240
+ if rc != 200:
241
+ print(f"[{label}] Warning: rollout health check failed ({rc}). Response: {body}")
242
+ with contextlib.suppress(Exception):
243
+ print(f"[{label}] Sent header X-API-Key → {mask_str(env_key)}")
244
+ else:
245
+ print(f"[{label}] Task app rollout health check OK.")
246
+
247
+ os.environ["TASK_APP_BASE_URL"] = task_url
248
+ os.environ["ENVIRONMENT_API_KEY"] = env_key
249
+ os.environ["TASK_APP_SECRET_NAME"] = DEFAULT_TASK_APP_SECRET_NAME
250
+ updated_env = demo_core.load_env()
251
+ updated_env.env_api_key = env_key
252
+ updated_env.task_app_base_url = task_url
253
+ updated_env.task_app_name = app_name if requires_modal_name else ""
254
+ updated_env.task_app_secret_name = DEFAULT_TASK_APP_SECRET_NAME
255
+ return updated_env
256
+
257
+
258
+ def ensure_modal_installed() -> None:
259
+ """Install the modal package if it is not already available and check authentication."""
260
+ modal_installed = False
261
+ try:
262
+ import importlib.util as import_util
263
+
264
+ if import_util.find_spec("modal") is not None:
265
+ modal_installed = True
266
+ except Exception:
267
+ pass
268
+
269
+ if not modal_installed:
270
+ print("modal not found; installing…")
271
+ try:
272
+ if shutil.which("uv"):
273
+ code, out = popen_capture(["uv", "pip", "install", "modal>=1.1.4"])
274
+ else:
275
+ code, out = popen_capture([sys.executable, "-m", "pip", "install", "modal>=1.1.4"])
276
+ if code != 0:
277
+ print(out)
278
+ print("Failed to install modal; continuing may fail.")
279
+ return
280
+ print("✓ modal installed successfully")
281
+ modal_installed = True
282
+ except Exception as exc:
283
+ print(f"modal install error: {exc}")
284
+ return
285
+
286
+ if modal_installed:
287
+ try:
288
+ import importlib.util as import_util
289
+
290
+ if import_util.find_spec("modal") is None:
291
+ print("Warning: modal is still not importable after install attempt.")
292
+ return
293
+ except Exception:
294
+ print("Warning: unable to verify modal installation.")
295
+ return
296
+
297
+ auth_ok, auth_msg = demo_core.modal_auth_status()
298
+ if auth_ok:
299
+ print(f"✓ Modal authenticated: {auth_msg}")
300
+ else:
301
+ print("\n⚠️ Modal authentication required")
302
+ print(f" Status: {auth_msg}")
303
+ print("\n To authenticate Modal, run:")
304
+ print(" modal setup")
305
+ print("\n Or set environment variables:")
306
+ print(" export MODAL_TOKEN_ID=your-token-id")
307
+ print(" export MODAL_TOKEN_SECRET=your-token-secret")
308
+ print("\n You can deploy later after authenticating.\n")
@@ -0,0 +1,212 @@
1
+ import os
2
+ import signal
3
+ import socket
4
+ import time
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ __all__ = [
9
+ "ensure_local_port_available",
10
+ "popen_capture",
11
+ "popen_stream",
12
+ "popen_stream_capture",
13
+ ]
14
+
15
+
16
+ def popen_capture(
17
+ cmd: list[str], cwd: str | None = None, env: dict[str, Any] | None = None
18
+ ) -> tuple[int, str]:
19
+ """Execute a subprocess and capture combined stdout/stderr."""
20
+ import subprocess
21
+
22
+ try:
23
+ proc = subprocess.Popen(
24
+ cmd,
25
+ cwd=cwd,
26
+ env=env,
27
+ stdout=subprocess.PIPE,
28
+ stderr=subprocess.STDOUT,
29
+ text=True,
30
+ )
31
+ out, _ = proc.communicate()
32
+ return int(proc.returncode or 0), out or ""
33
+ except Exception as exc:
34
+ return 1, str(exc)
35
+
36
+
37
+ def popen_stream(
38
+ cmd: list[str], cwd: str | None = None, env: dict[str, Any] | None = None
39
+ ) -> int:
40
+ """Stream subprocess output line-by-line to stdout for real-time feedback."""
41
+ import subprocess
42
+ import threading
43
+
44
+ try:
45
+ proc = subprocess.Popen(
46
+ cmd,
47
+ cwd=cwd,
48
+ env=env,
49
+ stdout=subprocess.PIPE,
50
+ stderr=subprocess.STDOUT,
51
+ text=True,
52
+ bufsize=1,
53
+ )
54
+ except Exception as exc:
55
+ print(f"Failed to launch {' '.join(cmd)}: {exc}")
56
+ return 1
57
+
58
+ def _pump(stdout) -> None:
59
+ try:
60
+ for line in stdout:
61
+ print(line.rstrip())
62
+ except Exception:
63
+ pass
64
+
65
+ if proc.stdout is not None:
66
+ t = threading.Thread(target=_pump, args=(proc.stdout,), daemon=True)
67
+ t.start()
68
+ proc.wait()
69
+ t.join(timeout=1.0)
70
+ else:
71
+ proc.wait()
72
+ return int(proc.returncode or 0)
73
+
74
+
75
+ def popen_stream_capture(
76
+ cmd: list[str], cwd: str | None = None, env: dict[str, Any] | None = None
77
+ ) -> tuple[int, str]:
78
+ """Stream subprocess output to stdout and also capture it into a buffer."""
79
+ import subprocess
80
+ import threading
81
+
82
+ buf_lines: list[str] = []
83
+ try:
84
+ proc = subprocess.Popen(
85
+ cmd,
86
+ cwd=cwd,
87
+ env=env,
88
+ stdout=subprocess.PIPE,
89
+ stderr=subprocess.STDOUT,
90
+ text=True,
91
+ bufsize=1,
92
+ )
93
+ except Exception as exc:
94
+ print(f"Failed to launch {' '.join(cmd)}: {exc}")
95
+ return 1, ""
96
+
97
+ def _pump(stdout) -> None:
98
+ try:
99
+ for line in stdout:
100
+ line = line.rstrip()
101
+ print(line)
102
+ buf_lines.append(line)
103
+ except Exception:
104
+ pass
105
+
106
+ if proc.stdout is not None:
107
+ t = threading.Thread(target=_pump, args=(proc.stdout,), daemon=True)
108
+ t.start()
109
+ proc.wait()
110
+ t.join(timeout=1.0)
111
+ else:
112
+ proc.wait()
113
+ return int(proc.returncode or 0), "\n".join(buf_lines)
114
+
115
+
116
+ def _list_process_ids(port: int) -> list[int]:
117
+ try:
118
+ import subprocess
119
+
120
+ out = subprocess.run(
121
+ ["lsof", "-ti", f"TCP:{port}"],
122
+ capture_output=True,
123
+ text=True,
124
+ check=False,
125
+ )
126
+ if not out.stdout:
127
+ return []
128
+ result: list[int] = []
129
+ for token in out.stdout.strip().splitlines():
130
+ token = token.strip()
131
+ if token.isdigit():
132
+ result.append(int(token))
133
+ return result
134
+ except Exception:
135
+ return []
136
+
137
+
138
+ def _terminate_pids(pids: Iterable[int], *, aggressive: bool) -> bool:
139
+ terminated_any = False
140
+ for pid in pids:
141
+ try:
142
+ os.kill(pid, signal.SIGTERM)
143
+ terminated_any = True
144
+ except Exception as exc:
145
+ print(f"Failed to terminate PID {pid}: {exc}")
146
+ if terminated_any:
147
+ time.sleep(1.0)
148
+
149
+ if aggressive and pids:
150
+ still_running = []
151
+ for pid in pids:
152
+ try:
153
+ os.kill(pid, 0)
154
+ except OSError:
155
+ continue
156
+ still_running.append(pid)
157
+ if still_running:
158
+ for pid in still_running:
159
+ try:
160
+ os.kill(pid, signal.SIGKILL)
161
+ except Exception as exc:
162
+ print(f"Failed to force terminate PID {pid}: {exc}")
163
+ time.sleep(0.5)
164
+ return terminated_any
165
+
166
+
167
+ def ensure_local_port_available(host: str, port: int, *, force: bool = False) -> bool:
168
+ """Ensure ``host:port`` is free before starting a local server."""
169
+
170
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
171
+ sock.settimeout(0.5)
172
+ in_use = sock.connect_ex((host, port)) == 0
173
+ if not in_use:
174
+ return True
175
+
176
+ print(f"Port {port} on {host} is already in use.")
177
+ pids = _list_process_ids(port)
178
+
179
+ if pids:
180
+ print("Found processes using this port:")
181
+ for pid in pids:
182
+ print(f" PID {pid}")
183
+ else:
184
+ print("Could not automatically identify the owning process.")
185
+
186
+ if not force:
187
+ try:
188
+ choice = input(f"Stop the existing process on port {port}? [y/N]: ").strip().lower() or "n"
189
+ except Exception:
190
+ choice = "n"
191
+ if not choice.startswith("y"):
192
+ print("Aborting; stop the running server and try again.")
193
+ return False
194
+ else:
195
+ print("Attempting to terminate the existing process...")
196
+
197
+ if pids:
198
+ _terminate_pids(pids, aggressive=force)
199
+ else:
200
+ print("Unable to determine owning process. Please stop it manually and retry.")
201
+ return False
202
+
203
+ for _ in range(10):
204
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
205
+ sock.settimeout(0.5)
206
+ if sock.connect_ex((host, port)) != 0:
207
+ print("Port is now available.")
208
+ return True
209
+ time.sleep(0.5)
210
+
211
+ print("Port still in use after terminating processes.")
212
+ return False