synth-ai 0.2.8.dev11__py3-none-any.whl → 0.2.8.dev13__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/__init__.py +5 -0
- synth_ai/api/train/builders.py +165 -0
- synth_ai/api/train/cli.py +429 -0
- synth_ai/api/train/config_finder.py +120 -0
- synth_ai/api/train/env_resolver.py +302 -0
- synth_ai/api/train/pollers.py +66 -0
- synth_ai/api/train/task_app.py +128 -0
- synth_ai/api/train/utils.py +232 -0
- synth_ai/cli/__init__.py +23 -0
- synth_ai/cli/rl_demo.py +2 -2
- synth_ai/cli/root.py +2 -1
- synth_ai/cli/task_apps.py +520 -0
- synth_ai/demos/demo_task_apps/math/modal_task_app.py +31 -25
- synth_ai/task/__init__.py +94 -1
- synth_ai/task/apps/__init__.py +88 -0
- synth_ai/task/apps/grpo_crafter.py +438 -0
- synth_ai/task/apps/math_single_step.py +852 -0
- synth_ai/task/auth.py +132 -0
- synth_ai/task/client.py +148 -0
- synth_ai/task/contracts.py +29 -14
- synth_ai/task/datasets.py +105 -0
- synth_ai/task/errors.py +49 -0
- synth_ai/task/json.py +77 -0
- synth_ai/task/proxy.py +258 -0
- synth_ai/task/rubrics.py +212 -0
- synth_ai/task/server.py +398 -0
- synth_ai/task/tracing_utils.py +79 -0
- synth_ai/task/vendors.py +61 -0
- synth_ai/tracing_v3/session_tracer.py +13 -5
- synth_ai/tracing_v3/storage/base.py +10 -12
- synth_ai/tracing_v3/turso/manager.py +20 -6
- {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/METADATA +3 -2
- {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/RECORD +37 -15
- {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .utils import REPO_ROOT, load_toml, preview_json
|
|
10
|
+
|
|
11
|
+
_SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", "dist", "build"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ConfigCandidate:
|
|
16
|
+
path: Path
|
|
17
|
+
train_type: str # "rl", "sft", or "unknown"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iter_candidate_paths() -> Iterable[Path]:
|
|
21
|
+
# Prefer explicit config directories first
|
|
22
|
+
preferred = [
|
|
23
|
+
REPO_ROOT / "configs",
|
|
24
|
+
REPO_ROOT / "examples",
|
|
25
|
+
REPO_ROOT / "training",
|
|
26
|
+
]
|
|
27
|
+
seen: set[Path] = set()
|
|
28
|
+
for base in preferred:
|
|
29
|
+
if not base.exists():
|
|
30
|
+
continue
|
|
31
|
+
for path in base.rglob("*.toml"):
|
|
32
|
+
if any(part in _SKIP_DIRS for part in path.parts):
|
|
33
|
+
continue
|
|
34
|
+
resolved = path.resolve()
|
|
35
|
+
if resolved in seen:
|
|
36
|
+
continue
|
|
37
|
+
seen.add(resolved)
|
|
38
|
+
yield resolved
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _infer_config_type(data: dict) -> str:
|
|
42
|
+
if isinstance(data.get("training"), dict) or isinstance(data.get("hyperparameters"), dict):
|
|
43
|
+
return "sft"
|
|
44
|
+
if data.get("job_type") == "rl":
|
|
45
|
+
return "rl"
|
|
46
|
+
services = data.get("services")
|
|
47
|
+
if isinstance(services, dict) and ("task_url" in services or "environment" in services):
|
|
48
|
+
return "rl"
|
|
49
|
+
compute = data.get("compute")
|
|
50
|
+
if isinstance(compute, dict) and "gpu_type" in compute:
|
|
51
|
+
# typically FFT toml
|
|
52
|
+
return "sft"
|
|
53
|
+
return "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def discover_configs(explicit: list[str], *, requested_type: str | None) -> list[ConfigCandidate]:
|
|
57
|
+
candidates: list[ConfigCandidate] = []
|
|
58
|
+
seen: set[Path] = set()
|
|
59
|
+
|
|
60
|
+
for raw in explicit:
|
|
61
|
+
path = Path(raw).expanduser().resolve()
|
|
62
|
+
if not path.exists():
|
|
63
|
+
raise click.ClickException(f"Config not found: {path}")
|
|
64
|
+
data = load_toml(path)
|
|
65
|
+
cfg_type = _infer_config_type(data)
|
|
66
|
+
candidates.append(ConfigCandidate(path=path, train_type=cfg_type))
|
|
67
|
+
seen.add(path)
|
|
68
|
+
|
|
69
|
+
if explicit:
|
|
70
|
+
return candidates
|
|
71
|
+
|
|
72
|
+
for path in _iter_candidate_paths():
|
|
73
|
+
if path in seen:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
data = load_toml(path)
|
|
77
|
+
except Exception:
|
|
78
|
+
continue
|
|
79
|
+
cfg_type = _infer_config_type(data)
|
|
80
|
+
candidates.append(ConfigCandidate(path=path, train_type=cfg_type))
|
|
81
|
+
|
|
82
|
+
if requested_type and requested_type != "auto":
|
|
83
|
+
candidates = [c for c in candidates if c.train_type in {requested_type, "unknown"}]
|
|
84
|
+
|
|
85
|
+
# De-dupe by path and keep deterministic ordering by directory depth then name
|
|
86
|
+
candidates.sort(key=lambda c: (len(c.path.parts), str(c.path)))
|
|
87
|
+
return candidates
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def prompt_for_config(candidates: list[ConfigCandidate], *, requested_type: str | None) -> ConfigCandidate:
|
|
91
|
+
if not candidates:
|
|
92
|
+
raise click.ClickException("No training configs found. Pass --config explicitly.")
|
|
93
|
+
|
|
94
|
+
click.echo("Select a training config:")
|
|
95
|
+
for idx, cand in enumerate(candidates, start=1):
|
|
96
|
+
label = cand.train_type if cand.train_type != "unknown" else "?"
|
|
97
|
+
click.echo(f" {idx}) [{label}] {cand.path}")
|
|
98
|
+
click.echo(" 0) Abort")
|
|
99
|
+
|
|
100
|
+
choice = click.prompt("Enter choice", type=int)
|
|
101
|
+
if choice == 0:
|
|
102
|
+
raise click.ClickException("Aborted by user")
|
|
103
|
+
if choice < 0 or choice > len(candidates):
|
|
104
|
+
raise click.ClickException("Invalid selection")
|
|
105
|
+
|
|
106
|
+
selection = candidates[choice - 1]
|
|
107
|
+
try:
|
|
108
|
+
data = load_toml(selection.path)
|
|
109
|
+
preview = preview_json({k: data.get(k) for k in list(data.keys())[:4]}, limit=320)
|
|
110
|
+
click.echo(f"Loaded {selection.path}: {preview}")
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
return selection
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"ConfigCandidate",
|
|
118
|
+
"discover_configs",
|
|
119
|
+
"prompt_for_config",
|
|
120
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Iterable, MutableMapping
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import task_app
|
|
11
|
+
from .utils import REPO_ROOT, mask_value, read_env_file, write_env_value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class KeySpec:
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
secret: bool = True
|
|
19
|
+
allow_modal_secret: bool = False
|
|
20
|
+
allow_modal_app: bool = False
|
|
21
|
+
modal_secret_pattern: str | None = None
|
|
22
|
+
optional: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EnvResolver:
|
|
26
|
+
def __init__(self, initial_candidates: list[Path]) -> None:
|
|
27
|
+
if not initial_candidates:
|
|
28
|
+
raise click.ClickException("No .env candidates discovered")
|
|
29
|
+
self._candidates = initial_candidates
|
|
30
|
+
self._current = initial_candidates[0]
|
|
31
|
+
self._cache: MutableMapping[Path, dict[str, str]] = {}
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def current_path(self) -> Path:
|
|
35
|
+
return self._current
|
|
36
|
+
|
|
37
|
+
def select_new_env(self) -> None:
|
|
38
|
+
path = prompt_for_env(self._candidates, current=self._current)
|
|
39
|
+
self._current = path
|
|
40
|
+
if path not in self._candidates:
|
|
41
|
+
self._candidates.append(path)
|
|
42
|
+
|
|
43
|
+
def get_value(self, key: str) -> str | None:
|
|
44
|
+
cache = self._cache.get(self._current)
|
|
45
|
+
if cache is None:
|
|
46
|
+
cache = read_env_file(self._current)
|
|
47
|
+
self._cache[self._current] = cache
|
|
48
|
+
return cache.get(key)
|
|
49
|
+
|
|
50
|
+
def set_value(self, key: str, value: str) -> None:
|
|
51
|
+
cache = self._cache.setdefault(self._current, {})
|
|
52
|
+
cache[key] = value
|
|
53
|
+
write_env_value(self._current, key, value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _collect_default_candidates(config_path: Path | None) -> list[Path]:
|
|
57
|
+
candidates: list[Path] = []
|
|
58
|
+
if config_path:
|
|
59
|
+
cfg_env = config_path.parent / ".env"
|
|
60
|
+
if cfg_env.exists():
|
|
61
|
+
candidates.append(cfg_env.resolve())
|
|
62
|
+
repo_env = REPO_ROOT / ".env"
|
|
63
|
+
if repo_env.exists():
|
|
64
|
+
candidates.append(repo_env.resolve())
|
|
65
|
+
examples_env = REPO_ROOT / "examples" / ".env"
|
|
66
|
+
if examples_env.exists():
|
|
67
|
+
candidates.append(examples_env.resolve())
|
|
68
|
+
# Search shallow depth for additional .env files
|
|
69
|
+
for sub in (REPO_ROOT / "examples").glob("**/.env"):
|
|
70
|
+
try:
|
|
71
|
+
resolved = sub.resolve()
|
|
72
|
+
except Exception:
|
|
73
|
+
continue
|
|
74
|
+
if resolved in candidates:
|
|
75
|
+
continue
|
|
76
|
+
# avoid nested venv caches
|
|
77
|
+
if any(part in {".venv", "node_modules", "__pycache__"} for part in resolved.parts):
|
|
78
|
+
continue
|
|
79
|
+
if len(candidates) >= 20:
|
|
80
|
+
break
|
|
81
|
+
candidates.append(resolved)
|
|
82
|
+
deduped: list[Path] = []
|
|
83
|
+
for path in candidates:
|
|
84
|
+
if path not in deduped:
|
|
85
|
+
deduped.append(path)
|
|
86
|
+
return deduped
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prompt_for_env(candidates: list[Path], *, current: Path | None = None) -> Path:
|
|
90
|
+
options = list(dict.fromkeys(candidates)) # preserve order, dedupe
|
|
91
|
+
click.echo("Select an .env file:")
|
|
92
|
+
for idx, path in enumerate(options, start=1):
|
|
93
|
+
marker = " (current)" if current and path == current else ""
|
|
94
|
+
click.echo(f" {idx}) {path}{marker}")
|
|
95
|
+
click.echo(" m) Enter path manually")
|
|
96
|
+
click.echo(" 0) Abort")
|
|
97
|
+
|
|
98
|
+
choice = click.prompt("Choice", default=None)
|
|
99
|
+
if choice is None:
|
|
100
|
+
raise click.ClickException("Selection required")
|
|
101
|
+
choice = choice.strip().lower()
|
|
102
|
+
if choice == "0":
|
|
103
|
+
raise click.ClickException("Aborted by user")
|
|
104
|
+
if choice in {"m", "manual"}:
|
|
105
|
+
manual = click.prompt("Enter path to .env", type=str).strip()
|
|
106
|
+
path = Path(manual).expanduser().resolve()
|
|
107
|
+
if not path.exists():
|
|
108
|
+
raise click.ClickException(f"Env file not found: {path}")
|
|
109
|
+
return path
|
|
110
|
+
try:
|
|
111
|
+
idx = int(choice)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise click.ClickException("Invalid selection") from exc
|
|
114
|
+
if idx < 1 or idx > len(options):
|
|
115
|
+
raise click.ClickException("Invalid selection")
|
|
116
|
+
return options[idx - 1]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_env(
|
|
120
|
+
*,
|
|
121
|
+
config_path: Path | None,
|
|
122
|
+
explicit_env_paths: Iterable[str],
|
|
123
|
+
required_keys: list[KeySpec],
|
|
124
|
+
) -> tuple[Path, dict[str, str]]:
|
|
125
|
+
provided = [Path(p).expanduser().resolve() for p in explicit_env_paths]
|
|
126
|
+
if provided:
|
|
127
|
+
for path in provided:
|
|
128
|
+
if not path.exists():
|
|
129
|
+
raise click.ClickException(f"Env file not found: {path}")
|
|
130
|
+
resolver = EnvResolver(provided)
|
|
131
|
+
else:
|
|
132
|
+
resolver = EnvResolver(_collect_default_candidates(config_path))
|
|
133
|
+
resolver.select_new_env() # force user selection even if one candidate
|
|
134
|
+
|
|
135
|
+
# Preload selected .env keys into process env so downstream lookups succeed
|
|
136
|
+
try:
|
|
137
|
+
current_env_map = read_env_file(resolver.current_path)
|
|
138
|
+
for k in (
|
|
139
|
+
"SYNTH_API_KEY",
|
|
140
|
+
"ENVIRONMENT_API_KEY",
|
|
141
|
+
"dev_environment_api_key",
|
|
142
|
+
"DEV_ENVIRONMENT_API_KEY",
|
|
143
|
+
"TASK_APP_URL",
|
|
144
|
+
"TASK_APP_BASE_URL",
|
|
145
|
+
):
|
|
146
|
+
v = current_env_map.get(k)
|
|
147
|
+
if v and not os.environ.get(k):
|
|
148
|
+
os.environ[k] = v
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
resolved: dict[str, str] = {}
|
|
153
|
+
for spec in required_keys:
|
|
154
|
+
value = _resolve_key(resolver, spec)
|
|
155
|
+
if value:
|
|
156
|
+
resolved[spec.name] = value
|
|
157
|
+
return resolver.current_path, resolved
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_key(resolver: EnvResolver, spec: KeySpec) -> str:
|
|
161
|
+
while True:
|
|
162
|
+
# Priority: existing environment variable
|
|
163
|
+
env_val = os.environ.get(spec.name) or resolver.get_value(spec.name)
|
|
164
|
+
# Allow common aliases in .env to satisfy required keys without extra prompts
|
|
165
|
+
if not env_val and spec.name == "ENVIRONMENT_API_KEY":
|
|
166
|
+
for alias in ("dev_environment_api_key", "DEV_ENVIRONMENT_API_KEY"):
|
|
167
|
+
alt = resolver.get_value(alias)
|
|
168
|
+
if alt:
|
|
169
|
+
env_val = alt
|
|
170
|
+
os.environ[spec.name] = alt
|
|
171
|
+
click.echo(f"Found {spec.name} via {alias}: {mask_value(alt)}")
|
|
172
|
+
break
|
|
173
|
+
if not env_val and spec.name == "TASK_APP_URL":
|
|
174
|
+
for alias in ("TASK_APP_BASE_URL",):
|
|
175
|
+
alt = resolver.get_value(alias)
|
|
176
|
+
if alt:
|
|
177
|
+
env_val = alt
|
|
178
|
+
os.environ[spec.name] = alt
|
|
179
|
+
click.echo(f"Found {spec.name} via {alias}: {mask_value(alt)}")
|
|
180
|
+
break
|
|
181
|
+
if env_val:
|
|
182
|
+
click.echo(f"Found {spec.name} in current sources: {mask_value(env_val)}")
|
|
183
|
+
if _prompt_yes_no(f"Use this value for {spec.name}?", default=True):
|
|
184
|
+
_maybe_persist(resolver, spec, env_val)
|
|
185
|
+
os.environ[spec.name] = env_val
|
|
186
|
+
return env_val
|
|
187
|
+
options: list[tuple[str, Callable[[], str | None]]] = []
|
|
188
|
+
|
|
189
|
+
def _enter_manual() -> str:
|
|
190
|
+
prompt = f"Enter {spec.description}" if spec.description else f"Enter {spec.name}"
|
|
191
|
+
value = click.prompt(prompt, hide_input=spec.secret).strip()
|
|
192
|
+
if not value:
|
|
193
|
+
raise click.ClickException(f"{spec.name} cannot be empty")
|
|
194
|
+
_maybe_persist(resolver, spec, value)
|
|
195
|
+
os.environ[spec.name] = value
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
options.append(("Enter manually", _enter_manual))
|
|
199
|
+
|
|
200
|
+
def _pick_env() -> str | None:
|
|
201
|
+
resolver.select_new_env()
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
options.append(("Choose another .env", _pick_env))
|
|
205
|
+
|
|
206
|
+
if spec.allow_modal_secret:
|
|
207
|
+
options.append(("Fetch from Modal secret", lambda: _fetch_modal_secret(resolver, spec)))
|
|
208
|
+
if spec.allow_modal_app:
|
|
209
|
+
options.append(("Choose Modal app URL", lambda: _fetch_modal_app(spec)))
|
|
210
|
+
if spec.optional:
|
|
211
|
+
options.append(("Skip", lambda: ""))
|
|
212
|
+
options.append(("Abort", lambda: (_raise_abort(spec))))
|
|
213
|
+
|
|
214
|
+
click.echo(f"Resolve {spec.name}:")
|
|
215
|
+
for idx, (label, _) in enumerate(options, start=1):
|
|
216
|
+
click.echo(f" {idx}) {label}")
|
|
217
|
+
choice = click.prompt("Choice", type=int)
|
|
218
|
+
if choice < 1 or choice > len(options):
|
|
219
|
+
click.echo("Invalid selection; try again")
|
|
220
|
+
continue
|
|
221
|
+
action = options[choice - 1][1]
|
|
222
|
+
result = action()
|
|
223
|
+
if result is None:
|
|
224
|
+
# e.g. user switched env; restart loop
|
|
225
|
+
continue
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _maybe_persist(resolver: EnvResolver, spec: KeySpec, value: str) -> None:
|
|
230
|
+
if not _prompt_yes_no(f"Save {spec.name} to {resolver.current_path}?", default=True):
|
|
231
|
+
return
|
|
232
|
+
resolver.set_value(spec.name, value)
|
|
233
|
+
click.echo(f"Saved {spec.name} to {resolver.current_path}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _prompt_yes_no(message: str, *, default: bool) -> bool:
|
|
237
|
+
"""Prompt the user for a yes/no answer, accepting numeric variants."""
|
|
238
|
+
|
|
239
|
+
default_token = "y" if default else "n"
|
|
240
|
+
suffix = "[Y/n]" if default else "[y/N]"
|
|
241
|
+
while True:
|
|
242
|
+
response = click.prompt(f"{message} {suffix}", default=default_token, show_default=False)
|
|
243
|
+
normalized = str(response).strip().lower()
|
|
244
|
+
if normalized in {"", "y", "yes", "1", "true", "t"}:
|
|
245
|
+
return True
|
|
246
|
+
if normalized in {"n", "no", "0", "false", "f"}:
|
|
247
|
+
return False
|
|
248
|
+
click.echo("Invalid input; enter 'y' or 'n'.")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _fetch_modal_secret(resolver: EnvResolver, spec: KeySpec) -> str | None:
|
|
252
|
+
try:
|
|
253
|
+
names = task_app.list_modal_secrets(spec.modal_secret_pattern)
|
|
254
|
+
except click.ClickException as exc:
|
|
255
|
+
click.echo(str(exc))
|
|
256
|
+
return None
|
|
257
|
+
if not names:
|
|
258
|
+
click.echo("No Modal secrets matched")
|
|
259
|
+
return None
|
|
260
|
+
click.echo(task_app.format_modal_secrets(names))
|
|
261
|
+
idx = click.prompt("Select secret (0 to cancel)", type=int)
|
|
262
|
+
if idx == 0:
|
|
263
|
+
return None
|
|
264
|
+
if idx < 1 or idx > len(names):
|
|
265
|
+
click.echo("Invalid selection")
|
|
266
|
+
return None
|
|
267
|
+
name = names[idx - 1]
|
|
268
|
+
value = task_app.get_modal_secret_value(name)
|
|
269
|
+
_maybe_persist(resolver, spec, value)
|
|
270
|
+
os.environ[spec.name] = value
|
|
271
|
+
return value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _fetch_modal_app(spec: KeySpec) -> str | None:
|
|
275
|
+
try:
|
|
276
|
+
apps = task_app.list_modal_apps("task-app")
|
|
277
|
+
except click.ClickException as exc:
|
|
278
|
+
click.echo(str(exc))
|
|
279
|
+
return None
|
|
280
|
+
if not apps:
|
|
281
|
+
click.echo("No Modal apps matched")
|
|
282
|
+
return None
|
|
283
|
+
click.echo(task_app.format_modal_apps(apps))
|
|
284
|
+
idx = click.prompt("Select app (0 to cancel)", type=int)
|
|
285
|
+
if idx == 0:
|
|
286
|
+
return None
|
|
287
|
+
if idx < 1 or idx > len(apps):
|
|
288
|
+
click.echo("Invalid selection")
|
|
289
|
+
return None
|
|
290
|
+
url = apps[idx - 1].url
|
|
291
|
+
os.environ[spec.name] = url
|
|
292
|
+
return url
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _raise_abort(spec: KeySpec) -> None:
|
|
296
|
+
raise click.ClickException(f"Missing required value for {spec.name}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
__all__ = [
|
|
300
|
+
"KeySpec",
|
|
301
|
+
"resolve_env",
|
|
302
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .utils import ensure_api_base, fmt_duration, http_get, sleep
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class PollOutcome:
|
|
13
|
+
status: str
|
|
14
|
+
payload: Mapping[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobPoller:
|
|
18
|
+
def __init__(self, base_url: str, api_key: str, *, interval: float = 5.0, timeout: float = 3600.0) -> None:
|
|
19
|
+
self.base_url = ensure_api_base(base_url)
|
|
20
|
+
self.api_key = api_key
|
|
21
|
+
self.interval = interval
|
|
22
|
+
self.timeout = timeout
|
|
23
|
+
|
|
24
|
+
def _headers(self) -> dict[str, str]:
|
|
25
|
+
return {
|
|
26
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def poll(self, path: str) -> PollOutcome:
|
|
31
|
+
elapsed = 0.0
|
|
32
|
+
status = "unknown"
|
|
33
|
+
info: Mapping[str, Any] = {}
|
|
34
|
+
click.echo("Polling job status...")
|
|
35
|
+
while elapsed <= self.timeout:
|
|
36
|
+
try:
|
|
37
|
+
resp = http_get(f"{self.base_url}{path}", headers=self._headers())
|
|
38
|
+
info = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
39
|
+
status = (info.get("status") or info.get("state") or "").lower()
|
|
40
|
+
click.echo(f"[poll] {elapsed:.0f}s status={status}")
|
|
41
|
+
if status in {"succeeded", "failed", "cancelled", "canceled", "completed"}:
|
|
42
|
+
break
|
|
43
|
+
except Exception as exc: # pragma: no cover - network failures
|
|
44
|
+
click.echo(f"[poll] error: {exc}")
|
|
45
|
+
sleep(self.interval)
|
|
46
|
+
elapsed += self.interval
|
|
47
|
+
else:
|
|
48
|
+
click.echo(f"[poll] timeout after {fmt_duration(self.timeout)}")
|
|
49
|
+
return PollOutcome(status=status, payload=info)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RLJobPoller(JobPoller):
|
|
53
|
+
def poll_job(self, job_id: str) -> PollOutcome:
|
|
54
|
+
return super().poll(f"/rl/jobs/{job_id}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SFTJobPoller(JobPoller):
|
|
58
|
+
def poll_job(self, job_id: str) -> PollOutcome:
|
|
59
|
+
return super().poll(f"/learning/jobs/{job_id}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"PollOutcome",
|
|
64
|
+
"RLJobPoller",
|
|
65
|
+
"SFTJobPoller",
|
|
66
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .utils import CLIResult, http_get, run_cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class TaskAppHealth:
|
|
15
|
+
ok: bool
|
|
16
|
+
health_status: int | None
|
|
17
|
+
task_info_status: int | None
|
|
18
|
+
detail: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_task_app_health(base_url: str, api_key: str, *, timeout: float = 10.0) -> TaskAppHealth:
|
|
22
|
+
headers = {"X-API-Key": api_key}
|
|
23
|
+
base = base_url.rstrip("/")
|
|
24
|
+
health_resp = None
|
|
25
|
+
detail_parts: list[str] = []
|
|
26
|
+
try:
|
|
27
|
+
health_resp = http_get(f"{base}/health", headers=headers, timeout=timeout)
|
|
28
|
+
detail_parts.append(f"/health={health_resp.status_code}")
|
|
29
|
+
except requests.RequestException as exc:
|
|
30
|
+
detail_parts.append(f"/health_error={exc}")
|
|
31
|
+
task_resp = None
|
|
32
|
+
try:
|
|
33
|
+
task_resp = http_get(f"{base}/task_info", headers=headers, timeout=timeout)
|
|
34
|
+
detail_parts.append(f"/task_info={task_resp.status_code}")
|
|
35
|
+
except requests.RequestException as exc:
|
|
36
|
+
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)
|
|
38
|
+
detail = ", ".join(detail_parts)
|
|
39
|
+
return TaskAppHealth(
|
|
40
|
+
ok=ok,
|
|
41
|
+
health_status=None if health_resp is None else health_resp.status_code,
|
|
42
|
+
task_info_status=None if task_resp is None else task_resp.status_code,
|
|
43
|
+
detail=detail,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class ModalSecret:
|
|
49
|
+
name: str
|
|
50
|
+
value: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class ModalApp:
|
|
55
|
+
app_id: str
|
|
56
|
+
label: str
|
|
57
|
+
url: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_modal(args: Iterable[str]) -> CLIResult:
|
|
61
|
+
return run_cli(["modal", *args], timeout=30.0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_modal_secrets(pattern: str | None = None) -> list[str]:
|
|
65
|
+
result = _run_modal(["secret", "list"])
|
|
66
|
+
if result.code != 0:
|
|
67
|
+
raise click.ClickException(f"modal secret list failed: {result.stderr or result.stdout}")
|
|
68
|
+
names: list[str] = []
|
|
69
|
+
for line in result.stdout.splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if not line or line.startswith("NAME"):
|
|
72
|
+
continue
|
|
73
|
+
parts = line.split()
|
|
74
|
+
name = parts[0]
|
|
75
|
+
if pattern and pattern.lower() not in name.lower():
|
|
76
|
+
continue
|
|
77
|
+
names.append(name)
|
|
78
|
+
return names
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_modal_secret_value(name: str) -> str:
|
|
82
|
+
result = _run_modal(["secret", "get", name])
|
|
83
|
+
if result.code != 0:
|
|
84
|
+
raise click.ClickException(f"modal secret get {name} failed: {result.stderr or result.stdout}")
|
|
85
|
+
value = result.stdout.strip()
|
|
86
|
+
if not value:
|
|
87
|
+
raise click.ClickException(f"Secret {name} is empty")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_modal_apps(pattern: str | None = None) -> list[ModalApp]:
|
|
92
|
+
result = _run_modal(["app", "list"])
|
|
93
|
+
if result.code != 0:
|
|
94
|
+
raise click.ClickException(f"modal app list failed: {result.stderr or result.stdout}")
|
|
95
|
+
apps: list[ModalApp] = []
|
|
96
|
+
for line in result.stdout.splitlines():
|
|
97
|
+
line = line.strip()
|
|
98
|
+
if not line or line.startswith("APP"):
|
|
99
|
+
continue
|
|
100
|
+
parts = line.split()
|
|
101
|
+
if len(parts) < 3:
|
|
102
|
+
continue
|
|
103
|
+
app_id, label, url = parts[0], parts[1], parts[-1]
|
|
104
|
+
if pattern and pattern.lower() not in (label.lower() + url.lower() + app_id.lower()):
|
|
105
|
+
continue
|
|
106
|
+
apps.append(ModalApp(app_id=app_id, label=label, url=url))
|
|
107
|
+
return apps
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_modal_apps(apps: list[ModalApp]) -> str:
|
|
111
|
+
rows = [f"{idx}) {app.label} {app.url}" for idx, app in enumerate(apps, start=1)]
|
|
112
|
+
return "\n".join(rows)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def format_modal_secrets(names: list[str]) -> str:
|
|
116
|
+
return "\n".join(f"{idx}) {name}" for idx, name in enumerate(names, start=1))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"ModalApp",
|
|
121
|
+
"ModalSecret",
|
|
122
|
+
"check_task_app_health",
|
|
123
|
+
"format_modal_apps",
|
|
124
|
+
"format_modal_secrets",
|
|
125
|
+
"get_modal_secret_value",
|
|
126
|
+
"list_modal_apps",
|
|
127
|
+
"list_modal_secrets",
|
|
128
|
+
]
|