synth-ai 0.2.6.dev6__py3-none-any.whl → 0.2.8__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.

@@ -5,11 +5,14 @@ import json
5
5
  import os
6
6
  import sys
7
7
  import time
8
+ from pathlib import Path
8
9
  from typing import Any, Dict, Callable
9
10
  import shutil
10
11
  import stat
12
+ import textwrap
11
13
 
12
14
  from synth_ai.demos.demo_task_apps import core as demo_core
15
+ from synth_ai.handshake import run_handshake, HandshakeError
13
16
  from synth_ai.demos.demo_task_apps.core import DemoEnv
14
17
 
15
18
 
@@ -23,8 +26,38 @@ def _is_modal_public_url(u: str) -> bool:
23
26
  return False
24
27
 
25
28
 
26
- def cmd_check(_args: argparse.Namespace) -> int:
27
- env = demo_core.load_env()
29
+ def cmd_setup(_args: argparse.Namespace) -> int:
30
+ # 1) Always perform SDK handshake and overwrite .env with returned keys
31
+ try:
32
+ print("\n⏳ Connecting SDK to your browser session…")
33
+ res = run_handshake()
34
+ user = res.get("user") or {}
35
+ org = res.get("org") or {}
36
+ keys = res.get("keys") or {}
37
+ synth_key = str(keys.get("synth") or "").strip()
38
+ rl_env_key = str(keys.get("rl_env") or "").strip()
39
+ if not synth_key or not rl_env_key:
40
+ raise HandshakeError("handshake returned missing keys")
41
+ # Overwrite .env with the latest values from the account/org
42
+ demo_core.persist_dotenv_values({
43
+ "SYNTH_API_KEY": synth_key,
44
+ "ENVIRONMENT_API_KEY": rl_env_key,
45
+ })
46
+ org_name = (org.get("name") or "this organization")
47
+ print(f"✅ Connected to {org_name}!")
48
+ except HandshakeError as e:
49
+ print(f"Handshake failed: {e}")
50
+ return 1
51
+ except Exception as e:
52
+ print(f"Unexpected handshake error: {e}")
53
+ return 1
54
+
55
+ # 2) Reload env after handshake to pick up values from .env (suppress env prints)
56
+ import io
57
+ import contextlib
58
+ _buf = io.StringIO()
59
+ with contextlib.redirect_stdout(_buf):
60
+ env = demo_core.load_env()
28
61
  cwd_env_path = os.path.join(os.getcwd(), ".env")
29
62
  local_env = demo_core.load_dotenv_file(cwd_env_path)
30
63
 
@@ -33,15 +66,6 @@ def cmd_check(_args: argparse.Namespace) -> int:
33
66
  env = demo_core.load_env()
34
67
  local_env = demo_core.load_dotenv_file(cwd_env_path)
35
68
 
36
- def _is_modal_public_url(u: str) -> bool:
37
- try:
38
- s = (u or "").strip().lower()
39
- if not (s.startswith("http://") or s.startswith("https://")):
40
- return False
41
- return (".modal.run" in s) and ("modal.local" not in s) and ("pypi-mirror" not in s)
42
- except Exception:
43
- return False
44
-
45
69
  def _maybe_fix_task_url() -> None:
46
70
  if not env.task_app_name:
47
71
  return
@@ -82,26 +106,14 @@ def cmd_check(_args: argparse.Namespace) -> int:
82
106
  os.environ["TASK_APP_BASE_URL"] = new_url
83
107
  _refresh_env()
84
108
 
109
+ # Keys have been written already via handshake; avoid any interactive prompts
85
110
  synth_key = env.synth_api_key.strip()
86
- if not synth_key:
87
- print("SYNTH_API_KEY missing from environment/.env.")
88
- entered = input("Enter SYNTH_API_KEY (required): ").strip()
89
- if not entered:
90
- print("SYNTH_API_KEY is required.")
91
- return 1
92
- os.environ["SYNTH_API_KEY"] = entered
93
- demo_core.persist_api_key(entered)
94
- path = demo_core.persist_dotenv_values({"SYNTH_API_KEY": entered})
95
- print(f"Stored SYNTH_API_KEY in {path}")
96
- _refresh_env()
97
- synth_key = entered
98
- elif not local_env.get("SYNTH_API_KEY"):
99
- path = demo_core.persist_dotenv_values({"SYNTH_API_KEY": synth_key})
100
- print(f"Stored SYNTH_API_KEY in {path}")
111
+ if not local_env.get("SYNTH_API_KEY") and synth_key:
112
+ demo_core.persist_dotenv_values({"SYNTH_API_KEY": synth_key})
101
113
  _refresh_env()
102
114
 
115
+ # Check Modal auth silently to avoid noisy output
103
116
  modal_ok, modal_msg = demo_core.modal_auth_status()
104
- print(f"Modal auth: {'OK' if modal_ok else 'MISSING'} ({modal_msg})")
105
117
 
106
118
  _maybe_fix_task_url()
107
119
 
@@ -110,32 +122,18 @@ def cmd_check(_args: argparse.Namespace) -> int:
110
122
  if env.dev_backend_url:
111
123
  api = env.dev_backend_url.rstrip("/") + ("" if env.dev_backend_url.endswith("/api") else "/api")
112
124
  ok_backend = demo_core.assert_http_ok(api + "/health", method="GET")
113
- print(f"Backend health: {'OK' if ok_backend else 'FAIL'} ({api}/health)")
114
- else:
115
- print("Backend URL missing; set DEV_BACKEND_URL.")
125
+ # Intentionally suppress backend health print for concise output
116
126
  if env.task_app_base_url:
117
127
  ok_task = demo_core.assert_http_ok(env.task_app_base_url.rstrip("/") + "/health", method="GET") or \
118
128
  demo_core.assert_http_ok(env.task_app_base_url.rstrip("/"), method="GET")
119
- print(f"Task app: {'OK' if ok_task else 'UNREACHABLE'} ({env.task_app_base_url})")
129
+ # Intentionally suppress task app health print
120
130
  else:
121
- print("Task app URL not set; run: uvx synth-ai rl_demo deploy")
131
+ print("\nSet your task app URL by running:\nuvx synth-ai rl_demo deploy\n")
122
132
 
123
- print("uv: ", end="")
124
- try:
125
- import subprocess
126
-
127
- subprocess.check_call(["uv", "--version"])
128
- except Exception:
129
- print("(uv not found; install with `pip install uv`)\n", flush=True)
133
+ # Omit uv version print to keep output concise
130
134
 
131
- status = 0
132
- if not ok_backend:
133
- status = 1
134
- if not modal_ok:
135
- status = 1
136
- if not env.synth_api_key:
137
- status = 1
138
- return status
135
+ # Keep exit code neutral; not all checks are critical for pairing
136
+ return 0
139
137
 
140
138
 
141
139
  def _popen_capture(cmd: list[str], cwd: str | None = None, env: dict | None = None) -> tuple[int, str]:
@@ -273,6 +271,433 @@ def _ensure_modal_secret(
273
271
  return True
274
272
 
275
273
 
274
+ def _fmt_float(value: float) -> str:
275
+ return f"{value:.10g}"
276
+
277
+
278
+ def _find_asgi_apps(root: Path) -> list[Path]:
279
+ """Recursively search for Python files that declare a Modal ASGI app.
280
+
281
+ A file is considered a Modal task app candidate if it contains one of:
282
+ - "@asgi_app()"
283
+ - "@modal.asgi_app()"
284
+ """
285
+ results: list[Path] = []
286
+ skip_dirs = {".git", ".hg", ".svn", "node_modules", "dist", "build", "__pycache__", ".ruff_cache", ".mypy_cache", "venv", ".venv"}
287
+ for dirpath, dirnames, filenames in os.walk(root):
288
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
289
+ for name in filenames:
290
+ if not name.endswith(".py"):
291
+ continue
292
+ path = Path(dirpath) / name
293
+ try:
294
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
295
+ txt = fh.read()
296
+ if ("@asgi_app()" in txt) or ("@modal.asgi_app()" in txt):
297
+ results.append(path)
298
+ except Exception:
299
+ continue
300
+ # Stable order: prioritize files under synth_demo/ first, then alphabetical
301
+ def _priority(p: Path) -> tuple[int, str]:
302
+ rel = str(p.resolve())
303
+ in_demo = "/synth_demo/" in rel or rel.endswith("/synth_demo/task_app.py")
304
+ return (0 if in_demo else 1, rel)
305
+ results.sort(key=_priority)
306
+ return results
307
+
308
+
309
+ def _prompt_value(label: str, default: str | int | float, cast: Callable[[str], Any] | None = None) -> Any:
310
+ prompt = f"{label} [{default}]: "
311
+ try:
312
+ raw = input(prompt).strip()
313
+ except Exception:
314
+ raw = ""
315
+ if not raw:
316
+ return default
317
+ if cast is None:
318
+ return raw
319
+ try:
320
+ return cast(raw)
321
+ except Exception:
322
+ print(f"Invalid value; keeping default {default}")
323
+ return default
324
+
325
+
326
+ def _find_vllm_tomls(root: Path) -> list[Path]:
327
+ results: list[Path] = []
328
+ skip_dirs = {".git", ".hg", ".svn", "node_modules", "dist", "build", "__pycache__", ".ruff_cache", ".mypy_cache", "venv", ".venv"}
329
+ for dirpath, dirnames, filenames in os.walk(root):
330
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
331
+ for name in filenames:
332
+ if not name.endswith(".toml"):
333
+ continue
334
+ path = Path(dirpath) / name
335
+ try:
336
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
337
+ if "[vllm]" in fh.read().lower():
338
+ results.append(path)
339
+ except Exception:
340
+ continue
341
+ return results
342
+
343
+
344
+ def _create_new_config(env: DemoEnv) -> str:
345
+ default_path = os.path.join(os.getcwd(), "demo_config.toml")
346
+ while True:
347
+ try:
348
+ destination = input(f"Path to save new config [{default_path}]: ").strip() or default_path
349
+ except Exception:
350
+ destination = default_path
351
+ destination = os.path.abspath(destination)
352
+ if os.path.isdir(destination):
353
+ print("Path points to a directory; provide a file path.")
354
+ continue
355
+ if os.path.exists(destination):
356
+ try:
357
+ overwrite = input(f"{destination} exists. Overwrite? [y/N]: ").strip().lower() or "n"
358
+ except Exception:
359
+ overwrite = "n"
360
+ if not overwrite.startswith("y"):
361
+ continue
362
+ break
363
+
364
+ env_name = _prompt_value("Environment name", "Crafter")
365
+ policy_name = _prompt_value("Policy name", "crafter-react")
366
+ model_name = _prompt_value("Model name", "Qwen/Qwen3-0.6B")
367
+ compute_gpu_type = _prompt_value("Compute GPU type", "H100")
368
+ compute_gpu_count = _prompt_value("Compute GPU count", 4, int)
369
+ topology_gpu_type = _prompt_value("Topology GPU type", f"{compute_gpu_type}:{compute_gpu_count}")
370
+ gpus_for_vllm = _prompt_value("Topology gpus_for_vllm", 2, int)
371
+ gpus_for_training = _prompt_value("Topology gpus_for_training", 1, int)
372
+ tensor_parallel = _prompt_value("Topology tensor_parallel", 2, int)
373
+ gpus_for_ref = _prompt_value("Topology gpus_for_ref", 1, int)
374
+ vllm_tp_size = _prompt_value("vLLM tensor parallel size", tensor_parallel, int)
375
+ vllm_max_model_len = _prompt_value("vLLM max_model_len", 8192, int)
376
+ vllm_max_num_seqs = _prompt_value("vLLM max_num_seqs", 32, int)
377
+ vllm_gpu_mem_util = _prompt_value("vLLM gpu_memory_utilization", 0.9, float)
378
+ vllm_max_parallel = _prompt_value("vLLM max_parallel_generations", 4, int)
379
+ training_num_epochs = _prompt_value("Training num_epochs", 1, int)
380
+ training_iters = _prompt_value("Training iterations_per_epoch", 2, int)
381
+ training_batch = _prompt_value("Training batch_size", 1, int)
382
+ training_group = _prompt_value("Training group_size", 8, int)
383
+ training_lr = _prompt_value("Training learning_rate", 5e-6, float)
384
+ task_url_default = env.task_app_base_url or ""
385
+ services_task_url = _prompt_value("services.task_url", task_url_default)
386
+
387
+ template = textwrap.dedent(
388
+ f"""\
389
+ # Crafter online RL training configuration (research local copy)
390
+
391
+ [model]
392
+ #name = \"fft:Qwen/Qwen3-4B:job_7243b8aa76fe4b59\"
393
+ name = \"{model_name}\"
394
+ dtype = \"bfloat16\"
395
+ seed = 42
396
+ trainer_mode = \"full\"
397
+
398
+ [lora]
399
+ r = 16
400
+ alpha = 32
401
+ dropout = 0.05
402
+ target_modules = [
403
+ \"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",
404
+ \"gate_proj\", \"up_proj\", \"down_proj\",
405
+ ]
406
+
407
+ [rdma]
408
+ enabled = false
409
+ ifname = \"eth0\"
410
+ ip_type = \"ipv4\"
411
+ p2p_disable = 0
412
+ shm_disable = 0
413
+ fast_nccl = false
414
+
415
+ gid_index = 3
416
+ cross_nic = 0
417
+ collnet_enable = 0
418
+ net_gdr_level = 2
419
+
420
+ nsocks_perthread = 4
421
+ socket_nthreads = 2
422
+
423
+ algo = \"Ring\"
424
+ proto = \"Simple\"
425
+ p2p_level = \"SYS\"
426
+ debug = \"INFO\"
427
+
428
+ [compute]
429
+ gpu_type = \"{compute_gpu_type}\"
430
+ gpu_count = {compute_gpu_count}
431
+
432
+ [topology]
433
+ type = \"single_node_split\"
434
+ gpu_type = \"{topology_gpu_type}\"
435
+ use_rdma = false
436
+ gpus_for_vllm = {gpus_for_vllm}
437
+ gpus_for_training = {gpus_for_training}
438
+ tensor_parallel = {tensor_parallel}
439
+ gpus_for_ref = {gpus_for_ref}
440
+
441
+ [vllm]
442
+ tensor_parallel_size = {vllm_tp_size}
443
+ gpu_memory_utilization = {_fmt_float(vllm_gpu_mem_util)}
444
+ max_model_len = {vllm_max_model_len}
445
+ max_num_seqs = {vllm_max_num_seqs}
446
+ enforce_eager = false
447
+ max_parallel_generations = {vllm_max_parallel}
448
+
449
+ # Reference scoring server (dedicated GPU)
450
+ [reference]
451
+ placement = \"dedicated\"
452
+ gpu_index = 1
453
+ port = 8002
454
+ tp = 1
455
+ health_max_wait_s = 180
456
+ health_interval_ms = 300
457
+
458
+ [training]
459
+ num_epochs = {training_num_epochs}
460
+ iterations_per_epoch = {training_iters}
461
+ batch_size = {training_batch}
462
+ group_size = {training_group}
463
+ learning_rate = {_fmt_float(training_lr)}
464
+ max_grad_norm = 0.5
465
+ log_interval = 1
466
+ update_reference_interval = 0
467
+ weight_sync_interval = 1
468
+
469
+ [training.weight_sync]
470
+ enable = true
471
+ targets = [\"policy\"]
472
+
473
+ [rollout]
474
+ env_name = \"{env_name}\"
475
+ policy_name = \"{policy_name}\"
476
+ env_config = {{}}
477
+ max_steps_per_episode = 5
478
+ sampling_temperature = 0.3
479
+ sampling_top_p = 0.95
480
+ max_tokens = 1024
481
+ max_concurrent_rollouts = 4
482
+ ops_per_rollout = 14
483
+ on_done = \"reset\"
484
+ thinking_mode = \"think\"
485
+ thinking_budget = 512
486
+
487
+ [policy]
488
+ config = {{}}
489
+
490
+ [evaluation]
491
+ seeds = [0, 1, 2, 3, 4, 5, 6, 7]
492
+ rollouts_per_seed = 1
493
+ instances = 0
494
+ max_concurrent_rollouts = 4
495
+ thinking_mode = \"think\"
496
+ every_n_iters = 5
497
+
498
+ [hyperparams]
499
+ epsilon_low = 0.1
500
+ epsilon_high = 0.3
501
+ delta = 5.0
502
+ beta = 0.01
503
+ kl_penalty = 0.01
504
+ advantage_normalization = true
505
+ group_normalization = true
506
+ num_inner_steps = 1
507
+ clip_epsilon = 0.2
508
+ completion_only = false
509
+
510
+ [step_rewards]
511
+ enabled = false
512
+ mode = \"off\"
513
+ step_beta = 0.0
514
+ indicator_lambda = 0.0
515
+
516
+ [trainer]
517
+ allow_ref_fallback = false
518
+
519
+ [checkpoint]
520
+ interval = 10
521
+ directory = \"/checkpoints\"
522
+ keep_last_n = 3
523
+ save_optimizer = true
524
+ save_scheduler = true
525
+ enabled = true
526
+
527
+ [services]
528
+ task_url = \"{services_task_url}\"
529
+ """
530
+ ).strip() + "\n"
531
+
532
+ with open(destination, "w", encoding="utf-8") as fh:
533
+ fh.write(template)
534
+ print(f"Wrote config to {destination}")
535
+ return destination
536
+
537
+
538
+ def _select_or_create_config(explicit: str | None, env: DemoEnv) -> str:
539
+ if explicit:
540
+ path = os.path.abspath(explicit)
541
+ if not os.path.isfile(path):
542
+ raise FileNotFoundError(f"Config not found: {path}")
543
+ return path
544
+
545
+ search_root = Path(os.getcwd())
546
+ discovered = _find_vllm_tomls(search_root)
547
+
548
+ extras: list[Path] = []
549
+ packaged = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml")))
550
+ extras.append(packaged)
551
+ home_cfg = Path(os.path.expanduser("~/.synth-ai/demo_config.toml"))
552
+ extras.append(home_cfg)
553
+
554
+ all_paths: list[Path] = []
555
+ seen: set[str] = set()
556
+ for candidate in discovered + extras:
557
+ if candidate.is_file():
558
+ resolved = str(candidate.resolve())
559
+ if resolved not in seen:
560
+ seen.add(resolved)
561
+ all_paths.append(candidate)
562
+
563
+ if not all_paths:
564
+ print("No existing RL TOML configs with [vllm] found; creating a new one.")
565
+ return _create_new_config(env)
566
+
567
+ print("Select a TOML config (found [vllm] section):")
568
+ for idx, path in enumerate(all_paths, 1):
569
+ rel = os.path.relpath(str(path), os.getcwd())
570
+ print(f" [{idx}] {rel}")
571
+ create_idx = len(all_paths) + 1
572
+ print(f" [{create_idx}] Create new config")
573
+ try:
574
+ sel = input(f"Enter choice [1-{create_idx}] (default 1): ").strip() or "1"
575
+ except Exception:
576
+ sel = "1"
577
+ try:
578
+ choice = int(sel)
579
+ except Exception:
580
+ choice = 1
581
+ if choice == create_idx:
582
+ return _create_new_config(env)
583
+ choice = max(1, min(choice, len(all_paths)))
584
+ selected = os.path.abspath(all_paths[choice - 1])
585
+ print(f"Using config: {selected}")
586
+ return selected
587
+
588
+
589
+ def _ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoEnv:
590
+ cwd_env_path = os.path.join(os.getcwd(), ".env")
591
+ local_env = demo_core.load_dotenv_file(cwd_env_path)
592
+
593
+ env_key = (env.env_api_key or "").strip()
594
+ if not env_key:
595
+ raise RuntimeError(f"[{label}] ENVIRONMENT_API_KEY missing. Run `uvx synth-ai rl_demo deploy` first.")
596
+
597
+ task_url = env.task_app_base_url
598
+ if not task_url or not _is_modal_public_url(task_url):
599
+ resolved = ""
600
+ if env.task_app_name:
601
+ try:
602
+ choice = input(
603
+ f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: "
604
+ ).strip().lower() or "y"
605
+ except Exception:
606
+ choice = "y"
607
+ if choice.startswith("y"):
608
+ code, out = _popen_capture([
609
+ "uv",
610
+ "run",
611
+ "python",
612
+ "-m",
613
+ "modal",
614
+ "app",
615
+ "url",
616
+ env.task_app_name,
617
+ ])
618
+ if code == 0 and out:
619
+ for tok in out.split():
620
+ if _is_modal_public_url(tok):
621
+ resolved = tok.strip().rstrip("/")
622
+ break
623
+ if not resolved:
624
+ print(f"[{label}] Task app URL not configured or not a valid Modal public URL.")
625
+ print("Examples: https://<app-name>-fastapi-app.modal.run")
626
+ entered = input("Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: ").strip()
627
+ if not entered or not _is_modal_public_url(entered):
628
+ raise RuntimeError(f"[{label}] Valid Task App URL is required.")
629
+ task_url = entered.rstrip("/")
630
+ else:
631
+ task_url = resolved
632
+ demo_core.persist_task_url(task_url, name=(env.task_app_name or None))
633
+
634
+ app_name = env.task_app_name.strip()
635
+ if not app_name:
636
+ fallback = input("Enter Modal app name for the task app (required): ").strip()
637
+ if not fallback:
638
+ raise RuntimeError(f"[{label}] Task app name is required.")
639
+ app_name = fallback
640
+ demo_core.persist_task_url(task_url, name=app_name)
641
+
642
+ secret_name = env.task_app_secret_name.strip() or f"{app_name}-secret"
643
+ demo_core.persist_task_url(task_url, name=app_name)
644
+ demo_core.persist_dotenv_values({
645
+ "TASK_APP_BASE_URL": task_url,
646
+ "TASK_APP_NAME": app_name,
647
+ "TASK_APP_SECRET_NAME": secret_name,
648
+ })
649
+
650
+ openai_key = (os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or "").strip()
651
+ secret_values: dict[str, str] = {"ENVIRONMENT_API_KEY": env_key}
652
+ if openai_key:
653
+ secret_values["OPENAI_API_KEY"] = openai_key
654
+ if synth_key:
655
+ secret_values["SYNTH_API_KEY"] = synth_key
656
+
657
+ _ensure_modal_secret(secret_name, values=secret_values, label=label, replace=True)
658
+
659
+ rollout_url = task_url.rstrip("/") + "/health/rollout"
660
+ print(f"[{label}] Verifying rollout health:")
661
+ try:
662
+ ek = (env_key or "").strip()
663
+ ek_len = len(ek)
664
+ ek_tail = ek[-5:] if ek_len >= 5 else ek
665
+ print(f"[{label}] Using ENVIRONMENT_API_KEY len={ek_len} last5={ek_tail}")
666
+ except Exception:
667
+ pass
668
+ health_base = task_url.rstrip("/")
669
+ health_urls = [f"{health_base}/health/rollout", f"{health_base}/health"]
670
+ rc = 0
671
+ body: Any = ""
672
+ for h in health_urls:
673
+ print(f"[{label}] GET", h)
674
+ rc, body = _http("GET", h, headers={"X-API-Key": env_key})
675
+ if rc == 200:
676
+ rollout_url = h
677
+ break
678
+ print(f"[{label}] status: {rc}")
679
+ try:
680
+ import json as _json
681
+
682
+ preview = _json.dumps(body)[:800] if isinstance(body, dict) else str(body)[:800]
683
+ except Exception:
684
+ preview = str(body)[:800]
685
+ print(f"[{label}] body:", preview)
686
+ if rc != 200:
687
+ print(f"[{label}] Warning: rollout health check failed ({rc}). Response: {body}")
688
+ else:
689
+ print(f"[{label}] Task app rollout health check OK.")
690
+
691
+ os.environ["TASK_APP_BASE_URL"] = task_url
692
+ os.environ["ENVIRONMENT_API_KEY"] = env_key
693
+ updated_env = demo_core.load_env()
694
+ updated_env.env_api_key = env_key
695
+ updated_env.task_app_base_url = task_url
696
+ updated_env.task_app_name = app_name
697
+ updated_env.task_app_secret_name = secret_name
698
+ return updated_env
699
+
700
+
276
701
  def cmd_deploy(args: argparse.Namespace) -> int:
277
702
  env = demo_core.load_env()
278
703
  cwd_env_path = os.path.join(os.getcwd(), ".env")
@@ -293,10 +718,10 @@ def cmd_deploy(args: argparse.Namespace) -> int:
293
718
  break
294
719
  time.sleep(1)
295
720
  else:
296
- # Auto-detect app path if not supplied; prompt for name and confirmation.
721
+ # Auto-detect app path if not supplied; prompt interactively from discovered ASGI apps
297
722
  app_path = os.path.abspath(args.app) if args.app else None
298
723
  if not app_path or not os.path.isfile(app_path):
299
- # Prefer the synth_demo/ app seeded by `rl_demo init` over any root-level files
724
+ # First pass: look for known common filenames
300
725
  candidates = [
301
726
  os.path.abspath(os.path.join(os.getcwd(), "synth_demo", "task_app.py")),
302
727
  os.path.abspath(os.path.join(os.getcwd(), "task_app.py")),
@@ -304,6 +729,24 @@ def cmd_deploy(args: argparse.Namespace) -> int:
304
729
  os.path.abspath(os.path.join(os.getcwd(), "math_task_app.py")),
305
730
  ]
306
731
  app_path = next((p for p in candidates if os.path.isfile(p)), None)
732
+ # If still not found, scan for any file containing @asgi_app()
733
+ if not app_path:
734
+ found = _find_asgi_apps(Path(os.getcwd()))
735
+ if found:
736
+ print("Select a Modal ASGI app to deploy:")
737
+ for idx, pth in enumerate(found, 1):
738
+ rel = os.path.relpath(str(pth), os.getcwd())
739
+ print(f" [{idx}] {rel}")
740
+ try:
741
+ sel = input(f"Enter choice [1-{len(found)}] (default 1): ").strip() or "1"
742
+ except Exception:
743
+ sel = "1"
744
+ try:
745
+ choice = int(sel)
746
+ except Exception:
747
+ choice = 1
748
+ choice = max(1, min(choice, len(found)))
749
+ app_path = str(found[choice - 1].resolve())
307
750
  if not app_path and args.script:
308
751
  # Legacy script fallback if user supplied --script explicitly
309
752
  from synth_ai.demos.demo_task_apps.math.deploy_modal import deploy as modal_deploy
@@ -320,7 +763,10 @@ def cmd_deploy(args: argparse.Namespace) -> int:
320
763
  raise FileNotFoundError(f"App file not found: {app_path}")
321
764
  # Surface the app path before asking for the name
322
765
  print(f"Using task app: {app_path}")
323
- suggested_name = args.name or f"synth-{os.path.splitext(os.path.basename(app_path))[0]}"
766
+ existing_name = (args.name or env.task_app_name or "").strip()
767
+ if not existing_name:
768
+ existing_name = f"synth-{os.path.splitext(os.path.basename(app_path))[0]}"
769
+ suggested_name = existing_name
324
770
  name_in = input(f"Modal app name [{suggested_name}]: ").strip() or suggested_name
325
771
  app_name = name_in
326
772
  print("\nAbout to deploy with:")
@@ -331,8 +777,23 @@ def cmd_deploy(args: argparse.Namespace) -> int:
331
777
  print("Aborted by user.")
332
778
  return 1
333
779
 
334
- secret_name = (env.task_app_secret_name or "").strip() or f"{name_in}-secret"
335
- env_key = (env.env_api_key or "").strip() or None
780
+ prev_secret = (env.task_app_secret_name or "").strip()
781
+ default_secret = f"{name_in}-secret"
782
+ secret_name = default_secret if not prev_secret else prev_secret
783
+ if prev_secret and prev_secret != default_secret:
784
+ secret_name = default_secret
785
+ existing_env_key = (env.env_api_key or "").strip()
786
+ env_key: str | None = existing_env_key or None
787
+ if existing_env_key:
788
+ try:
789
+ reuse_choice = input(
790
+ "Use existing ENVIRONMENT_API_KEY from state/.env? [Y/n]: "
791
+ ).strip().lower() or "y"
792
+ except Exception:
793
+ reuse_choice = "y"
794
+ if not reuse_choice.startswith("y"):
795
+ env_key = None
796
+
336
797
  if env_key is None:
337
798
  from synth_ai.rl.secrets import mint_environment_api_key
338
799
 
@@ -343,6 +804,33 @@ def cmd_deploy(args: argparse.Namespace) -> int:
343
804
  env.env_api_key = env_key
344
805
  local_env["ENVIRONMENT_API_KEY"] = env_key
345
806
  print("[deploy] Minted new ENVIRONMENT_API_KEY")
807
+
808
+ # Optionally upload the new key to the backend using sealed box helper
809
+ backend_base = (env.dev_backend_url or "").rstrip("/")
810
+ synth_key = (env.synth_api_key or os.environ.get("SYNTH_API_KEY") or local_env.get("SYNTH_API_KEY") or "").strip()
811
+ if backend_base and synth_key:
812
+ # Pass a base WITHOUT trailing /api to setup_environment_api_key,
813
+ # since it appends /api/v1/... internally.
814
+ non_api_base = backend_base[:-4] if backend_base.endswith("/api") else backend_base
815
+ try:
816
+ choice = input(
817
+ f"Upload ENVIRONMENT_API_KEY to backend {non_api_base}? [Y/n]: "
818
+ ).strip().lower() or "y"
819
+ except Exception:
820
+ choice = "y"
821
+ if choice.startswith("y"):
822
+ try:
823
+ print(f"[deploy] Uploading ENVIRONMENT_API_KEY to {non_api_base} …")
824
+ from synth_ai.rl.env_keys import setup_environment_api_key
825
+
826
+ setup_environment_api_key(non_api_base, synth_key, token=env_key)
827
+ print("[deploy] Backend sealed-box upload complete.")
828
+ except Exception as upload_err:
829
+ print(f"[deploy] Failed to upload ENVIRONMENT_API_KEY: {upload_err}")
830
+ print(
831
+ "Hint: run `uvx python -c \"from synth_ai.rl.env_keys import setup_environment_api_key as s;"
832
+ " s('<backend>', '<synth_api_key>')\"` once the backend is reachable."
833
+ )
346
834
 
347
835
  synth_key = (env.synth_api_key or os.environ.get("SYNTH_API_KEY") or local_env.get("SYNTH_API_KEY") or "").strip()
348
836
  if not synth_key:
@@ -356,7 +844,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
356
844
 
357
845
  openai_key = (os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or "").strip()
358
846
  if not openai_key:
359
- openai_key = input("Enter OPENAI_API_KEY for Modal secret (required): ").strip()
847
+ openai_key = input(
848
+ "Enter your OpenAI API key, found at https://platform.openai.com/api-keys\n> "
849
+ ).strip()
360
850
  if not openai_key:
361
851
  print("OPENAI_API_KEY is required to create the Modal secret.")
362
852
  return 1
@@ -434,197 +924,42 @@ def cmd_deploy(args: argparse.Namespace) -> int:
434
924
  print(f" export TASK_APP_NAME={app_name}")
435
925
  print(f" export TASK_APP_SECRET_NAME={app_name}-secret")
436
926
  print(f"Persisted to {dotenv_path}")
437
- print("Next: uvx synth-ai rl_demo configure")
927
+ print("\nNext step:\n$ uvx synth-ai run")
438
928
  return 0
439
929
  except Exception as e:
440
930
  print(f"Deploy error: {e}")
441
931
  return 2
442
932
 
443
933
 
444
- def cmd_configure(args: argparse.Namespace) -> int:
934
+ print("`rl_demo configure` prepares environment and secrets; `synth-ai run` now handles launches.")
445
935
  env = demo_core.load_env()
446
- cwd_env_path = os.path.join(os.getcwd(), ".env")
447
- local_env = demo_core.load_dotenv_file(cwd_env_path)
448
-
449
- synth_key = env.synth_api_key.strip()
936
+ synth_key = (env.synth_api_key or "").strip()
450
937
  if not synth_key:
451
- synth_key = input("Enter SYNTH_API_KEY (required): ").strip()
452
- if not synth_key:
938
+ entered = input("Enter SYNTH_API_KEY (required): ").strip()
939
+ if not entered:
453
940
  print("SYNTH_API_KEY is required.")
454
941
  return 1
455
- demo_core.persist_api_key(synth_key)
456
- demo_core.persist_dotenv_values({"SYNTH_API_KEY": synth_key})
457
-
458
- env_key = env.env_api_key.strip()
459
- if not env_key:
460
- print("ENVIRONMENT_API_KEY missing; run `uvx synth-ai rl_demo deploy` to mint and store one.")
942
+ os.environ["SYNTH_API_KEY"] = entered
943
+ demo_core.persist_api_key(entered)
944
+ demo_core.persist_dotenv_values({"SYNTH_API_KEY": entered})
945
+ env = demo_core.load_env()
946
+ synth_key = (env.synth_api_key or "").strip()
947
+ if not env.dev_backend_url:
948
+ print("Backend URL missing. Set DEV_BACKEND_URL or BACKEND_OVERRIDE.")
461
949
  return 1
462
-
463
- task_url = env.task_app_base_url
464
- if not task_url or not _is_modal_public_url(task_url):
465
- # If we have an app name, offer to resolve from Modal first
466
- resolved = ""
467
- if env.task_app_name:
468
- try:
469
- choice = input(f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: ").strip().lower() or "y"
470
- if choice.startswith("y"):
471
- code, out = _popen_capture([
472
- "uv", "run", "python", "-m", "modal", "app", "url", env.task_app_name
473
- ])
474
- if code == 0 and out:
475
- for tok in out.split():
476
- if _is_modal_public_url(tok):
477
- resolved = tok.strip().rstrip("/")
478
- break
479
- except Exception:
480
- resolved = ""
481
- if not resolved:
482
- print("Task app URL not configured or not a valid Modal public URL.")
483
- print("Examples: https://<app-name>-fastapi-app.modal.run")
484
- entered = input("Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: ").strip()
485
- if not entered or not _is_modal_public_url(entered):
486
- print("Valid Task App URL is required. Run: uvx synth-ai rl_demo deploy")
487
- return 1
488
- task_url = entered.rstrip("/")
489
- else:
490
- task_url = resolved
491
- demo_core.persist_task_url(task_url, name=(env.task_app_name or None))
492
-
493
- app_name = env.task_app_name.strip()
494
- if not app_name:
495
- fallback = input("Enter Modal app name for the task app (required): ").strip()
496
- if not fallback:
497
- print("Task app name is required to configure Modal secrets.")
498
- return 1
499
- app_name = fallback
500
- demo_core.persist_task_url(task_url, name=app_name)
501
-
502
- secret_name = env.task_app_secret_name.strip() or f"{app_name}-secret"
503
- demo_core.persist_task_url(task_url, name=app_name)
504
- demo_core.persist_dotenv_values({
505
- "TASK_APP_BASE_URL": task_url,
506
- "TASK_APP_NAME": app_name,
507
- "TASK_APP_SECRET_NAME": secret_name,
508
- })
509
-
510
- # Ensure Modal secret has the environment API key (and optional extras).
511
- openai_key = (os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or "").strip()
512
- synth_for_secret = synth_key
513
-
514
- secret_values: dict[str, str] = {"ENVIRONMENT_API_KEY": env_key}
515
- if openai_key:
516
- secret_values["OPENAI_API_KEY"] = openai_key
517
- if synth_for_secret:
518
- secret_values["SYNTH_API_KEY"] = synth_for_secret
519
-
520
950
  try:
521
- _ensure_modal_secret(secret_name, values=secret_values, label="configure", replace=True)
522
- except RuntimeError as err:
523
- print(f"[configure] Failed to provision Modal secret: {err}")
524
- return 2
525
-
526
- # Verify task app can read the secret by hitting rollout health with X-API-Key.
527
- rollout_url = task_url.rstrip("/") + "/health/rollout"
528
- print("[configure] Verifying rollout health:")
529
- # Prefer rollout-specific health first (auth-aware), then plain /health
530
- health_base = task_url.rstrip("/")
531
- health_urls = [f"{health_base}/health/rollout", f"{health_base}/health"]
532
- rc = 0
533
- body = ""
534
- for h in health_urls:
535
- print("[configure] GET", h)
536
- rc, body = _http("GET", h, headers={"X-API-Key": env_key})
537
- if rc == 200:
538
- rollout_url = h
539
- break
540
- print("[configure] status:", rc)
951
+ env = _ensure_task_app_ready(env, synth_key, label="configure")
952
+ except RuntimeError as exc:
953
+ print(exc)
954
+ return 1
955
+ os.environ["ENVIRONMENT_API_KEY"] = env.env_api_key
541
956
  try:
542
- import json as _json
543
- preview = _json.dumps(body)[:800] if isinstance(body, dict) else str(body)[:800]
957
+ review = input("Review or create an RL config now? [Y/n]: ").strip().lower() or "y"
544
958
  except Exception:
545
- preview = str(body)[:800]
546
- print("[configure] body:", preview)
547
- if rc != 200:
548
- print(f"Warning: rollout health check failed ({rc}). Response: {body}")
549
- else:
550
- print("Task app rollout health check OK.")
551
-
552
- env.synth_api_key = synth_key
553
- env.env_api_key = env_key
554
- env.task_app_name = app_name
555
- env.task_app_secret_name = secret_name
556
-
557
- # Prefer the seeded CWD config if present; otherwise fall back to packaged default
558
- seeded_cfg = os.path.abspath(os.path.join(os.getcwd(), "demo_config.toml"))
559
- if os.path.isfile(seeded_cfg):
560
- base_path = seeded_cfg
561
- else:
562
- defaults = [
563
- os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml")),
564
- ]
565
- mono = "/Users/joshpurtell/Documents/GitHub/monorepo/tests/applications/math/rl/math_online.toml"
566
- if os.path.isfile(mono):
567
- defaults.append(mono)
568
- print("Select a baseline TOML:")
569
- for i, p in enumerate(defaults, 1):
570
- print(f" [{i}] {p}")
571
- choice = input(f"Enter choice [1-{len(defaults)}] (default 1): ").strip() or "1"
572
- try:
573
- idx = max(1, min(int(choice), len(defaults))) - 1
574
- except Exception:
575
- idx = 0
576
- base_path = defaults[idx]
577
- with open(base_path, "r") as fh:
578
- text = fh.read()
579
- import re
580
- # Extract current defaults from the selected TOML
581
- def _extract(pattern: str, default: str) -> str:
582
- m = re.search(pattern, text, flags=re.M)
583
- if not m:
584
- return default
585
- val = (m.group(1) or "").strip()
586
- return val if val else default
587
- current_gpu_type = _extract(r"^gpu_type\s*=\s*\"([^\"]+)\"$", "A100")
588
- # topology form gpu_type = "TYPE:COUNT" also supported for deriving defaults
589
- topo_gpu = _extract(r"^gpu_type\s*=\s*\"([^\":]+):(\d+)\"$", current_gpu_type)
590
- if ":" in topo_gpu:
591
- current_gpu_type = topo_gpu.split(":", 1)[0]
592
- current_gpu_count = _extract(r"^gpu_count\s*=\s*(\d+)$", "4")
593
- if ":" in topo_gpu:
594
- current_gpu_count = topo_gpu.split(":", 1)[1]
595
- current_model = _extract(r"^name\s*=\s*\"([^\"]+)\"$", "Qwen/Qwen3-0.6B")
596
- current_tp = _extract(r"^tensor_parallel_size\s*=\s*(\d+)$", "2")
597
-
598
- # Prompts with defaults shown; Enter keeps current
599
- def _prompt(label: str, default_val: str) -> str:
600
- entered = input(f"{label} [{default_val}]: ").strip()
601
- return entered or default_val
602
-
603
- gpu_type = _prompt("GPU type", current_gpu_type)
604
- gpu_count = _prompt("GPU count", current_gpu_count)
605
- model = _prompt("Model", current_model)
606
- tp = _prompt("Tensor parallel", current_tp)
607
-
608
- text = re.sub(r"(?m)^gpu_type\s*=\s*\".*?\"$", f"gpu_type = \"{gpu_type}\"", text)
609
- text = re.sub(r"(?m)^gpu_count\s*=\s*\d+$", f"gpu_count = {int(gpu_count)}", text)
610
- text = re.sub(r"(?m)^name\s*=\s*\".*?\"$", f"name = \"{model}\"", text)
611
- text = re.sub(r"(?m)^tensor_parallel_size\s*=\s*\d+$", f"tensor_parallel_size = {int(tp)}", text)
612
- text = re.sub(r"(?m)^gpu_type\s*=\s*\".*?:\d+\"$", f"gpu_type = \"{gpu_type}:{int(gpu_count)}\"", text)
613
- out_path = os.path.abspath(os.path.join(os.getcwd(), "demo_config.toml"))
614
- _write_text(out_path, text)
615
- print(f"Prepared config at: {out_path}")
616
- here_cfg = os.path.abspath(out_path)
617
- print("Config path:", here_cfg)
618
- print("Environment (masked):")
619
- print(json.dumps({
620
- "DEV_BACKEND_URL": env.dev_backend_url,
621
- "SYNTH_API_KEY": (synth_key[:6] + "…") if synth_key else "",
622
- "ENVIRONMENT_API_KEY": (env_key[:6] + "…") if env_key else "",
623
- "TASK_APP_BASE_URL": task_url,
624
- "TASK_APP_NAME": app_name,
625
- "TASK_APP_SECRET_NAME": secret_name,
626
- }, indent=2))
627
- print("Next: uvx synth-ai rl_demo run")
959
+ review = "y"
960
+ if review.startswith("y"):
961
+ _select_or_create_config(None, env)
962
+ print("Environment ready. Use `uvx synth-ai run` to launch an RL job.")
628
963
  return 0
629
964
 
630
965
 
@@ -685,7 +1020,7 @@ def cmd_init(args: argparse.Namespace) -> int:
685
1020
  shutil.copy2(src_modal, dst_task_py)
686
1021
 
687
1022
  # Create deploy script in synth_demo/
688
- deploy_text = """#!/usr/bin/env bash
1023
+ deploy_text = r"""#!/usr/bin/env bash
689
1024
  set -euo pipefail
690
1025
 
691
1026
  HERE=$(cd "$(dirname "$0")" && pwd)
@@ -742,11 +1077,7 @@ fi
742
1077
  if os.path.exists(dst_cfg):
743
1078
  print(f" - {dst_cfg} (seeded)")
744
1079
  print("")
745
- print("Next steps:")
746
- print(" 1) cd synth_demo && put your ENVIRONMENT_API_KEY in ./.env")
747
- print(" 2) Deploy to Modal:")
748
- print(" uvx bash ./deploy_task_app.sh")
749
- print(" 3) From project root, run: uvx synth-ai rl_demo configure; uvx synth-ai rl_demo run")
1080
+ print("\nNext step:\n$ uvx synth-ai setup")
750
1081
  return 0
751
1082
  except Exception as e:
752
1083
  print(f"Init error: {e}")
@@ -754,13 +1085,18 @@ fi
754
1085
 
755
1086
 
756
1087
  def _http(method: str, url: str, headers: Dict[str, str] | None = None, body: Dict[str, Any] | None = None) -> tuple[int, Dict[str, Any] | str]:
757
- import urllib.request, urllib.error, json as _json
1088
+ import urllib.request, urllib.error, json as _json, ssl
758
1089
  data = None
759
1090
  if body is not None:
760
1091
  data = _json.dumps(body).encode("utf-8")
761
1092
  req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
762
1093
  try:
763
- with urllib.request.urlopen(req, timeout=60) as resp:
1094
+ # Default: disable SSL verification for local/dev convenience.
1095
+ # Set SYNTH_SSL_VERIFY=1 to enable verification.
1096
+ ctx = ssl._create_unverified_context() # nosec: disabled by default for dev
1097
+ if os.getenv("SYNTH_SSL_VERIFY", "0") == "1":
1098
+ ctx = None
1099
+ with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
764
1100
  code = getattr(resp, "status", 200)
765
1101
  txt = resp.read().decode("utf-8", errors="ignore")
766
1102
  try:
@@ -788,8 +1124,11 @@ def _write_text(path: str, content: str) -> None:
788
1124
 
789
1125
  def cmd_run(args: argparse.Namespace) -> int:
790
1126
  env = demo_core.load_env()
791
- # Prompt for missing SYNTH_API_KEY
792
- if not env.synth_api_key:
1127
+ cwd_env_path = os.path.join(os.getcwd(), ".env")
1128
+ local_env = demo_core.load_dotenv_file(cwd_env_path)
1129
+
1130
+ synth_key = (env.synth_api_key or "").strip()
1131
+ if not synth_key:
793
1132
  entered = input("Enter SYNTH_API_KEY (required): ").strip()
794
1133
  if not entered:
795
1134
  print("SYNTH_API_KEY is required.")
@@ -797,19 +1136,32 @@ def cmd_run(args: argparse.Namespace) -> int:
797
1136
  os.environ["SYNTH_API_KEY"] = entered
798
1137
  demo_core.persist_api_key(entered)
799
1138
  demo_core.persist_dotenv_values({"SYNTH_API_KEY": entered})
800
- # Re-resolve env after potential persist
801
1139
  env = demo_core.load_env()
802
- if not env.task_app_base_url:
803
- print("Task app URL missing. Run: uvx synth-ai rl_demo deploy")
1140
+ synth_key = (env.synth_api_key or "").strip()
1141
+ if not synth_key:
1142
+ print("SYNTH_API_KEY missing after persist.")
804
1143
  return 1
1144
+
805
1145
  if not env.dev_backend_url:
806
- print("Backend URL missing. Set DEV_BACKEND_URL in a .env or rely on default prod.")
1146
+ print("Backend URL missing. Set DEV_BACKEND_URL or BACKEND_OVERRIDE.")
807
1147
  return 1
808
- if not env.env_api_key:
809
- print("ENVIRONMENT_API_KEY missing. Run: uvx synth-ai rl_demo configure")
1148
+
1149
+ try:
1150
+ env = _ensure_task_app_ready(env, synth_key, label="run")
1151
+ except RuntimeError as exc:
1152
+ print(exc)
810
1153
  return 1
1154
+
811
1155
  os.environ["ENVIRONMENT_API_KEY"] = env.env_api_key
812
1156
 
1157
+ import tomllib
1158
+
1159
+ try:
1160
+ cfg_path = _select_or_create_config(getattr(args, "config", None), env)
1161
+ except FileNotFoundError as exc:
1162
+ print(exc)
1163
+ return 1
1164
+
813
1165
  # Detect monorepo launcher and delegate if available (aligns with run_clustered.sh which works)
814
1166
  launcher = "/Users/joshpurtell/Documents/GitHub/monorepo/tests/applications/math/rl/start_math_clustered.py"
815
1167
  if os.path.isfile(launcher):
@@ -819,6 +1171,7 @@ def cmd_run(args: argparse.Namespace) -> int:
819
1171
  run_env["SYNTH_API_KEY"] = env.synth_api_key
820
1172
  run_env["TASK_APP_BASE_URL"] = env.task_app_base_url
821
1173
  run_env["ENVIRONMENT_API_KEY"] = env.env_api_key
1174
+ run_env["RL_CONFIG_PATH"] = cfg_path
822
1175
  # Optional: TRAINER_START_URL passthrough if already set in environment
823
1176
  run_env["TRAINER_START_URL"] = run_env.get("TRAINER_START_URL", "")
824
1177
  # Forward convenience knobs
@@ -849,46 +1202,6 @@ def cmd_run(args: argparse.Namespace) -> int:
849
1202
  return code
850
1203
 
851
1204
  # Fallback: legacy jobs API flow
852
- import tomllib
853
- # Determine config path: --config overrides; otherwise prompt from detected candidates
854
- cfg_path = None
855
- if getattr(args, "config", None):
856
- cfg_path = os.path.abspath(args.config)
857
- if not os.path.isfile(cfg_path):
858
- print(f"Config not found: {cfg_path}")
859
- return 1
860
- else:
861
- candidates: list[str] = []
862
- # Prepared in CWD and home
863
- cwd_prepared = os.path.abspath(os.path.join(os.getcwd(), "demo_config.toml"))
864
- home_prepared = os.path.expanduser("~/.synth-ai/demo_config.toml")
865
- if os.path.isfile(cwd_prepared):
866
- candidates.append(cwd_prepared)
867
- if os.path.isfile(home_prepared):
868
- candidates.append(home_prepared)
869
- # Monorepo math_online.toml if present
870
- mono = "/Users/joshpurtell/Documents/GitHub/monorepo/tests/applications/math/rl/math_online.toml"
871
- if os.path.isfile(mono):
872
- candidates.append(mono)
873
- # Packaged default
874
- packaged = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml"))
875
- candidates.append(packaged)
876
- # Deduplicate while preserving order
877
- seen = set()
878
- uniq: list[str] = []
879
- for p in candidates:
880
- if p not in seen:
881
- seen.add(p)
882
- uniq.append(p)
883
- print("Choose a TOML config:")
884
- for i, p in enumerate(uniq, 1):
885
- print(f" [{i}] {p}")
886
- sel = input(f"Enter choice [1-{len(uniq)}] (default 1): ").strip() or "1"
887
- try:
888
- idx = max(1, min(int(sel), len(uniq))) - 1
889
- except Exception:
890
- idx = 0
891
- cfg_path = uniq[idx]
892
1205
  with open(cfg_path, "rb") as fh:
893
1206
  inline_cfg = tomllib.load(fh)
894
1207
  with open(cfg_path, "r") as fh2:
@@ -899,6 +1212,15 @@ def cmd_run(args: argparse.Namespace) -> int:
899
1212
  inline_cfg.setdefault("training", {})["group_size"] = int(args.group_size)
900
1213
  model_name = args.model or (inline_cfg.get("model", {}) or {}).get("name", "Qwen/Qwen3-0.6B")
901
1214
  api = env.dev_backend_url.rstrip("/") + ("" if env.dev_backend_url.endswith("/api") else "/api")
1215
+ # Print backend and key preview before request for clearer diagnostics
1216
+ try:
1217
+ sk = (env.synth_api_key or "").strip()
1218
+ sk_len = len(sk)
1219
+ sk_tail = sk[-5:] if sk_len >= 5 else sk
1220
+ print(f"[run] Backend API: {api}")
1221
+ print(f"[run] Using SYNTH_API_KEY len={sk_len} last5={sk_tail}")
1222
+ except Exception:
1223
+ pass
902
1224
  data_fragment: Dict[str, Any] = {
903
1225
  "model": model_name,
904
1226
  "endpoint_base_url": env.task_app_base_url,
@@ -936,6 +1258,7 @@ def cmd_run(args: argparse.Namespace) -> int:
936
1258
  }, body=body)
937
1259
  if code not in (200, 201) or not isinstance(js, dict):
938
1260
  print("Job create failed:", code)
1261
+ print(f"Backend: {api}")
939
1262
  try:
940
1263
  if isinstance(js, dict):
941
1264
  print(json.dumps(js, indent=2))
@@ -962,7 +1285,14 @@ def cmd_run(args: argparse.Namespace) -> int:
962
1285
  print("Request body was:\n" + json.dumps(body, indent=2))
963
1286
  return 2
964
1287
  print("JOB_ID:", job_id)
965
- _http("POST", api + f"/rl/jobs/{job_id}/start", headers={"Authorization": f"Bearer {env.synth_api_key}"})
1288
+ # Original behavior: start job and stream status/events until terminal
1289
+ _http(
1290
+ "POST",
1291
+ api + f"/rl/jobs/{job_id}/start",
1292
+ headers={"Authorization": f"Bearer {env.synth_api_key}"},
1293
+ )
1294
+ # Inform the user immediately that the job has started and where to track it
1295
+ print("Your job is running. Visit usesynth.ai to view its progress")
966
1296
  since = 0
967
1297
  terminal = {"succeeded", "failed", "cancelled", "error", "completed"}
968
1298
  last_status = ""
@@ -976,7 +1306,10 @@ def cmd_run(args: argparse.Namespace) -> int:
976
1306
  if status and status.lower() in terminal:
977
1307
  print("FINAL:", status)
978
1308
  break
979
- ec, ej = _http("GET", api + f"/orchestration/jobs/{job_id}/events?since_seq={since}&limit=200")
1309
+ ec, ej = _http(
1310
+ "GET",
1311
+ api + f"/orchestration/jobs/{job_id}/events?since_seq={since}&limit=200",
1312
+ )
980
1313
  if ec == 200 and isinstance(ej, dict):
981
1314
  events = ej.get("events") or ej.get("data") or []
982
1315
  for e in events:
@@ -986,9 +1319,17 @@ def cmd_run(args: argparse.Namespace) -> int:
986
1319
  since = seq
987
1320
  typ = str(e.get("type") or e.get("event_type") or "").lower()
988
1321
  msg = e.get("message") or e.get("msg") or ""
989
- if typ in ("rl.eval.started", "rl.eval.summary", "rl.train.step", "rl.metrics", "rl.performance.metrics"):
1322
+ if typ in (
1323
+ "rl.eval.started",
1324
+ "rl.eval.summary",
1325
+ "rl.train.step",
1326
+ "rl.metrics",
1327
+ "rl.performance.metrics",
1328
+ ):
990
1329
  print(f"[{seq}] {typ}: {msg}")
991
- mc, mj = _http("GET", api + f"/learning/jobs/{job_id}/metrics?after_step=-1&limit=50")
1330
+ mc, mj = _http(
1331
+ "GET", api + f"/learning/jobs/{job_id}/metrics?after_step=-1&limit=50"
1332
+ )
992
1333
  if mc == 200 and isinstance(mj, dict):
993
1334
  pts = mj.get("points") or []
994
1335
  for p in pts:
@@ -1012,7 +1353,7 @@ def main(argv: list[str] | None = None) -> int:
1012
1353
  parser = sub.add_parser(name)
1013
1354
  configure(parser)
1014
1355
 
1015
- _add_parser(["rl_demo.check", "demo.check"], configure=lambda parser: parser.set_defaults(func=cmd_check))
1356
+ _add_parser(["rl_demo.setup", "demo.setup"], configure=lambda parser: parser.set_defaults(func=cmd_setup))
1016
1357
 
1017
1358
  def _init_opts(parser):
1018
1359
  parser.add_argument("--force", action="store_true", help="Overwrite existing files in CWD")
@@ -1025,13 +1366,13 @@ def main(argv: list[str] | None = None) -> int:
1025
1366
  def _deploy_opts(parser):
1026
1367
  parser.add_argument("--local", action="store_true", help="Run local FastAPI instead of Modal deploy")
1027
1368
  parser.add_argument("--app", type=str, default=None, help="Path to Modal app.py for uv run modal deploy")
1028
- parser.add_argument("--name", type=str, default="synth-math-demo", help="Modal app name")
1369
+ parser.add_argument("--name", type=str, default=None, help="Modal app name")
1029
1370
  parser.add_argument("--script", type=str, default=None, help="Path to deploy_task_app.sh (optional legacy)")
1030
1371
  parser.set_defaults(func=cmd_deploy)
1031
1372
 
1032
1373
  _add_parser(["rl_demo.deploy", "demo.deploy"], configure=_deploy_opts)
1033
1374
 
1034
- _add_parser(["rl_demo.configure", "demo.configure"], configure=lambda parser: parser.set_defaults(func=cmd_configure))
1375
+ _add_parser(["rl_demo.configure", "demo.configure"], configure=lambda parser: parser.set_defaults(func=cmd_run))
1035
1376
 
1036
1377
  def _run_opts(parser):
1037
1378
  parser.add_argument("--config", type=str, default=None, help="Path to TOML config (skip prompt)")
@@ -1042,7 +1383,7 @@ def main(argv: list[str] | None = None) -> int:
1042
1383
  parser.add_argument("--dry-run", action="store_true", help="Print request body and exit")
1043
1384
  parser.set_defaults(func=cmd_run)
1044
1385
 
1045
- _add_parser(["rl_demo.run", "demo.run"], configure=_run_opts)
1386
+ _add_parser(["run", "rl_demo.run", "demo.run"], configure=_run_opts)
1046
1387
 
1047
1388
  args = p.parse_args(argv)
1048
1389
  if not hasattr(args, "func"):