synth-ai 0.2.4.dev7__py3-none-any.whl → 0.2.4.dev9__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 (154) hide show
  1. synth_ai/__init__.py +1 -1
  2. synth_ai/cli/__init__.py +6 -0
  3. synth_ai/cli/balance.py +3 -15
  4. synth_ai/cli/demo.py +68 -9
  5. synth_ai/cli/rl_demo.py +137 -0
  6. synth_ai/cli/root.py +65 -0
  7. synth_ai/config/base_url.py +47 -0
  8. synth_ai/demos/core/__init__.py +1 -0
  9. synth_ai/demos/core/cli.py +621 -0
  10. synth_ai/demos/demo_task_apps/__init__.py +1 -0
  11. synth_ai/demos/demo_task_apps/core.py +374 -0
  12. synth_ai/demos/demo_task_apps/math/__init__.py +1 -0
  13. synth_ai/demos/demo_task_apps/math/app.py +37 -0
  14. synth_ai/demos/demo_task_apps/math/config.toml +44 -0
  15. synth_ai/demos/demo_task_apps/math/deploy_modal.py +60 -0
  16. synth_ai/demos/demo_task_apps/math/deploy_task_app.sh +22 -0
  17. synth_ai/environments/examples/bandit/__init__.py +33 -0
  18. synth_ai/environments/examples/bandit/engine.py +294 -0
  19. synth_ai/environments/examples/bandit/environment.py +194 -0
  20. synth_ai/environments/examples/bandit/taskset.py +200 -0
  21. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +250 -0
  22. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_comprehensive_evaluation.py +59 -0
  23. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_browser.py +152 -0
  24. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_config.toml +24 -0
  25. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_framework.py +1194 -0
  26. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/crafter_synth_config.toml +56 -0
  27. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_config_modal.toml +32 -0
  28. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +724 -0
  29. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_modal.py +384 -0
  30. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_action_results.py +53 -0
  31. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_agent_actions.py +178 -0
  32. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_latest_run.py +222 -0
  33. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_lm_traces.py +183 -0
  34. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_no_rewards.py +210 -0
  35. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_trace_issue.py +206 -0
  36. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_db_schema.py +49 -0
  37. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_latest_results.py +64 -0
  38. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/debug_agent_responses.py +88 -0
  39. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/quick_trace_check.py +77 -0
  40. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/compare_experiments.py +324 -0
  41. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +580 -0
  42. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/kick_off_ft_oai.py +362 -0
  43. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/multi_model_config.toml +49 -0
  44. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_enhanced_hooks.py +332 -0
  45. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_events.py +97 -0
  46. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_results.py +217 -0
  47. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_hook_storage.py +87 -0
  48. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_seeds.py +88 -0
  49. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/compare_seed_performance.py +195 -0
  50. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/custom_eval_pipelines.py +400 -0
  51. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/plot_hook_frequency.py +195 -0
  52. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/seed_analysis_summary.py +56 -0
  53. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v3.py +858 -0
  54. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_quick_evaluation.py +52 -0
  55. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_react_agent.py +874 -0
  56. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +1412 -0
  57. synth_ai/environments/examples/crafter_classic/agent_demos/example_v3_usage.py +216 -0
  58. synth_ai/environments/examples/crafter_classic/agent_demos/old/compare_traces.py +296 -0
  59. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_comprehensive_evaluation.py +58 -0
  60. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_env_serialization.py +464 -0
  61. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_evaluation_browser.py +152 -0
  62. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_quick_evaluation.py +51 -0
  63. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_trace_evaluation.py +1412 -0
  64. synth_ai/environments/examples/crafter_classic/agent_demos/old/debug_player_loss.py +112 -0
  65. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_service.py +203 -0
  66. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_slowness.py +305 -0
  67. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_by_difficulty.py +126 -0
  68. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_example.py +94 -0
  69. synth_ai/environments/examples/crafter_classic/agent_demos/old/explore_saved_states.py +142 -0
  70. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft.py +26 -0
  71. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft_OLD.py +984 -0
  72. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_gemini.py +724 -0
  73. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_modal.py +386 -0
  74. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_metadata.py +205 -0
  75. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_gemini.py +150 -0
  76. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_modal.py +283 -0
  77. synth_ai/environments/examples/crafter_classic/agent_demos/old/prepare_vertex_ft.py +280 -0
  78. synth_ai/environments/examples/crafter_classic/agent_demos/old/profile_env_slowness.py +456 -0
  79. synth_ai/environments/examples/crafter_classic/agent_demos/old/replicate_issue.py +166 -0
  80. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_and_eval.py +102 -0
  81. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_comparison.py +128 -0
  82. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_qwen_rollouts.py +655 -0
  83. synth_ai/environments/examples/crafter_classic/agent_demos/old/trace_eval_OLD.py +202 -0
  84. synth_ai/environments/examples/crafter_classic/agent_demos/old/validate_openai_format.py +166 -0
  85. synth_ai/environments/examples/crafter_classic/environment.py +41 -2
  86. synth_ai/environments/examples/crafter_custom/agent_demos/__init__.py +1 -0
  87. synth_ai/environments/examples/crafter_custom/agent_demos/trace_eval.py +202 -0
  88. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_issue.py +159 -0
  89. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_spawning.py +158 -0
  90. synth_ai/environments/examples/crafter_custom/old/compare_worlds.py +71 -0
  91. synth_ai/environments/examples/crafter_custom/old/dataset_stats.py +105 -0
  92. synth_ai/environments/examples/crafter_custom/old/diamond_spawning_summary.py +119 -0
  93. synth_ai/environments/examples/crafter_custom/old/example_dataset_usage.py +52 -0
  94. synth_ai/environments/examples/enron/units/keyword_stats.py +112 -0
  95. synth_ai/environments/examples/minigrid/agent_demos/minigrid_evaluation_framework.py +1188 -0
  96. synth_ai/environments/examples/minigrid/agent_demos/minigrid_quick_evaluation.py +48 -0
  97. synth_ai/environments/examples/minigrid/agent_demos/minigrid_react_agent.py +562 -0
  98. synth_ai/environments/examples/minigrid/agent_demos/minigrid_trace_evaluation.py +221 -0
  99. synth_ai/environments/examples/nethack/agent_demos/nethack_evaluation_framework.py +981 -0
  100. synth_ai/environments/examples/nethack/agent_demos/nethack_quick_evaluation.py +74 -0
  101. synth_ai/environments/examples/nethack/agent_demos/nethack_react_agent.py +831 -0
  102. synth_ai/environments/examples/red/agent_demos/__init__.py +1 -0
  103. synth_ai/environments/examples/red/units/__init__.py +1 -0
  104. synth_ai/environments/examples/sokoban/agent_demos/sokoban_full_eval.py +899 -0
  105. synth_ai/environments/examples/sokoban/units/astar_common.py +95 -0
  106. synth_ai/environments/service/app.py +8 -0
  107. synth_ai/http.py +102 -0
  108. synth_ai/inference/__init__.py +7 -0
  109. synth_ai/inference/client.py +20 -0
  110. synth_ai/install_sqld.sh +40 -0
  111. synth_ai/jobs/client.py +246 -0
  112. synth_ai/learning/__init__.py +24 -0
  113. synth_ai/learning/client.py +149 -0
  114. synth_ai/learning/config.py +43 -0
  115. synth_ai/learning/constants.py +29 -0
  116. synth_ai/learning/ft_client.py +59 -0
  117. synth_ai/learning/health.py +43 -0
  118. synth_ai/learning/jobs.py +205 -0
  119. synth_ai/learning/rl_client.py +256 -0
  120. synth_ai/learning/sse.py +58 -0
  121. synth_ai/learning/validators.py +48 -0
  122. synth_ai/lm/core/main_v3.py +13 -0
  123. synth_ai/lm/core/synth_models.py +48 -0
  124. synth_ai/lm/core/vendor_clients.py +9 -6
  125. synth_ai/lm/vendors/core/openai_api.py +31 -3
  126. synth_ai/lm/vendors/openai_standard.py +45 -14
  127. synth_ai/lm/vendors/supported/custom_endpoint.py +12 -2
  128. synth_ai/lm/vendors/synth_client.py +372 -28
  129. synth_ai/rl/__init__.py +30 -0
  130. synth_ai/rl/contracts.py +32 -0
  131. synth_ai/rl/env_keys.py +137 -0
  132. synth_ai/rl/secrets.py +19 -0
  133. synth_ai/scripts/verify_rewards.py +100 -0
  134. synth_ai/task/__init__.py +10 -0
  135. synth_ai/task/contracts.py +120 -0
  136. synth_ai/task/health.py +28 -0
  137. synth_ai/task/validators.py +12 -0
  138. synth_ai/tracing_v3/hooks.py +3 -1
  139. synth_ai/tracing_v3/session_tracer.py +123 -2
  140. synth_ai/tracing_v3/turso/manager.py +218 -0
  141. synth_ai/tracing_v3/turso/models.py +53 -0
  142. synth_ai-0.2.4.dev9.dist-info/METADATA +91 -0
  143. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev9.dist-info}/RECORD +147 -30
  144. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev9.dist-info}/entry_points.txt +1 -0
  145. synth_ai/tui/__init__.py +0 -1
  146. synth_ai/tui/__main__.py +0 -13
  147. synth_ai/tui/cli/__init__.py +0 -1
  148. synth_ai/tui/cli/query_experiments.py +0 -164
  149. synth_ai/tui/cli/query_experiments_v3.py +0 -164
  150. synth_ai/tui/dashboard.py +0 -340
  151. synth_ai-0.2.4.dev7.dist-info/METADATA +0 -193
  152. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev9.dist-info}/WHEEL +0 -0
  153. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev9.dist-info}/licenses/LICENSE +0 -0
  154. {synth_ai-0.2.4.dev7.dist-info → synth_ai-0.2.4.dev9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional, Callable
4
+ import os
5
+ import time
6
+
7
+ from ..http import AsyncHttpClient, HTTPError, sleep
8
+
9
+
10
+ def _api_base(b: str) -> str:
11
+ b = (b or "").rstrip("/")
12
+ return b if b.endswith("/api") else f"{b}/api"
13
+
14
+
15
+ class RlClient:
16
+ """Lightweight RL client for provider-agnostic job control.
17
+
18
+ Notes:
19
+ - Uses learning/* for status/events/metrics and rl/* for creation/start.
20
+ - Trainer endpoints are resolved server-side via trainer_id.
21
+ """
22
+
23
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 600.0) -> None:
24
+ self._base_url = base_url.rstrip("/")
25
+ self._api_key = api_key
26
+ self._timeout = timeout
27
+
28
+ async def resolve_trainer_start_url(self, trainer_id: str) -> str:
29
+ """GET /api/rl/services/{id} → { training_start_url }"""
30
+ path = f"/api/rl/services/{trainer_id}"
31
+ async with AsyncHttpClient(self._base_url, self._api_key, timeout=30.0) as http:
32
+ js = await http.get(path)
33
+ if not isinstance(js, dict):
34
+ raise HTTPError(status=500, url=path, message="invalid_service_response", body_snippet=str(js)[:200])
35
+ start_url = js.get("training_start_url")
36
+ if not isinstance(start_url, str) or not start_url:
37
+ raise HTTPError(status=500, url=path, message="missing_training_start_url", body_snippet=str(js)[:200])
38
+ return start_url
39
+
40
+ async def create_job(
41
+ self,
42
+ *,
43
+ model: str,
44
+ task_app_url: str,
45
+ trainer: Dict[str, Any],
46
+ trainer_id: Optional[str] = None,
47
+ job_config_id: Optional[str] = None,
48
+ inline_config: Optional[Dict[str, Any]] = None,
49
+ ) -> Dict[str, Any]:
50
+ body = {
51
+ "job_type": "rl",
52
+ "data": {
53
+ "model": model,
54
+ "endpoint_base_url": task_app_url,
55
+ **({"job_config_id": job_config_id} if job_config_id else {}),
56
+ **({"config": inline_config} if inline_config else {}),
57
+ "trainer": {
58
+ "batch_size": int(trainer.get("batch_size", 1)),
59
+ "group_size": max(2, int(trainer.get("group_size", 2))),
60
+ },
61
+ },
62
+ }
63
+ async with AsyncHttpClient(self._base_url, self._api_key, timeout=self._timeout) as http:
64
+ js = await http.post_json(f"{_api_base(self._base_url)}/rl/jobs", json=body)
65
+ if not isinstance(js, dict):
66
+ raise HTTPError(status=500, url="/api/rl/jobs", message="invalid_create_response", body_snippet=str(js)[:200])
67
+ return js
68
+
69
+ async def start_job_if_supported(self, job_id: str) -> Optional[Dict[str, Any]]:
70
+ path = f"{_api_base(self._base_url)}/rl/jobs/{job_id}/start"
71
+ try:
72
+ async with AsyncHttpClient(self._base_url, self._api_key, timeout=30.0) as http:
73
+ return await http.post_json(path, json={})
74
+ except HTTPError as he: # noqa: PERF203
75
+ if he.status == 404:
76
+ return None
77
+ raise
78
+
79
+ async def get_job(self, job_id: str) -> Dict[str, Any]:
80
+ async with AsyncHttpClient(self._base_url, self._api_key, timeout=30.0) as http:
81
+ return await http.get(f"{_api_base(self._base_url)}/learning/jobs/{job_id}")
82
+
83
+ async def get_events(self, job_id: str, *, since_seq: int = 0, limit: int = 200) -> List[Dict[str, Any]]:
84
+ params = {"since_seq": since_seq, "limit": limit}
85
+ async with AsyncHttpClient(self._base_url, self._api_key, timeout=30.0) as http:
86
+ try:
87
+ js = await http.get(f"{_api_base(self._base_url)}/learning/jobs/{job_id}/events", params=params)
88
+ except HTTPError as he:
89
+ try:
90
+ print(
91
+ f"[poll] events HTTPError status={he.status} url={he.url} since_seq={since_seq} body={(he.body_snippet or '')[:200]}"
92
+ )
93
+ except Exception:
94
+ pass
95
+ raise
96
+ if isinstance(js, dict):
97
+ evs = js.get("events") or js.get("data")
98
+ if isinstance(evs, list):
99
+ return evs
100
+ return []
101
+
102
+ async def get_metrics(self, job_id: str, *, after_step: int = -1, limit: int = 200) -> List[Dict[str, Any]]:
103
+ params = {"after_step": after_step, "limit": limit}
104
+ async with AsyncHttpClient(self._base_url, self._api_key, timeout=30.0) as http:
105
+ js = await http.get(f"{_api_base(self._base_url)}/learning/jobs/{job_id}/metrics", params=params)
106
+ if isinstance(js, dict) and isinstance(js.get("points"), list):
107
+ return js["points"]
108
+ return []
109
+
110
+ async def poll_until_terminal(
111
+ self,
112
+ job_id: str,
113
+ *,
114
+ interval_seconds: float = 2.0,
115
+ max_seconds: float | None = None,
116
+ empty_polls_threshold: int = 5,
117
+ startup_deadline_s: int = 45,
118
+ on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
119
+ on_metric: Optional[Callable[[Dict[str, Any]], None]] = None,
120
+ ) -> Dict[str, Any]:
121
+ last_seq_by_stream: Dict[str, int] = {}
122
+ events_job_id: Optional[str] = None
123
+ last_status: Optional[str] = None
124
+ last_step_by_name: Dict[str, int] = {}
125
+ empty_polls = 0
126
+ saw_any_event = False
127
+ start_t = time.time()
128
+ terminal = {"succeeded", "failed", "cancelled", "canceled", "error", "completed"}
129
+
130
+ while True:
131
+ status_data: Optional[Dict[str, Any]] = None
132
+ try:
133
+ status_data = await self.get_job(job_id)
134
+ except Exception:
135
+ status_data = None
136
+ if status_data is None:
137
+ try:
138
+ print(f"[poll] get_job returned None base={self._base_url} job_id={job_id}")
139
+ except Exception:
140
+ pass
141
+ status = str((status_data or {}).get("status") or "").lower()
142
+ if status_data:
143
+ linked = status_data.get("linked_job_id")
144
+ if isinstance(linked, str) and linked and linked != events_job_id:
145
+ events_job_id = linked
146
+ try:
147
+ print(f"[poll] discovered linked_job_id stream={events_job_id}")
148
+ except Exception:
149
+ pass
150
+ if status and status != last_status:
151
+ last_status = status
152
+ # Status transitions only to avoid log spam
153
+ if on_event:
154
+ try:
155
+ on_event({"type": "rl.status", "message": status})
156
+ except Exception:
157
+ pass
158
+
159
+ # Events
160
+ stream_ids = [job_id]
161
+ if events_job_id and events_job_id not in stream_ids:
162
+ stream_ids.append(events_job_id)
163
+ try:
164
+ print(f"[poll] streams={stream_ids} intervals={interval_seconds}s since_map={last_seq_by_stream} empty_polls={empty_polls}")
165
+ except Exception:
166
+ pass
167
+ total_events_this_cycle = 0
168
+ terminal_event_seen = False
169
+ terminal_event_status: Optional[str] = None
170
+ for ev_id in stream_ids:
171
+ since = last_seq_by_stream.get(ev_id, 0)
172
+ try:
173
+ events = await self.get_events(ev_id, since_seq=since, limit=200)
174
+ except HTTPError as he:
175
+ try:
176
+ print(f"[poll] get_events error status={he.status} url={he.url} since={since} body={(he.body_snippet or '')[:200]}")
177
+ except Exception:
178
+ pass
179
+ events = []
180
+ except Exception as e:
181
+ try:
182
+ print(f"[poll] get_events unexpected error ev_id={ev_id} since={since} err={type(e).__name__}: {e}")
183
+ except Exception:
184
+ pass
185
+ events = []
186
+ total_events_this_cycle += len(events)
187
+ if events:
188
+ saw_any_event = True
189
+ for e in events:
190
+ seq_val = int(e.get("seq") or 0)
191
+ if seq_val <= last_seq_by_stream.get(ev_id, 0):
192
+ continue
193
+ last_seq_by_stream[ev_id] = seq_val
194
+ if on_event:
195
+ try:
196
+ on_event(e)
197
+ except Exception:
198
+ pass
199
+ et = str(e.get("type") or e.get("event_type") or "").lower()
200
+ if et in ("rl.job.completed", "workflow.completed", "rl.train.completed"):
201
+ terminal_event_seen = True
202
+ terminal_event_status = "succeeded"
203
+ elif et in ("rl.job.failed", "workflow.failed"):
204
+ terminal_event_seen = True
205
+ terminal_event_status = "failed"
206
+
207
+ # Metrics
208
+ try:
209
+ after = max(last_step_by_name.values()) if last_step_by_name else -1
210
+ points = await self.get_metrics(job_id, after_step=after, limit=200)
211
+ for p in points:
212
+ name = str(p.get("name") or "")
213
+ step = int(p.get("step") or -1)
214
+ if step <= last_step_by_name.get(name, -1):
215
+ continue
216
+ last_step_by_name[name] = step
217
+ if on_metric:
218
+ try:
219
+ on_metric(p)
220
+ except Exception:
221
+ pass
222
+ except Exception:
223
+ pass
224
+
225
+ if terminal_event_seen:
226
+ return {"status": terminal_event_status or status or "completed", "job_id": job_id}
227
+ if status and status in terminal:
228
+ return {"status": status, "job_id": job_id}
229
+
230
+ if total_events_this_cycle == 0:
231
+ empty_polls += 1
232
+ else:
233
+ empty_polls = 0
234
+ if empty_polls >= max(1, int(empty_polls_threshold)):
235
+ try:
236
+ print(
237
+ f"[poll] threshold hit: empty_polls={empty_polls} >= {empty_polls_threshold} streams={stream_ids} last_seq_map={last_seq_by_stream}"
238
+ )
239
+ except Exception:
240
+ pass
241
+ raise AssertionError(f"No new events detected for {empty_polls_threshold} consecutive polls. Check event ingestion.")
242
+
243
+ if not saw_any_event and (time.time() - start_t) > int(startup_deadline_s):
244
+ try:
245
+ print(
246
+ f"[poll] startup window exceeded: {startup_deadline_s}s base={self._base_url} job={job_id} streams={stream_ids} last_seq_map={last_seq_by_stream}"
247
+ )
248
+ except Exception:
249
+ pass
250
+ raise AssertionError(f"No events observed within startup window ({startup_deadline_s}s). Investigate event streaming.")
251
+
252
+ await sleep(interval_seconds)
253
+ if max_seconds is not None and (time.time() - start_t) >= max_seconds:
254
+ raise TimeoutError(f"Polling timed out after {max_seconds}s for job {job_id}")
255
+
256
+
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from typing import Any, Callable, Optional
6
+
7
+ import aiohttp
8
+
9
+
10
+ def _api_base(b: str) -> str:
11
+ b = (b or "").rstrip("/")
12
+ return b if b.endswith("/api") else f"{b}/api"
13
+
14
+
15
+ async def stream_events(
16
+ base_url: str,
17
+ api_key: str,
18
+ job_id: str,
19
+ *,
20
+ seconds: int = 60,
21
+ on_event: Optional[Callable[[dict], None]] = None,
22
+ ) -> None:
23
+ if seconds <= 0:
24
+ return
25
+ headers = {"Accept": "text/event-stream", "Authorization": f"Bearer {api_key}"}
26
+ candidates = [
27
+ f"{_api_base(base_url)}/rl/jobs/{job_id}/events?since_seq=0",
28
+ f"{_api_base(base_url)}/learning/jobs/{job_id}/events?since_seq=0",
29
+ ]
30
+ for url in candidates:
31
+ try:
32
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None)) as session:
33
+ async with session.get(url, headers=headers) as resp:
34
+ if resp.status != 200:
35
+ continue
36
+ start_t = time.time()
37
+ async for raw in resp.content:
38
+ line = raw.decode(errors="ignore").strip()
39
+ if not line or line.startswith(":"):
40
+ continue
41
+ if not line.startswith("data:"):
42
+ continue
43
+ data = line[5:].strip()
44
+ try:
45
+ obj = json.loads(data)
46
+ except Exception:
47
+ continue
48
+ if on_event:
49
+ try:
50
+ on_event(obj)
51
+ except Exception:
52
+ pass
53
+ if (time.time() - start_t) >= seconds:
54
+ return
55
+ except Exception:
56
+ continue
57
+
58
+
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import json
5
+ from typing import Any, Dict
6
+ from urllib.parse import urlparse
7
+
8
+
9
+ def validate_training_jsonl(path: str | Path, *, sample_lines: int = 50) -> None:
10
+ p = Path(path)
11
+ if not p.exists():
12
+ raise FileNotFoundError(str(p))
13
+ lines = p.read_text().splitlines()
14
+ if not lines:
15
+ raise ValueError("empty JSONL")
16
+ for i, line in enumerate(lines[: max(1, sample_lines) ], start=1):
17
+ if not line.strip():
18
+ continue
19
+ try:
20
+ obj = json.loads(line)
21
+ except Exception as e:
22
+ raise ValueError(f"invalid json on line {i}: {e}") from e
23
+ msgs = obj.get("messages")
24
+ if not isinstance(msgs, list) or len(msgs) < 2:
25
+ raise ValueError(f"line {i}: missing messages[] with at least 2 turns")
26
+ roles = [m.get("role") for m in msgs if isinstance(m, dict)]
27
+ if not roles or not isinstance(roles[0], str):
28
+ raise ValueError(f"line {i}: missing first role")
29
+ for m in msgs:
30
+ if not isinstance(m, dict):
31
+ raise ValueError(f"line {i}: non-dict message")
32
+ if not isinstance(m.get("role"), str) or not isinstance(m.get("content"), str) or not m["content"].strip():
33
+ raise ValueError(f"line {i}: invalid role/content")
34
+
35
+
36
+ def validate_task_app_url(url: str, *, name: str = "TASK_APP_BASE_URL") -> None:
37
+ from synth_ai.task.validators import validate_task_app_url as _vt
38
+
39
+ _vt(url, name=name)
40
+
41
+
42
+ def validate_trainer_cfg_rl(trainer: Dict[str, Any]) -> None:
43
+ bs = int(trainer.get("batch_size", 1))
44
+ gs = int(trainer.get("group_size", 2))
45
+ if bs < 1:
46
+ raise ValueError("trainer.batch_size must be >= 1")
47
+ if gs < 2:
48
+ raise ValueError("trainer.group_size must be >= 2")
@@ -117,7 +117,11 @@ class LM:
117
117
  if enable_v2_tracing is not None:
118
118
  enable_v3_tracing = enable_v2_tracing
119
119
 
120
+ # Debug logging
121
+ print(f"🔍 LM __init__: provider={provider}, vendor={vendor}, model={model}")
122
+
120
123
  # If vendor not provided, infer from model name
124
+ # But only if no explicit provider was given
121
125
  if vendor is None and model is not None:
122
126
  # Import vendor detection logic
123
127
  from synth_ai.lm.core.vendor_clients import (
@@ -156,6 +160,7 @@ class LM:
156
160
 
157
161
  self.vendor = vendor
158
162
  self.model = model
163
+ print(f"🔍 LM final: vendor={self.vendor}, model={self.model}")
159
164
  self.is_structured = is_structured
160
165
  self.structured_outputs_vendor = structured_outputs_vendor
161
166
  self.response_format = response_format
@@ -337,6 +342,14 @@ class LM:
337
342
  if hasattr(vendor_wrapper, "_hit_api_async"):
338
343
  # OpenAIStandard expects lm_config
339
344
  lm_config = {"temperature": self.temperature, **self.additional_params, **kwargs}
345
+ # Map convenience enable_thinking => thinking_mode unless explicitly set
346
+ if "enable_thinking" in lm_config and "thinking_mode" not in lm_config:
347
+ try:
348
+ et = lm_config.get("enable_thinking")
349
+ if isinstance(et, bool):
350
+ lm_config["thinking_mode"] = "think" if et else "no_think"
351
+ except Exception:
352
+ pass
340
353
  if self.json_mode:
341
354
  lm_config["response_format"] = {"type": "json_object"}
342
355
 
@@ -0,0 +1,48 @@
1
+ """
2
+ Synth-supported models registry.
3
+
4
+ This module defines the specific models that are supported by Synth's infrastructure.
5
+ Models are organized by family and size for easy maintenance and extension.
6
+
7
+ MAINTENANCE GUIDE:
8
+ 1. Add new model families to the appropriate lists (QWEN_MODELS, OTHER_SYNTH_MODELS)
9
+ 2. Fine-tuned models (ft:) are automatically detected by regex
10
+ 3. Update SYNTH_SUPPORTED_MODELS set when adding new models
11
+ 4. Test changes with: pytest tests/lms/test_qwen_chat_completions.py
12
+
13
+ WHY THIS EXISTS:
14
+ - The previous regex (^.*\/.*$) was too broad and caught unintended models
15
+ - This provides explicit control over which models use Synth infrastructure
16
+ - Easier to maintain and debug model routing issues
17
+ """
18
+
19
+ from typing import List, Set
20
+
21
+ # Qwen3 model families supported by Synth
22
+ QWEN3_MODELS: List[str] = [
23
+ # Qwen3 base models
24
+ "Qwen/Qwen3-0.6B",
25
+ "Qwen/Qwen3-1.7B",
26
+ "Qwen/Qwen3-4B",
27
+ "Qwen/Qwen3-8B",
28
+ "Qwen/Qwen3-14B",
29
+ "Qwen/Qwen3-32B",
30
+
31
+ # Qwen3 specialized variants
32
+ "Qwen/Qwen3-4B-Instruct-2507",
33
+ "Qwen/Qwen3-4B-Thinking-2507",
34
+ ]
35
+
36
+ # Fine-tuned models pattern - any model starting with "ft:" is considered Synth-compatible
37
+ # These are dynamically detected, but we can add specific known ones here
38
+ FINE_TUNED_MODELS: List[str] = [
39
+ # Add specific fine-tuned models that are known to work with Synth
40
+ # Examples:
41
+ # "ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-22",
42
+ ]
43
+
44
+ # Combine all Synth-supported models
45
+ SYNTH_SUPPORTED_MODELS: Set[str] = set(QWEN3_MODELS + FINE_TUNED_MODELS)
46
+
47
+ # Export the main set for easy import
48
+ __all__ = ["SYNTH_SUPPORTED_MODELS", "QWEN3_MODELS", "FINE_TUNED_MODELS"]
@@ -21,6 +21,7 @@ from synth_ai.lm.core.all import (
21
21
  OpenRouterClient,
22
22
  TogetherClient,
23
23
  )
24
+ from synth_ai.lm.core.synth_models import SYNTH_SUPPORTED_MODELS
24
25
 
25
26
  # Regular expressions to match model names to their respective providers
26
27
  openai_naming_regexes: list[Pattern] = [
@@ -39,8 +40,10 @@ gemini_naming_regexes: list[Pattern] = [
39
40
  deepseek_naming_regexes: list[Pattern] = [
40
41
  re.compile(r"^deepseek-.*$"),
41
42
  ]
42
- together_naming_regexes: list[Pattern] = [
43
- re.compile(r"^.*\/.*$"),
43
+ # Synth-specific model patterns (Qwen3 and fine-tuned models)
44
+ synth_naming_regexes: list[Pattern] = [
45
+ re.compile(r"^ft:.*$"), # Fine-tuned models (ft:model-name)
46
+ re.compile(r"^Qwen/Qwen3.*$"), # Qwen3 models specifically (Qwen/Qwen3-*)
44
47
  ]
45
48
 
46
49
  groq_naming_regexes: list[Pattern] = [
@@ -79,8 +82,6 @@ openrouter_naming_regexes: list[Pattern] = [
79
82
 
80
83
  # Custom endpoint patterns - check these before generic patterns
81
84
  custom_endpoint_naming_regexes: list[Pattern] = [
82
- # Modal endpoints: org--app.modal.run
83
- re.compile(r"^[a-zA-Z0-9\-]+--[a-zA-Z0-9\-]+\.modal\.run$"),
84
85
  # Generic domain patterns for custom endpoints
85
86
  re.compile(r"^[a-zA-Z0-9\-]+\.[a-zA-Z0-9\-]+\.[a-zA-Z]+$"), # domain.tld
86
87
  re.compile(r"^[a-zA-Z0-9\-]+\.[a-zA-Z0-9\-]+\.[a-zA-Z]+\/[a-zA-Z0-9\-\/]+$"), # domain.tld/path
@@ -179,7 +180,9 @@ def get_client(
179
180
  elif any(regex.match(model_name) for regex in custom_endpoint_naming_regexes):
180
181
  # Custom endpoints are passed as the endpoint URL
181
182
  return CustomEndpointClient(endpoint_url=model_name)
182
- elif any(regex.match(model_name) for regex in together_naming_regexes):
183
- return TogetherClient()
183
+ elif (any(regex.match(model_name) for regex in synth_naming_regexes) or
184
+ model_name in SYNTH_SUPPORTED_MODELS):
185
+ # Synth models use OpenAI-compatible client with custom endpoint
186
+ return OpenAIStructuredOutputClient(synth_logging=synth_logging)
184
187
  else:
185
188
  raise ValueError(f"Invalid model name: {model_name}")
@@ -6,6 +6,7 @@ supporting both standard and structured output modes.
6
6
  """
7
7
 
8
8
  import json
9
+ import os
9
10
  from typing import Any
10
11
 
11
12
  import openai
@@ -42,18 +43,45 @@ class OpenAIStructuredOutputClient(OpenAIStandard):
42
43
  """
43
44
 
44
45
  def __init__(self, synth_logging: bool = True):
45
- if synth_logging:
46
+ # Check if we should use Synth clients instead of OpenAI
47
+ openai_base = os.getenv("OPENAI_API_BASE", "")
48
+ use_synth = (openai_base.startswith("https://synth") or
49
+ openai_base.startswith("https://agent-learning") or
50
+ os.getenv("SYNTH_BASE_URL") or os.getenv("MODAL_BASE_URL"))
51
+
52
+ if use_synth:
53
+ # Use Synth clients for Synth endpoints
54
+ from synth_ai.lm.vendors.synth_client import AsyncSynthClient, SyncSynthClient
55
+ from synth_ai.lm.config import SynthConfig
56
+
57
+ # Create config from OPENAI_* environment variables if available
58
+ openai_base = os.getenv("OPENAI_API_BASE")
59
+ openai_key = os.getenv("OPENAI_API_KEY")
60
+
61
+ if openai_base and openai_key:
62
+ config = SynthConfig(base_url=openai_base, api_key=openai_key)
63
+ sync_client = SyncSynthClient(config)
64
+ async_client = AsyncSynthClient(config)
65
+ else:
66
+ # Fall back to default config loading
67
+ sync_client = SyncSynthClient()
68
+ async_client = AsyncSynthClient()
69
+ elif synth_logging:
46
70
  # print("Using synth logging - OpenAIStructuredOutputClient")
47
71
  from synth_ai.lm.provider_support.openai import AsyncOpenAI, OpenAI
72
+ sync_client = OpenAI()
73
+ async_client = AsyncOpenAI()
48
74
  else:
49
75
  # print("Not using synth logging - OpenAIStructuredOutputClient")
50
76
  from openai import AsyncOpenAI, OpenAI
77
+ sync_client = OpenAI()
78
+ async_client = AsyncOpenAI()
51
79
 
52
80
  super().__init__(
53
81
  used_for_structured_outputs=True,
54
82
  exceptions_to_retry=OPENAI_EXCEPTIONS_TO_RETRY,
55
- sync_client=OpenAI(),
56
- async_client=AsyncOpenAI(),
83
+ sync_client=sync_client,
84
+ async_client=async_client,
57
85
  )
58
86
 
59
87
  async def _hit_api_async_structured_output(
@@ -207,7 +207,22 @@ class OpenAIStandard(VendorBase, OpenAIResponsesAPIMixin):
207
207
  api_params = apply_tool_overrides(api_params)
208
208
  api_params = apply_param_overrides(api_params)
209
209
 
210
- # Forward Qwen3 chat template kwargs via extra_body when requested
210
+ # Thinking controls: route via extra_body.chat_template_kwargs for compatibility
211
+ thinking_mode_val = lm_config.get("thinking_mode")
212
+ thinking_budget_val = lm_config.get("thinking_budget")
213
+ if thinking_mode_val is not None or thinking_budget_val is not None:
214
+ api_params["extra_body"] = api_params.get("extra_body", {})
215
+ ctk = api_params["extra_body"].get("chat_template_kwargs", {})
216
+ if thinking_mode_val is not None:
217
+ ctk["thinking_mode"] = thinking_mode_val
218
+ if thinking_budget_val is not None:
219
+ try:
220
+ ctk["thinking_budget"] = int(thinking_budget_val)
221
+ except Exception:
222
+ ctk["thinking_budget"] = thinking_budget_val
223
+ api_params["extra_body"]["chat_template_kwargs"] = ctk
224
+
225
+ # Backward-compatible: forward legacy enable_thinking only via extra_body for callers still using it
211
226
  if lm_config.get("enable_thinking") is not None:
212
227
  api_params["extra_body"] = api_params.get("extra_body", {})
213
228
  ctk = api_params["extra_body"].get("chat_template_kwargs", {})
@@ -220,7 +235,7 @@ class OpenAIStandard(VendorBase, OpenAIResponsesAPIMixin):
220
235
  **api_params.get("extra_body", {}),
221
236
  **(lm_config.get("extra_body") or {}),
222
237
  }
223
- # Forward Qwen3 chat template kwargs via extra_body when requested
238
+ # Ensure legacy extra_body flag remains merged (do not override top-level fields)
224
239
  if lm_config.get("enable_thinking") is not None:
225
240
  api_params["extra_body"] = api_params.get("extra_body", {})
226
241
  ctk = api_params["extra_body"].get("chat_template_kwargs", {})
@@ -387,20 +402,36 @@ class OpenAIStandard(VendorBase, OpenAIResponsesAPIMixin):
387
402
  # raise
388
403
  message = output.choices[0].message
389
404
 
390
- # Convert tool calls to dict format
405
+ # Convert tool calls to dict format, preferring dict-shaped entries first
391
406
  tool_calls = None
392
407
  if message.tool_calls:
393
- tool_calls = [
394
- {
395
- "id": tc.id,
396
- "type": tc.type,
397
- "function": {
398
- "name": tc.function.name,
399
- "arguments": tc.function.arguments,
400
- },
401
- }
402
- for tc in message.tool_calls
403
- ]
408
+ converted: list[dict] = []
409
+ for tc in message.tool_calls:
410
+ if isinstance(tc, dict):
411
+ fn = tc.get("function") or {}
412
+ converted.append(
413
+ {
414
+ "id": tc.get("id"),
415
+ "type": tc.get("type", "function"),
416
+ "function": {
417
+ "name": fn.get("name") or tc.get("name"),
418
+ "arguments": fn.get("arguments") or tc.get("arguments"),
419
+ },
420
+ }
421
+ )
422
+ else:
423
+ # SDK object path
424
+ converted.append(
425
+ {
426
+ "id": getattr(tc, "id", None),
427
+ "type": getattr(tc, "type", "function"),
428
+ "function": {
429
+ "name": getattr(getattr(tc, "function", None), "name", None),
430
+ "arguments": getattr(getattr(tc, "function", None), "arguments", None),
431
+ },
432
+ }
433
+ )
434
+ tool_calls = converted or None
404
435
 
405
436
  # Attach basic usage if available
406
437
  usage_dict = None
@@ -38,8 +38,18 @@ class CustomEndpointAPI(VendorBase):
38
38
  # Construct full chat completions URL
39
39
  if endpoint_url.endswith("/"):
40
40
  endpoint_url = endpoint_url[:-1]
41
- self.chat_completions_url = f"https://{endpoint_url}/chat/completions"
42
- self.health_url = f"https://{endpoint_url}/health"
41
+
42
+ # Handle full URLs that already include protocol
43
+ if endpoint_url.startswith(("http://", "https://")):
44
+ # Remove protocol and domain part, keep only the base path if any
45
+ parsed = endpoint_url.replace("https://", "").replace("http://", "")
46
+ base_url = parsed.split("/")[0] # Get domain only
47
+ self.chat_completions_url = f"https://{base_url}/chat/completions"
48
+ self.health_url = f"https://{base_url}/health"
49
+ else:
50
+ # Original logic for domain-only URLs
51
+ self.chat_completions_url = f"https://{endpoint_url}/chat/completions"
52
+ self.health_url = f"https://{endpoint_url}/health"
43
53
 
44
54
  # Setup session with connection pooling and retries
45
55
  self.session = self._create_session()