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.

Files changed (37) hide show
  1. synth_ai/api/train/__init__.py +5 -0
  2. synth_ai/api/train/builders.py +165 -0
  3. synth_ai/api/train/cli.py +429 -0
  4. synth_ai/api/train/config_finder.py +120 -0
  5. synth_ai/api/train/env_resolver.py +302 -0
  6. synth_ai/api/train/pollers.py +66 -0
  7. synth_ai/api/train/task_app.py +128 -0
  8. synth_ai/api/train/utils.py +232 -0
  9. synth_ai/cli/__init__.py +23 -0
  10. synth_ai/cli/rl_demo.py +2 -2
  11. synth_ai/cli/root.py +2 -1
  12. synth_ai/cli/task_apps.py +520 -0
  13. synth_ai/demos/demo_task_apps/math/modal_task_app.py +31 -25
  14. synth_ai/task/__init__.py +94 -1
  15. synth_ai/task/apps/__init__.py +88 -0
  16. synth_ai/task/apps/grpo_crafter.py +438 -0
  17. synth_ai/task/apps/math_single_step.py +852 -0
  18. synth_ai/task/auth.py +132 -0
  19. synth_ai/task/client.py +148 -0
  20. synth_ai/task/contracts.py +29 -14
  21. synth_ai/task/datasets.py +105 -0
  22. synth_ai/task/errors.py +49 -0
  23. synth_ai/task/json.py +77 -0
  24. synth_ai/task/proxy.py +258 -0
  25. synth_ai/task/rubrics.py +212 -0
  26. synth_ai/task/server.py +398 -0
  27. synth_ai/task/tracing_utils.py +79 -0
  28. synth_ai/task/vendors.py +61 -0
  29. synth_ai/tracing_v3/session_tracer.py +13 -5
  30. synth_ai/tracing_v3/storage/base.py +10 -12
  31. synth_ai/tracing_v3/turso/manager.py +20 -6
  32. {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/METADATA +3 -2
  33. {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/RECORD +37 -15
  34. {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/WHEEL +0 -0
  35. {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/entry_points.txt +0 -0
  36. {synth_ai-0.2.8.dev11.dist-info → synth_ai-0.2.8.dev13.dist-info}/licenses/LICENSE +0 -0
  37. {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
+ ]