synth-ai 0.2.17__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 (169) hide show
  1. examples/baseline/banking77_baseline.py +204 -0
  2. examples/baseline/crafter_baseline.py +407 -0
  3. examples/baseline/pokemon_red_baseline.py +326 -0
  4. examples/baseline/simple_baseline.py +56 -0
  5. examples/baseline/warming_up_to_rl_baseline.py +239 -0
  6. examples/blog_posts/gepa/README.md +355 -0
  7. examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
  8. examples/blog_posts/gepa/configs/banking77_gepa_test.toml +82 -0
  9. examples/blog_posts/gepa/configs/banking77_mipro_local.toml +52 -0
  10. examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +59 -0
  11. examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +36 -0
  12. examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +53 -0
  13. examples/blog_posts/gepa/configs/hover_gepa_local.toml +59 -0
  14. examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +36 -0
  15. examples/blog_posts/gepa/configs/hover_mipro_local.toml +53 -0
  16. examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +59 -0
  17. examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +36 -0
  18. examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +53 -0
  19. examples/blog_posts/gepa/configs/pupa_gepa_local.toml +60 -0
  20. examples/blog_posts/gepa/configs/pupa_mipro_local.toml +54 -0
  21. examples/blog_posts/gepa/deploy_banking77_task_app.sh +41 -0
  22. examples/blog_posts/gepa/gepa_baseline.py +204 -0
  23. examples/blog_posts/gepa/query_prompts_example.py +97 -0
  24. examples/blog_posts/gepa/run_gepa_banking77.sh +87 -0
  25. examples/blog_posts/gepa/task_apps.py +105 -0
  26. examples/blog_posts/gepa/test_gepa_local.sh +67 -0
  27. examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
  28. examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
  29. examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +12 -10
  30. examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +1 -0
  31. examples/blog_posts/pokemon_vl/extract_images.py +239 -0
  32. examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
  33. examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
  34. examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
  35. examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
  36. examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
  37. examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
  38. examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
  39. examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
  40. examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
  41. examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
  42. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -0
  43. examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +1 -1
  44. examples/blog_posts/warming_up_to_rl/configs/smoke_test.toml +75 -0
  45. examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +60 -10
  46. examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +1 -1
  47. examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
  48. examples/multi_step/configs/VERILOG_REWARDS.md +4 -0
  49. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +4 -0
  50. examples/multi_step/configs/crafter_rl_outcome.toml +1 -0
  51. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +1 -0
  52. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +1 -0
  53. examples/rl/configs/rl_from_base_qwen17.toml +1 -0
  54. examples/swe/task_app/hosted/inference/openai_client.py +0 -34
  55. examples/swe/task_app/hosted/policy_routes.py +17 -0
  56. examples/swe/task_app/hosted/rollout.py +4 -2
  57. examples/task_apps/banking77/__init__.py +6 -0
  58. examples/task_apps/banking77/banking77_task_app.py +841 -0
  59. examples/task_apps/banking77/deploy_wrapper.py +46 -0
  60. examples/task_apps/crafter/CREATE_SFT_DATASET.md +4 -0
  61. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +4 -0
  62. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +4 -0
  63. examples/task_apps/crafter/task_app/grpo_crafter.py +24 -2
  64. examples/task_apps/crafter/task_app/synth_envs_hosted/hosted_app.py +49 -0
  65. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +355 -58
  66. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +68 -7
  67. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +78 -21
  68. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +194 -1
  69. examples/task_apps/gepa_benchmarks/__init__.py +7 -0
  70. examples/task_apps/gepa_benchmarks/common.py +260 -0
  71. examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
  72. examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
  73. examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
  74. examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
  75. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +4 -0
  76. examples/task_apps/pokemon_red/task_app.py +254 -36
  77. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +1 -0
  78. examples/warming_up_to_rl/task_app/grpo_crafter.py +53 -4
  79. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +49 -0
  80. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +152 -41
  81. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +31 -1
  82. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +33 -3
  83. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +67 -0
  84. examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +1 -0
  85. synth_ai/api/train/builders.py +90 -1
  86. synth_ai/api/train/cli.py +396 -21
  87. synth_ai/api/train/config_finder.py +13 -2
  88. synth_ai/api/train/configs/__init__.py +15 -1
  89. synth_ai/api/train/configs/prompt_learning.py +442 -0
  90. synth_ai/api/train/configs/rl.py +29 -0
  91. synth_ai/api/train/task_app.py +1 -1
  92. synth_ai/api/train/validators.py +277 -0
  93. synth_ai/baseline/__init__.py +25 -0
  94. synth_ai/baseline/config.py +209 -0
  95. synth_ai/baseline/discovery.py +214 -0
  96. synth_ai/baseline/execution.py +146 -0
  97. synth_ai/cli/__init__.py +85 -17
  98. synth_ai/cli/__main__.py +0 -0
  99. synth_ai/cli/claude.py +70 -0
  100. synth_ai/cli/codex.py +84 -0
  101. synth_ai/cli/commands/__init__.py +1 -0
  102. synth_ai/cli/commands/baseline/__init__.py +12 -0
  103. synth_ai/cli/commands/baseline/core.py +637 -0
  104. synth_ai/cli/commands/baseline/list.py +93 -0
  105. synth_ai/cli/commands/eval/core.py +13 -10
  106. synth_ai/cli/commands/filter/core.py +53 -17
  107. synth_ai/cli/commands/help/core.py +0 -1
  108. synth_ai/cli/commands/smoke/__init__.py +7 -0
  109. synth_ai/cli/commands/smoke/core.py +1436 -0
  110. synth_ai/cli/commands/status/subcommands/pricing.py +22 -0
  111. synth_ai/cli/commands/status/subcommands/usage.py +203 -0
  112. synth_ai/cli/commands/train/judge_schemas.py +1 -0
  113. synth_ai/cli/commands/train/judge_validation.py +1 -0
  114. synth_ai/cli/commands/train/validation.py +0 -57
  115. synth_ai/cli/demo.py +35 -3
  116. synth_ai/cli/deploy/__init__.py +40 -25
  117. synth_ai/cli/deploy.py +162 -0
  118. synth_ai/cli/legacy_root_backup.py +14 -8
  119. synth_ai/cli/opencode.py +107 -0
  120. synth_ai/cli/root.py +9 -5
  121. synth_ai/cli/task_app_deploy.py +1 -1
  122. synth_ai/cli/task_apps.py +53 -53
  123. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +7 -4
  124. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
  125. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
  126. synth_ai/judge_schemas.py +1 -0
  127. synth_ai/learning/__init__.py +10 -0
  128. synth_ai/learning/prompt_learning_client.py +276 -0
  129. synth_ai/learning/prompt_learning_types.py +184 -0
  130. synth_ai/pricing/__init__.py +2 -0
  131. synth_ai/pricing/model_pricing.py +57 -0
  132. synth_ai/streaming/handlers.py +53 -4
  133. synth_ai/streaming/streamer.py +19 -0
  134. synth_ai/task/apps/__init__.py +1 -0
  135. synth_ai/task/config.py +2 -0
  136. synth_ai/task/tracing_utils.py +25 -25
  137. synth_ai/task/validators.py +44 -8
  138. synth_ai/task_app_cfgs.py +21 -0
  139. synth_ai/tracing_v3/config.py +162 -19
  140. synth_ai/tracing_v3/constants.py +1 -1
  141. synth_ai/tracing_v3/db_config.py +24 -38
  142. synth_ai/tracing_v3/storage/config.py +47 -13
  143. synth_ai/tracing_v3/storage/factory.py +3 -3
  144. synth_ai/tracing_v3/turso/daemon.py +113 -11
  145. synth_ai/tracing_v3/turso/native_manager.py +92 -16
  146. synth_ai/types.py +8 -0
  147. synth_ai/urls.py +11 -0
  148. synth_ai/utils/__init__.py +30 -1
  149. synth_ai/utils/agents.py +74 -0
  150. synth_ai/utils/bin.py +39 -0
  151. synth_ai/utils/cli.py +149 -5
  152. synth_ai/utils/env.py +17 -17
  153. synth_ai/utils/json.py +72 -0
  154. synth_ai/utils/modal.py +283 -1
  155. synth_ai/utils/paths.py +48 -0
  156. synth_ai/utils/uvicorn.py +113 -0
  157. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/METADATA +102 -4
  158. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/RECORD +162 -88
  159. synth_ai/cli/commands/deploy/__init__.py +0 -23
  160. synth_ai/cli/commands/deploy/core.py +0 -614
  161. synth_ai/cli/commands/deploy/errors.py +0 -72
  162. synth_ai/cli/commands/deploy/validation.py +0 -11
  163. synth_ai/cli/deploy/core.py +0 -5
  164. synth_ai/cli/deploy/errors.py +0 -23
  165. synth_ai/cli/deploy/validation.py +0 -5
  166. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/WHEEL +0 -0
  167. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/entry_points.txt +0 -0
  168. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/licenses/LICENSE +0 -0
  169. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/top_level.txt +0 -0
synth_ai/utils/cli.py CHANGED
@@ -1,9 +1,29 @@
1
1
  from collections.abc import Sequence
2
- from typing import Any, cast
2
+ from pathlib import Path
3
+ from typing import Any, Callable, cast
3
4
 
4
5
  import click
5
6
 
6
7
 
8
+ def prompt_choice(msg: str, choices: list[str]) -> str:
9
+ print(msg)
10
+ for i, label in enumerate(choices, start=1):
11
+ print(f" [{i}] {label}")
12
+ while True:
13
+ try:
14
+ choice = click.prompt(
15
+ "Select an option",
16
+ default=1,
17
+ type=int,
18
+ show_choices=False
19
+ )
20
+ except click.Abort:
21
+ raise
22
+ if 1 <= choice <= len(choices):
23
+ return choices[choice - 1]
24
+ print(f"Invalid selection. Enter a number between 1 and {len(choices)}")
25
+
26
+
7
27
  class PromptedChoiceType(click.Choice):
8
28
  """`click.Choice` variant that reprompts with an interactive menu on failure.
9
29
 
@@ -61,9 +81,15 @@ class PromptedChoiceType(click.Choice):
61
81
  for index, choice in enumerate(self.choices, 1):
62
82
  click.echo(f" [{index}] {choice}")
63
83
  while True:
64
- selection = click.prompt("> ", type=int)
65
- if 1 <= selection <= len(self.choices):
66
- return cast(str, self.choices[selection - 1])
84
+ choice = click.prompt(
85
+ "Select an option",
86
+ default=1,
87
+ type=int,
88
+ show_choices=False
89
+ )
90
+ if 1 <= choice <= len(self.choices):
91
+ print('')
92
+ return cast(str, self.choices[choice - 1])
67
93
  click.echo(f"Invalid selection for {arg_name}, please try again")
68
94
 
69
95
  def _get_cmd_name(self, ctx: click.Context | None) -> str:
@@ -122,7 +148,125 @@ class PromptedChoiceOption(click.Option):
122
148
  if isinstance(option_type, PromptedChoiceType):
123
149
  return option_type._prompt_user(self, ctx)
124
150
  return super().prompt_for_value(ctx)
125
-
151
+
152
+
153
+ def prompt_for_path(
154
+ label: str,
155
+ *,
156
+ available_paths: Sequence[str | Path] | None = None,
157
+ file_type: str | None = None,
158
+ path_type: click.Path | None = None,
159
+ ) -> Path:
160
+ """Prompt for a filesystem path, optionally offering curated choices."""
161
+
162
+ def _normalize_suffix(ext: str | None) -> str | None:
163
+ if not ext:
164
+ return None
165
+ stripped = ext.strip()
166
+ if not stripped:
167
+ return None
168
+ if not stripped.startswith("."):
169
+ stripped = f".{stripped}"
170
+ return stripped.lower()
171
+
172
+ def _format_label(text: str) -> str:
173
+ return text.strip() or "path"
174
+
175
+ expected_suffix = _normalize_suffix(file_type)
176
+ prompt_label = _format_label(label)
177
+
178
+ path_type = path_type or click.Path(
179
+ exists=True,
180
+ dir_okay=False,
181
+ file_okay=True,
182
+ path_type=Path,
183
+ )
184
+
185
+ candidates: list[str] = []
186
+ if available_paths:
187
+ seen: set[str] = set()
188
+ for entry in available_paths:
189
+ candidate = str(Path(entry))
190
+ suffix = Path(candidate).suffix.lower()
191
+ if candidate in seen:
192
+ continue
193
+ if expected_suffix and suffix != expected_suffix:
194
+ continue
195
+ seen.add(candidate)
196
+ candidates.append(candidate)
197
+
198
+ ctx = click.get_current_context(silent=True)
199
+
200
+ while True:
201
+ if candidates:
202
+ click.echo(f"\nPlease choose a {prompt_label}:")
203
+ for index, option in enumerate(candidates, 1):
204
+ click.echo(f" [{index}] {option}")
205
+ custom_index = len(candidates) + 1
206
+ click.echo(f" [{custom_index}] Enter a custom path")
207
+
208
+ selection = click.prompt("> ", type=int)
209
+ if 1 <= selection <= len(candidates):
210
+ raw_value = candidates[selection - 1]
211
+ elif selection == custom_index:
212
+ raw_value = click.prompt(prompt_label, type=path_type)
213
+ else:
214
+ click.echo("Invalid selection, please try again")
215
+ continue
216
+ else:
217
+ raw_value = click.prompt(prompt_label, type=path_type)
218
+
219
+ try:
220
+ converted = path_type.convert(str(raw_value), None, ctx)
221
+ except click.BadParameter as exc:
222
+ click.echo(str(exc))
223
+ continue
224
+
225
+ result = converted if isinstance(converted, Path) else Path(converted)
226
+ if expected_suffix and result.suffix.lower() != expected_suffix:
227
+ click.echo(f"Expected a {expected_suffix} file. Received: {result}")
228
+ continue
229
+
230
+ return result
231
+
232
+
233
+ class PromptedPathOption(click.Option):
234
+ """Option that prompts for a filesystem path when omitted."""
235
+
236
+ def __init__(
237
+ self,
238
+ *args: Any,
239
+ available_paths: Sequence[str | Path] | None = None,
240
+ file_type: str | None = None,
241
+ path_type: click.Path | None = None,
242
+ prompt_guard: Callable[[click.Context], bool] | None = None,
243
+ **kwargs: Any,
244
+ ) -> None:
245
+ self._available_paths = available_paths
246
+ self._file_type = file_type
247
+ self._path_type = path_type
248
+ self._prompt_guard = prompt_guard
249
+ kwargs.setdefault("prompt", True)
250
+ kwargs.setdefault("prompt_required", True)
251
+ super().__init__(*args, **kwargs)
252
+
253
+ def prompt_for_value(self, ctx: click.Context) -> Any:
254
+ if not ctx:
255
+ return super().prompt_for_value(ctx)
256
+ if self._prompt_guard is not None:
257
+ try:
258
+ if not self._prompt_guard(ctx):
259
+ return None
260
+ except Exception:
261
+ return None
262
+ label = self.help or self.name or "path"
263
+ return prompt_for_path(
264
+ label,
265
+ available_paths=self._available_paths,
266
+ file_type=self._file_type,
267
+ path_type=self._path_type or getattr(self, "type", None),
268
+ )
269
+
126
270
 
127
271
  def print_next_step(message: str, lines: Sequence[str]) -> None:
128
272
  print(f"\n➡️ Next, {message}:")
synth_ai/utils/env.py CHANGED
@@ -5,6 +5,8 @@ from pathlib import Path
5
5
 
6
6
  import click
7
7
 
8
+ from .paths import get_env_file_paths, get_home_config_file_paths
9
+
8
10
  _ENV_SAFE_CHARS = set(string.ascii_letters + string.digits + "_-./:@+=")
9
11
 
10
12
 
@@ -84,18 +86,6 @@ def mask_str(input: str, position: int = 3) -> str:
84
86
  return input[:position] + "..." + input[-position:] if len(input) > position * 2 else "***"
85
87
 
86
88
 
87
- def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
88
- base = Path(base_dir).resolve()
89
- return [path for path in base.rglob(".env*") if path.is_file()]
90
-
91
-
92
- def get_synth_config_file_paths() -> list[Path]:
93
- dir = Path.home() / ".synth-ai"
94
- if not dir.exists():
95
- return []
96
- return [path for path in dir.glob("*.json") if path.is_file()]
97
-
98
-
99
89
  def filter_env_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
100
90
  matches: list[tuple[Path, str]] = []
101
91
  for path in paths:
@@ -133,18 +123,25 @@ def ensure_env_var(key: str, expected_value: str) -> None:
133
123
  raise ValueError(f"Expected: {key}={expected_value}\nActual: {key}={actual_value}")
134
124
 
135
125
 
136
- def resolve_env_var(key: str) -> str:
126
+ def resolve_env_var(
127
+ key: str,
128
+ override_process_env: bool = False
129
+ ) -> str:
137
130
  env_value = os.getenv(key)
138
- if env_value is not None:
131
+ if env_value is not None and not override_process_env:
139
132
  click.echo(f"Using {key}={mask_str(env_value)} from process environment")
140
133
  return env_value
141
134
 
142
135
  value: str = ""
143
136
 
144
137
  env_file_paths = filter_env_files_by_key(key, get_env_file_paths())
145
- synth_file_paths = filter_json_files_by_key(key, get_synth_config_file_paths())
138
+ synth_file_paths = filter_json_files_by_key(key, get_home_config_file_paths(".synth-ai"))
146
139
 
147
140
  options: list[tuple[str, str]] = []
141
+ if env_value is not None:
142
+ if not override_process_env:
143
+ return env_value
144
+ options.append((f"(process environment) {mask_str(env_value)}", env_value))
148
145
  for path, value in env_file_paths:
149
146
  resolved_path = path.resolve()
150
147
  try:
@@ -167,7 +164,7 @@ def resolve_env_var(key: str) -> str:
167
164
  while True:
168
165
  try:
169
166
  choice = click.prompt(
170
- "Select option",
167
+ "Select an option",
171
168
  default=1,
172
169
  type=str,
173
170
  show_choices=False,
@@ -204,6 +201,8 @@ def write_env_var_to_dotenv(
204
201
  key: str,
205
202
  value: str,
206
203
  output_file_path: str | Path | None = None,
204
+ print_msg: bool = True,
205
+ mask_msg: bool = True
207
206
  ) -> None:
208
207
  path = Path(".env") if output_file_path is None else Path(output_file_path)
209
208
  path = path.expanduser()
@@ -247,7 +246,8 @@ def write_env_var_to_dotenv(
247
246
  except OSError as exc:
248
247
  raise RuntimeError(f"Failed to write {path}: {exc}") from exc
249
248
 
250
- print(f"Wrote {key}={mask_str(value)} to {path.resolve()}")
249
+ if print_msg:
250
+ print(f"Wrote {key}={mask_str(value) if mask_msg else value} to {path.resolve()}")
251
251
 
252
252
 
253
253
  def write_env_var_to_json(
synth_ai/utils/json.py ADDED
@@ -0,0 +1,72 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def strip_json_comments(raw: str) -> str:
6
+ """Remove // and /* */ comments from JSONC text."""
7
+ result: list[str] = []
8
+ in_string = False
9
+ in_line_comment = False
10
+ in_block_comment = False
11
+ escape = False
12
+ i = 0
13
+ length = len(raw)
14
+ while i < length:
15
+ char = raw[i]
16
+ next_char = raw[i + 1] if i + 1 < length else ""
17
+
18
+ if in_line_comment:
19
+ if char == "\n":
20
+ in_line_comment = False
21
+ result.append(char)
22
+ i += 1
23
+ continue
24
+
25
+ if in_block_comment:
26
+ if char == "*" and next_char == "/":
27
+ in_block_comment = False
28
+ i += 2
29
+ else:
30
+ i += 1
31
+ continue
32
+
33
+ if in_string:
34
+ result.append(char)
35
+ if char == "\"" and not escape:
36
+ in_string = False
37
+ escape = (char == "\\") and not escape
38
+ i += 1
39
+ continue
40
+
41
+ if char == "/" and next_char == "/":
42
+ in_line_comment = True
43
+ i += 2
44
+ continue
45
+
46
+ if char == "/" and next_char == "*":
47
+ in_block_comment = True
48
+ i += 2
49
+ continue
50
+
51
+ if char == "\"":
52
+ in_string = True
53
+ escape = False
54
+
55
+ result.append(char)
56
+ i += 1
57
+
58
+ return "".join(result)
59
+
60
+
61
+ def create_and_write_json(path: Path, content: dict) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps(content, indent=2) + "\n")
64
+
65
+
66
+ def load_json_to_dict(path: Path) -> dict:
67
+ if not path.exists():
68
+ return {}
69
+ try:
70
+ return json.loads(strip_json_comments(path.read_text()))
71
+ except (json.JSONDecodeError, OSError):
72
+ return {}
synth_ai/utils/modal.py CHANGED
@@ -1,16 +1,25 @@
1
+ import ast
1
2
  import contextlib
2
3
  import json
3
4
  import os
5
+ import re
6
+ import shlex
4
7
  import shutil
8
+ import subprocess
5
9
  import sys
10
+ import tempfile
11
+ import textwrap
6
12
  from pathlib import Path
7
13
  from typing import Any
8
14
  from urllib.parse import urlparse, urlunparse
9
15
 
16
+ import click
17
+ from modal.config import config
10
18
  from synth_ai.demos import core as demo_core
11
19
  from synth_ai.demos.core import DEFAULT_TASK_APP_SECRET_NAME, DemoEnv
20
+ from synth_ai.task_app_cfgs import ModalTaskAppConfig
12
21
 
13
- from .env import mask_str
22
+ from .env import mask_str, resolve_env_var, write_env_var_to_dotenv
14
23
  from .http import http_request
15
24
  from .process import popen_capture
16
25
  from .user_config import load_user_config
@@ -25,6 +34,279 @@ __all__ = [
25
34
  ]
26
35
 
27
36
 
37
+ REPO_ROOT = Path(__file__).resolve().parents[2]
38
+
39
+ START_DIV = f"{'-' * 31} Modal start {'-' * 31}"
40
+ END_DIV = f"{'-' * 32} Modal end {'-' * 32}"
41
+ MODAL_URL_REGEX = re.compile(r"https?://[^\s]+modal\.run[^\s]*")
42
+
43
+
44
+ def get_default_modal_bin_path() -> Path | None:
45
+ resolved = shutil.which("modal")
46
+ return Path(resolved) if resolved else None
47
+
48
+
49
+ def ensure_py_file_defines_modal_app(file_path: Path) -> None:
50
+ if file_path.suffix != ".py":
51
+ raise TypeError()
52
+ try:
53
+ tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
54
+ except OSError as exc:
55
+ raise OSError() from exc
56
+
57
+ app_aliases: set[str] = set()
58
+ modal_aliases: set[str] = set()
59
+
60
+ def literal_name(call: ast.Call) -> str | None:
61
+ for kw in call.keywords:
62
+ if (
63
+ kw.arg in {"name", "app_name"}
64
+ and isinstance(kw.value, ast.Constant)
65
+ and isinstance(kw.value.value, str)
66
+ ):
67
+ return kw.value.value
68
+ if call.args:
69
+ first = call.args[0]
70
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
71
+ return first.value
72
+ return None
73
+
74
+ for node in ast.walk(tree):
75
+ if isinstance(node, ast.ImportFrom) and node.module == "modal":
76
+ for alias in node.names:
77
+ if alias.name == "App":
78
+ app_aliases.add(alias.asname or alias.name)
79
+ elif isinstance(node, ast.Import):
80
+ for alias in node.names:
81
+ if alias.name == "modal":
82
+ modal_aliases.add(alias.asname or alias.name)
83
+ elif isinstance(node, ast.Call):
84
+ func = node.func
85
+ if isinstance(func, ast.Name) and func.id in app_aliases:
86
+ if literal_name(node):
87
+ return None
88
+ elif (
89
+ isinstance(func, ast.Attribute)
90
+ and func.attr == "App"
91
+ and isinstance(func.value, ast.Name)
92
+ and func.value.id in modal_aliases
93
+ and literal_name(node)
94
+ ):
95
+ return None
96
+ raise ValueError()
97
+
98
+
99
+ def run_modal_setup(modal_bin_path: Path) -> None:
100
+
101
+ print("\n🌐 Connecting to your Modal account via https://modal.com")
102
+ print(START_DIV)
103
+ cmd = [str(modal_bin_path), "setup"]
104
+ try:
105
+ subprocess.run(cmd, check=True)
106
+ except subprocess.CalledProcessError as exc:
107
+ print(END_DIV)
108
+ raise RuntimeError(
109
+ f"`{' '.join(cmd)}` exited with status {exc.returncode}"
110
+ f"Run `{' '.join(cmd)} manually to inspect output"
111
+ ) from exc
112
+ print(END_DIV)
113
+ print("✅ Connected to your Modal account")
114
+
115
+
116
+ def ensure_modal_config() -> None:
117
+ token_id = os.environ.get("MODAL_TOKEN_ID") \
118
+ or config.get("token_id") \
119
+ or ''
120
+ token_secret = os.environ.get("MODAL_TOKEN_SECRET") \
121
+ or config.get("token_secret") \
122
+ or ''
123
+ if token_id and token_secret:
124
+ print(f"Found Modal token_id={mask_str(token_id)}")
125
+ print(f"Found Modal token_secret={mask_str(token_secret)}")
126
+ return
127
+
128
+ modal_bin_path = get_default_modal_bin_path()
129
+ if not modal_bin_path:
130
+ raise RuntimeError("Modal CLI not found on PATH")
131
+ run_modal_setup(modal_bin_path)
132
+
133
+
134
+ def deploy_modal_app(cfg: ModalTaskAppConfig) -> None:
135
+ ensure_py_file_defines_modal_app(cfg.modal_app_path)
136
+ ensure_modal_config()
137
+
138
+ py_paths: list[str] = []
139
+
140
+ source_dir = cfg.modal_app_path.parent.resolve()
141
+ py_paths.append(str(source_dir))
142
+ if (source_dir / "__init__.py").exists(): # if the modal app lives in a package, ensure the parent package is importable
143
+ py_paths.append(str(source_dir.parent.resolve()))
144
+
145
+ py_paths.append(str(REPO_ROOT))
146
+
147
+ env_api_key = resolve_env_var("ENVIRONMENT_API_KEY")
148
+ if not os.environ["ENVIRONMENT_API_KEY"]:
149
+ raise RuntimeError()
150
+
151
+ env_copy = os.environ.copy()
152
+ existing_python_path = env_copy.get("PYTHONPATH")
153
+ if existing_python_path:
154
+ py_paths.append(existing_python_path)
155
+ unique_python_paths = list(dict.fromkeys(py_paths))
156
+ env_copy["PYTHONPATH"] = os.pathsep.join(unique_python_paths)
157
+ if "PYTHONPATH" in env_copy: # ensure wrapper has access to synth source for intra-repo imports
158
+ env_copy["PYTHONPATH"] = os.pathsep.join(
159
+ [str(REPO_ROOT)] + env_copy["PYTHONPATH"].split(os.pathsep)
160
+ )
161
+ else:
162
+ env_copy["PYTHONPATH"] = str(REPO_ROOT)
163
+
164
+ modal_app_dir = cfg.modal_app_path.parent.resolve()
165
+ tmp_root = Path(tempfile.mkdtemp(prefix="synth_modal_app"))
166
+ wrapper_src = textwrap.dedent(f"""
167
+ from importlib import util as _util
168
+ from pathlib import Path as _Path
169
+ import sys as _sys
170
+
171
+ _source_dir = _Path({str(modal_app_dir)!r}).resolve()
172
+ _module_path = _source_dir / {cfg.modal_app_path.name!r}
173
+ _package_name = _source_dir.name
174
+ _repo_root = _Path({str(REPO_ROOT)!r}).resolve()
175
+ _synth_dir = _repo_root / "synth_ai"
176
+
177
+ for _path in (str(_source_dir), str(_source_dir.parent), str(_repo_root)):
178
+ if _path not in _sys.path:
179
+ _sys.path.insert(0, _path)
180
+
181
+ _spec = _util.spec_from_file_location("_synth_modal_target", str(_module_path))
182
+ if _spec is None or _spec.loader is None:
183
+ raise SystemExit("Unable to load modal task app from {cfg.modal_app_path}")
184
+ _module = _util.module_from_spec(_spec)
185
+ _sys.modules.setdefault("_synth_modal_target", _module)
186
+ _spec.loader.exec_module(_module)
187
+
188
+ try:
189
+ from modal import App as _ModalApp
190
+ from modal import Image as _ModalImage
191
+ except Exception:
192
+ _ModalApp = None # type: ignore[assignment]
193
+ _ModalImage = None # type: ignore[assignment]
194
+
195
+ def _apply_local_mounts(image):
196
+ if _ModalImage is None or not isinstance(image, _ModalImage):
197
+ return image
198
+ mounts = [
199
+ (str(_source_dir), f"/root/{{_package_name}}"),
200
+ (str(_synth_dir), "/root/synth_ai"),
201
+ ]
202
+ for local_path, remote_path in mounts:
203
+ try:
204
+ image = image.add_local_dir(local_path, remote_path=remote_path)
205
+ except Exception:
206
+ pass
207
+ return image
208
+
209
+ if hasattr(_module, "image"):
210
+ _module.image = _apply_local_mounts(getattr(_module, "image"))
211
+
212
+ _candidate = getattr(_module, "app", None)
213
+ if _ModalApp is None or not isinstance(_candidate, _ModalApp):
214
+ candidate_modal_app = getattr(_module, "modal_app", None)
215
+ if _ModalApp is not None and isinstance(candidate_modal_app, _ModalApp):
216
+ _candidate = candidate_modal_app
217
+ setattr(_module, "app", _candidate)
218
+
219
+ if _ModalApp is not None and not isinstance(_candidate, _ModalApp):
220
+ raise SystemExit(
221
+ "Modal task app must expose an 'app = modal.App(...)' (or modal_app) attribute."
222
+ )
223
+
224
+ try:
225
+ from modal import Secret as _Secret
226
+ except Exception:
227
+ _Secret = None
228
+
229
+ for remote_path in ("/root/synth_ai", f"/root/{{_package_name}}"):
230
+ if remote_path not in _sys.path:
231
+ _sys.path.insert(0, remote_path)
232
+
233
+ globals().update({{k: v for k, v in vars(_module).items() if not k.startswith("__")}})
234
+ app = getattr(_module, "app")
235
+ _ENVIRONMENT_API_KEY = {env_api_key!r}
236
+ if _Secret is not None and _ENVIRONMENT_API_KEY:
237
+ try:
238
+ _inline_secret = _Secret.from_dict({{"ENVIRONMENT_API_KEY": _ENVIRONMENT_API_KEY}})
239
+ except Exception:
240
+ _inline_secret = None
241
+ if _inline_secret is not None:
242
+ try:
243
+ _decorators = list(getattr(app, "_function_decorators", []))
244
+ except Exception:
245
+ _decorators = []
246
+ for _decorator in _decorators:
247
+ _existing = getattr(_decorator, "secrets", None)
248
+ if not _existing:
249
+ continue
250
+ try:
251
+ if _inline_secret not in _existing:
252
+ _decorator.secrets = list(_existing) + [_inline_secret]
253
+ except Exception:
254
+ pass
255
+ """).strip()
256
+ wrapper_path = tmp_root / "__modal_wrapper__.py"
257
+ wrapper_path.write_text(wrapper_src + '\n', encoding="utf-8")
258
+ wrapper_info = (wrapper_path, tmp_root)
259
+
260
+ cmd = [str(cfg.modal_bin_path), cfg.cmd_arg, str(wrapper_path)]
261
+ if cfg.task_app_name and cfg.cmd_arg == "deploy":
262
+ cmd.extend(["--name", cfg.task_app_name])
263
+
264
+ msg = " ".join(shlex.quote(c) for c in cmd)
265
+ if cfg.dry_run:
266
+ print("Dry run:\n", msg)
267
+ return
268
+ print(f"Running:\n{msg}")
269
+
270
+ try:
271
+ process = subprocess.Popen(
272
+ cmd,
273
+ stdout=subprocess.PIPE,
274
+ stderr=subprocess.STDOUT,
275
+ text=True,
276
+ bufsize=1,
277
+ env=env_copy
278
+ )
279
+ task_app_url = None
280
+ assert process.stdout is not None
281
+ print(START_DIV)
282
+ for line in process.stdout:
283
+ click.echo(line, nl=False)
284
+ if task_app_url is None:
285
+ match = MODAL_URL_REGEX.search(line)
286
+ if match:
287
+ task_app_url = match.group(0).rstrip(".,")
288
+ if task_app_url:
289
+ write_env_var_to_dotenv(
290
+ "TASK_APP_URL",
291
+ task_app_url,
292
+ print_msg=True,
293
+ mask_msg=False,
294
+ )
295
+ print(END_DIV)
296
+ rc = process.wait()
297
+ if rc != 0:
298
+ raise subprocess.CalledProcessError(rc, cmd)
299
+ except subprocess.CalledProcessError as exc:
300
+ raise click.ClickException(
301
+ f"modal {cfg.cmd_arg} failed with exit code: {exc.returncode}"
302
+ ) from exc
303
+ finally:
304
+ if wrapper_info is not None:
305
+ wrapper_path, tmp_root = wrapper_info
306
+ wrapper_path.unlink(missing_ok=True)
307
+ shutil.rmtree(tmp_root, ignore_errors=True)
308
+
309
+
28
310
  def is_modal_public_url(url: str | None) -> bool:
29
311
  try:
30
312
  candidate = (url or "").strip().lower()
@@ -0,0 +1,48 @@
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+
5
+ def find_bin_path(name: str) -> Path | None:
6
+ path = shutil.which(name)
7
+ if not path:
8
+ return None
9
+ return Path(path)
10
+
11
+
12
+ def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
13
+ base = Path(base_dir).resolve()
14
+ return [path for path in base.rglob(".env*") if path.is_file()]
15
+
16
+
17
+ def get_home_config_file_paths(
18
+ dir_name: str,
19
+ file_extension: str = "json"
20
+ ) -> list[Path]:
21
+ dir = Path.home() / dir_name
22
+ if not dir.exists():
23
+ return []
24
+ return [path for path in dir.glob(f"*.{file_extension}") if path.is_file()]
25
+
26
+
27
+ def find_config_path(
28
+ bin_path: Path,
29
+ home_subdir: str,
30
+ filename: str,
31
+ ) -> Path | None:
32
+ """
33
+ Return a config file located in the user's home directory or alongside the binary.
34
+
35
+ Args:
36
+ bin_path: Resolved path to the executable.
37
+ home_subdir: Directory under the user's home to inspect (e.g., ".codex").
38
+ filename: Name of the config file to locate.
39
+ """
40
+ home_candidate = Path.home() / home_subdir / filename
41
+ if home_candidate.exists():
42
+ return home_candidate
43
+
44
+ local_candidate = Path(bin_path).parent / home_subdir / filename
45
+ if local_candidate.exists():
46
+ return local_candidate
47
+
48
+ return None