synth-ai 0.2.6.dev6__py3-none-any.whl → 0.2.8.dev1__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
 
@@ -82,26 +115,14 @@ def cmd_check(_args: argparse.Namespace) -> int:
82
115
  os.environ["TASK_APP_BASE_URL"] = new_url
83
116
  _refresh_env()
84
117
 
118
+ # Keys have been written already via handshake; avoid any interactive prompts
85
119
  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}")
120
+ if not local_env.get("SYNTH_API_KEY") and synth_key:
121
+ demo_core.persist_dotenv_values({"SYNTH_API_KEY": synth_key})
101
122
  _refresh_env()
102
123
 
124
+ # Check Modal auth silently to avoid noisy output
103
125
  modal_ok, modal_msg = demo_core.modal_auth_status()
104
- print(f"Modal auth: {'OK' if modal_ok else 'MISSING'} ({modal_msg})")
105
126
 
106
127
  _maybe_fix_task_url()
107
128
 
@@ -110,32 +131,18 @@ def cmd_check(_args: argparse.Namespace) -> int:
110
131
  if env.dev_backend_url:
111
132
  api = env.dev_backend_url.rstrip("/") + ("" if env.dev_backend_url.endswith("/api") else "/api")
112
133
  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.")
134
+ # Intentionally suppress backend health print for concise output
116
135
  if env.task_app_base_url:
117
136
  ok_task = demo_core.assert_http_ok(env.task_app_base_url.rstrip("/") + "/health", method="GET") or \
118
137
  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})")
138
+ # Intentionally suppress task app health print
120
139
  else:
121
- print("Task app URL not set; run: uvx synth-ai rl_demo deploy")
140
+ print("\nSet your task app URL by running:\nuvx synth-ai rl_demo deploy\n")
122
141
 
123
- print("uv: ", end="")
124
- try:
125
- import subprocess
142
+ # Omit uv version print to keep output concise
126
143
 
127
- subprocess.check_call(["uv", "--version"])
128
- except Exception:
129
- print("(uv not found; install with `pip install uv`)\n", flush=True)
130
-
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
144
+ # Keep exit code neutral; not all checks are critical for pairing
145
+ return 0
139
146
 
140
147
 
141
148
  def _popen_capture(cmd: list[str], cwd: str | None = None, env: dict | None = None) -> tuple[int, str]:
@@ -273,6 +280,433 @@ def _ensure_modal_secret(
273
280
  return True
274
281
 
275
282
 
283
+ def _fmt_float(value: float) -> str:
284
+ return f"{value:.10g}"
285
+
286
+
287
+ def _find_asgi_apps(root: Path) -> list[Path]:
288
+ """Recursively search for Python files that declare a Modal ASGI app.
289
+
290
+ A file is considered a Modal task app candidate if it contains one of:
291
+ - "@asgi_app()"
292
+ - "@modal.asgi_app()"
293
+ """
294
+ results: list[Path] = []
295
+ skip_dirs = {".git", ".hg", ".svn", "node_modules", "dist", "build", "__pycache__", ".ruff_cache", ".mypy_cache", "venv", ".venv"}
296
+ for dirpath, dirnames, filenames in os.walk(root):
297
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
298
+ for name in filenames:
299
+ if not name.endswith(".py"):
300
+ continue
301
+ path = Path(dirpath) / name
302
+ try:
303
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
304
+ txt = fh.read()
305
+ if ("@asgi_app()" in txt) or ("@modal.asgi_app()" in txt):
306
+ results.append(path)
307
+ except Exception:
308
+ continue
309
+ # Stable order: prioritize files under synth_demo/ first, then alphabetical
310
+ def _priority(p: Path) -> tuple[int, str]:
311
+ rel = str(p.resolve())
312
+ in_demo = "/synth_demo/" in rel or rel.endswith("/synth_demo/task_app.py")
313
+ return (0 if in_demo else 1, rel)
314
+ results.sort(key=_priority)
315
+ return results
316
+
317
+
318
+ def _prompt_value(label: str, default: str | int | float, cast: Callable[[str], Any] | None = None) -> Any:
319
+ prompt = f"{label} [{default}]: "
320
+ try:
321
+ raw = input(prompt).strip()
322
+ except Exception:
323
+ raw = ""
324
+ if not raw:
325
+ return default
326
+ if cast is None:
327
+ return raw
328
+ try:
329
+ return cast(raw)
330
+ except Exception:
331
+ print(f"Invalid value; keeping default {default}")
332
+ return default
333
+
334
+
335
+ def _find_vllm_tomls(root: Path) -> list[Path]:
336
+ results: list[Path] = []
337
+ skip_dirs = {".git", ".hg", ".svn", "node_modules", "dist", "build", "__pycache__", ".ruff_cache", ".mypy_cache", "venv", ".venv"}
338
+ for dirpath, dirnames, filenames in os.walk(root):
339
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
340
+ for name in filenames:
341
+ if not name.endswith(".toml"):
342
+ continue
343
+ path = Path(dirpath) / name
344
+ try:
345
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
346
+ if "[vllm]" in fh.read().lower():
347
+ results.append(path)
348
+ except Exception:
349
+ continue
350
+ return results
351
+
352
+
353
+ def _create_new_config(env: DemoEnv) -> str:
354
+ default_path = os.path.join(os.getcwd(), "demo_config.toml")
355
+ while True:
356
+ try:
357
+ destination = input(f"Path to save new config [{default_path}]: ").strip() or default_path
358
+ except Exception:
359
+ destination = default_path
360
+ destination = os.path.abspath(destination)
361
+ if os.path.isdir(destination):
362
+ print("Path points to a directory; provide a file path.")
363
+ continue
364
+ if os.path.exists(destination):
365
+ try:
366
+ overwrite = input(f"{destination} exists. Overwrite? [y/N]: ").strip().lower() or "n"
367
+ except Exception:
368
+ overwrite = "n"
369
+ if not overwrite.startswith("y"):
370
+ continue
371
+ break
372
+
373
+ env_name = _prompt_value("Environment name", "Crafter")
374
+ policy_name = _prompt_value("Policy name", "crafter-react")
375
+ model_name = _prompt_value("Model name", "Qwen/Qwen3-0.6B")
376
+ compute_gpu_type = _prompt_value("Compute GPU type", "H100")
377
+ compute_gpu_count = _prompt_value("Compute GPU count", 4, int)
378
+ topology_gpu_type = _prompt_value("Topology GPU type", f"{compute_gpu_type}:{compute_gpu_count}")
379
+ gpus_for_vllm = _prompt_value("Topology gpus_for_vllm", 2, int)
380
+ gpus_for_training = _prompt_value("Topology gpus_for_training", 1, int)
381
+ tensor_parallel = _prompt_value("Topology tensor_parallel", 2, int)
382
+ gpus_for_ref = _prompt_value("Topology gpus_for_ref", 1, int)
383
+ vllm_tp_size = _prompt_value("vLLM tensor parallel size", tensor_parallel, int)
384
+ vllm_max_model_len = _prompt_value("vLLM max_model_len", 8192, int)
385
+ vllm_max_num_seqs = _prompt_value("vLLM max_num_seqs", 32, int)
386
+ vllm_gpu_mem_util = _prompt_value("vLLM gpu_memory_utilization", 0.9, float)
387
+ vllm_max_parallel = _prompt_value("vLLM max_parallel_generations", 4, int)
388
+ training_num_epochs = _prompt_value("Training num_epochs", 1, int)
389
+ training_iters = _prompt_value("Training iterations_per_epoch", 2, int)
390
+ training_batch = _prompt_value("Training batch_size", 1, int)
391
+ training_group = _prompt_value("Training group_size", 8, int)
392
+ training_lr = _prompt_value("Training learning_rate", 5e-6, float)
393
+ task_url_default = env.task_app_base_url or ""
394
+ services_task_url = _prompt_value("services.task_url", task_url_default)
395
+
396
+ template = textwrap.dedent(
397
+ f"""\
398
+ # Crafter online RL training configuration (research local copy)
399
+
400
+ [model]
401
+ #name = \"fft:Qwen/Qwen3-4B:job_7243b8aa76fe4b59\"
402
+ name = \"{model_name}\"
403
+ dtype = \"bfloat16\"
404
+ seed = 42
405
+ trainer_mode = \"full\"
406
+
407
+ [lora]
408
+ r = 16
409
+ alpha = 32
410
+ dropout = 0.05
411
+ target_modules = [
412
+ \"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",
413
+ \"gate_proj\", \"up_proj\", \"down_proj\",
414
+ ]
415
+
416
+ [rdma]
417
+ enabled = false
418
+ ifname = \"eth0\"
419
+ ip_type = \"ipv4\"
420
+ p2p_disable = 0
421
+ shm_disable = 0
422
+ fast_nccl = false
423
+
424
+ gid_index = 3
425
+ cross_nic = 0
426
+ collnet_enable = 0
427
+ net_gdr_level = 2
428
+
429
+ nsocks_perthread = 4
430
+ socket_nthreads = 2
431
+
432
+ algo = \"Ring\"
433
+ proto = \"Simple\"
434
+ p2p_level = \"SYS\"
435
+ debug = \"INFO\"
436
+
437
+ [compute]
438
+ gpu_type = \"{compute_gpu_type}\"
439
+ gpu_count = {compute_gpu_count}
440
+
441
+ [topology]
442
+ type = \"single_node_split\"
443
+ gpu_type = \"{topology_gpu_type}\"
444
+ use_rdma = false
445
+ gpus_for_vllm = {gpus_for_vllm}
446
+ gpus_for_training = {gpus_for_training}
447
+ tensor_parallel = {tensor_parallel}
448
+ gpus_for_ref = {gpus_for_ref}
449
+
450
+ [vllm]
451
+ tensor_parallel_size = {vllm_tp_size}
452
+ gpu_memory_utilization = {_fmt_float(vllm_gpu_mem_util)}
453
+ max_model_len = {vllm_max_model_len}
454
+ max_num_seqs = {vllm_max_num_seqs}
455
+ enforce_eager = false
456
+ max_parallel_generations = {vllm_max_parallel}
457
+
458
+ # Reference scoring server (dedicated GPU)
459
+ [reference]
460
+ placement = \"dedicated\"
461
+ gpu_index = 1
462
+ port = 8002
463
+ tp = 1
464
+ health_max_wait_s = 180
465
+ health_interval_ms = 300
466
+
467
+ [training]
468
+ num_epochs = {training_num_epochs}
469
+ iterations_per_epoch = {training_iters}
470
+ batch_size = {training_batch}
471
+ group_size = {training_group}
472
+ learning_rate = {_fmt_float(training_lr)}
473
+ max_grad_norm = 0.5
474
+ log_interval = 1
475
+ update_reference_interval = 0
476
+ weight_sync_interval = 1
477
+
478
+ [training.weight_sync]
479
+ enable = true
480
+ targets = [\"policy\"]
481
+
482
+ [rollout]
483
+ env_name = \"{env_name}\"
484
+ policy_name = \"{policy_name}\"
485
+ env_config = {{}}
486
+ max_steps_per_episode = 5
487
+ sampling_temperature = 0.3
488
+ sampling_top_p = 0.95
489
+ max_tokens = 1024
490
+ max_concurrent_rollouts = 4
491
+ ops_per_rollout = 14
492
+ on_done = \"reset\"
493
+ thinking_mode = \"think\"
494
+ thinking_budget = 512
495
+
496
+ [policy]
497
+ config = {{}}
498
+
499
+ [evaluation]
500
+ seeds = [0, 1, 2, 3, 4, 5, 6, 7]
501
+ rollouts_per_seed = 1
502
+ instances = 0
503
+ max_concurrent_rollouts = 4
504
+ thinking_mode = \"think\"
505
+ every_n_iters = 5
506
+
507
+ [hyperparams]
508
+ epsilon_low = 0.1
509
+ epsilon_high = 0.3
510
+ delta = 5.0
511
+ beta = 0.01
512
+ kl_penalty = 0.01
513
+ advantage_normalization = true
514
+ group_normalization = true
515
+ num_inner_steps = 1
516
+ clip_epsilon = 0.2
517
+ completion_only = false
518
+
519
+ [step_rewards]
520
+ enabled = false
521
+ mode = \"off\"
522
+ step_beta = 0.0
523
+ indicator_lambda = 0.0
524
+
525
+ [trainer]
526
+ allow_ref_fallback = false
527
+
528
+ [checkpoint]
529
+ interval = 10
530
+ directory = \"/checkpoints\"
531
+ keep_last_n = 3
532
+ save_optimizer = true
533
+ save_scheduler = true
534
+ enabled = true
535
+
536
+ [services]
537
+ task_url = \"{services_task_url}\"
538
+ """
539
+ ).strip() + "\n"
540
+
541
+ with open(destination, "w", encoding="utf-8") as fh:
542
+ fh.write(template)
543
+ print(f"Wrote config to {destination}")
544
+ return destination
545
+
546
+
547
+ def _select_or_create_config(explicit: str | None, env: DemoEnv) -> str:
548
+ if explicit:
549
+ path = os.path.abspath(explicit)
550
+ if not os.path.isfile(path):
551
+ raise FileNotFoundError(f"Config not found: {path}")
552
+ return path
553
+
554
+ search_root = Path(os.getcwd())
555
+ discovered = _find_vllm_tomls(search_root)
556
+
557
+ extras: list[Path] = []
558
+ packaged = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml")))
559
+ extras.append(packaged)
560
+ home_cfg = Path(os.path.expanduser("~/.synth-ai/demo_config.toml"))
561
+ extras.append(home_cfg)
562
+
563
+ all_paths: list[Path] = []
564
+ seen: set[str] = set()
565
+ for candidate in discovered + extras:
566
+ if candidate.is_file():
567
+ resolved = str(candidate.resolve())
568
+ if resolved not in seen:
569
+ seen.add(resolved)
570
+ all_paths.append(candidate)
571
+
572
+ if not all_paths:
573
+ print("No existing RL TOML configs with [vllm] found; creating a new one.")
574
+ return _create_new_config(env)
575
+
576
+ print("Select a TOML config (found [vllm] section):")
577
+ for idx, path in enumerate(all_paths, 1):
578
+ rel = os.path.relpath(str(path), os.getcwd())
579
+ print(f" [{idx}] {rel}")
580
+ create_idx = len(all_paths) + 1
581
+ print(f" [{create_idx}] Create new config")
582
+ try:
583
+ sel = input(f"Enter choice [1-{create_idx}] (default 1): ").strip() or "1"
584
+ except Exception:
585
+ sel = "1"
586
+ try:
587
+ choice = int(sel)
588
+ except Exception:
589
+ choice = 1
590
+ if choice == create_idx:
591
+ return _create_new_config(env)
592
+ choice = max(1, min(choice, len(all_paths)))
593
+ selected = os.path.abspath(all_paths[choice - 1])
594
+ print(f"Using config: {selected}")
595
+ return selected
596
+
597
+
598
+ def _ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoEnv:
599
+ cwd_env_path = os.path.join(os.getcwd(), ".env")
600
+ local_env = demo_core.load_dotenv_file(cwd_env_path)
601
+
602
+ env_key = (env.env_api_key or "").strip()
603
+ if not env_key:
604
+ raise RuntimeError(f"[{label}] ENVIRONMENT_API_KEY missing. Run `uvx synth-ai rl_demo deploy` first.")
605
+
606
+ task_url = env.task_app_base_url
607
+ if not task_url or not _is_modal_public_url(task_url):
608
+ resolved = ""
609
+ if env.task_app_name:
610
+ try:
611
+ choice = input(
612
+ f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: "
613
+ ).strip().lower() or "y"
614
+ except Exception:
615
+ choice = "y"
616
+ if choice.startswith("y"):
617
+ code, out = _popen_capture([
618
+ "uv",
619
+ "run",
620
+ "python",
621
+ "-m",
622
+ "modal",
623
+ "app",
624
+ "url",
625
+ env.task_app_name,
626
+ ])
627
+ if code == 0 and out:
628
+ for tok in out.split():
629
+ if _is_modal_public_url(tok):
630
+ resolved = tok.strip().rstrip("/")
631
+ break
632
+ if not resolved:
633
+ print(f"[{label}] Task app URL not configured or not a valid Modal public URL.")
634
+ print("Examples: https://<app-name>-fastapi-app.modal.run")
635
+ entered = input("Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: ").strip()
636
+ if not entered or not _is_modal_public_url(entered):
637
+ raise RuntimeError(f"[{label}] Valid Task App URL is required.")
638
+ task_url = entered.rstrip("/")
639
+ else:
640
+ task_url = resolved
641
+ demo_core.persist_task_url(task_url, name=(env.task_app_name or None))
642
+
643
+ app_name = env.task_app_name.strip()
644
+ if not app_name:
645
+ fallback = input("Enter Modal app name for the task app (required): ").strip()
646
+ if not fallback:
647
+ raise RuntimeError(f"[{label}] Task app name is required.")
648
+ app_name = fallback
649
+ demo_core.persist_task_url(task_url, name=app_name)
650
+
651
+ secret_name = env.task_app_secret_name.strip() or f"{app_name}-secret"
652
+ demo_core.persist_task_url(task_url, name=app_name)
653
+ demo_core.persist_dotenv_values({
654
+ "TASK_APP_BASE_URL": task_url,
655
+ "TASK_APP_NAME": app_name,
656
+ "TASK_APP_SECRET_NAME": secret_name,
657
+ })
658
+
659
+ openai_key = (os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or "").strip()
660
+ secret_values: dict[str, str] = {"ENVIRONMENT_API_KEY": env_key}
661
+ if openai_key:
662
+ secret_values["OPENAI_API_KEY"] = openai_key
663
+ if synth_key:
664
+ secret_values["SYNTH_API_KEY"] = synth_key
665
+
666
+ _ensure_modal_secret(secret_name, values=secret_values, label=label, replace=True)
667
+
668
+ rollout_url = task_url.rstrip("/") + "/health/rollout"
669
+ print(f"[{label}] Verifying rollout health:")
670
+ try:
671
+ ek = (env_key or "").strip()
672
+ ek_len = len(ek)
673
+ ek_tail = ek[-5:] if ek_len >= 5 else ek
674
+ print(f"[{label}] Using ENVIRONMENT_API_KEY len={ek_len} last5={ek_tail}")
675
+ except Exception:
676
+ pass
677
+ health_base = task_url.rstrip("/")
678
+ health_urls = [f"{health_base}/health/rollout", f"{health_base}/health"]
679
+ rc = 0
680
+ body: Any = ""
681
+ for h in health_urls:
682
+ print(f"[{label}] GET", h)
683
+ rc, body = _http("GET", h, headers={"X-API-Key": env_key})
684
+ if rc == 200:
685
+ rollout_url = h
686
+ break
687
+ print(f"[{label}] status: {rc}")
688
+ try:
689
+ import json as _json
690
+
691
+ preview = _json.dumps(body)[:800] if isinstance(body, dict) else str(body)[:800]
692
+ except Exception:
693
+ preview = str(body)[:800]
694
+ print(f"[{label}] body:", preview)
695
+ if rc != 200:
696
+ print(f"[{label}] Warning: rollout health check failed ({rc}). Response: {body}")
697
+ else:
698
+ print(f"[{label}] Task app rollout health check OK.")
699
+
700
+ os.environ["TASK_APP_BASE_URL"] = task_url
701
+ os.environ["ENVIRONMENT_API_KEY"] = env_key
702
+ updated_env = demo_core.load_env()
703
+ updated_env.env_api_key = env_key
704
+ updated_env.task_app_base_url = task_url
705
+ updated_env.task_app_name = app_name
706
+ updated_env.task_app_secret_name = secret_name
707
+ return updated_env
708
+
709
+
276
710
  def cmd_deploy(args: argparse.Namespace) -> int:
277
711
  env = demo_core.load_env()
278
712
  cwd_env_path = os.path.join(os.getcwd(), ".env")
@@ -293,10 +727,10 @@ def cmd_deploy(args: argparse.Namespace) -> int:
293
727
  break
294
728
  time.sleep(1)
295
729
  else:
296
- # Auto-detect app path if not supplied; prompt for name and confirmation.
730
+ # Auto-detect app path if not supplied; prompt interactively from discovered ASGI apps
297
731
  app_path = os.path.abspath(args.app) if args.app else None
298
732
  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
733
+ # First pass: look for known common filenames
300
734
  candidates = [
301
735
  os.path.abspath(os.path.join(os.getcwd(), "synth_demo", "task_app.py")),
302
736
  os.path.abspath(os.path.join(os.getcwd(), "task_app.py")),
@@ -304,6 +738,24 @@ def cmd_deploy(args: argparse.Namespace) -> int:
304
738
  os.path.abspath(os.path.join(os.getcwd(), "math_task_app.py")),
305
739
  ]
306
740
  app_path = next((p for p in candidates if os.path.isfile(p)), None)
741
+ # If still not found, scan for any file containing @asgi_app()
742
+ if not app_path:
743
+ found = _find_asgi_apps(Path(os.getcwd()))
744
+ if found:
745
+ print("Select a Modal ASGI app to deploy:")
746
+ for idx, pth in enumerate(found, 1):
747
+ rel = os.path.relpath(str(pth), os.getcwd())
748
+ print(f" [{idx}] {rel}")
749
+ try:
750
+ sel = input(f"Enter choice [1-{len(found)}] (default 1): ").strip() or "1"
751
+ except Exception:
752
+ sel = "1"
753
+ try:
754
+ choice = int(sel)
755
+ except Exception:
756
+ choice = 1
757
+ choice = max(1, min(choice, len(found)))
758
+ app_path = str(found[choice - 1].resolve())
307
759
  if not app_path and args.script:
308
760
  # Legacy script fallback if user supplied --script explicitly
309
761
  from synth_ai.demos.demo_task_apps.math.deploy_modal import deploy as modal_deploy
@@ -332,7 +784,18 @@ def cmd_deploy(args: argparse.Namespace) -> int:
332
784
  return 1
333
785
 
334
786
  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
787
+ existing_env_key = (env.env_api_key or "").strip()
788
+ env_key: str | None = existing_env_key or None
789
+ if existing_env_key:
790
+ try:
791
+ reuse_choice = input(
792
+ "Use existing ENVIRONMENT_API_KEY from state/.env? [Y/n]: "
793
+ ).strip().lower() or "y"
794
+ except Exception:
795
+ reuse_choice = "y"
796
+ if not reuse_choice.startswith("y"):
797
+ env_key = None
798
+
336
799
  if env_key is None:
337
800
  from synth_ai.rl.secrets import mint_environment_api_key
338
801
 
@@ -343,6 +806,33 @@ def cmd_deploy(args: argparse.Namespace) -> int:
343
806
  env.env_api_key = env_key
344
807
  local_env["ENVIRONMENT_API_KEY"] = env_key
345
808
  print("[deploy] Minted new ENVIRONMENT_API_KEY")
809
+
810
+ # Optionally upload the new key to the backend using sealed box helper
811
+ backend_base = env.dev_backend_url or ""
812
+ synth_key = (env.synth_api_key or os.environ.get("SYNTH_API_KEY") or local_env.get("SYNTH_API_KEY") or "").strip()
813
+ if backend_base and synth_key:
814
+ backend_base = backend_base.rstrip("/")
815
+ if not backend_base.endswith("/api"):
816
+ backend_base = f"{backend_base}/api"
817
+ try:
818
+ choice = input(
819
+ f"Upload ENVIRONMENT_API_KEY to backend {backend_base}? [Y/n]: "
820
+ ).strip().lower() or "y"
821
+ except Exception:
822
+ choice = "y"
823
+ if choice.startswith("y"):
824
+ try:
825
+ print(f"[deploy] Uploading ENVIRONMENT_API_KEY to {backend_base} …")
826
+ from synth_ai.rl.env_keys import setup_environment_api_key
827
+
828
+ setup_environment_api_key(backend_base.rstrip("/"), synth_key, token=env_key)
829
+ print("[deploy] Backend sealed-box upload complete.")
830
+ except Exception as upload_err:
831
+ print(f"[deploy] Failed to upload ENVIRONMENT_API_KEY: {upload_err}")
832
+ print(
833
+ "Hint: run `uvx python -c \"from synth_ai.rl.env_keys import setup_environment_api_key as s;"
834
+ " s('<backend>', '<synth_api_key>')\"` once the backend is reachable."
835
+ )
346
836
 
347
837
  synth_key = (env.synth_api_key or os.environ.get("SYNTH_API_KEY") or local_env.get("SYNTH_API_KEY") or "").strip()
348
838
  if not synth_key:
@@ -356,7 +846,9 @@ def cmd_deploy(args: argparse.Namespace) -> int:
356
846
 
357
847
  openai_key = (os.environ.get("OPENAI_API_KEY") or local_env.get("OPENAI_API_KEY") or "").strip()
358
848
  if not openai_key:
359
- openai_key = input("Enter OPENAI_API_KEY for Modal secret (required): ").strip()
849
+ openai_key = input(
850
+ "Enter your OpenAI API key, found at https://platform.openai.com/api-keys\n> "
851
+ ).strip()
360
852
  if not openai_key:
361
853
  print("OPENAI_API_KEY is required to create the Modal secret.")
362
854
  return 1
@@ -434,197 +926,42 @@ def cmd_deploy(args: argparse.Namespace) -> int:
434
926
  print(f" export TASK_APP_NAME={app_name}")
435
927
  print(f" export TASK_APP_SECRET_NAME={app_name}-secret")
436
928
  print(f"Persisted to {dotenv_path}")
437
- print("Next: uvx synth-ai rl_demo configure")
929
+ print("Next: uvx synth-ai run")
438
930
  return 0
439
931
  except Exception as e:
440
932
  print(f"Deploy error: {e}")
441
933
  return 2
442
934
 
443
935
 
444
- def cmd_configure(args: argparse.Namespace) -> int:
936
+ print("`rl_demo configure` prepares environment and secrets; `synth-ai run` now handles launches.")
445
937
  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()
938
+ synth_key = (env.synth_api_key or "").strip()
450
939
  if not synth_key:
451
- synth_key = input("Enter SYNTH_API_KEY (required): ").strip()
452
- if not synth_key:
940
+ entered = input("Enter SYNTH_API_KEY (required): ").strip()
941
+ if not entered:
453
942
  print("SYNTH_API_KEY is required.")
454
943
  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.")
944
+ os.environ["SYNTH_API_KEY"] = entered
945
+ demo_core.persist_api_key(entered)
946
+ demo_core.persist_dotenv_values({"SYNTH_API_KEY": entered})
947
+ env = demo_core.load_env()
948
+ synth_key = (env.synth_api_key or "").strip()
949
+ if not env.dev_backend_url:
950
+ print("Backend URL missing. Set DEV_BACKEND_URL or BACKEND_OVERRIDE.")
461
951
  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
952
  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)
953
+ env = _ensure_task_app_ready(env, synth_key, label="configure")
954
+ except RuntimeError as exc:
955
+ print(exc)
956
+ return 1
957
+ os.environ["ENVIRONMENT_API_KEY"] = env.env_api_key
541
958
  try:
542
- import json as _json
543
- preview = _json.dumps(body)[:800] if isinstance(body, dict) else str(body)[:800]
959
+ review = input("Review or create an RL config now? [Y/n]: ").strip().lower() or "y"
544
960
  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")
961
+ review = "y"
962
+ if review.startswith("y"):
963
+ _select_or_create_config(None, env)
964
+ print("Environment ready. Use `uvx synth-ai run` to launch an RL job.")
628
965
  return 0
629
966
 
630
967
 
@@ -685,7 +1022,7 @@ def cmd_init(args: argparse.Namespace) -> int:
685
1022
  shutil.copy2(src_modal, dst_task_py)
686
1023
 
687
1024
  # Create deploy script in synth_demo/
688
- deploy_text = """#!/usr/bin/env bash
1025
+ deploy_text = r"""#!/usr/bin/env bash
689
1026
  set -euo pipefail
690
1027
 
691
1028
  HERE=$(cd "$(dirname "$0")" && pwd)
@@ -746,7 +1083,7 @@ fi
746
1083
  print(" 1) cd synth_demo && put your ENVIRONMENT_API_KEY in ./.env")
747
1084
  print(" 2) Deploy to Modal:")
748
1085
  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")
1086
+ print(" 3) From project root, run: uvx synth-ai run")
750
1087
  return 0
751
1088
  except Exception as e:
752
1089
  print(f"Init error: {e}")
@@ -754,13 +1091,18 @@ fi
754
1091
 
755
1092
 
756
1093
  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
1094
+ import urllib.request, urllib.error, json as _json, ssl
758
1095
  data = None
759
1096
  if body is not None:
760
1097
  data = _json.dumps(body).encode("utf-8")
761
1098
  req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
762
1099
  try:
763
- with urllib.request.urlopen(req, timeout=60) as resp:
1100
+ # Default: disable SSL verification for local/dev convenience.
1101
+ # Set SYNTH_SSL_VERIFY=1 to enable verification.
1102
+ ctx = ssl._create_unverified_context() # nosec: disabled by default for dev
1103
+ if os.getenv("SYNTH_SSL_VERIFY", "0") == "1":
1104
+ ctx = None
1105
+ with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
764
1106
  code = getattr(resp, "status", 200)
765
1107
  txt = resp.read().decode("utf-8", errors="ignore")
766
1108
  try:
@@ -788,8 +1130,11 @@ def _write_text(path: str, content: str) -> None:
788
1130
 
789
1131
  def cmd_run(args: argparse.Namespace) -> int:
790
1132
  env = demo_core.load_env()
791
- # Prompt for missing SYNTH_API_KEY
792
- if not env.synth_api_key:
1133
+ cwd_env_path = os.path.join(os.getcwd(), ".env")
1134
+ local_env = demo_core.load_dotenv_file(cwd_env_path)
1135
+
1136
+ synth_key = (env.synth_api_key or "").strip()
1137
+ if not synth_key:
793
1138
  entered = input("Enter SYNTH_API_KEY (required): ").strip()
794
1139
  if not entered:
795
1140
  print("SYNTH_API_KEY is required.")
@@ -797,19 +1142,32 @@ def cmd_run(args: argparse.Namespace) -> int:
797
1142
  os.environ["SYNTH_API_KEY"] = entered
798
1143
  demo_core.persist_api_key(entered)
799
1144
  demo_core.persist_dotenv_values({"SYNTH_API_KEY": entered})
800
- # Re-resolve env after potential persist
801
1145
  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")
1146
+ synth_key = (env.synth_api_key or "").strip()
1147
+ if not synth_key:
1148
+ print("SYNTH_API_KEY missing after persist.")
804
1149
  return 1
1150
+
805
1151
  if not env.dev_backend_url:
806
- print("Backend URL missing. Set DEV_BACKEND_URL in a .env or rely on default prod.")
1152
+ print("Backend URL missing. Set DEV_BACKEND_URL or BACKEND_OVERRIDE.")
807
1153
  return 1
808
- if not env.env_api_key:
809
- print("ENVIRONMENT_API_KEY missing. Run: uvx synth-ai rl_demo configure")
1154
+
1155
+ try:
1156
+ env = _ensure_task_app_ready(env, synth_key, label="run")
1157
+ except RuntimeError as exc:
1158
+ print(exc)
810
1159
  return 1
1160
+
811
1161
  os.environ["ENVIRONMENT_API_KEY"] = env.env_api_key
812
1162
 
1163
+ import tomllib
1164
+
1165
+ try:
1166
+ cfg_path = _select_or_create_config(getattr(args, "config", None), env)
1167
+ except FileNotFoundError as exc:
1168
+ print(exc)
1169
+ return 1
1170
+
813
1171
  # Detect monorepo launcher and delegate if available (aligns with run_clustered.sh which works)
814
1172
  launcher = "/Users/joshpurtell/Documents/GitHub/monorepo/tests/applications/math/rl/start_math_clustered.py"
815
1173
  if os.path.isfile(launcher):
@@ -819,6 +1177,7 @@ def cmd_run(args: argparse.Namespace) -> int:
819
1177
  run_env["SYNTH_API_KEY"] = env.synth_api_key
820
1178
  run_env["TASK_APP_BASE_URL"] = env.task_app_base_url
821
1179
  run_env["ENVIRONMENT_API_KEY"] = env.env_api_key
1180
+ run_env["RL_CONFIG_PATH"] = cfg_path
822
1181
  # Optional: TRAINER_START_URL passthrough if already set in environment
823
1182
  run_env["TRAINER_START_URL"] = run_env.get("TRAINER_START_URL", "")
824
1183
  # Forward convenience knobs
@@ -849,46 +1208,6 @@ def cmd_run(args: argparse.Namespace) -> int:
849
1208
  return code
850
1209
 
851
1210
  # 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
1211
  with open(cfg_path, "rb") as fh:
893
1212
  inline_cfg = tomllib.load(fh)
894
1213
  with open(cfg_path, "r") as fh2:
@@ -899,6 +1218,15 @@ def cmd_run(args: argparse.Namespace) -> int:
899
1218
  inline_cfg.setdefault("training", {})["group_size"] = int(args.group_size)
900
1219
  model_name = args.model or (inline_cfg.get("model", {}) or {}).get("name", "Qwen/Qwen3-0.6B")
901
1220
  api = env.dev_backend_url.rstrip("/") + ("" if env.dev_backend_url.endswith("/api") else "/api")
1221
+ # Print backend and key preview before request for clearer diagnostics
1222
+ try:
1223
+ sk = (env.synth_api_key or "").strip()
1224
+ sk_len = len(sk)
1225
+ sk_tail = sk[-5:] if sk_len >= 5 else sk
1226
+ print(f"[run] Backend API: {api}")
1227
+ print(f"[run] Using SYNTH_API_KEY len={sk_len} last5={sk_tail}")
1228
+ except Exception:
1229
+ pass
902
1230
  data_fragment: Dict[str, Any] = {
903
1231
  "model": model_name,
904
1232
  "endpoint_base_url": env.task_app_base_url,
@@ -936,6 +1264,7 @@ def cmd_run(args: argparse.Namespace) -> int:
936
1264
  }, body=body)
937
1265
  if code not in (200, 201) or not isinstance(js, dict):
938
1266
  print("Job create failed:", code)
1267
+ print(f"Backend: {api}")
939
1268
  try:
940
1269
  if isinstance(js, dict):
941
1270
  print(json.dumps(js, indent=2))
@@ -962,7 +1291,14 @@ def cmd_run(args: argparse.Namespace) -> int:
962
1291
  print("Request body was:\n" + json.dumps(body, indent=2))
963
1292
  return 2
964
1293
  print("JOB_ID:", job_id)
965
- _http("POST", api + f"/rl/jobs/{job_id}/start", headers={"Authorization": f"Bearer {env.synth_api_key}"})
1294
+ # Original behavior: start job and stream status/events until terminal
1295
+ _http(
1296
+ "POST",
1297
+ api + f"/rl/jobs/{job_id}/start",
1298
+ headers={"Authorization": f"Bearer {env.synth_api_key}"},
1299
+ )
1300
+ # Inform the user immediately that the job has started and where to track it
1301
+ print("Your job is running. Visit usesynth.ai to view its progress")
966
1302
  since = 0
967
1303
  terminal = {"succeeded", "failed", "cancelled", "error", "completed"}
968
1304
  last_status = ""
@@ -976,7 +1312,10 @@ def cmd_run(args: argparse.Namespace) -> int:
976
1312
  if status and status.lower() in terminal:
977
1313
  print("FINAL:", status)
978
1314
  break
979
- ec, ej = _http("GET", api + f"/orchestration/jobs/{job_id}/events?since_seq={since}&limit=200")
1315
+ ec, ej = _http(
1316
+ "GET",
1317
+ api + f"/orchestration/jobs/{job_id}/events?since_seq={since}&limit=200",
1318
+ )
980
1319
  if ec == 200 and isinstance(ej, dict):
981
1320
  events = ej.get("events") or ej.get("data") or []
982
1321
  for e in events:
@@ -986,9 +1325,17 @@ def cmd_run(args: argparse.Namespace) -> int:
986
1325
  since = seq
987
1326
  typ = str(e.get("type") or e.get("event_type") or "").lower()
988
1327
  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"):
1328
+ if typ in (
1329
+ "rl.eval.started",
1330
+ "rl.eval.summary",
1331
+ "rl.train.step",
1332
+ "rl.metrics",
1333
+ "rl.performance.metrics",
1334
+ ):
990
1335
  print(f"[{seq}] {typ}: {msg}")
991
- mc, mj = _http("GET", api + f"/learning/jobs/{job_id}/metrics?after_step=-1&limit=50")
1336
+ mc, mj = _http(
1337
+ "GET", api + f"/learning/jobs/{job_id}/metrics?after_step=-1&limit=50"
1338
+ )
992
1339
  if mc == 200 and isinstance(mj, dict):
993
1340
  pts = mj.get("points") or []
994
1341
  for p in pts:
@@ -1012,7 +1359,7 @@ def main(argv: list[str] | None = None) -> int:
1012
1359
  parser = sub.add_parser(name)
1013
1360
  configure(parser)
1014
1361
 
1015
- _add_parser(["rl_demo.check", "demo.check"], configure=lambda parser: parser.set_defaults(func=cmd_check))
1362
+ _add_parser(["rl_demo.setup", "demo.setup"], configure=lambda parser: parser.set_defaults(func=cmd_setup))
1016
1363
 
1017
1364
  def _init_opts(parser):
1018
1365
  parser.add_argument("--force", action="store_true", help="Overwrite existing files in CWD")
@@ -1031,7 +1378,7 @@ def main(argv: list[str] | None = None) -> int:
1031
1378
 
1032
1379
  _add_parser(["rl_demo.deploy", "demo.deploy"], configure=_deploy_opts)
1033
1380
 
1034
- _add_parser(["rl_demo.configure", "demo.configure"], configure=lambda parser: parser.set_defaults(func=cmd_configure))
1381
+ _add_parser(["rl_demo.configure", "demo.configure"], configure=lambda parser: parser.set_defaults(func=cmd_run))
1035
1382
 
1036
1383
  def _run_opts(parser):
1037
1384
  parser.add_argument("--config", type=str, default=None, help="Path to TOML config (skip prompt)")
@@ -1042,7 +1389,7 @@ def main(argv: list[str] | None = None) -> int:
1042
1389
  parser.add_argument("--dry-run", action="store_true", help="Print request body and exit")
1043
1390
  parser.set_defaults(func=cmd_run)
1044
1391
 
1045
- _add_parser(["rl_demo.run", "demo.run"], configure=_run_opts)
1392
+ _add_parser(["run", "rl_demo.run", "demo.run"], configure=_run_opts)
1046
1393
 
1047
1394
  args = p.parse_args(argv)
1048
1395
  if not hasattr(args, "func"):