synth-ai 0.2.9.dev5__py3-none-any.whl → 0.2.9.dev6__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 (351) hide show
  1. examples/__init__.py +16 -0
  2. examples/crafter_debug_render.py +23 -17
  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/configs/eval_base_qwen.toml +1 -1
  19. examples/rl/configs/rl_from_base_qwen17.toml +1 -1
  20. examples/rl/download_dataset.py +26 -10
  21. examples/rl/run_eval.py +53 -52
  22. examples/rl/run_rl_and_save.py +29 -12
  23. examples/rl/task_app/math_single_step.py +180 -41
  24. examples/rl/task_app/math_task_app.py +14 -6
  25. examples/sft/README.md +139 -0
  26. examples/sft/configs/crafter_fft_qwen0p6b.toml +44 -0
  27. examples/sft/configs/crafter_lora_qwen0p6b.toml +45 -0
  28. examples/sft/evaluate.py +117 -0
  29. examples/sft/export_dataset.py +117 -0
  30. examples/sft/generate_traces.py +162 -0
  31. examples/swe/__init__.py +12 -0
  32. examples/swe/task_app/README.md +105 -0
  33. examples/swe/task_app/__init__.py +2 -0
  34. examples/swe/task_app/grpo_swe_mini.py +571 -0
  35. examples/swe/task_app/grpo_swe_mini_task_app.py +136 -0
  36. examples/swe/task_app/hosted/README.md +173 -0
  37. examples/swe/task_app/hosted/__init__.py +5 -0
  38. examples/swe/task_app/hosted/branching.py +143 -0
  39. examples/swe/task_app/hosted/environment_routes.py +1289 -0
  40. examples/swe/task_app/hosted/envs/__init__.py +1 -0
  41. examples/swe/task_app/hosted/envs/crafter/__init__.py +6 -0
  42. examples/swe/task_app/hosted/envs/crafter/app.py +1 -0
  43. examples/swe/task_app/hosted/envs/crafter/environment.py +522 -0
  44. examples/swe/task_app/hosted/envs/crafter/policy.py +478 -0
  45. examples/swe/task_app/hosted/envs/crafter/react_agent.py +108 -0
  46. examples/swe/task_app/hosted/envs/crafter/shared.py +305 -0
  47. examples/swe/task_app/hosted/envs/crafter/tools.py +47 -0
  48. examples/swe/task_app/hosted/envs/mini_swe/__init__.py +8 -0
  49. examples/swe/task_app/hosted/envs/mini_swe/environment.py +1164 -0
  50. examples/swe/task_app/hosted/envs/mini_swe/policy.py +355 -0
  51. examples/swe/task_app/hosted/envs/mini_swe/shared.py +83 -0
  52. examples/swe/task_app/hosted/envs/mini_swe/tools.py +96 -0
  53. examples/swe/task_app/hosted/hosted_app.py +204 -0
  54. examples/swe/task_app/hosted/inference/__init__.py +5 -0
  55. examples/swe/task_app/hosted/inference/openai_client.py +618 -0
  56. examples/swe/task_app/hosted/main.py +100 -0
  57. examples/swe/task_app/hosted/policy_routes.py +1079 -0
  58. examples/swe/task_app/hosted/registry.py +195 -0
  59. examples/swe/task_app/hosted/rollout.py +1869 -0
  60. examples/swe/task_app/hosted/storage/__init__.py +5 -0
  61. examples/swe/task_app/hosted/storage/volume.py +211 -0
  62. examples/swe/task_app/hosted/test_agents.py +161 -0
  63. examples/swe/task_app/hosted/test_service.py +137 -0
  64. examples/swe/task_app/hosted/utils.py +62 -0
  65. examples/vlm/README.md +68 -0
  66. examples/vlm/configs/crafter_vlm_gpt4o.toml +44 -0
  67. examples/vlm/crafter_image_only_agent.py +207 -0
  68. examples/vlm/crafter_openai_vlm_agent.py +277 -0
  69. examples/vlm/filter_image_rows.py +63 -0
  70. examples/vlm/run_crafter_vlm_benchmark.py +316 -0
  71. examples/warming_up_to_rl/analyze_trace_db.py +12 -10
  72. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +11 -1
  73. examples/warming_up_to_rl/export_trace_sft.py +218 -36
  74. examples/warming_up_to_rl/groq_test.py +15 -8
  75. examples/warming_up_to_rl/manage_secrets.py +29 -25
  76. examples/warming_up_to_rl/readme.md +9 -2
  77. examples/warming_up_to_rl/run_eval.py +137 -61
  78. examples/warming_up_to_rl/run_fft_and_save.py +131 -60
  79. examples/warming_up_to_rl/run_local_rollout.py +88 -39
  80. examples/warming_up_to_rl/run_local_rollout_modal.py +114 -28
  81. examples/warming_up_to_rl/run_local_rollout_parallel.py +81 -20
  82. examples/warming_up_to_rl/run_local_rollout_traced.py +126 -23
  83. examples/warming_up_to_rl/run_rl_and_save.py +35 -12
  84. examples/warming_up_to_rl/run_rollout_remote.py +44 -19
  85. examples/warming_up_to_rl/task_app/README.md +6 -2
  86. examples/warming_up_to_rl/task_app/grpo_crafter.py +319 -57
  87. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +11 -30
  88. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +1 -1
  89. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +9 -11
  90. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +137 -182
  91. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -1
  92. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +1 -1
  93. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -1
  94. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +150 -57
  95. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +105 -69
  96. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +19 -7
  97. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +45 -42
  98. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +1 -1
  99. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +47 -45
  100. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +1 -1
  101. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +198 -92
  102. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +0 -2
  103. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +361 -263
  104. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +21 -23
  105. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +394 -274
  106. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +1 -1
  107. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +56 -62
  108. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +1 -0
  109. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +6 -15
  110. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +4 -3
  111. synth/__init__.py +14 -0
  112. synth_ai/__init__.py +20 -4
  113. synth_ai/api/models/supported.py +376 -0
  114. synth_ai/api/train/builders.py +157 -26
  115. synth_ai/api/train/cli.py +213 -57
  116. synth_ai/api/train/config_finder.py +65 -5
  117. synth_ai/api/train/env_resolver.py +33 -15
  118. synth_ai/api/train/pollers.py +13 -4
  119. synth_ai/api/train/supported_algos.py +139 -0
  120. synth_ai/api/train/task_app.py +5 -3
  121. synth_ai/api/train/utils.py +33 -48
  122. synth_ai/cli/__init__.py +19 -4
  123. synth_ai/cli/_modal_wrapper.py +28 -0
  124. synth_ai/cli/_typer_patch.py +49 -0
  125. synth_ai/cli/balance.py +2 -3
  126. synth_ai/cli/calc.py +1 -1
  127. synth_ai/cli/demo.py +21 -6
  128. synth_ai/cli/recent.py +2 -2
  129. synth_ai/cli/rl_demo.py +77 -17
  130. synth_ai/cli/root.py +116 -39
  131. synth_ai/cli/status.py +2 -2
  132. synth_ai/cli/task_apps.py +1699 -259
  133. synth_ai/cli/traces.py +7 -4
  134. synth_ai/cli/turso.py +73 -0
  135. synth_ai/cli/watch.py +12 -18
  136. synth_ai/core/experiment.py +0 -2
  137. synth_ai/demo_registry.py +68 -31
  138. synth_ai/demos/core/cli.py +516 -194
  139. synth_ai/demos/demo_task_apps/__init__.py +3 -3
  140. synth_ai/demos/demo_task_apps/core.py +64 -28
  141. synth_ai/demos/demo_task_apps/crafter/configs/crafter_fft_4b.toml +2 -3
  142. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +37 -30
  143. synth_ai/demos/demo_task_apps/math/_common.py +1 -2
  144. synth_ai/demos/demo_task_apps/math/app.py +2 -1
  145. synth_ai/demos/demo_task_apps/math/deploy_modal.py +3 -6
  146. synth_ai/demos/demo_task_apps/math/modal_task_app.py +183 -82
  147. synth_ai/demos/demo_task_apps/math/task_app_entry.py +0 -2
  148. synth_ai/environments/examples/bandit/engine.py +12 -4
  149. synth_ai/environments/examples/bandit/taskset.py +4 -4
  150. synth_ai/environments/examples/crafter_classic/environment.py +76 -1
  151. synth_ai/environments/reproducibility/tree.py +5 -6
  152. synth_ai/environments/service/app.py +11 -12
  153. synth_ai/environments/service/core_routes.py +10 -9
  154. synth_ai/environments/stateful/engine.py +1 -1
  155. synth_ai/environments/tasks/core.py +1 -0
  156. synth_ai/environments/tasks/filters.py +5 -6
  157. synth_ai/environments/tasks/utils.py +4 -5
  158. synth_ai/evals/base.py +0 -2
  159. synth_ai/handshake.py +11 -9
  160. synth_ai/http.py +1 -1
  161. synth_ai/http_client.py +43 -11
  162. synth_ai/inference/__init__.py +0 -2
  163. synth_ai/inference/client.py +20 -6
  164. synth_ai/jobs/client.py +103 -78
  165. synth_ai/learning/__init__.py +41 -6
  166. synth_ai/learning/algorithms.py +14 -0
  167. synth_ai/learning/client.py +121 -29
  168. synth_ai/learning/config.py +2 -40
  169. synth_ai/learning/constants.py +0 -2
  170. synth_ai/learning/ft_client.py +4 -56
  171. synth_ai/learning/health.py +13 -7
  172. synth_ai/learning/jobs.py +43 -47
  173. synth_ai/{rl → learning/rl}/__init__.py +14 -5
  174. synth_ai/learning/rl/client.py +267 -0
  175. synth_ai/learning/rl/config.py +31 -0
  176. synth_ai/{rl → learning/rl}/contracts.py +5 -10
  177. synth_ai/{rl → learning/rl}/env_keys.py +45 -16
  178. synth_ai/learning/rl/secrets.py +13 -0
  179. synth_ai/learning/rl_client.py +2 -253
  180. synth_ai/learning/sft/__init__.py +29 -0
  181. synth_ai/learning/sft/client.py +68 -0
  182. synth_ai/learning/sft/config.py +270 -0
  183. synth_ai/learning/sft/data.py +295 -0
  184. synth_ai/learning/sse.py +25 -26
  185. synth_ai/learning/validators.py +25 -24
  186. synth_ai/lm/__init__.py +21 -47
  187. synth_ai/task/__init__.py +26 -27
  188. synth_ai/task/apps/__init__.py +18 -19
  189. synth_ai/task/auth.py +35 -23
  190. synth_ai/task/client.py +15 -13
  191. synth_ai/task/contracts.py +37 -35
  192. synth_ai/task/datasets.py +9 -6
  193. synth_ai/task/errors.py +11 -10
  194. synth_ai/task/health.py +17 -11
  195. synth_ai/task/json.py +58 -24
  196. synth_ai/task/proxy.py +15 -14
  197. synth_ai/task/rubrics.py +22 -15
  198. synth_ai/task/server.py +43 -17
  199. synth_ai/task/tracing_utils.py +12 -7
  200. synth_ai/task/validators.py +0 -1
  201. synth_ai/task/vendors.py +5 -7
  202. synth_ai/tracing_v3/__init__.py +2 -0
  203. synth_ai/tracing_v3/abstractions.py +21 -4
  204. synth_ai/tracing_v3/db_config.py +26 -1
  205. synth_ai/tracing_v3/decorators.py +18 -15
  206. synth_ai/tracing_v3/examples/basic_usage.py +3 -2
  207. synth_ai/tracing_v3/hooks.py +6 -4
  208. synth_ai/tracing_v3/llm_call_record_helpers.py +6 -6
  209. synth_ai/tracing_v3/replica_sync.py +1 -0
  210. synth_ai/tracing_v3/session_tracer.py +63 -16
  211. synth_ai/tracing_v3/storage/base.py +89 -1
  212. synth_ai/tracing_v3/storage/config.py +21 -8
  213. synth_ai/tracing_v3/storage/factory.py +10 -8
  214. synth_ai/tracing_v3/storage/utils.py +4 -2
  215. synth_ai/tracing_v3/turso/daemon.py +7 -2
  216. synth_ai/tracing_v3/turso/models.py +5 -2
  217. synth_ai/tracing_v3/turso/native_manager.py +1173 -0
  218. synth_ai/tracing_v3/utils.py +4 -3
  219. synth_ai/v0/api/__init__.py +8 -0
  220. synth_ai/v0/api/models/__init__.py +8 -0
  221. synth_ai/v0/api/models/supported.py +8 -0
  222. synth_ai/v0/config/__init__.py +15 -0
  223. synth_ai/v0/config/base_url.py +12 -0
  224. synth_ai/v0/lm/__init__.py +51 -0
  225. synth_ai/{lm → v0/lm}/caching/ephemeral.py +3 -5
  226. synth_ai/{lm → v0/lm}/caching/handler.py +4 -4
  227. synth_ai/{lm → v0/lm}/caching/initialize.py +1 -1
  228. synth_ai/{lm → v0/lm}/caching/persistent.py +1 -1
  229. synth_ai/{lm → v0/lm}/config.py +6 -1
  230. synth_ai/{lm → v0/lm}/core/all.py +9 -9
  231. synth_ai/{lm → v0/lm}/core/exceptions.py +0 -2
  232. synth_ai/{lm → v0/lm}/core/main.py +19 -7
  233. synth_ai/{lm → v0/lm}/core/main_v3.py +10 -10
  234. synth_ai/{lm → v0/lm}/core/synth_models.py +2 -15
  235. synth_ai/{lm → v0/lm}/core/vendor_clients.py +6 -4
  236. synth_ai/{lm → v0/lm}/overrides.py +4 -4
  237. synth_ai/{lm → v0/lm}/provider_support/anthropic.py +4 -4
  238. synth_ai/{lm → v0/lm}/provider_support/openai.py +5 -5
  239. synth_ai/{lm → v0/lm}/structured_outputs/handler.py +5 -5
  240. synth_ai/{lm → v0/lm}/structured_outputs/rehabilitate.py +1 -1
  241. synth_ai/{lm → v0/lm}/vendors/core/anthropic_api.py +16 -16
  242. synth_ai/{lm → v0/lm}/vendors/core/gemini_api.py +5 -5
  243. synth_ai/{lm → v0/lm}/vendors/core/mistral_api.py +5 -5
  244. synth_ai/{lm → v0/lm}/vendors/core/openai_api.py +12 -10
  245. synth_ai/{lm → v0/lm}/vendors/openai_standard.py +11 -9
  246. synth_ai/{lm → v0/lm}/vendors/openai_standard_responses.py +8 -5
  247. synth_ai/{lm → v0/lm}/vendors/supported/custom_endpoint.py +4 -6
  248. synth_ai/{lm → v0/lm}/vendors/supported/deepseek.py +2 -2
  249. synth_ai/{lm → v0/lm}/vendors/supported/grok.py +2 -2
  250. synth_ai/{lm → v0/lm}/vendors/supported/groq.py +1 -1
  251. synth_ai/{lm → v0/lm}/vendors/supported/ollama.py +1 -1
  252. synth_ai/{lm → v0/lm}/vendors/supported/openrouter.py +3 -3
  253. synth_ai/{lm → v0/lm}/vendors/supported/together.py +1 -1
  254. synth_ai/{lm → v0/lm}/vendors/synth_client.py +38 -11
  255. synth_ai/v0/tracing/upload.py +32 -135
  256. synth_ai/v0/tracing_v3/__init__.py +10 -0
  257. synth_ai/v0/tracing_v3/abstractions.py +3 -0
  258. synth_ai/v0/tracing_v3/decorators.py +3 -0
  259. synth_ai/v0/tracing_v3/llm_call_record_helpers.py +3 -0
  260. synth_ai/v0/tracing_v3/session_tracer.py +3 -0
  261. synth_ai-0.2.9.dev6.dist-info/METADATA +191 -0
  262. {synth_ai-0.2.9.dev5.dist-info → synth_ai-0.2.9.dev6.dist-info}/RECORD +291 -262
  263. {synth_ai-0.2.9.dev5.dist-info → synth_ai-0.2.9.dev6.dist-info}/top_level.txt +1 -0
  264. examples/common_old/backend.py +0 -21
  265. examples/evals_old/README.md +0 -98
  266. examples/evals_old/__init__.py +0 -6
  267. examples/evals_old/compare_models.py +0 -1037
  268. examples/evals_old/example_log.md +0 -145
  269. examples/evals_old/run_demo.sh +0 -126
  270. examples/evals_old/trace_analysis.py +0 -270
  271. examples/finetuning_old/_backup_synth_qwen/config.toml +0 -29
  272. examples/finetuning_old/_backup_synth_qwen/example_log.md +0 -324
  273. examples/finetuning_old/_backup_synth_qwen/filter_traces.py +0 -60
  274. examples/finetuning_old/_backup_synth_qwen/filter_traces_achievements.py +0 -239
  275. examples/finetuning_old/_backup_synth_qwen/purge_v3_traces.py +0 -109
  276. examples/finetuning_old/_backup_synth_qwen/react_agent_lm.py +0 -1924
  277. examples/finetuning_old/_backup_synth_qwen/readme.md +0 -49
  278. examples/finetuning_old/_backup_synth_qwen/run_crafter_qwen4b.py +0 -114
  279. examples/finetuning_old/_backup_synth_qwen/run_demo.sh +0 -195
  280. examples/finetuning_old/_backup_synth_qwen/sft_kickoff.py +0 -118
  281. examples/finetuning_old/synth_qwen_v1/README.md +0 -68
  282. examples/finetuning_old/synth_qwen_v1/filter_traces.py +0 -60
  283. examples/finetuning_old/synth_qwen_v1/filter_traces_achievements.py +0 -239
  284. examples/finetuning_old/synth_qwen_v1/finetune.py +0 -46
  285. examples/finetuning_old/synth_qwen_v1/hello_ft_model.py +0 -71
  286. examples/finetuning_old/synth_qwen_v1/infer.py +0 -37
  287. examples/finetuning_old/synth_qwen_v1/poll.py +0 -44
  288. examples/finetuning_old/synth_qwen_v1/prepare_data.py +0 -35
  289. examples/finetuning_old/synth_qwen_v1/purge_v3_traces.py +0 -109
  290. examples/finetuning_old/synth_qwen_v1/react_agent_lm.py +0 -1932
  291. examples/finetuning_old/synth_qwen_v1/run_crafter_sft_job.py +0 -207
  292. examples/finetuning_old/synth_qwen_v1/run_ft_job.py +0 -232
  293. examples/finetuning_old/synth_qwen_v1/upload_data.py +0 -34
  294. examples/finetuning_old/synth_qwen_v1/util.py +0 -147
  295. examples/rl_old/task_app.py +0 -962
  296. examples/warming_up_to_rl/old/event_rewards.md +0 -234
  297. examples/warming_up_to_rl/old/notes.md +0 -73
  298. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +0 -738
  299. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +0 -580
  300. synth_ai/experimental/synth_oss.py +0 -446
  301. synth_ai/install_sqld.sh +0 -40
  302. synth_ai/learning/filtering.py +0 -0
  303. synth_ai/learning/offline/dpo.py +0 -0
  304. synth_ai/learning/offline/providers.py +0 -7
  305. synth_ai/learning/offline/sft.py +0 -0
  306. synth_ai/learning/offline/shared.py +0 -0
  307. synth_ai/learning/online/grpo.py +0 -0
  308. synth_ai/learning/online/irft.py +0 -0
  309. synth_ai/learning/prompts/banking77_injection_eval.py +0 -168
  310. synth_ai/learning/prompts/gepa.py +0 -0
  311. synth_ai/learning/prompts/hello_world_in_context_injection_ex.py +0 -213
  312. synth_ai/learning/prompts/mipro.py +0 -289
  313. synth_ai/learning/prompts/random_search.py +0 -246
  314. synth_ai/learning/prompts/run_mipro_banking77.py +0 -172
  315. synth_ai/learning/prompts/run_random_search_banking77.py +0 -324
  316. synth_ai/rl/secrets.py +0 -19
  317. synth_ai/scripts/verify_rewards.py +0 -100
  318. synth_ai/tracing/__init__.py +0 -30
  319. synth_ai/tracing_v1/__init__.py +0 -33
  320. synth_ai/tracing_v3/turso/__init__.py +0 -25
  321. synth_ai/tracing_v3/turso/manager.py +0 -774
  322. synth_ai/zyk/__init__.py +0 -30
  323. synth_ai-0.2.9.dev5.dist-info/METADATA +0 -131
  324. /synth_ai/{lm → v0/lm}/caching/__init__.py +0 -0
  325. /synth_ai/{lm → v0/lm}/caching/constants.py +0 -0
  326. /synth_ai/{lm → v0/lm}/caching/dbs.py +0 -0
  327. /synth_ai/{lm → v0/lm}/constants.py +0 -0
  328. /synth_ai/{lm → v0/lm}/core/__init__.py +0 -0
  329. /synth_ai/{lm → v0/lm}/cost/__init__.py +0 -0
  330. /synth_ai/{lm → v0/lm}/cost/monitor.py +0 -0
  331. /synth_ai/{lm → v0/lm}/cost/statefulness.py +0 -0
  332. /synth_ai/{lm → v0/lm}/injection.py +0 -0
  333. /synth_ai/{lm → v0/lm}/provider_support/__init__.py +0 -0
  334. /synth_ai/{lm → v0/lm}/provider_support/suppress_logging.py +0 -0
  335. /synth_ai/{lm → v0/lm}/structured_outputs/__init__.py +0 -0
  336. /synth_ai/{lm → v0/lm}/structured_outputs/inject.py +0 -0
  337. /synth_ai/{lm → v0/lm}/tools/__init__.py +0 -0
  338. /synth_ai/{lm → v0/lm}/tools/base.py +0 -0
  339. /synth_ai/{lm → v0/lm}/unified_interface.py +0 -0
  340. /synth_ai/{lm → v0/lm}/vendors/__init__.py +0 -0
  341. /synth_ai/{lm → v0/lm}/vendors/base.py +0 -0
  342. /synth_ai/{lm → v0/lm}/vendors/core/__init__.py +0 -0
  343. /synth_ai/{lm → v0/lm}/vendors/core/synth_dev_api.py +0 -0
  344. /synth_ai/{lm → v0/lm}/vendors/local/__init__.py +0 -0
  345. /synth_ai/{lm → v0/lm}/vendors/local/ollama.py +0 -0
  346. /synth_ai/{lm → v0/lm}/vendors/retries.py +0 -0
  347. /synth_ai/{lm → v0/lm}/vendors/supported/__init__.py +0 -0
  348. /synth_ai/{lm → v0/lm}/warmup.py +0 -0
  349. {synth_ai-0.2.9.dev5.dist-info → synth_ai-0.2.9.dev6.dist-info}/WHEEL +0 -0
  350. {synth_ai-0.2.9.dev5.dist-info → synth_ai-0.2.9.dev6.dist-info}/entry_points.txt +0 -0
  351. {synth_ai-0.2.9.dev5.dist-info → synth_ai-0.2.9.dev6.dist-info}/licenses/LICENSE +0 -0
synth_ai/api/train/cli.py CHANGED
@@ -2,21 +2,22 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
- from typing import Any, Dict
5
+ from typing import Any
6
6
 
7
7
  import click
8
+ from synth_ai.config.base_url import get_backend_from_env
8
9
 
9
- from .builders import RLBuildResult, SFTBuildResult, build_rl_payload, build_sft_payload
10
+ from .builders import build_rl_payload, build_sft_payload
10
11
  from .config_finder import discover_configs, prompt_for_config
11
12
  from .env_resolver import KeySpec, resolve_env
12
13
  from .pollers import RLJobPoller, SFTJobPoller
13
14
  from .task_app import check_task_app_health
14
15
  from .utils import (
15
- TrainError,
16
16
  REPO_ROOT,
17
+ TrainError,
17
18
  ensure_api_base,
18
- http_post,
19
19
  http_get,
20
+ http_post,
20
21
  limit_jsonl_examples,
21
22
  mask_value,
22
23
  post_multipart,
@@ -92,20 +93,72 @@ def _prompt_manual_dataset() -> Path:
92
93
  return Path(manual).expanduser()
93
94
 
94
95
 
96
+ def _default_backend() -> str:
97
+ """Resolve backend URL with proper production default."""
98
+ # Check explicit override first
99
+ explicit = os.getenv("BACKEND_BASE_URL", "").strip()
100
+ if explicit:
101
+ return explicit
102
+ # Use standard resolution logic
103
+ base, _ = get_backend_from_env()
104
+ return f"{base}/api" if not base.endswith("/api") else base
105
+
106
+
95
107
  @click.command("train")
96
- @click.option("--config", "config_paths", multiple=True, type=click.Path(), help="Path to training TOML (repeatable)")
108
+ @click.option(
109
+ "--config",
110
+ "config_paths",
111
+ multiple=True,
112
+ type=click.Path(),
113
+ help="Path to training TOML (repeatable)",
114
+ )
97
115
  @click.option("--type", "train_type", type=click.Choice(["auto", "rl", "sft"]), default="auto")
98
- @click.option("--env-file", "env_files", multiple=True, type=click.Path(), help=".env file(s) to preload (skips selection prompt)")
116
+ @click.option(
117
+ "--env-file",
118
+ "env_files",
119
+ multiple=True,
120
+ type=click.Path(),
121
+ help=".env file(s) to preload (skips selection prompt)",
122
+ )
99
123
  @click.option("--task-url", default=None, help="Override task app base URL (RL only)")
100
- @click.option("--dataset", "dataset_path", type=click.Path(), default=None, help="Override dataset JSONL path (SFT)")
101
- @click.option("--backend", default=lambda: os.getenv("BACKEND_BASE_URL", "http://localhost:8000/api"), help="Backend base URL")
124
+ @click.option(
125
+ "--dataset",
126
+ "dataset_path",
127
+ type=click.Path(),
128
+ default=None,
129
+ help="Override dataset JSONL path (SFT)",
130
+ )
131
+ @click.option("--backend", default=_default_backend, help="Backend base URL")
102
132
  @click.option("--model", default=None, help="Override model identifier")
133
+ @click.option(
134
+ "--allow-experimental",
135
+ "allow_experimental",
136
+ is_flag=True,
137
+ flag_value=True,
138
+ default=None,
139
+ help="Allow experimental models (overrides SDK_EXPERIMENTAL env)",
140
+ )
141
+ @click.option(
142
+ "--no-allow-experimental",
143
+ "allow_experimental",
144
+ is_flag=True,
145
+ flag_value=False,
146
+ help="Disallow experimental models (overrides SDK_EXPERIMENTAL env)",
147
+ )
103
148
  @click.option("--idempotency", default=None, help="Idempotency-Key header for job creation")
104
- @click.option("--dry-run", is_flag=True, help="Preview payload without submitting")
149
+ @click.option("--dry-run", is_flag=True, hidden=True, help="Deprecated: no-op")
105
150
  @click.option("--poll/--no-poll", default=True, help="Poll job status until terminal state")
106
- @click.option("--poll-timeout", default=3600.0, type=float, help="Maximum seconds to poll before timing out")
151
+ @click.option(
152
+ "--poll-timeout", default=3600.0, type=float, help="Maximum seconds to poll before timing out"
153
+ )
107
154
  @click.option("--poll-interval", default=5.0, type=float, help="Seconds between poll attempts")
108
- @click.option("--examples", "examples_limit", type=int, default=None, help="Limit SFT training to the first N examples")
155
+ @click.option(
156
+ "--examples",
157
+ "examples_limit",
158
+ type=int,
159
+ default=None,
160
+ help="Limit SFT training to the first N examples",
161
+ )
109
162
  def train_command(
110
163
  config_paths: tuple[str, ...],
111
164
  train_type: str,
@@ -114,6 +167,7 @@ def train_command(
114
167
  dataset_path: str | None,
115
168
  backend: str,
116
169
  model: str | None,
170
+ allow_experimental: bool | None,
117
171
  idempotency: str | None,
118
172
  dry_run: bool,
119
173
  poll: bool,
@@ -123,12 +177,20 @@ def train_command(
123
177
  ) -> None:
124
178
  """Interactive launcher for RL / SFT jobs."""
125
179
 
126
- candidates = discover_configs(list(config_paths), requested_type=train_type if train_type != "auto" else None)
127
- selection = prompt_for_config(candidates, requested_type=train_type if train_type != "auto" else None)
180
+ candidates = discover_configs(
181
+ list(config_paths), requested_type=train_type if train_type != "auto" else None
182
+ )
183
+ selection = prompt_for_config(
184
+ candidates,
185
+ requested_type=train_type if train_type != "auto" else None,
186
+ allow_autoselect=bool(config_paths),
187
+ )
128
188
 
129
189
  effective_type = train_type if train_type != "auto" else selection.train_type
130
190
  if effective_type not in {"rl", "sft"}:
131
- effective_type = click.prompt("Detected config type is ambiguous. Enter type", type=click.Choice(["rl", "sft"]))
191
+ effective_type = click.prompt(
192
+ "Detected config type is ambiguous. Enter type", type=click.Choice(["rl", "sft"])
193
+ )
132
194
 
133
195
  cfg_path = selection.path
134
196
  click.echo(f"Using config: {cfg_path} ({effective_type})")
@@ -199,6 +261,7 @@ def train_command(
199
261
  task_url_override=task_url,
200
262
  model_override=model,
201
263
  idempotency=idempotency,
264
+ allow_experimental=allow_experimental,
202
265
  dry_run=dry_run,
203
266
  poll=poll,
204
267
  poll_timeout=poll_timeout,
@@ -211,6 +274,7 @@ def train_command(
211
274
  backend_base=backend_base,
212
275
  synth_key=synth_key,
213
276
  dataset_override=dataset_override_path,
277
+ allow_experimental=allow_experimental,
214
278
  dry_run=dry_run,
215
279
  poll=poll,
216
280
  poll_timeout=poll_timeout,
@@ -219,11 +283,14 @@ def train_command(
219
283
  )
220
284
 
221
285
 
222
- def _wait_for_training_file(backend_base: str, api_key: str, file_id: str, *, timeout: float = 120.0) -> None:
286
+ def _wait_for_training_file(
287
+ backend_base: str, api_key: str, file_id: str, *, timeout: float = 120.0
288
+ ) -> None:
223
289
  url = f"{backend_base}/learning/files/{file_id}"
224
290
  headers = {"Authorization": f"Bearer {api_key}"}
225
291
  elapsed = 0.0
226
292
  interval = 2.0
293
+ first_check = True
227
294
  while True:
228
295
  resp = http_get(url, headers=headers, timeout=30.0)
229
296
  if resp.status_code == 200:
@@ -231,17 +298,55 @@ def _wait_for_training_file(backend_base: str, api_key: str, file_id: str, *, ti
231
298
  data = resp.json()
232
299
  except Exception:
233
300
  data = {}
234
- status = str(data.get("status") or data.get("state") or data.get("storage_state") or "ready").lower()
301
+ status = str(
302
+ data.get("status") or data.get("state") or data.get("storage_state") or "ready"
303
+ ).lower()
304
+ if first_check:
305
+ click.echo(f"File uploaded successfully (id={file_id}, status={status})")
306
+ first_check = False
235
307
  if status in {"ready", "uploaded", "stored", "complete"}:
308
+ click.echo(f"✓ Training file ready (status={status})")
236
309
  return
310
+ # Show progress for processing states
311
+ if status in {"processing", "pending", "validating"}:
312
+ click.echo(
313
+ f" Waiting for file processing... (status={status}, {elapsed:.0f}s elapsed)"
314
+ )
237
315
  elif resp.status_code == 404:
238
316
  # Keep polling; object may not be visible yet
239
- pass
317
+ if first_check:
318
+ click.echo(f"Waiting for file {file_id} to become visible...")
319
+ first_check = False
320
+ elif resp.status_code in {401, 403}:
321
+ # Auth errors won't resolve by polling - fail immediately
322
+ try:
323
+ error_body = resp.json()
324
+ except Exception:
325
+ error_body = resp.text[:400]
326
+ click.echo("\n[ERROR] Authentication failed when checking training file:")
327
+ click.echo(f" URL: {url}")
328
+ click.echo(f" Status: {resp.status_code}")
329
+ click.echo(f" Response: {error_body}")
330
+ click.echo(f" API key: {mask_value(api_key)}")
331
+ raise click.ClickException(
332
+ f"Authentication error ({resp.status_code}). "
333
+ "Check that your SYNTH_API_KEY is valid and has permission to access this organization's files."
334
+ )
240
335
  else:
241
- click.echo(f"[WARN] Unexpected response while checking training file {file_id}: {resp.status_code}")
336
+ # Other errors - show details but keep polling
337
+ try:
338
+ error_body = resp.json()
339
+ except Exception:
340
+ error_body = resp.text[:400]
341
+ click.echo(f"[WARN] Unexpected response checking file {file_id}:")
342
+ click.echo(f" URL: {url}")
343
+ click.echo(f" Status: {resp.status_code}")
344
+ click.echo(f" Response: {error_body}")
242
345
 
243
346
  if elapsed >= timeout:
244
- raise click.ClickException(f"Training file {file_id} not ready after {timeout:.0f}s")
347
+ raise click.ClickException(
348
+ f"Training file {file_id} not ready after {timeout:.0f}s (last status: {resp.status_code})"
349
+ )
245
350
  sleep(interval)
246
351
  elapsed += interval
247
352
 
@@ -254,31 +359,41 @@ def handle_rl(
254
359
  task_url_override: str | None,
255
360
  model_override: str | None,
256
361
  idempotency: str | None,
362
+ allow_experimental: bool | None,
257
363
  dry_run: bool,
258
364
  poll: bool,
259
365
  poll_timeout: float,
260
366
  poll_interval: float,
261
367
  ) -> None:
262
- overrides: Dict[str, Any] = {"backend": backend_base, "task_url": task_url_override, "model": model_override}
368
+ overrides: dict[str, Any] = {
369
+ "backend": backend_base,
370
+ "task_url": task_url_override,
371
+ "model": model_override,
372
+ }
263
373
  build = build_rl_payload(
264
374
  config_path=cfg_path,
265
375
  task_url=task_url_override or os.environ.get("TASK_APP_URL", ""),
266
376
  overrides=overrides,
267
377
  idempotency=idempotency,
378
+ allow_experimental=allow_experimental,
268
379
  )
269
380
 
270
381
  # Backend-side verification: try ALL org environment keys against /health and /task_info
271
382
  verify_url = f"{backend_base}/rl/verify_task_app"
272
383
  verify_headers = {"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}
273
384
  try:
274
- vresp = http_post(verify_url, headers=verify_headers, json_body={"endpoint_base_url": build.task_url})
385
+ vresp = http_post(
386
+ verify_url, headers=verify_headers, json_body={"endpoint_base_url": build.task_url}
387
+ )
275
388
  try:
276
389
  vjs = vresp.json()
277
390
  except Exception:
278
391
  vjs = {"status": vresp.status_code, "text": (vresp.text or "")[:400]}
279
392
  except Exception as _ve:
280
- raise click.ClickException(f"Task app verification call failed: {type(_ve).__name__}: {_ve}") from _ve
281
- if vresp.status_code >= 400:
393
+ raise click.ClickException(
394
+ f"Task app verification call failed: {type(_ve).__name__}: {_ve}"
395
+ ) from _ve
396
+ if vresp.status_code is not None and vresp.status_code >= 400:
282
397
  click.echo("Task app verification error:\n" + preview_json(vjs, limit=800))
283
398
  raise click.ClickException(f"Verification failed with status {vresp.status_code}")
284
399
  if not bool(vjs.get("any_ok")):
@@ -314,9 +429,6 @@ def handle_rl(
314
429
 
315
430
  click.echo(f"POST {create_url}")
316
431
  click.echo("Payload preview:\n" + preview_json(build.payload, limit=800))
317
- if dry_run:
318
- click.echo("Dry run enabled; skipping submission")
319
- return
320
432
 
321
433
  resp = http_post(create_url, headers=headers, json_body=build.payload)
322
434
  try:
@@ -346,6 +458,7 @@ def handle_sft(
346
458
  backend_base: str,
347
459
  synth_key: str,
348
460
  dataset_override: Path | None,
461
+ allow_experimental: bool | None,
349
462
  dry_run: bool,
350
463
  poll: bool,
351
464
  poll_timeout: float,
@@ -356,7 +469,11 @@ def handle_sft(
356
469
 
357
470
  while True:
358
471
  try:
359
- build = build_sft_payload(config_path=cfg_path, dataset_override=dataset_path)
472
+ build = build_sft_payload(
473
+ config_path=cfg_path,
474
+ dataset_override=dataset_path,
475
+ allow_experimental=allow_experimental,
476
+ )
360
477
  break
361
478
  except TrainError as exc:
362
479
  click.echo(str(exc))
@@ -379,55 +496,94 @@ def handle_sft(
379
496
  validate_sft_jsonl(build.validation_file)
380
497
 
381
498
  upload_url = f"{backend_base}/learning/files"
382
- click.echo(f"Uploading dataset {build.train_file}")
383
- if dry_run:
384
- click.echo("Dry run: skipping upload")
385
- train_file_id = "dry-run-train"
386
- val_file_id = None
387
- else:
388
- resp = post_multipart(upload_url, api_key=synth_key, file_field="file", file_path=build.train_file)
389
- js = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
390
- if resp.status_code >= 400 or "id" not in js:
391
- raise click.ClickException(f"Training file upload failed ({resp.status_code}): {js or resp.text[:200]}")
392
- train_file_id = js["id"]
393
- val_file_id = None
394
- if build.validation_file:
395
- click.echo(f"Uploading validation dataset {build.validation_file}")
396
- vresp = post_multipart(upload_url, api_key=synth_key, file_field="file", file_path=build.validation_file)
397
- vjs = vresp.json() if vresp.headers.get("content-type", "").startswith("application/json") else {}
398
- if vresp.status_code < 400 and "id" in vjs:
399
- val_file_id = vjs["id"]
400
- else:
401
- click.echo(f"[WARN] Validation upload failed: {vresp.status_code} {vjs or vresp.text[:200]}")
499
+ click.echo("\n=== Uploading Training Data ===")
500
+ click.echo(f"Dataset: {build.train_file}")
501
+ click.echo(f"Destination: {upload_url}")
502
+ resp = post_multipart(
503
+ upload_url, api_key=synth_key, file_field="file", file_path=build.train_file
504
+ )
505
+ js = (
506
+ resp.json()
507
+ if resp.headers.get("content-type", "").startswith("application/json")
508
+ else {}
509
+ )
510
+ if resp.status_code is not None and resp.status_code >= 400 or "id" not in js:
511
+ click.echo("\n[ERROR] Training file upload failed:")
512
+ click.echo(f" URL: {upload_url}")
513
+ click.echo(f" Status: {resp.status_code}")
514
+ click.echo(f" Response: {js or resp.text[:400]}")
515
+ click.echo(f" File: {build.train_file}")
516
+ raise click.ClickException(
517
+ f"Training file upload failed with status {resp.status_code}"
518
+ )
519
+ train_file_id = js["id"]
520
+ click.echo(f"✓ Training file uploaded (id={train_file_id})")
521
+ val_file_id = None
522
+ if build.validation_file:
523
+ click.echo(f"Uploading validation dataset: {build.validation_file}")
524
+ vresp = post_multipart(
525
+ upload_url,
526
+ api_key=synth_key,
527
+ file_field="file",
528
+ file_path=build.validation_file,
529
+ )
530
+ vjs = (
531
+ vresp.json()
532
+ if vresp.headers.get("content-type", "").startswith("application/json")
533
+ else {}
534
+ )
535
+ if vresp.status_code is not None and vresp.status_code < 400 and "id" in vjs:
536
+ val_file_id = vjs["id"]
537
+ click.echo(f"✓ Validation file uploaded (id={val_file_id})")
538
+ else:
539
+ click.echo(
540
+ f"[WARN] Validation upload failed ({vresp.status_code}): {vjs or vresp.text[:200]}"
541
+ )
402
542
  payload = dict(build.payload)
403
543
  payload["training_file_id"] = train_file_id
404
544
  if val_file_id:
405
- payload.setdefault("metadata", {}).setdefault("effective_config", {}).setdefault("data", {})["validation_files"] = [val_file_id]
545
+ payload.setdefault("metadata", {}).setdefault("effective_config", {}).setdefault(
546
+ "data", {}
547
+ )["validation_files"] = [val_file_id]
406
548
 
549
+ click.echo("\n=== Checking File Processing Status ===")
407
550
  try:
408
551
  _wait_for_training_file(backend_base, synth_key, train_file_id)
409
552
  except click.ClickException as exc:
410
553
  raise click.ClickException(f"Training file {train_file_id} not ready: {exc}") from exc
411
554
 
412
- click.echo("FFT job payload:\n" + preview_json(payload, limit=800))
413
- if dry_run:
414
- click.echo("Dry run: skipping job submission")
415
- return
555
+ click.echo("\n=== Creating Training Job ===")
556
+ click.echo("Job payload preview:")
557
+ click.echo(preview_json(payload, limit=800))
416
558
 
417
559
  create_url = f"{backend_base}/learning/jobs"
418
560
  headers = {"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}
561
+ click.echo(f"\nPOST {create_url}")
419
562
  resp = http_post(create_url, headers=headers, json_body=payload)
420
- js = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
421
- click.echo(f"Response {resp.status_code}: {preview_json(js, limit=400)}")
563
+ js = (
564
+ resp.json()
565
+ if resp.headers.get("content-type", "").startswith("application/json")
566
+ else {}
567
+ )
422
568
  if resp.status_code not in (200, 201):
423
- raise click.ClickException("Failed to create learning job")
569
+ click.echo("\n[ERROR] Job creation failed:")
570
+ click.echo(f" URL: {create_url}")
571
+ click.echo(f" Status: {resp.status_code}")
572
+ click.echo(f" Response: {preview_json(js, limit=600)}")
573
+ raise click.ClickException(f"Job creation failed with status {resp.status_code}")
424
574
  job_id = js.get("job_id") or js.get("id")
425
575
  if not job_id:
426
576
  raise click.ClickException("Response missing job id")
577
+ click.echo(f"✓ Job created (id={job_id})")
427
578
 
579
+ click.echo("\n=== Starting Training Job ===")
428
580
  start_url = f"{backend_base}/learning/jobs/{job_id}/start"
429
- click.echo(f"POST {start_url} (start)")
430
- _ = http_post(start_url, headers=headers, json_body={})
581
+ click.echo(f"POST {start_url}")
582
+ start_resp = http_post(start_url, headers=headers, json_body={})
583
+ if start_resp.status_code not in (200, 201):
584
+ click.echo(f"[WARN] Job start returned status {start_resp.status_code}")
585
+ else:
586
+ click.echo("✓ Job started")
431
587
 
432
588
  if not poll:
433
589
  click.echo(f"Started job {job_id} (polling disabled)")
@@ -1,14 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import os
5
+ from collections.abc import Iterable
3
6
  from dataclasses import dataclass
4
7
  from pathlib import Path
5
- from typing import Iterable
6
8
 
7
9
  import click
8
10
 
9
11
  from .utils import REPO_ROOT, load_toml, preview_json
10
12
 
11
13
  _SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", "dist", "build"}
14
+ _STATE_FILE = os.path.expanduser("~/.synth-ai/demo.json")
12
15
 
13
16
 
14
17
  @dataclass(slots=True)
@@ -17,9 +20,43 @@ class ConfigCandidate:
17
20
  train_type: str # "rl", "sft", or "unknown"
18
21
 
19
22
 
23
+ def _load_last_config() -> Path | None:
24
+ """Load the last used training config path from state file."""
25
+ try:
26
+ if os.path.isfile(_STATE_FILE):
27
+ with open(_STATE_FILE) as fh:
28
+ data = json.load(fh)
29
+ if isinstance(data, dict):
30
+ last_config = data.get("LAST_CONFIG")
31
+ if last_config:
32
+ path = Path(last_config).resolve()
33
+ if path.exists():
34
+ return path
35
+ except Exception:
36
+ pass
37
+ return None
38
+
39
+
40
+ def _save_last_config(config_path: Path) -> None:
41
+ """Save the last used training config path to state file."""
42
+ try:
43
+ data = {}
44
+ if os.path.isfile(_STATE_FILE):
45
+ with open(_STATE_FILE) as fh:
46
+ data = json.load(fh) or {}
47
+ if not isinstance(data, dict):
48
+ data = {}
49
+ data["LAST_CONFIG"] = str(config_path.resolve())
50
+ os.makedirs(os.path.dirname(_STATE_FILE), exist_ok=True)
51
+ with open(_STATE_FILE, "w") as fh:
52
+ json.dump(data, fh)
53
+ except Exception:
54
+ pass
55
+
56
+
20
57
  def _iter_candidate_paths() -> Iterable[Path]:
21
58
  seen: set[Path] = set()
22
-
59
+
23
60
  # Prioritize current working directory first
24
61
  try:
25
62
  cwd = Path.cwd().resolve()
@@ -135,23 +172,46 @@ def discover_configs(explicit: list[str], *, requested_type: str | None) -> list
135
172
  return candidates
136
173
 
137
174
 
138
- def prompt_for_config(candidates: list[ConfigCandidate], *, requested_type: str | None) -> ConfigCandidate:
175
+ def prompt_for_config(
176
+ candidates: list[ConfigCandidate], *, requested_type: str | None, allow_autoselect: bool = False
177
+ ) -> ConfigCandidate:
139
178
  if not candidates:
140
179
  raise click.ClickException("No training configs found. Pass --config explicitly.")
141
180
 
181
+ # Check for last used config and move it to the top if found
182
+ last_config = _load_last_config()
183
+ default_idx = 1
184
+
185
+ if allow_autoselect and len(candidates) == 1:
186
+ chosen = candidates[0]
187
+ _save_last_config(chosen.path)
188
+ return chosen
189
+
190
+ if last_config:
191
+ for idx, cand in enumerate(candidates):
192
+ if cand.path.resolve() == last_config:
193
+ # Move last used config to the front
194
+ candidates.insert(0, candidates.pop(idx))
195
+ break
196
+
142
197
  click.echo("Select a training config:")
143
198
  for idx, cand in enumerate(candidates, start=1):
144
199
  label = cand.train_type if cand.train_type != "unknown" else "?"
145
- click.echo(f" {idx}) [{label}] {cand.path}")
200
+ last_marker = " (last used)" if last_config and cand.path.resolve() == last_config else ""
201
+ click.echo(f" {idx}) [{label}] {cand.path}{last_marker}")
146
202
  click.echo(" 0) Abort")
147
203
 
148
- choice = click.prompt("Enter choice", type=int)
204
+ choice = click.prompt("Enter choice", type=int, default=default_idx)
149
205
  if choice == 0:
150
206
  raise click.ClickException("Aborted by user")
151
207
  if choice < 0 or choice > len(candidates):
152
208
  raise click.ClickException("Invalid selection")
153
209
 
154
210
  selection = candidates[choice - 1]
211
+
212
+ # Save this config as the last used
213
+ _save_last_config(selection.path)
214
+
155
215
  try:
156
216
  data = load_toml(selection.path)
157
217
  preview = preview_json({k: data.get(k) for k in list(data.keys())[:4]}, limit=320)
@@ -1,9 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Callable, Iterable, MutableMapping
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
- from typing import Callable, Iterable, MutableMapping
7
7
 
8
8
  import click
9
9
 
@@ -56,12 +56,12 @@ class EnvResolver:
56
56
  def _collect_default_candidates(config_path: Path | None) -> list[Path]:
57
57
  candidates: list[Path] = []
58
58
  cwd = Path.cwd()
59
-
59
+
60
60
  # Prioritize CWD env files
61
61
  cwd_env = cwd / ".env"
62
62
  if cwd_env.exists():
63
63
  candidates.append(cwd_env.resolve())
64
-
64
+
65
65
  # Search for additional .env files in CWD subdirectories
66
66
  for sub in cwd.glob("**/.env"):
67
67
  try:
@@ -76,13 +76,13 @@ def _collect_default_candidates(config_path: Path | None) -> list[Path]:
76
76
  if len(candidates) >= 20:
77
77
  break
78
78
  candidates.append(resolved)
79
-
79
+
80
80
  # Then config path env file
81
81
  if config_path:
82
82
  cfg_env = config_path.parent / ".env"
83
83
  if cfg_env.exists():
84
84
  candidates.append(cfg_env.resolve())
85
-
85
+
86
86
  # Then repo env files
87
87
  repo_env = REPO_ROOT / ".env"
88
88
  if repo_env.exists():
@@ -90,7 +90,7 @@ def _collect_default_candidates(config_path: Path | None) -> list[Path]:
90
90
  examples_env = REPO_ROOT / "examples" / ".env"
91
91
  if examples_env.exists():
92
92
  candidates.append(examples_env.resolve())
93
-
93
+
94
94
  # Search shallow depth for additional .env files in examples
95
95
  for sub in (REPO_ROOT / "examples").glob("**/.env"):
96
96
  try:
@@ -105,7 +105,7 @@ def _collect_default_candidates(config_path: Path | None) -> list[Path]:
105
105
  if len(candidates) >= 20:
106
106
  break
107
107
  candidates.append(resolved)
108
-
108
+
109
109
  deduped: list[Path] = []
110
110
  for path in candidates:
111
111
  if path not in deduped:
@@ -156,8 +156,27 @@ def resolve_env(
156
156
  raise click.ClickException(f"Env file not found: {path}")
157
157
  resolver = EnvResolver(provided)
158
158
  else:
159
- resolver = EnvResolver(_collect_default_candidates(config_path))
160
- resolver.select_new_env() # force user selection even if one candidate
159
+ # Check for saved .env path from demo command
160
+ try:
161
+ from synth_ai.demos.demo_task_apps.core import load_env_file_path
162
+
163
+ saved_env_path = load_env_file_path()
164
+ if saved_env_path:
165
+ saved_path = Path(saved_env_path)
166
+ if saved_path.exists():
167
+ click.echo(f"Using .env file: {saved_path}")
168
+ resolver = EnvResolver([saved_path])
169
+ else:
170
+ # Saved path no longer exists, fall back to prompt
171
+ resolver = EnvResolver(_collect_default_candidates(config_path))
172
+ resolver.select_new_env()
173
+ else:
174
+ resolver = EnvResolver(_collect_default_candidates(config_path))
175
+ resolver.select_new_env()
176
+ except Exception:
177
+ # If import fails or any error, fall back to original behavior
178
+ resolver = EnvResolver(_collect_default_candidates(config_path))
179
+ resolver.select_new_env()
161
180
 
162
181
  # Preload selected .env keys into process env so downstream lookups succeed
163
182
  try:
@@ -207,10 +226,10 @@ def _resolve_key(resolver: EnvResolver, spec: KeySpec) -> str:
207
226
  break
208
227
  if env_val:
209
228
  click.echo(f"Found {spec.name} in current sources: {mask_value(env_val)}")
210
- if _prompt_yes_no(f"Use this value for {spec.name}?", default=True):
211
- _maybe_persist(resolver, spec, env_val)
212
- os.environ[spec.name] = env_val
213
- return env_val
229
+ # Automatically use and persist the value (no prompt)
230
+ _maybe_persist(resolver, spec, env_val)
231
+ os.environ[spec.name] = env_val
232
+ return env_val
214
233
  options: list[tuple[str, Callable[[], str | None]]] = []
215
234
 
216
235
  def _enter_manual() -> str:
@@ -254,8 +273,7 @@ def _resolve_key(resolver: EnvResolver, spec: KeySpec) -> str:
254
273
 
255
274
 
256
275
  def _maybe_persist(resolver: EnvResolver, spec: KeySpec, value: str) -> None:
257
- if not _prompt_yes_no(f"Save {spec.name} to {resolver.current_path}?", default=True):
258
- return
276
+ # Automatically save (no prompt)
259
277
  resolver.set_value(spec.name, value)
260
278
  click.echo(f"Saved {spec.name} to {resolver.current_path}")
261
279