synth-ai 0.2.9.dev4__py3-none-any.whl → 0.2.9.dev7__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 (157) hide show
  1. examples/common_old/backend.py +0 -1
  2. examples/crafter_debug_render.py +15 -6
  3. examples/evals_old/compare_models.py +1 -0
  4. examples/finetuning_old/_backup_synth_qwen/filter_traces_achievements.py +6 -2
  5. examples/finetuning_old/_backup_synth_qwen/react_agent_lm.py +4 -4
  6. examples/finetuning_old/_backup_synth_qwen/sft_kickoff.py +4 -3
  7. examples/finetuning_old/synth_qwen_v1/filter_traces_achievements.py +6 -2
  8. examples/finetuning_old/synth_qwen_v1/finetune.py +1 -1
  9. examples/finetuning_old/synth_qwen_v1/hello_ft_model.py +4 -4
  10. examples/finetuning_old/synth_qwen_v1/infer.py +1 -2
  11. examples/finetuning_old/synth_qwen_v1/poll.py +4 -2
  12. examples/finetuning_old/synth_qwen_v1/prepare_data.py +8 -8
  13. examples/finetuning_old/synth_qwen_v1/react_agent_lm.py +5 -4
  14. examples/finetuning_old/synth_qwen_v1/run_crafter_sft_job.py +11 -8
  15. examples/finetuning_old/synth_qwen_v1/run_ft_job.py +17 -12
  16. examples/finetuning_old/synth_qwen_v1/upload_data.py +1 -1
  17. examples/finetuning_old/synth_qwen_v1/util.py +7 -2
  18. examples/rl/configs/eval_base_qwen.toml +1 -1
  19. examples/rl/configs/rl_from_base_qwen17.toml +1 -1
  20. examples/rl/download_dataset.py +26 -10
  21. examples/rl/run_eval.py +17 -15
  22. examples/rl/run_rl_and_save.py +24 -7
  23. examples/rl/task_app/math_single_step.py +128 -11
  24. examples/rl/task_app/math_task_app.py +11 -3
  25. examples/rl_old/task_app.py +222 -53
  26. examples/warming_up_to_rl/analyze_trace_db.py +7 -5
  27. examples/warming_up_to_rl/export_trace_sft.py +141 -16
  28. examples/warming_up_to_rl/groq_test.py +11 -4
  29. examples/warming_up_to_rl/manage_secrets.py +15 -6
  30. examples/warming_up_to_rl/readme.md +9 -2
  31. examples/warming_up_to_rl/run_eval.py +108 -30
  32. examples/warming_up_to_rl/run_fft_and_save.py +128 -52
  33. examples/warming_up_to_rl/run_local_rollout.py +87 -36
  34. examples/warming_up_to_rl/run_local_rollout_modal.py +113 -25
  35. examples/warming_up_to_rl/run_local_rollout_parallel.py +80 -16
  36. examples/warming_up_to_rl/run_local_rollout_traced.py +125 -20
  37. examples/warming_up_to_rl/run_rl_and_save.py +31 -7
  38. examples/warming_up_to_rl/run_rollout_remote.py +37 -10
  39. examples/warming_up_to_rl/task_app/grpo_crafter.py +90 -27
  40. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +9 -27
  41. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +46 -108
  42. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -1
  43. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +1 -1
  44. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -1
  45. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +50 -17
  46. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +35 -21
  47. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +8 -4
  48. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +29 -26
  49. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +1 -1
  50. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +17 -13
  51. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +1 -1
  52. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +106 -63
  53. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +82 -84
  54. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +76 -59
  55. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +1 -1
  56. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +43 -49
  57. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +5 -15
  58. synth_ai/__init__.py +1 -0
  59. synth_ai/api/train/builders.py +34 -10
  60. synth_ai/api/train/cli.py +172 -32
  61. synth_ai/api/train/config_finder.py +59 -4
  62. synth_ai/api/train/env_resolver.py +32 -14
  63. synth_ai/api/train/pollers.py +11 -3
  64. synth_ai/api/train/task_app.py +4 -1
  65. synth_ai/api/train/utils.py +20 -4
  66. synth_ai/cli/__init__.py +11 -4
  67. synth_ai/cli/balance.py +1 -1
  68. synth_ai/cli/demo.py +19 -5
  69. synth_ai/cli/rl_demo.py +75 -16
  70. synth_ai/cli/root.py +116 -37
  71. synth_ai/cli/task_apps.py +1286 -170
  72. synth_ai/cli/traces.py +1 -0
  73. synth_ai/cli/turso.py +73 -0
  74. synth_ai/core/experiment.py +0 -2
  75. synth_ai/demo_registry.py +67 -30
  76. synth_ai/demos/core/cli.py +493 -164
  77. synth_ai/demos/demo_task_apps/core.py +50 -6
  78. synth_ai/demos/demo_task_apps/crafter/configs/crafter_fft_4b.toml +2 -3
  79. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +36 -28
  80. synth_ai/demos/demo_task_apps/math/_common.py +1 -2
  81. synth_ai/demos/demo_task_apps/math/deploy_modal.py +0 -2
  82. synth_ai/demos/demo_task_apps/math/modal_task_app.py +168 -65
  83. synth_ai/demos/demo_task_apps/math/task_app_entry.py +0 -1
  84. synth_ai/environments/examples/bandit/engine.py +12 -4
  85. synth_ai/environments/examples/bandit/taskset.py +4 -4
  86. synth_ai/environments/reproducibility/tree.py +3 -1
  87. synth_ai/environments/service/core_routes.py +6 -2
  88. synth_ai/evals/base.py +0 -2
  89. synth_ai/experimental/synth_oss.py +11 -12
  90. synth_ai/handshake.py +3 -1
  91. synth_ai/http_client.py +31 -7
  92. synth_ai/inference/__init__.py +0 -2
  93. synth_ai/inference/client.py +8 -4
  94. synth_ai/jobs/client.py +40 -10
  95. synth_ai/learning/client.py +33 -8
  96. synth_ai/learning/config.py +0 -2
  97. synth_ai/learning/constants.py +0 -2
  98. synth_ai/learning/ft_client.py +6 -3
  99. synth_ai/learning/health.py +9 -2
  100. synth_ai/learning/jobs.py +17 -5
  101. synth_ai/learning/prompts/hello_world_in_context_injection_ex.py +1 -3
  102. synth_ai/learning/prompts/random_search.py +4 -1
  103. synth_ai/learning/prompts/run_random_search_banking77.py +6 -1
  104. synth_ai/learning/rl_client.py +42 -14
  105. synth_ai/learning/sse.py +0 -2
  106. synth_ai/learning/validators.py +6 -2
  107. synth_ai/lm/caching/ephemeral.py +1 -3
  108. synth_ai/lm/core/exceptions.py +0 -2
  109. synth_ai/lm/core/main.py +13 -1
  110. synth_ai/lm/core/synth_models.py +0 -1
  111. synth_ai/lm/core/vendor_clients.py +4 -2
  112. synth_ai/lm/overrides.py +2 -2
  113. synth_ai/lm/vendors/core/anthropic_api.py +7 -7
  114. synth_ai/lm/vendors/core/openai_api.py +2 -0
  115. synth_ai/lm/vendors/openai_standard.py +3 -1
  116. synth_ai/lm/vendors/openai_standard_responses.py +6 -3
  117. synth_ai/lm/vendors/supported/custom_endpoint.py +1 -3
  118. synth_ai/lm/vendors/synth_client.py +37 -10
  119. synth_ai/rl/__init__.py +0 -1
  120. synth_ai/rl/contracts.py +0 -2
  121. synth_ai/rl/env_keys.py +6 -1
  122. synth_ai/task/__init__.py +1 -0
  123. synth_ai/task/apps/__init__.py +11 -11
  124. synth_ai/task/auth.py +29 -17
  125. synth_ai/task/client.py +3 -1
  126. synth_ai/task/contracts.py +1 -0
  127. synth_ai/task/datasets.py +3 -1
  128. synth_ai/task/errors.py +3 -2
  129. synth_ai/task/health.py +0 -2
  130. synth_ai/task/json.py +0 -1
  131. synth_ai/task/proxy.py +2 -5
  132. synth_ai/task/rubrics.py +9 -3
  133. synth_ai/task/server.py +31 -5
  134. synth_ai/task/tracing_utils.py +8 -3
  135. synth_ai/task/validators.py +0 -1
  136. synth_ai/task/vendors.py +0 -1
  137. synth_ai/tracing_v3/db_config.py +26 -1
  138. synth_ai/tracing_v3/decorators.py +1 -0
  139. synth_ai/tracing_v3/examples/basic_usage.py +3 -2
  140. synth_ai/tracing_v3/hooks.py +2 -0
  141. synth_ai/tracing_v3/replica_sync.py +1 -0
  142. synth_ai/tracing_v3/session_tracer.py +24 -3
  143. synth_ai/tracing_v3/storage/base.py +4 -1
  144. synth_ai/tracing_v3/storage/factory.py +0 -1
  145. synth_ai/tracing_v3/turso/manager.py +102 -38
  146. synth_ai/tracing_v3/turso/models.py +4 -1
  147. synth_ai/tracing_v3/utils.py +1 -0
  148. synth_ai/v0/tracing/upload.py +32 -135
  149. {synth_ai-0.2.9.dev4.dist-info → synth_ai-0.2.9.dev7.dist-info}/METADATA +1 -1
  150. {synth_ai-0.2.9.dev4.dist-info → synth_ai-0.2.9.dev7.dist-info}/RECORD +154 -156
  151. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_stepwise_rewards.py +0 -58
  152. synth_ai/environments/examples/sokoban/units/astar_common.py +0 -95
  153. synth_ai/install_sqld.sh +0 -40
  154. {synth_ai-0.2.9.dev4.dist-info → synth_ai-0.2.9.dev7.dist-info}/WHEEL +0 -0
  155. {synth_ai-0.2.9.dev4.dist-info → synth_ai-0.2.9.dev7.dist-info}/entry_points.txt +0 -0
  156. {synth_ai-0.2.9.dev4.dist-info → synth_ai-0.2.9.dev7.dist-info}/licenses/LICENSE +0 -0
  157. {synth_ai-0.2.9.dev4.dist-info → synth_ai-0.2.9.dev7.dist-info}/top_level.txt +0 -0
@@ -45,7 +45,17 @@ def _is_modal_public_url(u: str) -> bool:
45
45
 
46
46
 
47
47
  def cmd_setup(_args: argparse.Namespace) -> int:
48
- # 1) Always perform SDK handshake and overwrite .env with returned keys
48
+ # Change to demo directory if stored
49
+ demo_dir = demo_core.load_demo_dir()
50
+ if demo_dir and os.path.isdir(demo_dir):
51
+ os.chdir(demo_dir)
52
+ print(f"Using demo directory: {demo_dir}")
53
+
54
+ # 1) Try to fetch keys from frontend; fall back to manual input if fetch fails
55
+ synth_key = ""
56
+ rl_env_key = ""
57
+ org_name = "this organization"
58
+
49
59
  try:
50
60
  print("\n⏳ Connecting SDK to your browser session…")
51
61
  res = run_handshake()
@@ -54,25 +64,52 @@ def cmd_setup(_args: argparse.Namespace) -> int:
54
64
  keys = res.get("keys") or {}
55
65
  synth_key = str(keys.get("synth") or "").strip()
56
66
  rl_env_key = str(keys.get("rl_env") or "").strip()
57
- if not synth_key or not rl_env_key:
58
- raise HandshakeError("handshake returned missing keys")
59
- # Overwrite .env with the latest values from the account/org
60
- demo_core.persist_dotenv_values({
67
+ org_name = org.get("name") or "this organization"
68
+ print(f" Connected to {org_name}!")
69
+ except (HandshakeError, Exception) as e:
70
+ print(f"⚠️ Failed to fetch keys from frontend: {e}")
71
+ print("Falling back to manual entry...")
72
+
73
+ # Prompt for manual input if any key is missing
74
+ if not synth_key:
75
+ try:
76
+ synth_key = input(
77
+ "Failed to fetch your Synth API key. Please enter your Synth API key here:\n> "
78
+ ).strip()
79
+ except (EOFError, KeyboardInterrupt):
80
+ print("\nSetup cancelled.")
81
+ return 1
82
+ if not synth_key:
83
+ print("Synth API key is required.")
84
+ return 1
85
+
86
+ if not rl_env_key:
87
+ try:
88
+ rl_env_key = input(
89
+ "Failed to fetch your RL Environment API key. Please enter your RL Environment API key here:\n> "
90
+ ).strip()
91
+ except (EOFError, KeyboardInterrupt):
92
+ print("\nSetup cancelled.")
93
+ return 1
94
+ if not rl_env_key:
95
+ print("RL Environment API key is required.")
96
+ return 1
97
+
98
+ # Persist both keys to .env
99
+ dotenv_path = demo_core.persist_dotenv_values(
100
+ {
61
101
  "SYNTH_API_KEY": synth_key,
62
102
  "ENVIRONMENT_API_KEY": rl_env_key,
63
- })
64
- org_name = (org.get("name") or "this organization")
65
- print(f"✅ Connected to {org_name}!")
66
- except HandshakeError as e:
67
- print(f"Handshake failed: {e}")
68
- return 1
69
- except Exception as e:
70
- print(f"Unexpected handshake error: {e}")
71
- return 1
103
+ }
104
+ )
105
+
106
+ # Store .env path for subsequent commands
107
+ demo_core.persist_env_file_path(dotenv_path)
72
108
 
73
109
  # 2) Reload env after handshake to pick up values from .env (suppress env prints)
74
110
  import io
75
111
  import contextlib
112
+
76
113
  _buf = io.StringIO()
77
114
  with contextlib.redirect_stdout(_buf):
78
115
  env = demo_core.load_env()
@@ -95,16 +132,18 @@ def cmd_setup(_args: argparse.Namespace) -> int:
95
132
  needs_lookup = True
96
133
  if not needs_lookup:
97
134
  return
98
- code, out = _popen_capture([
99
- "uv",
100
- "run",
101
- "python",
102
- "-m",
103
- "modal",
104
- "app",
105
- "url",
106
- env.task_app_name,
107
- ])
135
+ code, out = _popen_capture(
136
+ [
137
+ "uv",
138
+ "run",
139
+ "python",
140
+ "-m",
141
+ "modal",
142
+ "app",
143
+ "url",
144
+ env.task_app_name,
145
+ ]
146
+ )
108
147
  if code != 0 or not out:
109
148
  return
110
149
  new_url = ""
@@ -137,12 +176,15 @@ def cmd_setup(_args: argparse.Namespace) -> int:
137
176
  ok_backend = False
138
177
  ok_task = False
139
178
  if env.dev_backend_url:
140
- api = env.dev_backend_url.rstrip("/") + ("" if env.dev_backend_url.endswith("/api") else "/api")
179
+ api = env.dev_backend_url.rstrip("/") + (
180
+ "" if env.dev_backend_url.endswith("/api") else "/api"
181
+ )
141
182
  ok_backend = demo_core.assert_http_ok(api + "/health", method="GET")
142
183
  # Intentionally suppress backend health print for concise output
143
184
  if env.task_app_base_url:
144
- ok_task = demo_core.assert_http_ok(env.task_app_base_url.rstrip("/") + "/health", method="GET") or \
145
- demo_core.assert_http_ok(env.task_app_base_url.rstrip("/"), method="GET")
185
+ ok_task = demo_core.assert_http_ok(
186
+ env.task_app_base_url.rstrip("/") + "/health", method="GET"
187
+ ) or demo_core.assert_http_ok(env.task_app_base_url.rstrip("/"), method="GET")
146
188
  # Intentionally suppress task app health print
147
189
  else:
148
190
  print("\nSet your task app URL by running:\nuvx synth-ai rl_demo deploy\n")
@@ -150,13 +192,19 @@ def cmd_setup(_args: argparse.Namespace) -> int:
150
192
  # Omit uv version print to keep output concise
151
193
 
152
194
  # Keep exit code neutral; not all checks are critical for pairing
195
+ print(f"\nKeys saved to: {dotenv_path}")
153
196
  return 0
154
197
 
155
198
 
156
- def _popen_capture(cmd: list[str], cwd: str | None = None, env: dict | None = None) -> tuple[int, str]:
199
+ def _popen_capture(
200
+ cmd: list[str], cwd: str | None = None, env: dict | None = None
201
+ ) -> tuple[int, str]:
157
202
  import subprocess
203
+
158
204
  try:
159
- proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
205
+ proc = subprocess.Popen(
206
+ cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
207
+ )
160
208
  out, _ = proc.communicate()
161
209
  return int(proc.returncode or 0), out or ""
162
210
  except Exception as e:
@@ -200,7 +248,9 @@ def _popen_stream(cmd: list[str], cwd: str | None = None, env: dict | None = Non
200
248
  return int(proc.returncode or 0)
201
249
 
202
250
 
203
- def _popen_stream_capture(cmd: list[str], cwd: str | None = None, env: dict | None = None) -> tuple[int, str]:
251
+ def _popen_stream_capture(
252
+ cmd: list[str], cwd: str | None = None, env: dict | None = None
253
+ ) -> tuple[int, str]:
204
254
  """Stream subprocess output to stdout and also capture it into a buffer."""
205
255
  import subprocess
206
256
  import threading
@@ -251,7 +301,19 @@ def _find_asgi_apps(root: Path) -> list[Path]:
251
301
  - "@modal.asgi_app()"
252
302
  """
253
303
  results: list[Path] = []
254
- skip_dirs = {".git", ".hg", ".svn", "node_modules", "dist", "build", "__pycache__", ".ruff_cache", ".mypy_cache", "venv", ".venv"}
304
+ skip_dirs = {
305
+ ".git",
306
+ ".hg",
307
+ ".svn",
308
+ "node_modules",
309
+ "dist",
310
+ "build",
311
+ "__pycache__",
312
+ ".ruff_cache",
313
+ ".mypy_cache",
314
+ "venv",
315
+ ".venv",
316
+ }
255
317
  for dirpath, dirnames, filenames in os.walk(root):
256
318
  dirnames[:] = [d for d in dirnames if d not in skip_dirs]
257
319
  for name in filenames:
@@ -265,16 +327,20 @@ def _find_asgi_apps(root: Path) -> list[Path]:
265
327
  results.append(path)
266
328
  except Exception:
267
329
  continue
330
+
268
331
  # Stable order: prioritize files under synth_demo/ first, then alphabetical
269
332
  def _priority(p: Path) -> tuple[int, str]:
270
333
  rel = str(p.resolve())
271
334
  in_demo = "/synth_demo/" in rel or rel.endswith("/synth_demo/task_app.py")
272
335
  return (0 if in_demo else 1, rel)
336
+
273
337
  results.sort(key=_priority)
274
338
  return results
275
339
 
276
340
 
277
- def _prompt_value(label: str, default: str | int | float, cast: Callable[[str], Any] | None = None) -> Any:
341
+ def _prompt_value(
342
+ label: str, default: str | int | float, cast: Callable[[str], Any] | None = None
343
+ ) -> Any:
278
344
  prompt = f"{label} [{default}]: "
279
345
  try:
280
346
  raw = input(prompt).strip()
@@ -293,7 +359,19 @@ def _prompt_value(label: str, default: str | int | float, cast: Callable[[str],
293
359
 
294
360
  def _find_vllm_tomls(root: Path) -> list[Path]:
295
361
  results: list[Path] = []
296
- skip_dirs = {".git", ".hg", ".svn", "node_modules", "dist", "build", "__pycache__", ".ruff_cache", ".mypy_cache", "venv", ".venv"}
362
+ skip_dirs = {
363
+ ".git",
364
+ ".hg",
365
+ ".svn",
366
+ "node_modules",
367
+ "dist",
368
+ "build",
369
+ "__pycache__",
370
+ ".ruff_cache",
371
+ ".mypy_cache",
372
+ "venv",
373
+ ".venv",
374
+ }
297
375
  for dirpath, dirnames, filenames in os.walk(root):
298
376
  dirnames[:] = [d for d in dirnames if d not in skip_dirs]
299
377
  for name in filenames:
@@ -313,7 +391,9 @@ def _create_new_config(env: DemoEnv) -> str:
313
391
  default_path = os.path.join(os.getcwd(), "demo_config.toml")
314
392
  while True:
315
393
  try:
316
- destination = input(f"Path to save new config [{default_path}]: ").strip() or default_path
394
+ destination = (
395
+ input(f"Path to save new config [{default_path}]: ").strip() or default_path
396
+ )
317
397
  except Exception:
318
398
  destination = default_path
319
399
  destination = os.path.abspath(destination)
@@ -322,7 +402,9 @@ def _create_new_config(env: DemoEnv) -> str:
322
402
  continue
323
403
  if os.path.exists(destination):
324
404
  try:
325
- overwrite = input(f"{destination} exists. Overwrite? [y/N]: ").strip().lower() or "n"
405
+ overwrite = (
406
+ input(f"{destination} exists. Overwrite? [y/N]: ").strip().lower() or "n"
407
+ )
326
408
  except Exception:
327
409
  overwrite = "n"
328
410
  if not overwrite.startswith("y"):
@@ -334,7 +416,9 @@ def _create_new_config(env: DemoEnv) -> str:
334
416
  model_name = _prompt_value("Model name", "Qwen/Qwen3-0.6B")
335
417
  compute_gpu_type = _prompt_value("Compute GPU type", "H100")
336
418
  compute_gpu_count = _prompt_value("Compute GPU count", 4, int)
337
- topology_gpu_type = _prompt_value("Topology GPU type", f"{compute_gpu_type}:{compute_gpu_count}")
419
+ topology_gpu_type = _prompt_value(
420
+ "Topology GPU type", f"{compute_gpu_type}:{compute_gpu_count}"
421
+ )
338
422
  gpus_for_vllm = _prompt_value("Topology gpus_for_vllm", 2, int)
339
423
  gpus_for_training = _prompt_value("Topology gpus_for_training", 1, int)
340
424
  tensor_parallel = _prompt_value("Topology tensor_parallel", 2, int)
@@ -352,8 +436,9 @@ def _create_new_config(env: DemoEnv) -> str:
352
436
  task_url_default = env.task_app_base_url or ""
353
437
  services_task_url = _prompt_value("services.task_url", task_url_default)
354
438
 
355
- template = textwrap.dedent(
356
- f"""\
439
+ template = (
440
+ textwrap.dedent(
441
+ f"""\
357
442
  # Crafter online RL training configuration (research local copy)
358
443
 
359
444
  [model]
@@ -495,7 +580,9 @@ def _create_new_config(env: DemoEnv) -> str:
495
580
  [services]
496
581
  task_url = \"{services_task_url}\"
497
582
  """
498
- ).strip() + "\n"
583
+ ).strip()
584
+ + "\n"
585
+ )
499
586
 
500
587
  with open(destination, "w", encoding="utf-8") as fh:
501
588
  fh.write(template)
@@ -514,7 +601,11 @@ def _select_or_create_config(explicit: str | None, env: DemoEnv) -> str:
514
601
  discovered = _find_vllm_tomls(search_root)
515
602
 
516
603
  extras: list[Path] = []
517
- packaged = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml")))
604
+ packaged = Path(
605
+ os.path.abspath(
606
+ os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml")
607
+ )
608
+ )
518
609
  extras.append(packaged)
519
610
  home_cfg = Path(os.path.expanduser("~/.synth-ai/demo_config.toml"))
520
611
  extras.append(home_cfg)
@@ -560,29 +651,36 @@ def _ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoE
560
651
 
561
652
  env_key = (env.env_api_key or "").strip()
562
653
  if not env_key:
563
- raise RuntimeError(f"[{label}] ENVIRONMENT_API_KEY missing. Run `uvx synth-ai rl_demo deploy` first.")
654
+ raise RuntimeError(
655
+ f"[{label}] ENVIRONMENT_API_KEY missing. Run `uvx synth-ai rl_demo deploy` first."
656
+ )
564
657
 
565
658
  task_url = env.task_app_base_url
566
659
  if not task_url or not _is_modal_public_url(task_url):
567
660
  resolved = ""
568
661
  if env.task_app_name:
569
662
  try:
570
- choice = input(
571
- f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: "
572
- ).strip().lower() or "y"
663
+ choice = (
664
+ input(f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: ")
665
+ .strip()
666
+ .lower()
667
+ or "y"
668
+ )
573
669
  except Exception:
574
670
  choice = "y"
575
671
  if choice.startswith("y"):
576
- code, out = _popen_capture([
577
- "uv",
578
- "run",
579
- "python",
580
- "-m",
581
- "modal",
582
- "app",
583
- "url",
584
- env.task_app_name,
585
- ])
672
+ code, out = _popen_capture(
673
+ [
674
+ "uv",
675
+ "run",
676
+ "python",
677
+ "-m",
678
+ "modal",
679
+ "app",
680
+ "url",
681
+ env.task_app_name,
682
+ ]
683
+ )
586
684
  if code == 0 and out:
587
685
  for tok in out.split():
588
686
  if _is_modal_public_url(tok):
@@ -591,7 +689,9 @@ def _ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoE
591
689
  if not resolved:
592
690
  print(f"[{label}] Task app URL not configured or not a valid Modal public URL.")
593
691
  print("Examples: https://<app-name>-fastapi-app.modal.run")
594
- entered = input("Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: ").strip()
692
+ entered = input(
693
+ "Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: "
694
+ ).strip()
595
695
  if not entered or not _is_modal_public_url(entered):
596
696
  raise RuntimeError(f"[{label}] Valid Task App URL is required.")
597
697
  task_url = entered.rstrip("/")
@@ -608,11 +708,13 @@ def _ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoE
608
708
  demo_core.persist_task_url(task_url, name=app_name)
609
709
 
610
710
  demo_core.persist_task_url(task_url, name=app_name)
611
- demo_core.persist_dotenv_values({
612
- "TASK_APP_BASE_URL": task_url,
613
- "TASK_APP_NAME": app_name,
614
- "TASK_APP_SECRET_NAME": DEFAULT_TASK_APP_SECRET_NAME,
615
- })
711
+ demo_core.persist_dotenv_values(
712
+ {
713
+ "TASK_APP_BASE_URL": task_url,
714
+ "TASK_APP_NAME": app_name,
715
+ "TASK_APP_SECRET_NAME": DEFAULT_TASK_APP_SECRET_NAME,
716
+ }
717
+ )
616
718
 
617
719
  if synth_key:
618
720
  os.environ["SYNTH_API_KEY"] = synth_key
@@ -667,6 +769,12 @@ def _ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoE
667
769
 
668
770
 
669
771
  def cmd_deploy(args: argparse.Namespace) -> int:
772
+ # Change to demo directory if stored
773
+ demo_dir = demo_core.load_demo_dir()
774
+ if demo_dir and os.path.isdir(demo_dir):
775
+ os.chdir(demo_dir)
776
+ print(f"Using demo directory: {demo_dir}")
777
+
670
778
  env = demo_core.load_env()
671
779
  os.environ["TASK_APP_SECRET_NAME"] = DEFAULT_TASK_APP_SECRET_NAME
672
780
  cwd_env_path = os.path.join(os.getcwd(), ".env")
@@ -677,12 +785,22 @@ def cmd_deploy(args: argparse.Namespace) -> int:
677
785
  if args.local:
678
786
  print("Starting local Task App…")
679
787
  import subprocess
680
- subprocess.Popen([sys.executable, "-c", "from synth_ai.demos.demo_task_apps.math.app import run; run()"],
681
- stdout=sys.stdout, stderr=sys.stderr)
788
+
789
+ subprocess.Popen(
790
+ [
791
+ sys.executable,
792
+ "-c",
793
+ "from synth_ai.demos.demo_task_apps.math.app import run; run()",
794
+ ],
795
+ stdout=sys.stdout,
796
+ stderr=sys.stderr,
797
+ )
682
798
  target = "http://127.0.0.1:8080"
683
799
  app_name = ""
684
800
  for _ in range(30):
685
- if demo_core.assert_http_ok(target + "/health", method="GET") or demo_core.assert_http_ok(target, method="GET"):
801
+ if demo_core.assert_http_ok(
802
+ target + "/health", method="GET"
803
+ ) or demo_core.assert_http_ok(target, method="GET"):
686
804
  url = target
687
805
  break
688
806
  time.sleep(1)
@@ -707,7 +825,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
707
825
  rel = os.path.relpath(str(pth), os.getcwd())
708
826
  print(f" [{idx}] {rel}")
709
827
  try:
710
- sel = input(f"Enter choice [1-{len(found)}] (default 1): ").strip() or "1"
828
+ sel = (
829
+ input(f"Enter choice [1-{len(found)}] (default 1): ").strip() or "1"
830
+ )
711
831
  except Exception:
712
832
  sel = "1"
713
833
  try:
@@ -719,6 +839,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:
719
839
  if not app_path and args.script:
720
840
  # Legacy script fallback if user supplied --script explicitly
721
841
  from synth_ai.demos.demo_task_apps.math.deploy_modal import deploy as modal_deploy
842
+
722
843
  url = modal_deploy(script_path=args.script, env_api_key=env.env_api_key)
723
844
  if args.name:
724
845
  app_name = args.name
@@ -750,9 +871,12 @@ def cmd_deploy(args: argparse.Namespace) -> int:
750
871
  env_key: str | None = existing_env_key or None
751
872
  if existing_env_key:
752
873
  try:
753
- reuse_choice = input(
754
- "Use existing ENVIRONMENT_API_KEY from state/.env? [Y/n]: "
755
- ).strip().lower() or "y"
874
+ reuse_choice = (
875
+ input("Use existing ENVIRONMENT_API_KEY from state/.env? [Y/n]: ")
876
+ .strip()
877
+ .lower()
878
+ or "y"
879
+ )
756
880
  except Exception:
757
881
  reuse_choice = "y"
758
882
  if not reuse_choice.startswith("y"):
@@ -770,35 +894,50 @@ def cmd_deploy(args: argparse.Namespace) -> int:
770
894
  print("[deploy] Minted new ENVIRONMENT_API_KEY")
771
895
  elif env_key:
772
896
  os.environ["ENVIRONMENT_API_KEY"] = env_key
773
-
897
+
774
898
  # Optionally upload the new key to the backend using sealed box helper
775
899
  backend_base = (env.dev_backend_url or "").rstrip("/")
776
- synth_key = (env.synth_api_key or os.environ.get("SYNTH_API_KEY") or local_env.get("SYNTH_API_KEY") or "").strip()
900
+ synth_key = (
901
+ env.synth_api_key
902
+ or os.environ.get("SYNTH_API_KEY")
903
+ or local_env.get("SYNTH_API_KEY")
904
+ or ""
905
+ ).strip()
777
906
  if backend_base and synth_key:
778
- # Pass a base WITHOUT trailing /api to setup_environment_api_key,
779
- # since it appends /api/v1/... internally.
780
- non_api_base = backend_base[:-4] if backend_base.endswith("/api") else backend_base
907
+ # Pass a base WITHOUT trailing /api to setup_environment_api_key,
908
+ # since it appends /api/v1/... internally.
909
+ non_api_base = (
910
+ backend_base[:-4] if backend_base.endswith("/api") else backend_base
911
+ )
912
+ try:
913
+ choice = (
914
+ input(f"Upload ENVIRONMENT_API_KEY to backend {non_api_base}? [Y/n]: ")
915
+ .strip()
916
+ .lower()
917
+ or "y"
918
+ )
919
+ except Exception:
920
+ choice = "y"
921
+ if choice.startswith("y"):
781
922
  try:
782
- choice = input(
783
- f"Upload ENVIRONMENT_API_KEY to backend {non_api_base}? [Y/n]: "
784
- ).strip().lower() or "y"
785
- except Exception:
786
- choice = "y"
787
- if choice.startswith("y"):
788
- try:
789
- print(f"[deploy] Uploading ENVIRONMENT_API_KEY to {non_api_base} …")
790
- from synth_ai.rl.env_keys import setup_environment_api_key
791
-
792
- setup_environment_api_key(non_api_base, synth_key, token=env_key)
793
- print("[deploy] Backend sealed-box upload complete.")
794
- except Exception as upload_err:
795
- print(f"[deploy] Failed to upload ENVIRONMENT_API_KEY: {upload_err}")
796
- print(
797
- "Hint: run `uvx python -c \"from synth_ai.rl.env_keys import setup_environment_api_key as s;"
798
- " s('<backend>', '<synth_api_key>')\"` once the backend is reachable."
799
- )
800
-
801
- synth_key = (env.synth_api_key or os.environ.get("SYNTH_API_KEY") or local_env.get("SYNTH_API_KEY") or "").strip()
923
+ print(f"[deploy] Uploading ENVIRONMENT_API_KEY to {non_api_base} …")
924
+ from synth_ai.rl.env_keys import setup_environment_api_key
925
+
926
+ setup_environment_api_key(non_api_base, synth_key, token=env_key)
927
+ print("[deploy] Backend sealed-box upload complete.")
928
+ except Exception as upload_err:
929
+ print(f"[deploy] Failed to upload ENVIRONMENT_API_KEY: {upload_err}")
930
+ print(
931
+ 'Hint: run `uvx python -c "from synth_ai.rl.env_keys import setup_environment_api_key as s;'
932
+ " s('<backend>', '<synth_api_key>')\"` once the backend is reachable."
933
+ )
934
+
935
+ synth_key = (
936
+ env.synth_api_key
937
+ or os.environ.get("SYNTH_API_KEY")
938
+ or local_env.get("SYNTH_API_KEY")
939
+ or ""
940
+ ).strip()
802
941
  if not synth_key:
803
942
  synth_key = input("Enter SYNTH_API_KEY for deployment (required): ").strip()
804
943
  if not synth_key:
@@ -809,7 +948,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
809
948
  env.synth_api_key = synth_key
810
949
  os.environ["SYNTH_API_KEY"] = synth_key
811
950
 
812
- openai_key = (os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or "").strip()
951
+ openai_key = (
952
+ os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or ""
953
+ ).strip()
813
954
  if not openai_key:
814
955
  openai_key = input(
815
956
  "Enter your OpenAI API key, found at https://platform.openai.com/api-keys\n> "
@@ -821,8 +962,20 @@ def cmd_deploy(args: argparse.Namespace) -> int:
821
962
  local_env["OPENAI_API_KEY"] = openai_key
822
963
  os.environ["OPENAI_API_KEY"] = openai_key
823
964
 
824
- deploy_cmd = ["uv", "run", "python", "-m", "modal", "deploy", "--name", name_in, app_path]
825
- print("\nStreaming Modal build/deploy logs (this can take several minutes on first run)…\n")
965
+ deploy_cmd = [
966
+ "uv",
967
+ "run",
968
+ "python",
969
+ "-m",
970
+ "modal",
971
+ "deploy",
972
+ "--name",
973
+ name_in,
974
+ app_path,
975
+ ]
976
+ print(
977
+ "\nStreaming Modal build/deploy logs (this can take several minutes on first run)…\n"
978
+ )
826
979
  code, deploy_logs = _popen_stream_capture(deploy_cmd)
827
980
  if code != 0:
828
981
  raise RuntimeError(f"modal deploy failed (exit {code})")
@@ -830,6 +983,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:
830
983
  if not url:
831
984
  try:
832
985
  import re as _re
986
+
833
987
  m_all = _re.findall(r"https?://[^\s]+\.modal\.run", deploy_logs or "")
834
988
  if m_all:
835
989
  url = m_all[-1].strip().rstrip("/")
@@ -844,7 +998,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
844
998
  break
845
999
  # Fallback: try reading recent Modal logs for the app to find a URL line
846
1000
  if not url:
847
- code3, out3 = _popen_capture(["uv", "run", "python", "-m", "modal", "app", "list"])
1001
+ code3, out3 = _popen_capture(
1002
+ ["uv", "run", "python", "-m", "modal", "app", "list"]
1003
+ )
848
1004
  if code3 == 0 and out3:
849
1005
  for line in out3.splitlines():
850
1006
  if name_in in line:
@@ -857,7 +1013,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
857
1013
  # Prompt user if still no valid URL
858
1014
  if not url:
859
1015
  print("\nCould not auto-detect a public Modal URL for the app.")
860
- entered = input("Enter the Modal public URL (must contain '.modal.run'), or press Enter to abort: ").strip()
1016
+ entered = input(
1017
+ "Enter the Modal public URL (must contain '.modal.run'), or press Enter to abort: "
1018
+ ).strip()
861
1019
  if entered and _is_modal_public_url(entered):
862
1020
  url = entered.rstrip("/")
863
1021
  if not url:
@@ -885,8 +1043,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
885
1043
  print(f"Deploy error: {e}")
886
1044
  return 2
887
1045
 
888
-
889
- print("`rl_demo configure` prepares environment and secrets; `synth-ai run` now handles launches.")
1046
+ print(
1047
+ "`rl_demo configure` prepares environment and secrets; `synth-ai run` now handles launches."
1048
+ )
890
1049
  env = demo_core.load_env()
891
1050
  synth_key = (env.synth_api_key or "").strip()
892
1051
  if not synth_key:
@@ -919,40 +1078,62 @@ def cmd_deploy(args: argparse.Namespace) -> int:
919
1078
 
920
1079
 
921
1080
  def _ensure_modal_installed() -> None:
922
- """Install the modal package if it is not already available."""
1081
+ """Install the modal package if it is not already available and check authentication."""
923
1082
 
1083
+ # Check if modal is installed
1084
+ modal_installed = False
924
1085
  try:
925
1086
  import importlib.util as _iu
926
1087
 
927
1088
  if _iu.find_spec("modal") is not None:
928
- print("modal package found")
929
- return
1089
+ modal_installed = True
930
1090
  except Exception:
931
1091
  pass
932
1092
 
933
- print("modal not found; installing…")
934
- try:
935
- if shutil.which("uv"):
936
- code, out = _popen_capture(["uv", "pip", "install", "modal>=1.1.4"])
937
- else:
938
- code, out = _popen_capture([sys.executable, "-m", "pip", "install", "modal>=1.1.4"])
939
- if code != 0:
940
- print(out)
941
- print("Failed to install modal; continuing may fail.")
942
- else:
943
- print("modal installed successfully.")
944
- except Exception as exc:
945
- print(f"modal install error: {exc}")
1093
+ # Install modal if needed
1094
+ if not modal_installed:
1095
+ print("modal not found; installing…")
1096
+ try:
1097
+ if shutil.which("uv"):
1098
+ code, out = _popen_capture(["uv", "pip", "install", "modal>=1.1.4"])
1099
+ else:
1100
+ code, out = _popen_capture([sys.executable, "-m", "pip", "install", "modal>=1.1.4"])
1101
+ if code != 0:
1102
+ print(out)
1103
+ print("Failed to install modal; continuing may fail.")
1104
+ return
1105
+ else:
1106
+ print("✓ modal installed successfully")
1107
+ modal_installed = True
1108
+ except Exception as exc:
1109
+ print(f"modal install error: {exc}")
1110
+ return
946
1111
 
947
- try:
948
- import importlib.util as _iu
1112
+ # Verify modal is importable
1113
+ if modal_installed:
1114
+ try:
1115
+ import importlib.util as _iu
949
1116
 
950
- if _iu.find_spec("modal") is None:
951
- print("Warning: modal is still not importable after install attempt.")
952
- else:
953
- print("modal package ready")
954
- except Exception:
955
- print("Warning: unable to verify modal installation.")
1117
+ if _iu.find_spec("modal") is None:
1118
+ print("Warning: modal is still not importable after install attempt.")
1119
+ return
1120
+ except Exception:
1121
+ print("Warning: unable to verify modal installation.")
1122
+ return
1123
+
1124
+ # Check modal authentication status
1125
+ auth_ok, auth_msg = demo_core.modal_auth_status()
1126
+ if auth_ok:
1127
+ print(f"✓ Modal authenticated: {auth_msg}")
1128
+ else:
1129
+ print(f"\n⚠️ Modal authentication required")
1130
+ print(f" Status: {auth_msg}")
1131
+ print(f"\n To authenticate Modal, run:")
1132
+ print(f" modal setup")
1133
+ print(f"\n Or set environment variables:")
1134
+ print(f" export MODAL_TOKEN_ID=your-token-id")
1135
+ print(f" export MODAL_TOKEN_SECRET=your-token-secret")
1136
+ print(f"\n You can deploy later after authenticating.\n")
956
1137
 
957
1138
 
958
1139
  def cmd_init(args: argparse.Namespace) -> int:
@@ -991,20 +1172,61 @@ def cmd_init(args: argparse.Namespace) -> int:
991
1172
  assert selected is not None
992
1173
 
993
1174
  default_subdir = selected.default_subdir or selected.template_id
994
- default_dest = Path(args.dest).expanduser().resolve() if args.dest else (Path.cwd() / default_subdir).resolve()
1175
+
1176
+ # Check if default destination is already occupied and switch to local_demos/ if needed
1177
+ if args.dest:
1178
+ default_dest = Path(args.dest).expanduser().resolve()
1179
+ else:
1180
+ primary_dest = Path.cwd() / default_subdir
1181
+ if primary_dest.exists() and any(primary_dest.iterdir()):
1182
+ # Switch to local_demos/ automatically if primary location is occupied
1183
+ default_dest = (Path.cwd() / "local_demos" / default_subdir).resolve()
1184
+ else:
1185
+ default_dest = primary_dest.resolve()
1186
+
995
1187
  try:
996
1188
  dest_input = input(f"Destination directory [{default_dest}]: ").strip()
997
1189
  except Exception:
998
1190
  dest_input = ""
999
1191
  destination = Path(dest_input).expanduser().resolve() if dest_input else default_dest
1000
1192
 
1193
+ # Track whether we should skip individual file prompts (if we already cleared the directory)
1194
+ directory_cleared = False
1195
+
1001
1196
  if destination.exists():
1002
1197
  if destination.is_file():
1003
1198
  print(f"Destination {destination} is a file. Provide a directory path.")
1004
1199
  return 1
1005
- if not args.force and any(destination.iterdir()):
1006
- print(f"Destination {destination} is not empty. Use --force or choose another directory.")
1007
- return 1
1200
+ if any(destination.iterdir()):
1201
+ try:
1202
+ response = (
1203
+ input(f"Destination {destination} is not empty. Overwrite? [y/N]: ")
1204
+ .strip()
1205
+ .lower()
1206
+ )
1207
+ except (EOFError, KeyboardInterrupt):
1208
+ print("\nCancelled.")
1209
+ return 1
1210
+ if response not in ("y", "yes"):
1211
+ print("Cancelled. Choose another directory or delete the existing one.")
1212
+ return 1
1213
+ # User agreed to overwrite - clear the entire directory including hidden files
1214
+ print(f"Clearing {destination}...")
1215
+ try:
1216
+ # Remove all contents including hidden files (.env, .git, etc.)
1217
+ shutil.rmtree(destination)
1218
+ except Exception as e:
1219
+ print(f"Error clearing directory: {e}")
1220
+ print("Please manually remove the directory and try again.")
1221
+ return 1
1222
+ # Recreate empty directory
1223
+ destination.mkdir(parents=True, exist_ok=True)
1224
+ # Verify it's actually empty
1225
+ if any(destination.iterdir()):
1226
+ print(f"Warning: Directory {destination} still contains files after clearing.")
1227
+ print("Some files may not have been removed. Please check manually.")
1228
+ return 1
1229
+ directory_cleared = True
1008
1230
  else:
1009
1231
  destination.mkdir(parents=True, exist_ok=True)
1010
1232
 
@@ -1018,29 +1240,83 @@ def cmd_init(args: argparse.Namespace) -> int:
1018
1240
  print(f"Template source missing: {src_path}")
1019
1241
  return 1
1020
1242
  dest_path = (destination / spec.destination).resolve()
1021
- dest_path.parent.mkdir(parents=True, exist_ok=True)
1022
- if dest_path.exists() and not args.force:
1023
- print(f"Refusing to overwrite existing file: {dest_path} (use --force)")
1024
- return 1
1025
- shutil.copy2(src_path, dest_path)
1026
- if spec.make_executable:
1027
- try:
1028
- st = os.stat(dest_path)
1029
- os.chmod(dest_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1030
- except Exception:
1031
- pass
1243
+
1244
+ # Handle directory copying
1245
+ if src_path.is_dir():
1246
+ if dest_path.exists() and not directory_cleared:
1247
+ try:
1248
+ response = (
1249
+ input(f"Directory {dest_path.name} exists. Overwrite? [y/N]: ")
1250
+ .strip()
1251
+ .lower()
1252
+ )
1253
+ except (EOFError, KeyboardInterrupt):
1254
+ print("\nCancelled.")
1255
+ return 1
1256
+ if response not in ("y", "yes"):
1257
+ print(f"Skipping {dest_path.name}")
1258
+ continue
1259
+ shutil.rmtree(dest_path)
1260
+ elif dest_path.exists() and directory_cleared:
1261
+ shutil.rmtree(dest_path)
1262
+ shutil.copytree(src_path, dest_path)
1263
+ else:
1264
+ # Handle file copying
1265
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
1266
+ if dest_path.exists() and not directory_cleared:
1267
+ try:
1268
+ response = (
1269
+ input(f"File {dest_path.name} exists. Overwrite? [y/N]: ")
1270
+ .strip()
1271
+ .lower()
1272
+ )
1273
+ except (EOFError, KeyboardInterrupt):
1274
+ print("\nCancelled.")
1275
+ return 1
1276
+ if response not in ("y", "yes"):
1277
+ print(f"Skipping {dest_path.name}")
1278
+ continue
1279
+ shutil.copy2(src_path, dest_path)
1280
+ if spec.make_executable:
1281
+ try:
1282
+ st = os.stat(dest_path)
1283
+ os.chmod(dest_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1284
+ except Exception:
1285
+ pass
1032
1286
 
1033
1287
  if selected.env_lines:
1034
1288
  env_path = destination / ".env"
1035
- if not env_path.exists() or args.force:
1289
+ should_write = True
1290
+ if env_path.exists() and not directory_cleared:
1291
+ try:
1292
+ response = input(f"File .env exists. Overwrite? [y/N]: ").strip().lower()
1293
+ except (EOFError, KeyboardInterrupt):
1294
+ print("\nCancelled.")
1295
+ return 1
1296
+ should_write = response in ("y", "yes")
1297
+ if should_write:
1036
1298
  _write_text(env_path, "\n".join(selected.env_lines) + "\n")
1299
+ elif not directory_cleared:
1300
+ print("Skipping .env")
1037
1301
 
1038
1302
  config_src = selected.config_source_path()
1039
1303
  if config_src and config_src.exists():
1040
1304
  cfg_dst = (destination / selected.config_destination).resolve()
1041
- if not cfg_dst.exists() or args.force:
1305
+ should_copy = True
1306
+ if cfg_dst.exists() and not directory_cleared:
1307
+ try:
1308
+ response = (
1309
+ input(f"File {cfg_dst.name} exists. Overwrite? [y/N]: ").strip().lower()
1310
+ )
1311
+ except (EOFError, KeyboardInterrupt):
1312
+ print("\nCancelled.")
1313
+ return 1
1314
+ should_copy = response in ("y", "yes")
1315
+ if should_copy:
1042
1316
  cfg_dst.parent.mkdir(parents=True, exist_ok=True)
1043
1317
  shutil.copy2(config_src, cfg_dst)
1318
+ elif not directory_cleared:
1319
+ print(f"Skipping {cfg_dst.name}")
1044
1320
 
1045
1321
  if selected.post_copy is not None:
1046
1322
  try:
@@ -1049,6 +1325,14 @@ def cmd_init(args: argparse.Namespace) -> int:
1049
1325
  print(f"Post-processing failed: {post_exc}")
1050
1326
  return 1
1051
1327
 
1328
+ # Store demo directory for subsequent commands
1329
+ demo_core.persist_demo_dir(str(destination))
1330
+
1331
+ # Store .env path if it was created
1332
+ env_file = destination / ".env"
1333
+ if env_file.exists():
1334
+ demo_core.persist_env_file_path(str(env_file))
1335
+
1052
1336
  print(f"Demo template '{selected.name}' materialised at {destination}.")
1053
1337
  print("Files created:")
1054
1338
  for spec in selected.iter_copy_specs():
@@ -1057,6 +1341,7 @@ def cmd_init(args: argparse.Namespace) -> int:
1057
1341
  print(" - .env")
1058
1342
  if selected.config_source_path():
1059
1343
  print(f" - {selected.config_destination}")
1344
+ print("\nDemo directory stored. Subsequent commands will use this directory automatically.")
1060
1345
  print("Review the files, edit .env, and run any provided deploy scripts when ready.")
1061
1346
  return 0
1062
1347
  except KeyboardInterrupt:
@@ -1067,8 +1352,11 @@ def cmd_init(args: argparse.Namespace) -> int:
1067
1352
  return 1
1068
1353
 
1069
1354
 
1070
- def _http(method: str, url: str, headers: Dict[str, str] | None = None, body: Dict[str, Any] | None = None) -> tuple[int, Dict[str, Any] | str]:
1355
+ def _http(
1356
+ method: str, url: str, headers: Dict[str, str] | None = None, body: Dict[str, Any] | None = None
1357
+ ) -> tuple[int, Dict[str, Any] | str]:
1071
1358
  import urllib.request, urllib.error, json as _json, ssl
1359
+
1072
1360
  data = None
1073
1361
  if body is not None:
1074
1362
  data = _json.dumps(body).encode("utf-8")
@@ -1106,6 +1394,12 @@ def _write_text(path: str, content: str) -> None:
1106
1394
 
1107
1395
 
1108
1396
  def cmd_run(args: argparse.Namespace) -> int:
1397
+ # Change to demo directory if stored
1398
+ demo_dir = demo_core.load_demo_dir()
1399
+ if demo_dir and os.path.isdir(demo_dir):
1400
+ os.chdir(demo_dir)
1401
+ print(f"Using demo directory: {demo_dir}")
1402
+
1109
1403
  env = demo_core.load_env()
1110
1404
  cwd_env_path = os.path.join(os.getcwd(), ".env")
1111
1405
  local_env = demo_core.load_dotenv_file(cwd_env_path)
@@ -1148,7 +1442,11 @@ def cmd_run(args: argparse.Namespace) -> int:
1148
1442
  # Detect monorepo launcher and delegate if available (aligns with run_clustered.sh which works)
1149
1443
  launcher = "/Users/joshpurtell/Documents/GitHub/monorepo/tests/applications/math/rl/start_math_clustered.py"
1150
1444
  if os.path.isfile(launcher):
1151
- backend_base = env.dev_backend_url[:-4] if env.dev_backend_url.endswith("/api") else env.dev_backend_url
1445
+ backend_base = (
1446
+ env.dev_backend_url[:-4]
1447
+ if env.dev_backend_url.endswith("/api")
1448
+ else env.dev_backend_url
1449
+ )
1152
1450
  run_env = os.environ.copy()
1153
1451
  run_env["BACKEND_URL"] = backend_base
1154
1452
  run_env["SYNTH_API_KEY"] = env.synth_api_key
@@ -1181,7 +1479,9 @@ def cmd_run(args: argparse.Namespace) -> int:
1181
1479
  print(f" {_key_preview(sk, 'SYNTH_API_KEY')}")
1182
1480
  if ek:
1183
1481
  print(f" {_key_preview(ek, 'ENVIRONMENT_API_KEY')}")
1184
- print("Ensure the ENVIRONMENT_API_KEY you deployed with matches the task app and remains exported.")
1482
+ print(
1483
+ "Ensure the ENVIRONMENT_API_KEY you deployed with matches the task app and remains exported."
1484
+ )
1185
1485
  return code
1186
1486
 
1187
1487
  # Fallback: legacy jobs API flow
@@ -1222,7 +1522,7 @@ def cmd_run(args: argparse.Namespace) -> int:
1222
1522
  if inline_cfg["compute"].get("gpu_type"):
1223
1523
  compute["gpu_type"] = str(inline_cfg["compute"]["gpu_type"]).upper()
1224
1524
  if inline_cfg["compute"].get("gpu_count"):
1225
- compute["gpu_count"] = int(inline_cfg["compute"]["gpu_count"])
1525
+ compute["gpu_count"] = int(inline_cfg["compute"]["gpu_count"])
1226
1526
  if not compute:
1227
1527
  topo = inline_cfg.get("topology") or {}
1228
1528
  gshape = str(topo.get("gpu_type") or "")
@@ -1235,10 +1535,15 @@ def cmd_run(args: argparse.Namespace) -> int:
1235
1535
  }
1236
1536
  if compute:
1237
1537
  body["compute"] = compute
1238
- code, js = _http("POST", api + "/rl/jobs", headers={
1239
- "Content-Type": "application/json",
1240
- "Authorization": f"Bearer {env.synth_api_key}",
1241
- }, body=body)
1538
+ code, js = _http(
1539
+ "POST",
1540
+ api + "/rl/jobs",
1541
+ headers={
1542
+ "Content-Type": "application/json",
1543
+ "Authorization": f"Bearer {env.synth_api_key}",
1544
+ },
1545
+ body=body,
1546
+ )
1242
1547
  if code not in (200, 201) or not isinstance(js, dict):
1243
1548
  print("Job create failed:", code)
1244
1549
  print(f"Backend: {api}")
@@ -1276,7 +1581,9 @@ def cmd_run(args: argparse.Namespace) -> int:
1276
1581
  try:
1277
1582
  sent_key = detail.get("sent_key")
1278
1583
  if isinstance(sent_key, str):
1279
- print(f"[run] Backend detail.sent_key {_key_preview(sent_key, 'detail.sent_key')}")
1584
+ print(
1585
+ f"[run] Backend detail.sent_key {_key_preview(sent_key, 'detail.sent_key')}"
1586
+ )
1280
1587
  except Exception:
1281
1588
  pass
1282
1589
  try:
@@ -1306,12 +1613,19 @@ def cmd_run(args: argparse.Namespace) -> int:
1306
1613
  # Extra hints for auth failures
1307
1614
  try:
1308
1615
  sk = (env.synth_api_key or "").strip()
1309
- if int(code) == 401 or (isinstance(js, dict) and any(isinstance(v, str) and "Invalid API key" in v for v in js.values())):
1616
+ if int(code) == 401 or (
1617
+ isinstance(js, dict)
1618
+ and any(isinstance(v, str) and "Invalid API key" in v for v in js.values())
1619
+ ):
1310
1620
  base_url = env.dev_backend_url
1311
- print("Hint: HTTP 401 Unauthorized from backend. Verify SYNTH_API_KEY for:", base_url)
1621
+ print(
1622
+ "Hint: HTTP 401 Unauthorized from backend. Verify SYNTH_API_KEY for:", base_url
1623
+ )
1312
1624
  if sk:
1313
1625
  print(f" {_key_preview(sk, 'SYNTH_API_KEY')}")
1314
- print("Ensure the ENVIRONMENT_API_KEY and OPENAI_API_KEY used for deployment remain valid.")
1626
+ print(
1627
+ "Ensure the ENVIRONMENT_API_KEY and OPENAI_API_KEY used for deployment remain valid."
1628
+ )
1315
1629
  except Exception:
1316
1630
  pass
1317
1631
  return 2
@@ -1363,9 +1677,7 @@ def cmd_run(args: argparse.Namespace) -> int:
1363
1677
  "rl.performance.metrics",
1364
1678
  ):
1365
1679
  print(f"[{seq}] {typ}: {msg}")
1366
- mc, mj = _http(
1367
- "GET", api + f"/learning/jobs/{job_id}/metrics?after_step=-1&limit=50"
1368
- )
1680
+ mc, mj = _http("GET", api + f"/learning/jobs/{job_id}/metrics?after_step=-1&limit=50")
1369
1681
  if mc == 200 and isinstance(mj, dict):
1370
1682
  pts = mj.get("points") or []
1371
1683
  for p in pts:
@@ -1384,17 +1696,23 @@ def main(argv: list[str] | None = None) -> int:
1384
1696
  p = argparse.ArgumentParser(prog="synth-ai")
1385
1697
  sub = p.add_subparsers(dest="cmd")
1386
1698
 
1387
- def _add_parser(names: list[str], *, configure: Callable[[argparse.ArgumentParser], None]) -> None:
1699
+ def _add_parser(
1700
+ names: list[str], *, configure: Callable[[argparse.ArgumentParser], None]
1701
+ ) -> None:
1388
1702
  for name in names:
1389
1703
  parser = sub.add_parser(name)
1390
1704
  configure(parser)
1391
1705
 
1392
- _add_parser(["rl_demo.setup", "demo.setup"], configure=lambda parser: parser.set_defaults(func=cmd_setup))
1706
+ _add_parser(
1707
+ ["rl_demo.setup", "demo.setup"],
1708
+ configure=lambda parser: parser.set_defaults(func=cmd_setup),
1709
+ )
1393
1710
 
1394
1711
  def _init_opts(parser):
1395
1712
  parser.add_argument("--template", type=str, default=None, help="Template id to instantiate")
1396
- parser.add_argument("--dest", type=str, default=None, help="Destination directory for files")
1397
- parser.add_argument("--force", action="store_true", help="Overwrite existing files in destination")
1713
+ parser.add_argument(
1714
+ "--dest", type=str, default=None, help="Destination directory for files"
1715
+ )
1398
1716
  parser.set_defaults(func=cmd_init)
1399
1717
 
1400
1718
  _add_parser(["rl_demo.init", "demo.init"], configure=_init_opts)
@@ -1402,18 +1720,29 @@ def main(argv: list[str] | None = None) -> int:
1402
1720
  # (prepare command removed)
1403
1721
 
1404
1722
  def _deploy_opts(parser):
1405
- parser.add_argument("--local", action="store_true", help="Run local FastAPI instead of Modal deploy")
1406
- parser.add_argument("--app", type=str, default=None, help="Path to Modal app.py for uv run modal deploy")
1723
+ parser.add_argument(
1724
+ "--local", action="store_true", help="Run local FastAPI instead of Modal deploy"
1725
+ )
1726
+ parser.add_argument(
1727
+ "--app", type=str, default=None, help="Path to Modal app.py for uv run modal deploy"
1728
+ )
1407
1729
  parser.add_argument("--name", type=str, default=None, help="Modal app name")
1408
- parser.add_argument("--script", type=str, default=None, help="Path to deploy_task_app.sh (optional legacy)")
1730
+ parser.add_argument(
1731
+ "--script", type=str, default=None, help="Path to deploy_task_app.sh (optional legacy)"
1732
+ )
1409
1733
  parser.set_defaults(func=cmd_deploy)
1410
1734
 
1411
1735
  _add_parser(["rl_demo.deploy", "demo.deploy"], configure=_deploy_opts)
1412
1736
 
1413
- _add_parser(["rl_demo.configure", "demo.configure"], configure=lambda parser: parser.set_defaults(func=cmd_run))
1737
+ _add_parser(
1738
+ ["rl_demo.configure", "demo.configure"],
1739
+ configure=lambda parser: parser.set_defaults(func=cmd_run),
1740
+ )
1414
1741
 
1415
1742
  def _run_opts(parser):
1416
- parser.add_argument("--config", type=str, default=None, help="Path to TOML config (skip prompt)")
1743
+ parser.add_argument(
1744
+ "--config", type=str, default=None, help="Path to TOML config (skip prompt)"
1745
+ )
1417
1746
  parser.add_argument("--batch-size", type=int, default=None)
1418
1747
  parser.add_argument("--group-size", type=int, default=None)
1419
1748
  parser.add_argument("--model", type=str, default=None)