synth-ai 0.2.8.dev13__py3-none-any.whl → 0.2.9.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

synth_ai/api/train/cli.py CHANGED
@@ -161,6 +161,27 @@ def train_command(
161
161
  explicit_env_paths=env_files,
162
162
  required_keys=required_keys,
163
163
  )
164
+
165
+ missing_keys = [
166
+ spec.name
167
+ for spec in required_keys
168
+ if not spec.optional and not (env_values.get(spec.name) or os.environ.get(spec.name))
169
+ ]
170
+ if missing_keys:
171
+ try:
172
+ from synth_ai.cli.task_apps import _interactive_fill_env
173
+ except Exception as exc: # pragma: no cover - protective fallback
174
+ raise click.ClickException(f"Unable to prompt for env values: {exc}") from exc
175
+
176
+ target_dir = cfg_path.parent
177
+ generated = _interactive_fill_env(target_dir / ".env")
178
+ if generated is None:
179
+ raise click.ClickException("Required environment values missing; aborting.")
180
+ env_path, env_values = resolve_env(
181
+ config_path=cfg_path,
182
+ explicit_env_paths=(str(generated),),
183
+ required_keys=required_keys,
184
+ )
164
185
  click.echo(f"Using env file: {env_path}")
165
186
 
166
187
  synth_key = env_values.get("SYNTH_API_KEY") or os.environ.get("SYNTH_API_KEY")
@@ -37,19 +37,67 @@ def _iter_candidate_paths() -> Iterable[Path]:
37
37
  seen.add(resolved)
38
38
  yield resolved
39
39
 
40
+ # Additionally, discover configs anywhere under the current working directory
41
+ # so users can run `uvx synth-ai train` from project roots without passing --config.
42
+ try:
43
+ cwd = Path.cwd().resolve()
44
+ except Exception:
45
+ cwd = None
46
+ if cwd and cwd.exists():
47
+ for path in cwd.rglob("*.toml"):
48
+ if any(part in _SKIP_DIRS for part in path.parts):
49
+ continue
50
+ resolved = path.resolve()
51
+ if resolved in seen:
52
+ continue
53
+ seen.add(resolved)
54
+ yield resolved
55
+
40
56
 
41
57
  def _infer_config_type(data: dict) -> str:
42
- if isinstance(data.get("training"), dict) or isinstance(data.get("hyperparameters"), dict):
43
- return "sft"
58
+ # 1) Strong signals from [algorithm]
59
+ algo = data.get("algorithm")
60
+ if isinstance(algo, dict):
61
+ method = str(algo.get("method") or "").lower()
62
+ algo_type = str(algo.get("type") or "").lower()
63
+ variety = str(algo.get("variety") or "").lower()
64
+
65
+ # RL indicators
66
+ if method in {"policy_gradient", "ppo", "reinforce"}:
67
+ return "rl"
68
+ if algo_type == "online":
69
+ return "rl"
70
+ if variety in {"gspo", "grpo", "ppo"}:
71
+ return "rl"
72
+
73
+ # SFT indicators
74
+ if method in {"supervised_finetune", "sft"}:
75
+ return "sft"
76
+ if algo_type == "offline":
77
+ return "sft"
78
+ if variety in {"fft"}:
79
+ return "sft"
80
+
81
+ # 2) Other RL signals
44
82
  if data.get("job_type") == "rl":
45
83
  return "rl"
46
84
  services = data.get("services")
47
85
  if isinstance(services, dict) and ("task_url" in services or "environment" in services):
48
86
  return "rl"
49
- compute = data.get("compute")
50
- if isinstance(compute, dict) and "gpu_type" in compute:
51
- # typically FFT toml
52
- return "sft"
87
+
88
+ # 3) Other SFT signals
89
+ training = data.get("training")
90
+ if isinstance(training, dict):
91
+ mode = str(training.get("mode") or "").lower()
92
+ if mode.startswith("sft") or mode == "sft_offline":
93
+ return "sft"
94
+ hyper = data.get("hyperparameters")
95
+ if isinstance(hyper, dict):
96
+ kind = str(hyper.get("train_kind") or "").lower()
97
+ if kind in {"sft", "fft"}:
98
+ return "sft"
99
+
100
+ # 4) Fallback
53
101
  return "unknown"
54
102
 
55
103
 
@@ -18,23 +18,88 @@ class TaskAppHealth:
18
18
  detail: str | None = None
19
19
 
20
20
 
21
+ def _health_response_ok(resp: requests.Response | None) -> tuple[bool, str]:
22
+ if resp is None:
23
+ return False, ""
24
+ status = resp.status_code
25
+ if status == 200:
26
+ return True, ""
27
+ if status in {401, 403}:
28
+ try:
29
+ payload = resp.json()
30
+ except ValueError:
31
+ payload = {}
32
+ prefix = payload.get("expected_api_key_prefix")
33
+ detail = str(payload.get("detail", ""))
34
+ if prefix or "expected prefix" in detail.lower():
35
+ note = "auth-optional"
36
+ if prefix:
37
+ note += f" (expected-prefix={prefix})"
38
+ return True, note
39
+ return False, ""
40
+
41
+
21
42
  def check_task_app_health(base_url: str, api_key: str, *, timeout: float = 10.0) -> TaskAppHealth:
43
+ # Send ALL known environment keys so the server can authorize any valid one
44
+ import os
22
45
  headers = {"X-API-Key": api_key}
46
+ aliases = (os.getenv("ENVIRONMENT_API_KEY_ALIASES") or "").strip()
47
+ keys: list[str] = [api_key]
48
+ if aliases:
49
+ keys.extend([p.strip() for p in aliases.split(",") if p.strip()])
50
+ if keys:
51
+ headers["X-API-Keys"] = ",".join(keys)
52
+ headers.setdefault("Authorization", f"Bearer {api_key}")
23
53
  base = base_url.rstrip("/")
24
- health_resp = None
25
54
  detail_parts: list[str] = []
55
+
56
+ health_resp: requests.Response | None = None
57
+ health_ok = False
26
58
  try:
27
59
  health_resp = http_get(f"{base}/health", headers=headers, timeout=timeout)
28
- detail_parts.append(f"/health={health_resp.status_code}")
60
+ health_ok, note = _health_response_ok(health_resp)
61
+ suffix = f" ({note})" if note else ""
62
+ # On non-200, include brief JSON detail if present
63
+ if not health_ok and health_resp is not None:
64
+ try:
65
+ hjs = health_resp.json()
66
+ # pull a few helpful fields without dumping everything
67
+ expected = hjs.get("expected_api_key_prefix")
68
+ authorized = hjs.get("authorized")
69
+ detail = hjs.get("detail")
70
+ extras = []
71
+ if authorized is not None:
72
+ extras.append(f"authorized={authorized}")
73
+ if expected:
74
+ extras.append(f"expected_prefix={expected}")
75
+ if detail:
76
+ extras.append(f"detail={str(detail)[:80]}")
77
+ if extras:
78
+ suffix += " [" + ", ".join(extras) + "]"
79
+ except Exception:
80
+ pass
81
+ detail_parts.append(f"/health={health_resp.status_code}{suffix}")
29
82
  except requests.RequestException as exc:
30
83
  detail_parts.append(f"/health_error={exc}")
31
- task_resp = None
84
+
85
+ task_resp: requests.Response | None = None
86
+ task_ok = False
32
87
  try:
33
88
  task_resp = http_get(f"{base}/task_info", headers=headers, timeout=timeout)
34
- detail_parts.append(f"/task_info={task_resp.status_code}")
89
+ task_ok = bool(task_resp.status_code == 200)
90
+ if not task_ok and task_resp is not None:
91
+ try:
92
+ tjs = task_resp.json()
93
+ msg = tjs.get("detail") or tjs.get("status")
94
+ detail_parts.append(f"/task_info={task_resp.status_code} ({str(msg)[:80]})")
95
+ except Exception:
96
+ detail_parts.append(f"/task_info={task_resp.status_code}")
97
+ else:
98
+ detail_parts.append(f"/task_info={task_resp.status_code}")
35
99
  except requests.RequestException as exc:
36
100
  detail_parts.append(f"/task_info_error={exc}")
37
- ok = bool(health_resp and health_resp.status_code == 200 and task_resp and task_resp.status_code == 200)
101
+
102
+ ok = bool(health_ok and task_ok)
38
103
  detail = ", ".join(detail_parts)
39
104
  return TaskAppHealth(
40
105
  ok=ok,
synth_ai/cli/rl_demo.py CHANGED
@@ -67,9 +67,15 @@ def register(cli):
67
67
  _forward(["rl_demo.configure"])
68
68
 
69
69
  @_rlg.command("init")
70
- @click.option("--force", is_flag=True, help="Overwrite existing files in CWD")
71
- def rl_init(force: bool):
70
+ @click.option("--template", type=str, default=None, help="Template id to instantiate")
71
+ @click.option("--dest", type=click.Path(), default=None, help="Destination directory for files")
72
+ @click.option("--force", is_flag=True, help="Overwrite existing files in destination")
73
+ def rl_init(template: str | None, dest: str | None, force: bool):
72
74
  args = ["rl_demo.init"]
75
+ if template:
76
+ args.extend(["--template", template])
77
+ if dest:
78
+ args.extend(["--dest", dest])
73
79
  if force:
74
80
  args.append("--force")
75
81
  _forward(args)
@@ -130,9 +136,15 @@ def register(cli):
130
136
  _forward(["rl_demo.configure"])
131
137
 
132
138
  @cli.command("rl_demo.init")
133
- @click.option("--force", is_flag=True, help="Overwrite existing files in CWD")
134
- def rl_init_alias(force: bool):
139
+ @click.option("--template", type=str, default=None, help="Template id to instantiate")
140
+ @click.option("--dest", type=click.Path(), default=None, help="Destination directory for files")
141
+ @click.option("--force", is_flag=True, help="Overwrite existing files in destination")
142
+ def rl_init_alias(template: str | None, dest: str | None, force: bool):
135
143
  args = ["rl_demo.init"]
144
+ if template:
145
+ args.extend(["--template", template])
146
+ if dest:
147
+ args.extend(["--dest", dest])
136
148
  if force:
137
149
  args.append("--force")
138
150
  _forward(args)
synth_ai/cli/root.py CHANGED
@@ -14,6 +14,20 @@ import sys
14
14
  import time
15
15
 
16
16
  import click
17
+ try:
18
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
19
+ try:
20
+ __pkg_version__ = _pkg_version("synth-ai")
21
+ except PackageNotFoundError:
22
+ try:
23
+ from synth_ai import __version__ as __pkg_version__ # type: ignore
24
+ except Exception:
25
+ __pkg_version__ = "unknown"
26
+ except Exception:
27
+ try:
28
+ from synth_ai import __version__ as __pkg_version__ # type: ignore
29
+ except Exception:
30
+ __pkg_version__ = "unknown"
17
31
 
18
32
 
19
33
  def find_sqld_binary() -> str | None:
@@ -66,9 +80,10 @@ rm -rf "$TMP_DIR"
66
80
  return os.path.expanduser("~/.local/bin/sqld")
67
81
 
68
82
 
69
- @click.group()
83
+ @click.group(help=f"Synth AI v{__pkg_version__} - Software for aiding the best and multiplying the will.")
84
+ @click.version_option(version=__pkg_version__, prog_name="synth-ai")
70
85
  def cli():
71
- """Synth AI - Software for aiding the best and multiplying the will."""
86
+ """Top-level command group for Synth AI."""
72
87
 
73
88
 
74
89
  # === Legacy demo command group (aliases new rl_demo implementation) ===
@@ -84,7 +99,7 @@ def _forward_to_demo(args: list[str]) -> None:
84
99
  except Exception as e: # pragma: no cover
85
100
  click.echo(f"Failed to import demo CLI: {e}")
86
101
  sys.exit(1)
87
- rc = int(demo_cli.main(args) or 0)
102
+ rc = int(getattr(demo_cli, "main")(args) or 0) # type: ignore[attr-defined]
88
103
  if rc != 0:
89
104
  sys.exit(rc)
90
105
 
@@ -123,6 +138,22 @@ def setup():
123
138
  _forward_to_demo(["rl_demo.setup"])
124
139
 
125
140
 
141
+ @demo.command()
142
+ @click.option("--template", type=str, default=None, help="Template id to instantiate")
143
+ @click.option("--dest", type=str, default=None, help="Destination directory for files")
144
+ @click.option("--force", is_flag=True, help="Overwrite existing files in destination")
145
+ def init(template: str | None, dest: str | None, force: bool):
146
+ """Copy demo task app template into the current directory."""
147
+ args: list[str] = ["demo.init"]
148
+ if template:
149
+ args.extend(["--template", template])
150
+ if dest:
151
+ args.extend(["--dest", dest])
152
+ if force:
153
+ args.append("--force")
154
+ _forward_to_demo(args)
155
+
156
+
126
157
  @demo.command()
127
158
  @click.option("--batch-size", type=int, default=None)
128
159
  @click.option("--group-size", type=int, default=None)
@@ -142,8 +173,8 @@ def run(batch_size: int | None, group_size: int | None, model: str | None, timeo
142
173
  _forward_to_demo(args)
143
174
 
144
175
 
145
- @cli.command()
146
- def setup():
176
+ @cli.command(name="setup")
177
+ def setup_command():
147
178
  """Perform SDK handshake and write keys to .env."""
148
179
  _forward_to_demo(["rl_demo.setup"])
149
180