synth-ai 0.2.6.dev5__py3-none-any.whl → 0.2.7__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.
- synth_ai/cli/balance.py +8 -2
- synth_ai/cli/demo.py +11 -3
- synth_ai/cli/rl_demo.py +51 -5
- synth_ai/cli/root.py +6 -0
- synth_ai/config/base_url.py +9 -0
- synth_ai/demos/core/cli.py +625 -278
- synth_ai/demos/demo_task_apps/core.py +44 -16
- synth_ai/demos/demo_task_apps/math/config.toml +98 -13
- synth_ai/handshake.py +63 -0
- synth_ai/lm/vendors/core/openai_api.py +8 -3
- synth_ai/v0/tracing/config.py +3 -1
- synth_ai/v0/tracing/decorators.py +3 -1
- synth_ai/v0/tracing/upload.py +3 -1
- synth_ai/v0/tracing_v1/config.py +3 -1
- synth_ai/v0/tracing_v1/decorators.py +3 -1
- synth_ai/v0/tracing_v1/upload.py +3 -1
- {synth_ai-0.2.6.dev5.dist-info → synth_ai-0.2.7.dist-info}/METADATA +26 -4
- {synth_ai-0.2.6.dev5.dist-info → synth_ai-0.2.7.dist-info}/RECORD +22 -21
- {synth_ai-0.2.6.dev5.dist-info → synth_ai-0.2.7.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.6.dev5.dist-info → synth_ai-0.2.7.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.6.dev5.dist-info → synth_ai-0.2.7.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.6.dev5.dist-info → synth_ai-0.2.7.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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
# Intentionally suppress task app health print
|
|
120
139
|
else:
|
|
121
|
-
print("
|
|
140
|
+
print("\nSet your task app URL by running:\nuvx synth-ai rl_demo deploy\n")
|
|
122
141
|
|
|
123
|
-
|
|
124
|
-
try:
|
|
125
|
-
import subprocess
|
|
142
|
+
# Omit uv version print to keep output concise
|
|
126
143
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
936
|
+
print("`rl_demo configure` prepares environment and secrets; `synth-ai run` now handles launches.")
|
|
445
937
|
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()
|
|
938
|
+
synth_key = (env.synth_api_key or "").strip()
|
|
450
939
|
if not synth_key:
|
|
451
|
-
|
|
452
|
-
if not
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
|
1152
|
+
print("Backend URL missing. Set DEV_BACKEND_URL or BACKEND_OVERRIDE.")
|
|
807
1153
|
return 1
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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.
|
|
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=
|
|
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"):
|