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.
- synth_ai/cli/demo.py +55 -42
- synth_ai/cli/rl_demo.py +51 -5
- synth_ai/cli/root.py +12 -0
- synth_ai/demos/core/cli.py +635 -294
- synth_ai/demos/demo_task_apps/core.py +20 -10
- synth_ai/demos/demo_task_apps/math/config.toml +98 -13
- synth_ai/demos/demo_task_apps/math/modal_task_app.py +9 -3
- synth_ai/handshake.py +107 -0
- {synth_ai-0.2.6.dev6.dist-info → synth_ai-0.2.8.dist-info}/METADATA +30 -6
- {synth_ai-0.2.6.dev6.dist-info → synth_ai-0.2.8.dist-info}/RECORD +14 -13
- {synth_ai-0.2.6.dev6.dist-info → synth_ai-0.2.8.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.6.dev6.dist-info → synth_ai-0.2.8.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.6.dev6.dist-info → synth_ai-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.6.dev6.dist-info → synth_ai-0.2.8.dist-info}/top_level.txt +0 -0
synth_ai/demos/core/cli.py
CHANGED
|
@@ -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
|
|
27
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
# Intentionally suppress task app health print
|
|
120
130
|
else:
|
|
121
|
-
print("
|
|
131
|
+
print("\nSet your task app URL by running:\nuvx synth-ai rl_demo deploy\n")
|
|
122
132
|
|
|
123
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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(
|
|
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("
|
|
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
|
-
|
|
934
|
+
print("`rl_demo configure` prepares environment and secrets; `synth-ai run` now handles launches.")
|
|
445
935
|
env = demo_core.load_env()
|
|
446
|
-
|
|
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
|
-
|
|
452
|
-
if not
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
522
|
-
except RuntimeError as
|
|
523
|
-
print(
|
|
524
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
|
1146
|
+
print("Backend URL missing. Set DEV_BACKEND_URL or BACKEND_OVERRIDE.")
|
|
807
1147
|
return 1
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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.
|
|
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=
|
|
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=
|
|
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"):
|