synth-ai 0.2.16__py3-none-any.whl → 0.2.17__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 (192) hide show
  1. examples/analyze_semantic_words.sh +2 -2
  2. examples/blog_posts/pokemon_vl/README.md +98 -0
  3. examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +25 -0
  4. examples/blog_posts/pokemon_vl/configs/eval_rl_final.toml +24 -0
  5. examples/blog_posts/pokemon_vl/configs/filter_high_reward.toml +10 -0
  6. examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +42 -0
  7. examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
  8. examples/blog_posts/warming_up_to_rl/README.md +158 -0
  9. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
  10. examples/blog_posts/warming_up_to_rl/configs/eval_groq_qwen32b.toml +25 -0
  11. examples/blog_posts/warming_up_to_rl/configs/eval_openai_gpt_oss_120b.toml +29 -0
  12. examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +10 -0
  13. examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +41 -0
  14. examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
  15. examples/dev/qwen3_32b_qlora_4xh100.toml +5 -0
  16. examples/multi_step/configs/crafter_rl_outcome.toml +1 -1
  17. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +65 -107
  18. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +1 -1
  19. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +1 -1
  20. examples/multi_step/configs/crafter_rl_stepwise_simple_NEW_FORMAT.toml +105 -0
  21. examples/multi_step/configs/verilog_rl_lora.toml +80 -123
  22. examples/qwen_coder/configs/coder_lora_30b.toml +1 -3
  23. examples/qwen_coder/configs/coder_lora_4b.toml +4 -1
  24. examples/qwen_coder/configs/coder_lora_small.toml +1 -3
  25. examples/qwen_vl/README.md +10 -12
  26. examples/qwen_vl/SETUP_COMPLETE.md +7 -8
  27. examples/qwen_vl/VISION_TESTS_COMPLETE.md +2 -3
  28. examples/qwen_vl/collect_data_via_cli.md +76 -84
  29. examples/qwen_vl/collect_vision_traces.py +4 -4
  30. examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +40 -57
  31. examples/qwen_vl/configs/crafter_vlm_sft_example.toml +1 -2
  32. examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +20 -37
  33. examples/qwen_vl/configs/eval_gpt5nano_vision.toml +21 -40
  34. examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
  35. examples/qwen_vl/configs/{filter_qwen2vl_sft.toml → filter_qwen3vl_sft.toml} +4 -5
  36. examples/qwen_vl/configs/filter_vision_sft.toml +2 -3
  37. examples/qwen_vl/crafter_qwen_vl_agent.py +5 -5
  38. examples/qwen_vl/run_vision_comparison.sh +6 -7
  39. examples/rl/README.md +5 -5
  40. examples/rl/configs/rl_from_base_qwen.toml +26 -1
  41. examples/rl/configs/rl_from_base_qwen17.toml +5 -2
  42. examples/rl/task_app/README.md +1 -2
  43. examples/rl/task_app/math_single_step.py +2 -2
  44. examples/run_crafter_demo.sh +2 -2
  45. examples/sft/README.md +1 -1
  46. examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -1
  47. examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -1
  48. examples/swe/task_app/README.md +32 -2
  49. examples/swe/task_app/grpo_swe_mini.py +4 -0
  50. examples/swe/task_app/hosted/envs/crafter/react_agent.py +1 -1
  51. examples/swe/task_app/hosted/envs/mini_swe/environment.py +37 -10
  52. examples/swe/task_app/hosted/inference/openai_client.py +4 -4
  53. examples/swe/task_app/morph_backend.py +178 -0
  54. examples/task_apps/crafter/task_app/README.md +1 -1
  55. examples/task_apps/crafter/task_app/grpo_crafter.py +66 -3
  56. examples/task_apps/crafter/task_app/grpo_crafter_task_app.py +1 -1
  57. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +4 -26
  58. examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -2
  59. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +17 -49
  60. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +13 -5
  61. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +15 -1
  62. examples/task_apps/enron/task_app/grpo_enron_task_app.py +1 -1
  63. examples/task_apps/math/README.md +1 -2
  64. examples/task_apps/pokemon_red/README.md +3 -4
  65. examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +6 -5
  66. examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +1 -2
  67. examples/task_apps/pokemon_red/task_app.py +36 -5
  68. examples/task_apps/sokoban/README.md +2 -3
  69. examples/task_apps/verilog/eval_groq_qwen32b.toml +12 -14
  70. examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +1 -1
  71. examples/vlm/configs/crafter_vlm_gpt4o.toml +4 -1
  72. examples/warming_up_to_rl/configs/crafter_fft.toml +4 -1
  73. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +0 -2
  74. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +2 -2
  75. examples/warming_up_to_rl/run_local_rollout_traced.py +1 -1
  76. examples/warming_up_to_rl/task_app/README.md +1 -1
  77. examples/warming_up_to_rl/task_app/grpo_crafter.py +134 -3
  78. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +1 -1
  79. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +3 -27
  80. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -1
  81. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +4 -4
  82. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +6 -3
  83. examples/workflows/math_rl/configs/rl_from_base_qwen.toml +27 -0
  84. examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +5 -0
  85. synth_ai/api/train/builders.py +9 -3
  86. synth_ai/api/train/cli.py +125 -10
  87. synth_ai/api/train/configs/__init__.py +8 -1
  88. synth_ai/api/train/configs/rl.py +32 -7
  89. synth_ai/api/train/configs/sft.py +6 -2
  90. synth_ai/api/train/configs/shared.py +59 -2
  91. synth_ai/auth/credentials.py +119 -0
  92. synth_ai/cli/__init__.py +12 -4
  93. synth_ai/cli/commands/__init__.py +17 -0
  94. synth_ai/cli/commands/demo/__init__.py +6 -0
  95. synth_ai/cli/commands/demo/core.py +163 -0
  96. synth_ai/cli/commands/deploy/__init__.py +23 -0
  97. synth_ai/cli/commands/deploy/core.py +614 -0
  98. synth_ai/cli/commands/deploy/errors.py +72 -0
  99. synth_ai/cli/commands/deploy/validation.py +11 -0
  100. synth_ai/cli/commands/eval/__init__.py +19 -0
  101. synth_ai/cli/commands/eval/core.py +1109 -0
  102. synth_ai/cli/commands/eval/errors.py +81 -0
  103. synth_ai/cli/commands/eval/validation.py +133 -0
  104. synth_ai/cli/commands/filter/__init__.py +12 -0
  105. synth_ai/cli/commands/filter/core.py +388 -0
  106. synth_ai/cli/commands/filter/errors.py +55 -0
  107. synth_ai/cli/commands/filter/validation.py +77 -0
  108. synth_ai/cli/commands/help/__init__.py +177 -0
  109. synth_ai/cli/commands/help/core.py +73 -0
  110. synth_ai/cli/commands/status/__init__.py +64 -0
  111. synth_ai/cli/commands/status/client.py +192 -0
  112. synth_ai/cli/commands/status/config.py +92 -0
  113. synth_ai/cli/commands/status/errors.py +20 -0
  114. synth_ai/cli/commands/status/formatters.py +164 -0
  115. synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
  116. synth_ai/cli/commands/status/subcommands/files.py +79 -0
  117. synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
  118. synth_ai/cli/commands/status/subcommands/models.py +79 -0
  119. synth_ai/cli/commands/status/subcommands/runs.py +81 -0
  120. synth_ai/cli/commands/status/subcommands/summary.py +47 -0
  121. synth_ai/cli/commands/status/utils.py +114 -0
  122. synth_ai/cli/commands/train/__init__.py +53 -0
  123. synth_ai/cli/commands/train/core.py +21 -0
  124. synth_ai/cli/commands/train/errors.py +117 -0
  125. synth_ai/cli/commands/train/judge_schemas.py +199 -0
  126. synth_ai/cli/commands/train/judge_validation.py +304 -0
  127. synth_ai/cli/commands/train/validation.py +443 -0
  128. synth_ai/cli/demo.py +2 -162
  129. synth_ai/cli/deploy/__init__.py +28 -0
  130. synth_ai/cli/deploy/core.py +5 -0
  131. synth_ai/cli/deploy/errors.py +23 -0
  132. synth_ai/cli/deploy/validation.py +5 -0
  133. synth_ai/cli/eval/__init__.py +36 -0
  134. synth_ai/cli/eval/core.py +5 -0
  135. synth_ai/cli/eval/errors.py +31 -0
  136. synth_ai/cli/eval/validation.py +5 -0
  137. synth_ai/cli/filter/__init__.py +28 -0
  138. synth_ai/cli/filter/core.py +5 -0
  139. synth_ai/cli/filter/errors.py +23 -0
  140. synth_ai/cli/filter/validation.py +5 -0
  141. synth_ai/cli/modal_serve/__init__.py +12 -0
  142. synth_ai/cli/modal_serve/core.py +14 -0
  143. synth_ai/cli/modal_serve/errors.py +8 -0
  144. synth_ai/cli/modal_serve/validation.py +11 -0
  145. synth_ai/cli/serve/__init__.py +12 -0
  146. synth_ai/cli/serve/core.py +14 -0
  147. synth_ai/cli/serve/errors.py +8 -0
  148. synth_ai/cli/serve/validation.py +11 -0
  149. synth_ai/cli/setup.py +20 -265
  150. synth_ai/cli/status.py +7 -126
  151. synth_ai/cli/task_app_deploy.py +1 -10
  152. synth_ai/cli/task_app_modal_serve.py +4 -9
  153. synth_ai/cli/task_app_serve.py +4 -11
  154. synth_ai/cli/task_apps.py +58 -1487
  155. synth_ai/cli/train/__init__.py +12 -0
  156. synth_ai/cli/train/core.py +21 -0
  157. synth_ai/cli/train/errors.py +8 -0
  158. synth_ai/cli/train/validation.py +24 -0
  159. synth_ai/cli/train.py +1 -14
  160. synth_ai/demos/crafter/grpo_crafter_task_app.py +1 -1
  161. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
  162. synth_ai/environments/examples/red/engine.py +33 -12
  163. synth_ai/environments/examples/red/engine_helpers/reward_components.py +151 -179
  164. synth_ai/environments/examples/red/environment.py +26 -0
  165. synth_ai/environments/examples/red/trace_hooks_v3.py +168 -0
  166. synth_ai/http.py +12 -0
  167. synth_ai/judge_schemas.py +10 -11
  168. synth_ai/learning/rl/client.py +3 -1
  169. synth_ai/streaming/__init__.py +29 -0
  170. synth_ai/streaming/config.py +94 -0
  171. synth_ai/streaming/handlers.py +469 -0
  172. synth_ai/streaming/streamer.py +301 -0
  173. synth_ai/streaming/types.py +95 -0
  174. synth_ai/task/validators.py +2 -2
  175. synth_ai/tracing_v3/migration_helper.py +1 -2
  176. synth_ai/utils/env.py +25 -18
  177. synth_ai/utils/http.py +4 -1
  178. synth_ai/utils/modal.py +2 -2
  179. {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/METADATA +8 -3
  180. {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/RECORD +184 -109
  181. examples/qwen_vl/configs/eval_qwen2vl_vision.toml +0 -44
  182. synth_ai/cli/tui.py +0 -62
  183. synth_ai/tui/__init__.py +0 -5
  184. synth_ai/tui/__main__.py +0 -13
  185. synth_ai/tui/cli/__init__.py +0 -1
  186. synth_ai/tui/cli/query_experiments.py +0 -164
  187. synth_ai/tui/cli/query_experiments_v3.py +0 -164
  188. synth_ai/tui/dashboard.py +0 -911
  189. {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/WHEEL +0 -0
  190. {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/entry_points.txt +0 -0
  191. {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/licenses/LICENSE +0 -0
  192. {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,301 @@
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 rl(cls, job_id: str) -> StreamEndpoints:
56
+ base = f"/rl/jobs/{job_id}"
57
+ return cls(
58
+ status=base,
59
+ events=f"{base}/events",
60
+ metrics=f"{base}/metrics",
61
+ timeline=f"{base}/timeline",
62
+ status_fallbacks=(
63
+ f"/learning/jobs/{job_id}",
64
+ f"/orchestration/jobs/{job_id}",
65
+ ),
66
+ event_fallbacks=(
67
+ f"/learning/jobs/{job_id}/events",
68
+ f"/orchestration/jobs/{job_id}/events",
69
+ ),
70
+ metric_fallbacks=(
71
+ f"/learning/jobs/{job_id}/metrics",
72
+ ),
73
+ timeline_fallbacks=(
74
+ f"/learning/jobs/{job_id}/timeline",
75
+ ),
76
+ )
77
+
78
+
79
+ class JobStreamer:
80
+ """Poll job endpoints and dispatch messages to configured handlers."""
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ base_url: str,
86
+ api_key: str,
87
+ job_id: str,
88
+ endpoints: StreamEndpoints | None = None,
89
+ config: StreamConfig | None = None,
90
+ handlers: Sequence[StreamHandler] | None = None,
91
+ interval_seconds: float = 2.0,
92
+ timeout_seconds: float | None = None,
93
+ http_timeout: float = 60.0,
94
+ http_client: AsyncHttpClient | None = None,
95
+ sleep_fn= sleep,
96
+ ) -> None:
97
+ self.base_url = base_url.rstrip("/")
98
+ self.api_key = api_key
99
+ self.job_id = job_id
100
+ self.endpoints = endpoints or StreamEndpoints.learning(job_id)
101
+ self.config = config or StreamConfig.default()
102
+ self.handlers: list[StreamHandler] = list(handlers or [])
103
+ self.interval_seconds = interval_seconds
104
+ self.timeout_seconds = timeout_seconds
105
+ self.http_timeout = http_timeout
106
+ self._http = http_client
107
+ self._sleep = sleep_fn
108
+
109
+ status_sources: list[str | None] = [self.endpoints.status]
110
+ status_sources.extend(self.endpoints.status_fallbacks)
111
+ self._status_paths = [p for p in status_sources if p]
112
+
113
+ event_sources: list[str | None] = [self.endpoints.events]
114
+ event_sources.extend(self.endpoints.event_fallbacks)
115
+ self._event_paths = [p for p in event_sources if p]
116
+
117
+ metric_sources: list[str | None] = [self.endpoints.metrics]
118
+ metric_sources.extend(self.endpoints.metric_fallbacks)
119
+ self._metric_paths = [p for p in metric_sources if p]
120
+
121
+ timeline_sources: list[str | None] = [self.endpoints.timeline]
122
+ timeline_sources.extend(self.endpoints.timeline_fallbacks)
123
+ self._timeline_paths = [p for p in timeline_sources if p]
124
+
125
+ self._last_seq_by_stream: dict[str, int] = {}
126
+ self._last_step_by_metric: dict[str, int] = {}
127
+ self._seen_messages: set[str] = set()
128
+ self._last_status_payload: dict[str, Any] | None = None
129
+ self._last_status_value: str | None = None
130
+ self._terminal_seen = False
131
+ self._terminal_event_status: str | None = None
132
+
133
+ if not self.handlers:
134
+ from .handlers import CLIHandler
135
+
136
+ self.handlers = [CLIHandler()]
137
+
138
+ async def stream_until_terminal(self) -> dict[str, Any]:
139
+ """Stream configured endpoints until the job reaches a terminal state."""
140
+ http_cm = self._http or AsyncHttpClient(self.base_url, self.api_key, timeout=self.http_timeout)
141
+ async with http_cm as http:
142
+ while True:
143
+ status = await self._refresh_status(http)
144
+
145
+ event_messages = await self._poll_events(http)
146
+ metric_messages = await self._poll_metrics(http)
147
+ timeline_messages = await self._poll_timeline(http)
148
+
149
+ self._dispatch(event_messages + metric_messages + timeline_messages)
150
+
151
+ if self._terminal_seen or (status and status in TERMINAL_STATUSES):
152
+ break
153
+
154
+ await self._sleep(self.interval_seconds)
155
+
156
+ for handler in self.handlers:
157
+ with contextlib.suppress(Exception):
158
+ handler.flush()
159
+
160
+ final_status = self._terminal_event_status or self._last_status_value or "unknown"
161
+ if self._last_status_payload:
162
+ self._last_status_payload["status"] = final_status
163
+ return self._last_status_payload
164
+ return {"job_id": self.job_id, "status": final_status}
165
+
166
+ async def _refresh_status(self, http: AsyncHttpClient) -> str:
167
+ status_payload = await self._poll_status(http)
168
+ if status_payload:
169
+ self._last_status_payload = status_payload
170
+ status = str(status_payload.get("status") or status_payload.get("state") or "").lower()
171
+ if status:
172
+ self._last_status_value = status
173
+ if status in TERMINAL_STATUSES:
174
+ self._terminal_seen = True
175
+ return status
176
+ return self._last_status_value or ""
177
+
178
+ async def _poll_status(self, http: AsyncHttpClient) -> dict[str, Any] | None:
179
+ if StreamType.STATUS not in self.config.enabled_streams or not self._status_paths:
180
+ return None
181
+
182
+ for path in self._status_paths:
183
+ try:
184
+ data = await http.get(path)
185
+ except Exception:
186
+ continue
187
+ if isinstance(data, dict):
188
+ message = StreamMessage.from_status(self.job_id, data)
189
+ self._dispatch([message])
190
+ return data
191
+ return None
192
+
193
+ async def _poll_events(self, http: AsyncHttpClient) -> list[StreamMessage]:
194
+ if StreamType.EVENTS not in self.config.enabled_streams or not self._event_paths:
195
+ return []
196
+ messages: list[StreamMessage] = []
197
+ total = 0
198
+ for path in self._event_paths:
199
+ since = self._last_seq_by_stream.get(path, 0)
200
+ params = {"since_seq": since, "limit": 200}
201
+ try:
202
+ data = await http.get(path, params=params)
203
+ except Exception:
204
+ continue
205
+ raw_events = _extract_list(data, "events")
206
+ for event in raw_events:
207
+ seq = int(event.get("seq") or 0)
208
+ if seq <= self._last_seq_by_stream.get(path, 0):
209
+ continue
210
+ if not self.config.should_include_event(event):
211
+ continue
212
+ self._last_seq_by_stream[path] = seq
213
+ event_job_id = event.get("job_id") or self.job_id
214
+ event_message = StreamMessage.from_event(event_job_id, event)
215
+ event_type = str(event.get("type") or "").lower()
216
+ if event_type in TERMINAL_EVENT_SUCCESS:
217
+ self._terminal_seen = True
218
+ self._terminal_event_status = "succeeded"
219
+ elif event_type in TERMINAL_EVENT_FAILURE:
220
+ self._terminal_seen = True
221
+ self._terminal_event_status = "failed"
222
+ messages.append(event_message)
223
+ total += 1
224
+ if self.config.max_events_per_poll and total >= self.config.max_events_per_poll:
225
+ return messages
226
+ return messages
227
+
228
+ async def _poll_metrics(self, http: AsyncHttpClient) -> list[StreamMessage]:
229
+ if StreamType.METRICS not in self.config.enabled_streams or not self._metric_paths:
230
+ return []
231
+ messages: list[StreamMessage] = []
232
+ for path in self._metric_paths:
233
+ after = max(self._last_step_by_metric.values()) if self._last_step_by_metric else -1
234
+ params = {"after_step": after, "limit": 200}
235
+ try:
236
+ data = await http.get(path, params=params)
237
+ except Exception:
238
+ continue
239
+ points = _extract_list(data, "points")
240
+ for point in points:
241
+ name = point.get("name", "")
242
+ step = int(point.get("step") or -1)
243
+ if step <= self._last_step_by_metric.get(name, -1):
244
+ continue
245
+ if not self.config.should_include_metric(point):
246
+ continue
247
+ self._last_step_by_metric[name] = step
248
+ metric_job_id = point.get("job_id") or self.job_id
249
+ messages.append(StreamMessage.from_metric(metric_job_id, point))
250
+ return messages
251
+
252
+ async def _poll_timeline(self, http: AsyncHttpClient) -> list[StreamMessage]:
253
+ if StreamType.TIMELINE not in self.config.enabled_streams or not self._timeline_paths:
254
+ return []
255
+ messages: list[StreamMessage] = []
256
+ for path in self._timeline_paths:
257
+ try:
258
+ data = await http.get(path)
259
+ except Exception:
260
+ continue
261
+
262
+ timeline_entries = _extract_list(data, "events")
263
+ for entry in timeline_entries:
264
+ if not self.config.should_include_timeline(entry):
265
+ continue
266
+ timeline_job_id = entry.get("job_id") or self.job_id
267
+ phase = str(entry.get("phase") or "").lower()
268
+ if phase in TERMINAL_STATUSES:
269
+ self._terminal_seen = True
270
+ if phase in {"failed", "cancelled", "canceled"}:
271
+ self._terminal_event_status = "failed"
272
+ elif phase:
273
+ self._terminal_event_status = "succeeded"
274
+ messages.append(StreamMessage.from_timeline(timeline_job_id, entry))
275
+ return messages
276
+
277
+ def _dispatch(self, messages: Iterable[StreamMessage]) -> None:
278
+ for message in messages:
279
+ if self.config.deduplicate and message.key in self._seen_messages:
280
+ continue
281
+ if self.config.sample_rate < 1.0 and random.random() > self.config.sample_rate:
282
+ continue
283
+ if self.config.deduplicate:
284
+ self._seen_messages.add(message.key)
285
+
286
+ for handler in self.handlers:
287
+ try:
288
+ if handler.should_handle(message):
289
+ handler.handle(message)
290
+ except Exception:
291
+ pass
292
+
293
+
294
+ def _extract_list(data: Any, field: str) -> list[dict[str, Any]]:
295
+ raw = (data or {}).get(field) if isinstance(data, dict) else None
296
+ if isinstance(raw, list):
297
+ return [item for item in raw if isinstance(item, dict)]
298
+ return []
299
+
300
+
301
+ __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"]
@@ -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
@@ -151,7 +151,7 @@ def normalize_inference_url(url: str | None, *, default: str = "https://api.open
151
151
  new_path = f"{path}/v1/chat/completions" if path else "/v1/chat/completions"
152
152
 
153
153
  # Reconstruct URL with new path and original query/fragment
154
- return cast(str, urlunparse(parsed._replace(path=new_path)))
154
+ return urlunparse(parsed._replace(path=new_path))
155
155
 
156
156
 
157
157
  def validate_task_app_url(url: str | None) -> str:
@@ -68,7 +68,7 @@ def categorize_files(v2_files: list[tuple[str, list[str]]]) -> dict:
68
68
  categories["examples"].append((file_path, imports))
69
69
  elif any(
70
70
  core in file_path
71
- for core in ["synth_ai/lm/", "synth_ai/tui/", "synth_ai/environments/"]
71
+ for core in ["synth_ai/lm/", "synth_ai/environments/"]
72
72
  ):
73
73
  categories["core_library"].append((file_path, imports))
74
74
  else:
@@ -104,7 +104,6 @@ def print_migration_report():
104
104
  print("2. Debug scripts: Can be deleted or archived")
105
105
  print("3. Core library files: Need careful migration to v3")
106
106
  print(" - synth_ai/lm/core/main_v2.py")
107
- print(" - synth_ai/tui/cli/query_experiments.py")
108
107
  print(" - synth_ai/environments/service/core_routes.py")
109
108
  print("4. Examples: Should be updated to demonstrate v3 usage")
110
109
 
synth_ai/utils/env.py CHANGED
@@ -127,11 +127,17 @@ def filter_json_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, st
127
127
  return matches
128
128
 
129
129
 
130
- def resolve_env_var(key: str) -> None:
130
+ def ensure_env_var(key: str, expected_value: str) -> None:
131
+ actual_value = os.getenv(key)
132
+ if expected_value != actual_value:
133
+ raise ValueError(f"Expected: {key}={expected_value}\nActual: {key}={actual_value}")
134
+
135
+
136
+ def resolve_env_var(key: str) -> str:
131
137
  env_value = os.getenv(key)
132
138
  if env_value is not None:
133
139
  click.echo(f"Using {key}={mask_str(env_value)} from process environment")
134
- return
140
+ return env_value
135
141
 
136
142
  value: str = ""
137
143
 
@@ -167,8 +173,7 @@ def resolve_env_var(key: str) -> None:
167
173
  show_choices=False,
168
174
  ).strip()
169
175
  except click.Abort:
170
- return
171
-
176
+ raise
172
177
  if choice.lower() == 'm':
173
178
  value = _prompt_manual_env_value(key)
174
179
  break
@@ -186,20 +191,22 @@ def resolve_env_var(key: str) -> None:
186
191
  click.echo(f"Invalid selection. Enter a number between 1 and {len(options)} or 'm'.")
187
192
 
188
193
  else:
189
- click.echo(f"No value found for {key}")
194
+ print(f"No value found for {key}")
190
195
  value = _prompt_manual_env_value(key)
191
196
 
192
197
  os.environ[key] = value
193
- click.echo(f"Loaded {key}={mask_str(value)} into process environment")
194
- return
198
+ ensure_env_var(key, value)
199
+ print(f"Loaded {key}={mask_str(value)} into process environment")
200
+ return value
195
201
 
196
202
 
197
203
  def write_env_var_to_dotenv(
198
204
  key: str,
199
205
  value: str,
200
- output_file_path: str | Path,
206
+ output_file_path: str | Path | None = None,
201
207
  ) -> None:
202
- path = Path(output_file_path).expanduser()
208
+ path = Path(".env") if output_file_path is None else Path(output_file_path)
209
+ path = path.expanduser()
203
210
  path.parent.mkdir(parents=True, exist_ok=True)
204
211
 
205
212
  encoded_value = _format_env_value(value)
@@ -212,7 +219,7 @@ def write_env_var_to_dotenv(
212
219
  with path.open('r', encoding="utf-8") as handle:
213
220
  lines = handle.readlines()
214
221
  except OSError as exc:
215
- raise click.ClickException(f"Failed to read {path}: {exc}") from exc
222
+ raise RuntimeError(f"Failed to read {path}: {exc}") from exc
216
223
 
217
224
  for index, line in enumerate(lines):
218
225
  parsed = _parse_env_assignment(line)
@@ -238,9 +245,9 @@ def write_env_var_to_dotenv(
238
245
  with path.open('w', encoding="utf-8") as handle:
239
246
  handle.writelines(lines)
240
247
  except OSError as exc:
241
- raise click.ClickException(f"Failed to write {path}: {exc}") from exc
248
+ raise RuntimeError(f"Failed to write {path}: {exc}") from exc
242
249
 
243
- click.echo(f"Wrote {key}={mask_str(value)} to {path}")
250
+ print(f"Wrote {key}={mask_str(value)} to {path.resolve()}")
244
251
 
245
252
 
246
253
  def write_env_var_to_json(
@@ -250,7 +257,7 @@ def write_env_var_to_json(
250
257
  ) -> None:
251
258
  path = Path(output_file_path).expanduser()
252
259
  if path.exists() and not path.is_file():
253
- raise click.ClickException(f"{path} exists and is not a file")
260
+ raise RuntimeError(f"{path} exists and is not a file")
254
261
 
255
262
  data: dict[str, str] = {}
256
263
 
@@ -259,12 +266,12 @@ def write_env_var_to_json(
259
266
  with path.open('r', encoding="utf-8") as handle:
260
267
  existing = json.load(handle)
261
268
  except json.JSONDecodeError as exc:
262
- raise click.ClickException(f"Invalid JSON in {path}: {exc}") from exc
269
+ raise RuntimeError(f"Invalid JSON in {path}: {exc}") from exc
263
270
  except OSError as exc:
264
- raise click.ClickException(f"Failed to read {path}: {exc}") from exc
271
+ raise RuntimeError(f"Failed to read {path}: {exc}") from exc
265
272
 
266
273
  if not isinstance(existing, dict):
267
- raise click.ClickException(f"Expected JSON object in {path}")
274
+ raise RuntimeError(f"Expected JSON object in {path}")
268
275
 
269
276
  for existing_key, existing_value in existing.items():
270
277
  if existing_key == key:
@@ -282,6 +289,6 @@ def write_env_var_to_json(
282
289
  json.dump(data, handle, indent=2, sort_keys=True)
283
290
  handle.write('\n')
284
291
  except OSError as exc:
285
- raise click.ClickException(f"Failed to write {path}: {exc}") from exc
292
+ raise RuntimeError(f"Failed to write {path}: {exc}") from exc
286
293
 
287
- click.echo(f"Wrote {key}={mask_str(value)} to {path}")
294
+ print(f"Wrote {key}={mask_str(value)} to {path}")
synth_ai/utils/http.py CHANGED
@@ -34,7 +34,10 @@ class AsyncHttpClient:
34
34
 
35
35
  async def __aenter__(self) -> AsyncHttpClient:
36
36
  if self._session is None:
37
- headers = {"authorization": f"Bearer {self._api_key}"}
37
+ headers = {
38
+ "authorization": f"Bearer {self._api_key}",
39
+ "accept": "application/json",
40
+ }
38
41
  user_id = os.getenv("SYNTH_USER_ID") or os.getenv("X_USER_ID") or os.getenv("USER_ID")
39
42
  if user_id:
40
43
  headers["X-User-ID"] = user_id
synth_ai/utils/modal.py CHANGED
@@ -4,7 +4,7 @@ import os
4
4
  import shutil
5
5
  import sys
6
6
  from pathlib import Path
7
- from typing import Any, cast
7
+ from typing import Any
8
8
  from urllib.parse import urlparse, urlunparse
9
9
 
10
10
  from synth_ai.demos import core as demo_core
@@ -63,7 +63,7 @@ def normalize_endpoint_url(url: str) -> str:
63
63
  creds += f":{parsed.password}"
64
64
  netloc = f"{creds}@{netloc}"
65
65
  parsed = parsed._replace(netloc=netloc)
66
- return cast(str, urlunparse(parsed))
66
+ return urlunparse(parsed)
67
67
  except Exception:
68
68
  pass
69
69
  return url
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synth-ai
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: RL as a service SDK - Core AI functionality and tracing
5
5
  Author-email: Synth AI <josh@usesynth.ai>
6
6
  License-Expression: MIT
@@ -19,6 +19,7 @@ Requires-Dist: tqdm>=4.66.4
19
19
  Requires-Dist: jsonschema>=4.23.0
20
20
  Requires-Dist: backoff>=2.0.0
21
21
  Requires-Dist: typing_extensions>=4.0.0
22
+ Requires-Dist: rich>=13.9.0
22
23
  Requires-Dist: openai>=1.99.0
23
24
  Requires-Dist: anthropic>=0.42.0
24
25
  Requires-Dist: langfuse<3.0.0,>=2.53.9
@@ -46,7 +47,6 @@ Requires-Dist: google-api-core>=2.25.1
46
47
  Requires-Dist: google-generativeai>=0.8.5
47
48
  Requires-Dist: crafter>=1.8.3
48
49
  Requires-Dist: click<8.2,>=8.1.7
49
- Requires-Dist: textual>=1.1.0
50
50
  Requires-Dist: openai-harmony>=0.0.1
51
51
  Requires-Dist: asyncpg>=0.30.0
52
52
  Requires-Dist: aiohttp>=3.8.0
@@ -71,9 +71,14 @@ Requires-Dist: ruff>=0.1.0; extra == "dev"
71
71
  Provides-Extra: research
72
72
  Requires-Dist: crafter>=1.8.3; extra == "research"
73
73
  Requires-Dist: datasets>=4.0.0; extra == "research"
74
+ Provides-Extra: swe
75
+ Requires-Dist: morphcloud>=0.1.3; extra == "swe"
76
+ Requires-Dist: swebench>=2.3.0; extra == "swe"
74
77
  Provides-Extra: all
75
78
  Requires-Dist: crafter>=1.8.3; extra == "all"
76
79
  Requires-Dist: datasets>=4.0.0; extra == "all"
80
+ Requires-Dist: morphcloud>=0.1.3; extra == "all"
81
+ Requires-Dist: swebench>=2.3.0; extra == "all"
77
82
  Provides-Extra: analytics
78
83
  Requires-Dist: pandas>=2.2.3; extra == "analytics"
79
84
  Dynamic: license-file
@@ -92,7 +97,7 @@ Dynamic: license-file
92
97
 
93
98
  ---
94
99
 
95
- ## 🚀 Install
100
+ ## 🚀 Install version 0.2.16
96
101
 
97
102
  ```bash
98
103
  pip install synth-ai