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.
- examples/baseline/banking77_baseline.py +204 -0
- examples/baseline/crafter_baseline.py +407 -0
- examples/baseline/pokemon_red_baseline.py +326 -0
- examples/baseline/simple_baseline.py +56 -0
- examples/baseline/warming_up_to_rl_baseline.py +239 -0
- examples/blog_posts/gepa/README.md +355 -0
- examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
- examples/blog_posts/gepa/configs/banking77_gepa_test.toml +82 -0
- examples/blog_posts/gepa/configs/banking77_mipro_local.toml +52 -0
- examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/hover_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/hover_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/pupa_gepa_local.toml +60 -0
- examples/blog_posts/gepa/configs/pupa_mipro_local.toml +54 -0
- examples/blog_posts/gepa/deploy_banking77_task_app.sh +41 -0
- examples/blog_posts/gepa/gepa_baseline.py +204 -0
- examples/blog_posts/gepa/query_prompts_example.py +97 -0
- examples/blog_posts/gepa/run_gepa_banking77.sh +87 -0
- examples/blog_posts/gepa/task_apps.py +105 -0
- examples/blog_posts/gepa/test_gepa_local.sh +67 -0
- examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
- examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
- examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +12 -10
- examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +1 -0
- examples/blog_posts/pokemon_vl/extract_images.py +239 -0
- examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
- examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
- examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
- examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
- examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
- examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
- examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
- examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
- examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -0
- examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +1 -1
- examples/blog_posts/warming_up_to_rl/configs/smoke_test.toml +75 -0
- examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +60 -10
- examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +1 -1
- examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
- examples/multi_step/configs/VERILOG_REWARDS.md +4 -0
- examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +4 -0
- examples/multi_step/configs/crafter_rl_outcome.toml +1 -0
- examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +1 -0
- examples/multi_step/configs/crafter_rl_stepwise_simple.toml +1 -0
- examples/rl/configs/rl_from_base_qwen17.toml +1 -0
- examples/swe/task_app/hosted/inference/openai_client.py +0 -34
- examples/swe/task_app/hosted/policy_routes.py +17 -0
- examples/swe/task_app/hosted/rollout.py +4 -2
- examples/task_apps/banking77/__init__.py +6 -0
- examples/task_apps/banking77/banking77_task_app.py +841 -0
- examples/task_apps/banking77/deploy_wrapper.py +46 -0
- examples/task_apps/crafter/CREATE_SFT_DATASET.md +4 -0
- examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +4 -0
- examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +4 -0
- examples/task_apps/crafter/task_app/grpo_crafter.py +24 -2
- examples/task_apps/crafter/task_app/synth_envs_hosted/hosted_app.py +49 -0
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +355 -58
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +68 -7
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +78 -21
- examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +194 -1
- examples/task_apps/gepa_benchmarks/__init__.py +7 -0
- examples/task_apps/gepa_benchmarks/common.py +260 -0
- examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
- examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
- examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
- examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
- examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +4 -0
- examples/task_apps/pokemon_red/task_app.py +254 -36
- examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +1 -0
- examples/warming_up_to_rl/task_app/grpo_crafter.py +53 -4
- examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +49 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +152 -41
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +31 -1
- examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +33 -3
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +67 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +1 -0
- synth_ai/api/train/builders.py +90 -1
- synth_ai/api/train/cli.py +396 -21
- synth_ai/api/train/config_finder.py +13 -2
- synth_ai/api/train/configs/__init__.py +15 -1
- synth_ai/api/train/configs/prompt_learning.py +442 -0
- synth_ai/api/train/configs/rl.py +29 -0
- synth_ai/api/train/task_app.py +1 -1
- synth_ai/api/train/validators.py +277 -0
- synth_ai/baseline/__init__.py +25 -0
- synth_ai/baseline/config.py +209 -0
- synth_ai/baseline/discovery.py +214 -0
- synth_ai/baseline/execution.py +146 -0
- synth_ai/cli/__init__.py +85 -17
- synth_ai/cli/__main__.py +0 -0
- synth_ai/cli/claude.py +70 -0
- synth_ai/cli/codex.py +84 -0
- synth_ai/cli/commands/__init__.py +1 -0
- synth_ai/cli/commands/baseline/__init__.py +12 -0
- synth_ai/cli/commands/baseline/core.py +637 -0
- synth_ai/cli/commands/baseline/list.py +93 -0
- synth_ai/cli/commands/eval/core.py +13 -10
- synth_ai/cli/commands/filter/core.py +53 -17
- synth_ai/cli/commands/help/core.py +0 -1
- synth_ai/cli/commands/smoke/__init__.py +7 -0
- synth_ai/cli/commands/smoke/core.py +1436 -0
- synth_ai/cli/commands/status/subcommands/pricing.py +22 -0
- synth_ai/cli/commands/status/subcommands/usage.py +203 -0
- synth_ai/cli/commands/train/judge_schemas.py +1 -0
- synth_ai/cli/commands/train/judge_validation.py +1 -0
- synth_ai/cli/commands/train/validation.py +0 -57
- synth_ai/cli/demo.py +35 -3
- synth_ai/cli/deploy/__init__.py +40 -25
- synth_ai/cli/deploy.py +162 -0
- synth_ai/cli/legacy_root_backup.py +14 -8
- synth_ai/cli/opencode.py +107 -0
- synth_ai/cli/root.py +9 -5
- synth_ai/cli/task_app_deploy.py +1 -1
- synth_ai/cli/task_apps.py +53 -53
- synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +7 -4
- synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
- synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
- synth_ai/judge_schemas.py +1 -0
- synth_ai/learning/__init__.py +10 -0
- synth_ai/learning/prompt_learning_client.py +276 -0
- synth_ai/learning/prompt_learning_types.py +184 -0
- synth_ai/pricing/__init__.py +2 -0
- synth_ai/pricing/model_pricing.py +57 -0
- synth_ai/streaming/handlers.py +53 -4
- synth_ai/streaming/streamer.py +19 -0
- synth_ai/task/apps/__init__.py +1 -0
- synth_ai/task/config.py +2 -0
- synth_ai/task/tracing_utils.py +25 -25
- synth_ai/task/validators.py +44 -8
- synth_ai/task_app_cfgs.py +21 -0
- synth_ai/tracing_v3/config.py +162 -19
- synth_ai/tracing_v3/constants.py +1 -1
- synth_ai/tracing_v3/db_config.py +24 -38
- synth_ai/tracing_v3/storage/config.py +47 -13
- synth_ai/tracing_v3/storage/factory.py +3 -3
- synth_ai/tracing_v3/turso/daemon.py +113 -11
- synth_ai/tracing_v3/turso/native_manager.py +92 -16
- synth_ai/types.py +8 -0
- synth_ai/urls.py +11 -0
- synth_ai/utils/__init__.py +30 -1
- synth_ai/utils/agents.py +74 -0
- synth_ai/utils/bin.py +39 -0
- synth_ai/utils/cli.py +149 -5
- synth_ai/utils/env.py +17 -17
- synth_ai/utils/json.py +72 -0
- synth_ai/utils/modal.py +283 -1
- synth_ai/utils/paths.py +48 -0
- synth_ai/utils/uvicorn.py +113 -0
- {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/METADATA +102 -4
- {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/RECORD +162 -88
- synth_ai/cli/commands/deploy/__init__.py +0 -23
- synth_ai/cli/commands/deploy/core.py +0 -614
- synth_ai/cli/commands/deploy/errors.py +0 -72
- synth_ai/cli/commands/deploy/validation.py +0 -11
- synth_ai/cli/deploy/core.py +0 -5
- synth_ai/cli/deploy/errors.py +0 -23
- synth_ai/cli/deploy/validation.py +0 -5
- {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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()
|
synth_ai/utils/paths.py
ADDED
|
@@ -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
|