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
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import random
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable, Sequence
7
+
8
+ from synth_ai.http import AsyncHttpClient, sleep
9
+
10
+ from .config import StreamConfig
11
+ from .handlers import StreamHandler
12
+ from .types import StreamMessage, StreamType
13
+
14
+ TERMINAL_STATUSES = {"succeeded", "failed", "cancelled", "canceled", "completed"}
15
+ TERMINAL_EVENT_SUCCESS = {
16
+ "sft.job.completed",
17
+ "rl.train.completed",
18
+ "rl.job.completed",
19
+ "workflow.completed",
20
+ "training.completed",
21
+ }
22
+ TERMINAL_EVENT_FAILURE = {
23
+ "sft.job.failed",
24
+ "rl.train.failed",
25
+ "rl.job.failed",
26
+ "workflow.failed",
27
+ "training.failed",
28
+ }
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class StreamEndpoints:
33
+ """Collection of endpoint paths (with optional fallbacks) to poll for a job."""
34
+
35
+ status: str | None
36
+ events: str | None = None
37
+ metrics: str | None = None
38
+ timeline: str | None = None
39
+ status_fallbacks: tuple[str, ...] = ()
40
+ event_fallbacks: tuple[str, ...] = ()
41
+ metric_fallbacks: tuple[str, ...] = ()
42
+ timeline_fallbacks: tuple[str, ...] = ()
43
+
44
+ @classmethod
45
+ def learning(cls, job_id: str) -> StreamEndpoints:
46
+ base = f"/learning/jobs/{job_id}"
47
+ return cls(
48
+ status=base,
49
+ events=f"{base}/events",
50
+ metrics=f"{base}/metrics",
51
+ timeline=f"{base}/timeline",
52
+ )
53
+
54
+ @classmethod
55
+ def prompt_learning(cls, job_id: str) -> StreamEndpoints:
56
+ """Endpoints for prompt learning jobs (MIPRO/GEPA)."""
57
+ base = f"/prompt-learning/online/jobs/{job_id}"
58
+ return cls(
59
+ status=base,
60
+ events=f"{base}/events",
61
+ metrics=f"{base}/metrics",
62
+ timeline=None,
63
+ status_fallbacks=(
64
+ f"/learning/jobs/{job_id}",
65
+ f"/orchestration/jobs/{job_id}",
66
+ ),
67
+ event_fallbacks=(
68
+ f"/learning/jobs/{job_id}/events",
69
+ f"/orchestration/jobs/{job_id}/events",
70
+ ),
71
+ )
72
+
73
+ @classmethod
74
+ def rl(cls, job_id: str) -> StreamEndpoints:
75
+ base = f"/rl/jobs/{job_id}"
76
+ return cls(
77
+ status=base,
78
+ events=f"{base}/events",
79
+ metrics=f"{base}/metrics",
80
+ timeline=f"{base}/timeline",
81
+ status_fallbacks=(
82
+ f"/learning/jobs/{job_id}",
83
+ f"/orchestration/jobs/{job_id}",
84
+ ),
85
+ event_fallbacks=(
86
+ f"/learning/jobs/{job_id}/events",
87
+ f"/orchestration/jobs/{job_id}/events",
88
+ ),
89
+ metric_fallbacks=(
90
+ f"/learning/jobs/{job_id}/metrics",
91
+ ),
92
+ timeline_fallbacks=(
93
+ f"/learning/jobs/{job_id}/timeline",
94
+ ),
95
+ )
96
+
97
+
98
+ class JobStreamer:
99
+ """Poll job endpoints and dispatch messages to configured handlers."""
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ base_url: str,
105
+ api_key: str,
106
+ job_id: str,
107
+ endpoints: StreamEndpoints | None = None,
108
+ config: StreamConfig | None = None,
109
+ handlers: Sequence[StreamHandler] | None = None,
110
+ interval_seconds: float = 2.0,
111
+ timeout_seconds: float | None = None,
112
+ http_timeout: float = 60.0,
113
+ http_client: AsyncHttpClient | None = None,
114
+ sleep_fn= sleep,
115
+ ) -> None:
116
+ self.base_url = base_url.rstrip("/")
117
+ self.api_key = api_key
118
+ self.job_id = job_id
119
+ self.endpoints = endpoints or StreamEndpoints.learning(job_id)
120
+ self.config = config or StreamConfig.default()
121
+ self.handlers: list[StreamHandler] = list(handlers or [])
122
+ self.interval_seconds = interval_seconds
123
+ self.timeout_seconds = timeout_seconds
124
+ self.http_timeout = http_timeout
125
+ self._http = http_client
126
+ self._sleep = sleep_fn
127
+
128
+ status_sources: list[str | None] = [self.endpoints.status]
129
+ status_sources.extend(self.endpoints.status_fallbacks)
130
+ self._status_paths = [p for p in status_sources if p]
131
+
132
+ event_sources: list[str | None] = [self.endpoints.events]
133
+ event_sources.extend(self.endpoints.event_fallbacks)
134
+ self._event_paths = [p for p in event_sources if p]
135
+
136
+ metric_sources: list[str | None] = [self.endpoints.metrics]
137
+ metric_sources.extend(self.endpoints.metric_fallbacks)
138
+ self._metric_paths = [p for p in metric_sources if p]
139
+
140
+ timeline_sources: list[str | None] = [self.endpoints.timeline]
141
+ timeline_sources.extend(self.endpoints.timeline_fallbacks)
142
+ self._timeline_paths = [p for p in timeline_sources if p]
143
+
144
+ self._last_seq_by_stream: dict[str, int] = {}
145
+ self._last_step_by_metric: dict[str, int] = {}
146
+ self._seen_messages: set[str] = set()
147
+ self._last_status_payload: dict[str, Any] | None = None
148
+ self._last_status_value: str | None = None
149
+ self._terminal_seen = False
150
+ self._terminal_event_status: str | None = None
151
+
152
+ if not self.handlers:
153
+ from .handlers import CLIHandler
154
+
155
+ self.handlers = [CLIHandler()]
156
+
157
+ async def stream_until_terminal(self) -> dict[str, Any]:
158
+ """Stream configured endpoints until the job reaches a terminal state."""
159
+ http_cm = self._http or AsyncHttpClient(self.base_url, self.api_key, timeout=self.http_timeout)
160
+ async with http_cm as http:
161
+ while True:
162
+ status = await self._refresh_status(http)
163
+
164
+ event_messages = await self._poll_events(http)
165
+ metric_messages = await self._poll_metrics(http)
166
+ timeline_messages = await self._poll_timeline(http)
167
+
168
+ self._dispatch(event_messages + metric_messages + timeline_messages)
169
+
170
+ if self._terminal_seen or (status and status in TERMINAL_STATUSES):
171
+ break
172
+
173
+ await self._sleep(self.interval_seconds)
174
+
175
+ for handler in self.handlers:
176
+ with contextlib.suppress(Exception):
177
+ handler.flush()
178
+
179
+ final_status = self._terminal_event_status or self._last_status_value or "unknown"
180
+ if self._last_status_payload:
181
+ self._last_status_payload["status"] = final_status
182
+ return self._last_status_payload
183
+ return {"job_id": self.job_id, "status": final_status}
184
+
185
+ async def _refresh_status(self, http: AsyncHttpClient) -> str:
186
+ status_payload = await self._poll_status(http)
187
+ if status_payload:
188
+ self._last_status_payload = status_payload
189
+ status = str(status_payload.get("status") or status_payload.get("state") or "").lower()
190
+ if status:
191
+ self._last_status_value = status
192
+ if status in TERMINAL_STATUSES:
193
+ self._terminal_seen = True
194
+ return status
195
+ return self._last_status_value or ""
196
+
197
+ async def _poll_status(self, http: AsyncHttpClient) -> dict[str, Any] | None:
198
+ if StreamType.STATUS not in self.config.enabled_streams or not self._status_paths:
199
+ return None
200
+
201
+ for path in self._status_paths:
202
+ try:
203
+ data = await http.get(path)
204
+ except Exception:
205
+ continue
206
+ if isinstance(data, dict):
207
+ message = StreamMessage.from_status(self.job_id, data)
208
+ self._dispatch([message])
209
+ return data
210
+ return None
211
+
212
+ async def _poll_events(self, http: AsyncHttpClient) -> list[StreamMessage]:
213
+ if StreamType.EVENTS not in self.config.enabled_streams or not self._event_paths:
214
+ return []
215
+ messages: list[StreamMessage] = []
216
+ total = 0
217
+ for path in self._event_paths:
218
+ since = self._last_seq_by_stream.get(path, 0)
219
+ params = {"since_seq": since, "limit": 200}
220
+ try:
221
+ data = await http.get(path, params=params)
222
+ except Exception:
223
+ continue
224
+ raw_events = _extract_list(data, "events")
225
+ for event in raw_events:
226
+ seq = int(event.get("seq") or 0)
227
+ if seq <= self._last_seq_by_stream.get(path, 0):
228
+ continue
229
+ if not self.config.should_include_event(event):
230
+ continue
231
+ self._last_seq_by_stream[path] = seq
232
+ event_job_id = event.get("job_id") or self.job_id
233
+ event_message = StreamMessage.from_event(event_job_id, event)
234
+ event_type = str(event.get("type") or "").lower()
235
+ if event_type in TERMINAL_EVENT_SUCCESS:
236
+ self._terminal_seen = True
237
+ self._terminal_event_status = "succeeded"
238
+ elif event_type in TERMINAL_EVENT_FAILURE:
239
+ self._terminal_seen = True
240
+ self._terminal_event_status = "failed"
241
+ messages.append(event_message)
242
+ total += 1
243
+ if self.config.max_events_per_poll and total >= self.config.max_events_per_poll:
244
+ return messages
245
+ return messages
246
+
247
+ async def _poll_metrics(self, http: AsyncHttpClient) -> list[StreamMessage]:
248
+ if StreamType.METRICS not in self.config.enabled_streams or not self._metric_paths:
249
+ return []
250
+ messages: list[StreamMessage] = []
251
+ for path in self._metric_paths:
252
+ after = max(self._last_step_by_metric.values()) if self._last_step_by_metric else -1
253
+ params = {"after_step": after, "limit": 200}
254
+ try:
255
+ data = await http.get(path, params=params)
256
+ except Exception:
257
+ continue
258
+ points = _extract_list(data, "points")
259
+ for point in points:
260
+ name = point.get("name", "")
261
+ step = int(point.get("step") or -1)
262
+ if step <= self._last_step_by_metric.get(name, -1):
263
+ continue
264
+ if not self.config.should_include_metric(point):
265
+ continue
266
+ self._last_step_by_metric[name] = step
267
+ metric_job_id = point.get("job_id") or self.job_id
268
+ messages.append(StreamMessage.from_metric(metric_job_id, point))
269
+ return messages
270
+
271
+ async def _poll_timeline(self, http: AsyncHttpClient) -> list[StreamMessage]:
272
+ if StreamType.TIMELINE not in self.config.enabled_streams or not self._timeline_paths:
273
+ return []
274
+ messages: list[StreamMessage] = []
275
+ for path in self._timeline_paths:
276
+ try:
277
+ data = await http.get(path)
278
+ except Exception:
279
+ continue
280
+
281
+ timeline_entries = _extract_list(data, "events")
282
+ for entry in timeline_entries:
283
+ if not self.config.should_include_timeline(entry):
284
+ continue
285
+ timeline_job_id = entry.get("job_id") or self.job_id
286
+ phase = str(entry.get("phase") or "").lower()
287
+ if phase in TERMINAL_STATUSES:
288
+ self._terminal_seen = True
289
+ if phase in {"failed", "cancelled", "canceled"}:
290
+ self._terminal_event_status = "failed"
291
+ elif phase:
292
+ self._terminal_event_status = "succeeded"
293
+ messages.append(StreamMessage.from_timeline(timeline_job_id, entry))
294
+ return messages
295
+
296
+ def _dispatch(self, messages: Iterable[StreamMessage]) -> None:
297
+ for message in messages:
298
+ if self.config.deduplicate and message.key in self._seen_messages:
299
+ continue
300
+ if self.config.sample_rate < 1.0 and random.random() > self.config.sample_rate:
301
+ continue
302
+ if self.config.deduplicate:
303
+ self._seen_messages.add(message.key)
304
+
305
+ for handler in self.handlers:
306
+ try:
307
+ if handler.should_handle(message):
308
+ handler.handle(message)
309
+ except Exception:
310
+ pass
311
+
312
+
313
+ def _extract_list(data: Any, field: str) -> list[dict[str, Any]]:
314
+ raw = (data or {}).get(field) if isinstance(data, dict) else None
315
+ if isinstance(raw, list):
316
+ return [item for item in raw if isinstance(item, dict)]
317
+ return []
318
+
319
+
320
+ __all__ = ["JobStreamer", "StreamEndpoints"]
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, auto
5
+ from typing import Any
6
+
7
+
8
+ class StreamType(Enum):
9
+ """Categories of streaming payloads emitted by training jobs."""
10
+
11
+ STATUS = auto()
12
+ EVENTS = auto()
13
+ METRICS = auto()
14
+ TIMELINE = auto()
15
+
16
+ @property
17
+ def endpoint_path(self) -> str:
18
+ """Return the endpoint suffix used when polling this stream."""
19
+ return {
20
+ StreamType.STATUS: "",
21
+ StreamType.EVENTS: "/events",
22
+ StreamType.METRICS: "/metrics",
23
+ StreamType.TIMELINE: "/timeline",
24
+ }[self]
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class StreamMessage:
29
+ """Unified representation of a streaming payload."""
30
+
31
+ stream_type: StreamType
32
+ timestamp: str
33
+ job_id: str
34
+ data: dict[str, Any]
35
+ seq: int | None = None
36
+ step: int | None = None
37
+ phase: str | None = None
38
+
39
+ @property
40
+ def key(self) -> str:
41
+ """Return a unique identifier used for deduplication."""
42
+ if self.stream_type is StreamType.EVENTS:
43
+ return f"event:{self.seq}"
44
+ if self.stream_type is StreamType.METRICS:
45
+ name = self.data.get("name", "")
46
+ return f"metric:{name}:{self.step}"
47
+ if self.stream_type is StreamType.TIMELINE:
48
+ return f"timeline:{self.phase}:{self.timestamp}"
49
+ return f"status:{self.timestamp}"
50
+
51
+ @classmethod
52
+ def from_status(cls, job_id: str, status_data: dict[str, Any]) -> StreamMessage:
53
+ """Create a message representing a job status payload."""
54
+ return cls(
55
+ stream_type=StreamType.STATUS,
56
+ timestamp=status_data.get("updated_at", "") or status_data.get("created_at", ""),
57
+ job_id=job_id,
58
+ data=status_data,
59
+ )
60
+
61
+ @classmethod
62
+ def from_event(cls, job_id: str, event_data: dict[str, Any]) -> StreamMessage:
63
+ """Create a message describing a job event."""
64
+ return cls(
65
+ stream_type=StreamType.EVENTS,
66
+ timestamp=event_data.get("created_at", ""),
67
+ job_id=job_id,
68
+ data=event_data,
69
+ seq=event_data.get("seq"),
70
+ )
71
+
72
+ @classmethod
73
+ def from_metric(cls, job_id: str, metric_data: dict[str, Any]) -> StreamMessage:
74
+ """Create a message describing a metric point."""
75
+ return cls(
76
+ stream_type=StreamType.METRICS,
77
+ timestamp=metric_data.get("created_at", ""),
78
+ job_id=job_id,
79
+ data=metric_data,
80
+ step=metric_data.get("step"),
81
+ )
82
+
83
+ @classmethod
84
+ def from_timeline(cls, job_id: str, timeline_data: dict[str, Any]) -> StreamMessage:
85
+ """Create a message describing a status timeline entry."""
86
+ return cls(
87
+ stream_type=StreamType.TIMELINE,
88
+ timestamp=timeline_data.get("created_at", ""),
89
+ job_id=job_id,
90
+ data=timeline_data,
91
+ phase=timeline_data.get("phase"),
92
+ )
93
+
94
+
95
+ __all__ = ["StreamMessage", "StreamType"]
@@ -22,6 +22,7 @@ class ModalDeploymentConfig:
22
22
  extra_local_dirs: Sequence[tuple[str, str]] = field(default_factory=tuple)
23
23
  secret_names: Sequence[str] = field(default_factory=tuple)
24
24
  volume_mounts: Sequence[tuple[str, str]] = field(default_factory=tuple)
25
+ env_vars: dict[str, str] = field(default_factory=dict)
25
26
  timeout: int = 600
26
27
  memory: int = 4096
27
28
  cpu: float = 2.0
synth_ai/task/config.py CHANGED
@@ -257,3 +257,5 @@ class FilterConfig:
257
257
  output_path.parent.mkdir(parents=True, exist_ok=True)
258
258
  return output_path
259
259
 
260
+
261
+
@@ -26,34 +26,34 @@ def tracing_env_enabled(default: bool = False) -> bool:
26
26
 
27
27
 
28
28
  def resolve_tracing_db_url() -> str | None:
29
- """Resolve tracing database URL and prefer async drivers for SQLite."""
30
-
31
- db_url = os.getenv("TURSO_LOCAL_DB_URL")
32
- if db_url:
29
+ """Resolve tracing database URL using centralized tracing_v3 config logic.
30
+
31
+ This delegates to synth_ai.tracing_v3.config.resolve_trace_db_settings() which
32
+ handles Modal detection, remote Turso, local sqld, and SQLite fallbacks.
33
+ """
34
+ try:
35
+ from synth_ai.tracing_v3.config import resolve_trace_db_settings
36
+ db_url, _ = resolve_trace_db_settings(ensure_dir=True)
33
37
  return db_url
34
-
35
- sqld_path = os.getenv("SQLD_DB_PATH")
36
- if sqld_path:
37
- path = Path(sqld_path).expanduser()
38
- if path.is_dir():
39
- candidate = path / "dbs" / "default" / "data"
40
- candidate.parent.mkdir(parents=True, exist_ok=True)
41
- return f"sqlite+aiosqlite:///{candidate}"
42
- else:
43
- path.parent.mkdir(parents=True, exist_ok=True)
44
- return f"sqlite+aiosqlite:///{path}"
45
-
46
- existing = os.getenv("TASKAPP_TRACE_DB_PATH")
47
- if existing:
48
- path = Path(existing).expanduser()
49
- else:
38
+ except ImportError:
39
+ # Fallback if tracing_v3 is not available (shouldn't happen in normal usage)
40
+ db_url = (
41
+ os.getenv("TURSO_LOCAL_DB_URL")
42
+ or os.getenv("LIBSQL_URL")
43
+ or os.getenv("SYNTH_TRACES_DB")
44
+ )
45
+ if db_url:
46
+ return db_url
47
+
48
+ # Auto-provision local sqld location for callers that rely on trace directories.
50
49
  base_dir = TRACE_DB_DIR.expanduser()
51
50
  base_dir.mkdir(parents=True, exist_ok=True)
52
- path = base_dir / canonical_trace_db_name(timestamp=datetime.now())
53
- os.environ["TASKAPP_TRACE_DB_PATH"] = str(path)
54
- os.environ.setdefault("SQLD_DB_PATH", str(path))
55
- path.parent.mkdir(parents=True, exist_ok=True)
56
- return f"sqlite+aiosqlite:///{path}"
51
+ candidate = base_dir / canonical_trace_db_name(timestamp=datetime.now())
52
+ os.environ["TASKAPP_TRACE_DB_PATH"] = str(candidate)
53
+ os.environ.setdefault("SQLD_DB_PATH", str(candidate))
54
+
55
+ default_url = os.getenv("LIBSQL_DEFAULT_URL", "http://127.0.0.1:8081")
56
+ return default_url
57
57
 
58
58
 
59
59
  def build_tracer_factory(
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- from typing import Any, cast
6
+ from typing import Any
7
7
  from urllib.parse import urlparse, urlunparse
8
8
 
9
9
  import click
@@ -133,13 +133,46 @@ def normalize_inference_url(url: str | None, *, default: str = "https://api.open
133
133
  if not candidate:
134
134
  candidate = default
135
135
 
136
- # Parse the URL to separate path and query components
137
136
  parsed = urlparse(candidate)
138
-
137
+ path = (parsed.path or "").rstrip("/")
138
+ query = parsed.query or ""
139
+
140
+ # Repair malformed URLs where the completions path ended up in the query string.
141
+ # Example: https://host?cid=trace/v1/chat/completions -> https://host/v1/chat/completions?cid=trace
142
+ if query and "/" in query:
143
+ base_query, remainder = query.split("/", 1)
144
+ remainder_path = remainder
145
+ extra_query = ""
146
+ for separator in ("&", "?"):
147
+ idx = remainder_path.find(separator)
148
+ if idx != -1:
149
+ extra_query = remainder_path[idx + 1 :]
150
+ remainder_path = remainder_path[:idx]
151
+ break
152
+
153
+ query_path = "/" + remainder_path.lstrip("/")
154
+ merged_query_parts: list[str] = []
155
+ if base_query:
156
+ merged_query_parts.append(base_query)
157
+ if extra_query:
158
+ merged_query_parts.append(extra_query)
159
+ merged_query = "&".join(part for part in merged_query_parts if part)
160
+
161
+ if query_path and query_path != "/":
162
+ combined_path = f"{path.rstrip('/')}{query_path}" if path else query_path
163
+ else:
164
+ combined_path = path
165
+
166
+ parsed = parsed._replace(path=combined_path or "", query=merged_query)
167
+ path = (parsed.path or "").rstrip("/")
168
+ query = parsed.query or ""
169
+
139
170
  # Check if path already ends with a completions endpoint
140
- path = parsed.path.rstrip('/')
141
171
  if path.endswith("/v1/chat/completions") or path.endswith("/chat/completions"):
142
- return candidate
172
+ final_query = parsed.query or ""
173
+ if final_query and "/" in final_query:
174
+ parsed = parsed._replace(query=final_query.split("/", 1)[0])
175
+ return urlunparse(parsed)
143
176
 
144
177
  # Determine what to append based on existing path
145
178
  if path.endswith("/v1"):
@@ -147,11 +180,14 @@ def normalize_inference_url(url: str | None, *, default: str = "https://api.open
147
180
  elif path.endswith("/chat"):
148
181
  new_path = f"{path}/completions"
149
182
  else:
150
- # Default: append full path
151
183
  new_path = f"{path}/v1/chat/completions" if path else "/v1/chat/completions"
152
-
153
- # Reconstruct URL with new path and original query/fragment
154
- return cast(str, urlunparse(parsed._replace(path=new_path)))
184
+
185
+ parsed = parsed._replace(path=new_path)
186
+ final_query = parsed.query or ""
187
+ if final_query and "/" in final_query:
188
+ parsed = parsed._replace(query=final_query.split("/", 1)[0])
189
+
190
+ return urlunparse(parsed)
155
191
 
156
192
 
157
193
  def validate_task_app_url(url: str | None) -> str:
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+ from typing import Literal, Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class LocalTaskAppConfig(BaseModel):
8
+ task_app_path: Path
9
+ trace: bool = True
10
+ host: str = "127.0.0.1"
11
+ port: int = 8000
12
+
13
+
14
+
15
+ class ModalTaskAppConfig(BaseModel):
16
+ task_app_path: Path
17
+ modal_app_path: Path
18
+ modal_bin_path: Path
19
+ cmd_arg: Literal["deploy", "serve"] = "deploy"
20
+ task_app_name: Optional[str] = None
21
+ dry_run: bool = False