synth-ai 0.2.8.dev12__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/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.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/METADATA +3 -2
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/RECORD +36 -14
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from synth_ai.task.apps import ModalDeploymentConfig, TaskAppEntry, registry
|
|
16
|
+
from synth_ai.task.server import run_task_app
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(
|
|
20
|
+
name='task-app',
|
|
21
|
+
help='Utilities for serving and deploying Synth task apps.'
|
|
22
|
+
)
|
|
23
|
+
def task_app_group() -> None:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@task_app_group.command('list')
|
|
28
|
+
def list_apps() -> None:
|
|
29
|
+
"""List registered task apps."""
|
|
30
|
+
|
|
31
|
+
entries = registry.list()
|
|
32
|
+
if not entries:
|
|
33
|
+
click.echo("No task apps registered.")
|
|
34
|
+
return
|
|
35
|
+
for entry in entries:
|
|
36
|
+
aliases = f" (aliases: {', '.join(entry.aliases)})" if entry.aliases else ""
|
|
37
|
+
click.echo(f"- {entry.app_id}{aliases}: {entry.description}")
|
|
38
|
+
def _load_env_files_into_process(paths: Sequence[str]) -> None:
|
|
39
|
+
for p in paths:
|
|
40
|
+
try:
|
|
41
|
+
txt = Path(p).expanduser().read_text()
|
|
42
|
+
except Exception:
|
|
43
|
+
continue
|
|
44
|
+
for line in txt.splitlines():
|
|
45
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
46
|
+
continue
|
|
47
|
+
k, v = line.split('=', 1)
|
|
48
|
+
key = k.strip()
|
|
49
|
+
val = v.strip().strip('"').strip("'")
|
|
50
|
+
if key and key not in os.environ:
|
|
51
|
+
os.environ[key] = val
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.command('serve')
|
|
56
|
+
@click.argument('app_id', type=str)
|
|
57
|
+
@click.option('--host', default='0.0.0.0', show_default=True)
|
|
58
|
+
@click.option('--port', default=8001, show_default=True, type=int)
|
|
59
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
|
|
60
|
+
@click.option('--reload/--no-reload', 'reload_flag', default=False, help='Enable uvicorn auto-reload')
|
|
61
|
+
@click.option('--force/--no-force', 'force', default=False, help='Kill any process already bound to the selected port before starting')
|
|
62
|
+
@click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
|
|
63
|
+
@click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
|
|
64
|
+
def serve_command(
|
|
65
|
+
app_id: str,
|
|
66
|
+
host: str,
|
|
67
|
+
port: int,
|
|
68
|
+
env_file: Sequence[str],
|
|
69
|
+
reload_flag: bool,
|
|
70
|
+
force: bool,
|
|
71
|
+
trace_dir: str | None,
|
|
72
|
+
trace_db: str | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
_serve(app_id, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@task_app_group.command('serve')
|
|
78
|
+
@click.argument('app_id', type=str)
|
|
79
|
+
@click.option('--host', default='0.0.0.0', show_default=True)
|
|
80
|
+
@click.option('--port', default=8001, show_default=True, type=int)
|
|
81
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
|
|
82
|
+
@click.option('--reload/--no-reload', 'reload_flag', default=False, help='Enable uvicorn auto-reload')
|
|
83
|
+
@click.option('--force/--no-force', 'force', default=False, help='Kill any process already bound to the selected port before starting')
|
|
84
|
+
@click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
|
|
85
|
+
@click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
|
|
86
|
+
def serve_task_group(
|
|
87
|
+
app_id: str,
|
|
88
|
+
host: str,
|
|
89
|
+
port: int,
|
|
90
|
+
env_file: Sequence[str],
|
|
91
|
+
reload_flag: bool,
|
|
92
|
+
force: bool,
|
|
93
|
+
trace_dir: str | None,
|
|
94
|
+
trace_db: str | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
_serve(app_id, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
|
|
97
|
+
|
|
98
|
+
def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) -> list[Path]:
|
|
99
|
+
resolved: list[Path] = []
|
|
100
|
+
for candidate in user_env_files:
|
|
101
|
+
p = Path(candidate).expanduser()
|
|
102
|
+
if not p.exists():
|
|
103
|
+
raise click.ClickException(f"Env file not found: {p}")
|
|
104
|
+
resolved.append(p)
|
|
105
|
+
if resolved:
|
|
106
|
+
return resolved
|
|
107
|
+
|
|
108
|
+
defaults = [Path(path).expanduser() for path in (entry.env_files or []) if Path(path).expanduser().exists()]
|
|
109
|
+
if defaults:
|
|
110
|
+
return defaults
|
|
111
|
+
|
|
112
|
+
env_candidates = sorted(REPO_ROOT.glob('**/*.env'))
|
|
113
|
+
if not env_candidates:
|
|
114
|
+
raise click.ClickException('No env file found. Pass --env-file explicitly.')
|
|
115
|
+
|
|
116
|
+
click.echo('Select env file to load:')
|
|
117
|
+
for idx, path in enumerate(env_candidates, start=1):
|
|
118
|
+
click.echo(f" {idx}) {path}")
|
|
119
|
+
choice = click.prompt('Enter choice', type=click.IntRange(1, len(env_candidates)))
|
|
120
|
+
return [env_candidates[choice - 1]]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
|
|
124
|
+
import os
|
|
125
|
+
import socket
|
|
126
|
+
import subprocess
|
|
127
|
+
import time
|
|
128
|
+
|
|
129
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
130
|
+
in_use = s.connect_ex((host, port)) == 0
|
|
131
|
+
if not in_use:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
out = subprocess.run(["lsof", "-ti", f"TCP:{port}"], capture_output=True, text=True, check=False)
|
|
136
|
+
pids = [pid for pid in out.stdout.strip().splitlines() if pid]
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
pids = []
|
|
139
|
+
|
|
140
|
+
if not force:
|
|
141
|
+
message = f"Port {port} appears to be in use"
|
|
142
|
+
if pids:
|
|
143
|
+
message += f" (PIDs: {', '.join(pids)})"
|
|
144
|
+
raise click.ClickException(message)
|
|
145
|
+
|
|
146
|
+
for pid in pids:
|
|
147
|
+
try:
|
|
148
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
raise click.ClickException(f'Failed to terminate PID {pid}: {exc}')
|
|
151
|
+
|
|
152
|
+
time.sleep(0.5)
|
|
153
|
+
|
|
154
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
155
|
+
still_in_use = s.connect_ex((host, port)) == 0
|
|
156
|
+
|
|
157
|
+
if still_in_use:
|
|
158
|
+
for pid in pids:
|
|
159
|
+
try:
|
|
160
|
+
os.kill(int(pid), signal.SIGKILL)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
raise click.ClickException(f'Failed to force terminate PID {pid}: {exc}')
|
|
163
|
+
time.sleep(0.5)
|
|
164
|
+
|
|
165
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
166
|
+
in_use_after = s.connect_ex((host, port)) == 0
|
|
167
|
+
if in_use_after:
|
|
168
|
+
raise click.ClickException(f'Port {port} is still in use after attempting to terminate processes.')
|
|
169
|
+
|
|
170
|
+
def _serve(
|
|
171
|
+
app_id: str,
|
|
172
|
+
host: str,
|
|
173
|
+
port: int,
|
|
174
|
+
env_file: Sequence[str],
|
|
175
|
+
reload_flag: bool,
|
|
176
|
+
force: bool,
|
|
177
|
+
*,
|
|
178
|
+
trace_dir: str | None = None,
|
|
179
|
+
trace_db: str | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
try:
|
|
182
|
+
entry = registry.get(app_id)
|
|
183
|
+
except KeyError as exc: # pragma: no cover - CLI input validation
|
|
184
|
+
raise click.ClickException(str(exc)) from exc
|
|
185
|
+
|
|
186
|
+
env_files = list(entry.env_files)
|
|
187
|
+
env_files.extend(env_file)
|
|
188
|
+
|
|
189
|
+
trace_enabled = trace_dir is not None or trace_db is not None
|
|
190
|
+
if trace_enabled:
|
|
191
|
+
os.environ['TASKAPP_TRACING_ENABLED'] = '1'
|
|
192
|
+
if trace_dir is not None:
|
|
193
|
+
dir_path = Path(trace_dir).expanduser()
|
|
194
|
+
try:
|
|
195
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
raise click.ClickException(f"Failed to create trace directory {dir_path}: {exc}") from exc
|
|
198
|
+
os.environ['TASKAPP_SFT_OUTPUT_DIR'] = str(dir_path)
|
|
199
|
+
click.echo(f"Tracing enabled. SFT JSONL will be written to {dir_path}")
|
|
200
|
+
if trace_db is not None:
|
|
201
|
+
db_path = Path(trace_db).expanduser()
|
|
202
|
+
os.environ['SQLD_DB_PATH'] = str(db_path)
|
|
203
|
+
os.environ.pop('TURSO_LOCAL_DB_URL', None)
|
|
204
|
+
click.echo(f"Tracing DB path set to {db_path}")
|
|
205
|
+
from synth_ai.tracing_v3.config import CONFIG as TRACE_CONFIG
|
|
206
|
+
# recompute db_url based on current environment
|
|
207
|
+
new_db_url = os.getenv('TURSO_LOCAL_DB_URL') or TRACE_CONFIG.db_url
|
|
208
|
+
TRACE_CONFIG.db_url = new_db_url
|
|
209
|
+
if new_db_url:
|
|
210
|
+
os.environ['TURSO_LOCAL_DB_URL'] = new_db_url
|
|
211
|
+
click.echo(f"Tracing DB URL resolved to {new_db_url}")
|
|
212
|
+
elif os.getenv('TASKAPP_TRACING_ENABLED'):
|
|
213
|
+
click.echo("Tracing enabled via environment variables")
|
|
214
|
+
|
|
215
|
+
_ensure_port_free(port, host, force=force)
|
|
216
|
+
|
|
217
|
+
# Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before serving
|
|
218
|
+
try:
|
|
219
|
+
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
220
|
+
backend_base = raw_backend.rstrip("/")
|
|
221
|
+
if not backend_base.endswith("/api"):
|
|
222
|
+
backend_base = backend_base + "/api"
|
|
223
|
+
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
224
|
+
env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
|
|
225
|
+
if synth_key and env_api_key:
|
|
226
|
+
import base64, httpx
|
|
227
|
+
click.echo(f"[preflight] backend={backend_base}")
|
|
228
|
+
# Fetch sealed-box public key
|
|
229
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
230
|
+
click.echo("[preflight] fetching public key…")
|
|
231
|
+
rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
|
|
232
|
+
if rpk.status_code == 200:
|
|
233
|
+
pk = (rpk.json() or {}).get("public_key")
|
|
234
|
+
else:
|
|
235
|
+
pk = None
|
|
236
|
+
if pk:
|
|
237
|
+
# Encrypt env_api_key using libsodium sealed box
|
|
238
|
+
try:
|
|
239
|
+
from nacl.public import SealedBox, PublicKey
|
|
240
|
+
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
241
|
+
sb = SealedBox(pub)
|
|
242
|
+
ct = sb.encrypt(env_api_key.encode("utf-8"))
|
|
243
|
+
ct_b64 = base64.b64encode(ct).decode()
|
|
244
|
+
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
245
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
246
|
+
click.echo("[preflight] upserting env key…")
|
|
247
|
+
up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
|
|
248
|
+
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
249
|
+
# Verify
|
|
250
|
+
click.echo("[preflight] verifying env key presence…")
|
|
251
|
+
ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
|
|
252
|
+
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
253
|
+
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
254
|
+
else:
|
|
255
|
+
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
256
|
+
except Exception:
|
|
257
|
+
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
258
|
+
except Exception:
|
|
259
|
+
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
260
|
+
|
|
261
|
+
run_task_app(
|
|
262
|
+
entry.config_factory,
|
|
263
|
+
host=host,
|
|
264
|
+
port=port,
|
|
265
|
+
reload=reload_flag,
|
|
266
|
+
env_files=env_files,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@task_app_group.command('deploy')
|
|
271
|
+
@click.argument("app_id", type=str)
|
|
272
|
+
@click.option("--name", "modal_name", default=None, help="Override Modal app name")
|
|
273
|
+
@click.option("--dry-run", is_flag=True, help="Print modal deploy command without executing")
|
|
274
|
+
@click.option("--modal-cli", default="modal", help="Path to modal CLI executable")
|
|
275
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
|
|
276
|
+
def deploy_app(app_id: str, modal_name: str | None, dry_run: bool, modal_cli: str, env_file: Sequence[str]) -> None:
|
|
277
|
+
"""Deploy a task app to Modal."""
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
entry = registry.get(app_id)
|
|
281
|
+
except KeyError as exc: # pragma: no cover - CLI input validation
|
|
282
|
+
raise click.ClickException(str(exc)) from exc
|
|
283
|
+
|
|
284
|
+
modal_cfg = entry.modal
|
|
285
|
+
if modal_cfg is None:
|
|
286
|
+
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
287
|
+
|
|
288
|
+
env_paths = _determine_env_files(entry, env_file)
|
|
289
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
290
|
+
|
|
291
|
+
modal_path = shutil.which(modal_cli)
|
|
292
|
+
if modal_path is None:
|
|
293
|
+
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
294
|
+
|
|
295
|
+
# Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before deploy
|
|
296
|
+
try:
|
|
297
|
+
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
298
|
+
backend_base = raw_backend.rstrip("/")
|
|
299
|
+
if not backend_base.endswith("/api"):
|
|
300
|
+
backend_base = backend_base + "/api"
|
|
301
|
+
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
302
|
+
env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
|
|
303
|
+
if synth_key and env_api_key:
|
|
304
|
+
import base64, httpx
|
|
305
|
+
click.echo(f"[preflight] backend={backend_base}")
|
|
306
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
307
|
+
click.echo("[preflight] fetching public key…")
|
|
308
|
+
rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
|
|
309
|
+
pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
|
|
310
|
+
if pk:
|
|
311
|
+
try:
|
|
312
|
+
from nacl.public import SealedBox, PublicKey
|
|
313
|
+
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
314
|
+
sb = SealedBox(pub)
|
|
315
|
+
ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode("utf-8"))).decode()
|
|
316
|
+
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
317
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
318
|
+
click.echo("[preflight] upserting env key…")
|
|
319
|
+
up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
|
|
320
|
+
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
321
|
+
ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
|
|
322
|
+
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
323
|
+
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
324
|
+
else:
|
|
325
|
+
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
326
|
+
except Exception:
|
|
327
|
+
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
328
|
+
except Exception:
|
|
329
|
+
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
330
|
+
|
|
331
|
+
script_path = _write_modal_entrypoint(
|
|
332
|
+
entry,
|
|
333
|
+
modal_cfg,
|
|
334
|
+
modal_name,
|
|
335
|
+
dotenv_paths=[str(path) for path in env_paths],
|
|
336
|
+
)
|
|
337
|
+
cmd = [modal_path, "deploy", str(script_path)]
|
|
338
|
+
if dry_run:
|
|
339
|
+
click.echo("Dry run: " + " ".join(cmd))
|
|
340
|
+
script_path.unlink(missing_ok=True)
|
|
341
|
+
return
|
|
342
|
+
try:
|
|
343
|
+
subprocess.run(cmd, check=True)
|
|
344
|
+
finally:
|
|
345
|
+
script_path.unlink(missing_ok=True)
|
|
346
|
+
|
|
347
|
+
@task_app_group.command('modal-serve')
|
|
348
|
+
@click.argument('app_id', type=str, required=False)
|
|
349
|
+
@click.option('--modal-cli', default='modal', help='Path to modal CLI executable')
|
|
350
|
+
@click.option('--name', 'modal_name', default=None, help='Override Modal app name (optional)')
|
|
351
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
|
|
352
|
+
def modal_serve_app(app_id: str | None, modal_cli: str, modal_name: str | None, env_file: Sequence[str]) -> None:
|
|
353
|
+
entries = registry.list()
|
|
354
|
+
if app_id is None:
|
|
355
|
+
if len(entries) == 1:
|
|
356
|
+
entry = entries[0]
|
|
357
|
+
else:
|
|
358
|
+
available = ', '.join(e.app_id for e in entries) or 'none'
|
|
359
|
+
raise click.ClickException(f"APP_ID required (available: {available})")
|
|
360
|
+
else:
|
|
361
|
+
try:
|
|
362
|
+
entry = registry.get(app_id)
|
|
363
|
+
except KeyError as exc:
|
|
364
|
+
raise click.ClickException(str(exc)) from exc
|
|
365
|
+
|
|
366
|
+
modal_cfg = entry.modal
|
|
367
|
+
if modal_cfg is None:
|
|
368
|
+
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
369
|
+
|
|
370
|
+
env_paths = _determine_env_files(entry, env_file)
|
|
371
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
372
|
+
# Make values available for preflight
|
|
373
|
+
_load_env_files_into_process([str(p) for p in env_paths])
|
|
374
|
+
|
|
375
|
+
modal_path = shutil.which(modal_cli)
|
|
376
|
+
if modal_path is None:
|
|
377
|
+
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
378
|
+
|
|
379
|
+
# Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before serve
|
|
380
|
+
try:
|
|
381
|
+
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
382
|
+
backend_base = raw_backend.rstrip('/')
|
|
383
|
+
if not backend_base.endswith('/api'):
|
|
384
|
+
backend_base = backend_base + '/api'
|
|
385
|
+
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
386
|
+
env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
|
|
387
|
+
if synth_key and env_api_key:
|
|
388
|
+
import base64, httpx
|
|
389
|
+
click.echo(f"[preflight] backend={backend_base}")
|
|
390
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
391
|
+
click.echo("[preflight] fetching public key…")
|
|
392
|
+
rpk = c.get(f"{backend_base}/v1/crypto/public-key")
|
|
393
|
+
pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
|
|
394
|
+
if pk:
|
|
395
|
+
try:
|
|
396
|
+
from nacl.public import SealedBox, PublicKey
|
|
397
|
+
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
398
|
+
sb = SealedBox(pub)
|
|
399
|
+
ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode('utf-8'))).decode()
|
|
400
|
+
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
401
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
402
|
+
click.echo("[preflight] upserting env key…")
|
|
403
|
+
up = c.post(f"{backend_base}/v1/env-keys", json=payload)
|
|
404
|
+
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
405
|
+
click.echo("[preflight] verifying env key presence…")
|
|
406
|
+
ver = c.get(f"{backend_base}/v1/env-keys/verify")
|
|
407
|
+
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
408
|
+
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
409
|
+
else:
|
|
410
|
+
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
411
|
+
except Exception:
|
|
412
|
+
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
413
|
+
except Exception:
|
|
414
|
+
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
415
|
+
|
|
416
|
+
script_path = _write_modal_entrypoint(
|
|
417
|
+
entry,
|
|
418
|
+
modal_cfg,
|
|
419
|
+
modal_name,
|
|
420
|
+
dotenv_paths=[str(path) for path in env_paths],
|
|
421
|
+
)
|
|
422
|
+
cmd = [modal_path, 'serve', str(script_path)]
|
|
423
|
+
try:
|
|
424
|
+
subprocess.run(cmd, check=True)
|
|
425
|
+
except subprocess.CalledProcessError as exc:
|
|
426
|
+
raise click.ClickException(f"modal serve failed with exit code {exc.returncode}") from exc
|
|
427
|
+
finally:
|
|
428
|
+
script_path.unlink(missing_ok=True)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _write_modal_entrypoint(
|
|
432
|
+
entry: TaskAppEntry,
|
|
433
|
+
modal_cfg: ModalDeploymentConfig,
|
|
434
|
+
override_name: str | None,
|
|
435
|
+
*,
|
|
436
|
+
dotenv_paths: Sequence[str] | None = None,
|
|
437
|
+
) -> Path:
|
|
438
|
+
modal_name = override_name or modal_cfg.app_name
|
|
439
|
+
|
|
440
|
+
module_name = entry.config_factory.__module__
|
|
441
|
+
dotenv_paths = [str(Path(path)) for path in (dotenv_paths or [])]
|
|
442
|
+
|
|
443
|
+
pip_packages = list(modal_cfg.pip_packages)
|
|
444
|
+
|
|
445
|
+
local_dirs = [(str(Path(src)), dst) for src, dst in modal_cfg.extra_local_dirs]
|
|
446
|
+
secret_names = list(modal_cfg.secret_names)
|
|
447
|
+
volume_mounts = [(name, mount) for name, mount in modal_cfg.volume_mounts]
|
|
448
|
+
|
|
449
|
+
script = f"""from __future__ import annotations
|
|
450
|
+
|
|
451
|
+
import importlib
|
|
452
|
+
import sys
|
|
453
|
+
sys.path.insert(0, '/opt/synth_ai_repo')
|
|
454
|
+
|
|
455
|
+
from modal import App, Image, Secret, Volume, asgi_app
|
|
456
|
+
|
|
457
|
+
from synth_ai.task.apps import registry
|
|
458
|
+
from synth_ai.task.server import create_task_app
|
|
459
|
+
|
|
460
|
+
ENTRY_ID = {entry.app_id!r}
|
|
461
|
+
MODAL_APP_NAME = {modal_name!r}
|
|
462
|
+
MODULE_NAME = {module_name!r}
|
|
463
|
+
DOTENV_PATHS = {dotenv_paths!r}
|
|
464
|
+
|
|
465
|
+
image = Image.debian_slim(python_version={modal_cfg.python_version!r})
|
|
466
|
+
|
|
467
|
+
pip_packages = {pip_packages!r}
|
|
468
|
+
if pip_packages:
|
|
469
|
+
image = image.pip_install(*pip_packages)
|
|
470
|
+
|
|
471
|
+
local_dirs = {local_dirs!r}
|
|
472
|
+
for local_src, remote_dst in local_dirs:
|
|
473
|
+
image = image.add_local_dir(local_src, remote_dst)
|
|
474
|
+
|
|
475
|
+
secrets = {secret_names!r}
|
|
476
|
+
secret_objs = [Secret.from_name(name) for name in secrets]
|
|
477
|
+
|
|
478
|
+
if DOTENV_PATHS:
|
|
479
|
+
secret_objs.extend(Secret.from_dotenv(path) for path in DOTENV_PATHS)
|
|
480
|
+
|
|
481
|
+
volume_mounts = {volume_mounts!r}
|
|
482
|
+
volume_map = {{}}
|
|
483
|
+
for vol_name, mount_path in volume_mounts:
|
|
484
|
+
volume_map[mount_path] = Volume.from_name(vol_name, create_if_missing=True)
|
|
485
|
+
|
|
486
|
+
importlib.import_module(MODULE_NAME)
|
|
487
|
+
|
|
488
|
+
entry = registry.get(ENTRY_ID)
|
|
489
|
+
modal_cfg = entry.modal
|
|
490
|
+
if modal_cfg is None:
|
|
491
|
+
raise RuntimeError("Modal configuration missing for task app {entry.app_id}")
|
|
492
|
+
|
|
493
|
+
app = App(MODAL_APP_NAME)
|
|
494
|
+
|
|
495
|
+
@app.function(
|
|
496
|
+
image=image,
|
|
497
|
+
timeout={modal_cfg.timeout},
|
|
498
|
+
memory={modal_cfg.memory},
|
|
499
|
+
cpu={modal_cfg.cpu},
|
|
500
|
+
min_containers={modal_cfg.min_containers},
|
|
501
|
+
max_containers={modal_cfg.max_containers},
|
|
502
|
+
secrets=secret_objs,
|
|
503
|
+
volumes=volume_map,
|
|
504
|
+
)
|
|
505
|
+
@asgi_app()
|
|
506
|
+
def fastapi_app():
|
|
507
|
+
config = entry.config_factory()
|
|
508
|
+
return create_task_app(config)
|
|
509
|
+
"""
|
|
510
|
+
|
|
511
|
+
tmp = tempfile.NamedTemporaryFile("w", suffix=f"_{entry.app_id}_modal.py", delete=False)
|
|
512
|
+
tmp.write(script)
|
|
513
|
+
tmp.flush()
|
|
514
|
+
tmp.close()
|
|
515
|
+
return Path(tmp.name)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def register(cli: click.Group) -> None:
|
|
519
|
+
cli.add_command(serve_command)
|
|
520
|
+
cli.add_command(task_app_group)
|
synth_ai/task/__init__.py
CHANGED
|
@@ -1,10 +1,103 @@
|
|
|
1
1
|
from .validators import validate_task_app_url
|
|
2
2
|
from .health import task_app_health
|
|
3
|
-
from .contracts import
|
|
3
|
+
from .contracts import (
|
|
4
|
+
TaskAppContract,
|
|
5
|
+
TaskAppEndpoints,
|
|
6
|
+
RolloutEnvSpec,
|
|
7
|
+
RolloutPolicySpec,
|
|
8
|
+
RolloutRecordConfig,
|
|
9
|
+
RolloutSafetyConfig,
|
|
10
|
+
RolloutRequest,
|
|
11
|
+
RolloutResponse,
|
|
12
|
+
RolloutTrajectory,
|
|
13
|
+
RolloutStep,
|
|
14
|
+
RolloutMetrics,
|
|
15
|
+
TaskInfo,
|
|
16
|
+
)
|
|
17
|
+
from .json import to_jsonable
|
|
18
|
+
from .auth import (
|
|
19
|
+
normalize_environment_api_key,
|
|
20
|
+
is_api_key_header_authorized,
|
|
21
|
+
require_api_key_dependency,
|
|
22
|
+
)
|
|
23
|
+
from .vendors import (
|
|
24
|
+
normalize_vendor_keys,
|
|
25
|
+
get_openai_key_or_503,
|
|
26
|
+
get_groq_key_or_503,
|
|
27
|
+
)
|
|
28
|
+
from .proxy import (
|
|
29
|
+
INTERACT_TOOL_SCHEMA,
|
|
30
|
+
prepare_for_openai,
|
|
31
|
+
prepare_for_groq,
|
|
32
|
+
inject_system_hint,
|
|
33
|
+
extract_message_text,
|
|
34
|
+
parse_tool_call_from_text,
|
|
35
|
+
synthesize_tool_call_if_missing,
|
|
36
|
+
)
|
|
37
|
+
from .datasets import TaskDatasetSpec, TaskDatasetRegistry
|
|
38
|
+
from .rubrics import (
|
|
39
|
+
Criterion,
|
|
40
|
+
Rubric,
|
|
41
|
+
load_rubric,
|
|
42
|
+
blend_rubrics,
|
|
43
|
+
score_events_against_rubric,
|
|
44
|
+
score_outcome_against_rubric,
|
|
45
|
+
)
|
|
46
|
+
from .client import TaskAppClient
|
|
47
|
+
from .errors import error_payload, http_exception, json_error_response
|
|
4
48
|
|
|
49
|
+
|
|
50
|
+
from .server import (
|
|
51
|
+
TaskAppConfig,
|
|
52
|
+
ProxyConfig,
|
|
53
|
+
RubricBundle,
|
|
54
|
+
create_task_app,
|
|
55
|
+
run_task_app,
|
|
56
|
+
)
|
|
5
57
|
__all__ = [
|
|
6
58
|
"validate_task_app_url",
|
|
7
59
|
"task_app_health",
|
|
8
60
|
"TaskAppContract",
|
|
9
61
|
"TaskAppEndpoints",
|
|
62
|
+
"RolloutEnvSpec",
|
|
63
|
+
"RolloutPolicySpec",
|
|
64
|
+
"RolloutRecordConfig",
|
|
65
|
+
"RolloutSafetyConfig",
|
|
66
|
+
"RolloutRequest",
|
|
67
|
+
"RolloutResponse",
|
|
68
|
+
"RolloutTrajectory",
|
|
69
|
+
"RolloutStep",
|
|
70
|
+
"RolloutMetrics",
|
|
71
|
+
"TaskInfo",
|
|
72
|
+
"to_jsonable",
|
|
73
|
+
"normalize_environment_api_key",
|
|
74
|
+
"is_api_key_header_authorized",
|
|
75
|
+
"require_api_key_dependency",
|
|
76
|
+
"normalize_vendor_keys",
|
|
77
|
+
"get_openai_key_or_503",
|
|
78
|
+
"get_groq_key_or_503",
|
|
79
|
+
"INTERACT_TOOL_SCHEMA",
|
|
80
|
+
"prepare_for_openai",
|
|
81
|
+
"prepare_for_groq",
|
|
82
|
+
"inject_system_hint",
|
|
83
|
+
"extract_message_text",
|
|
84
|
+
"parse_tool_call_from_text",
|
|
85
|
+
"synthesize_tool_call_if_missing",
|
|
86
|
+
"TaskDatasetSpec",
|
|
87
|
+
"TaskDatasetRegistry",
|
|
88
|
+
"Criterion",
|
|
89
|
+
"Rubric",
|
|
90
|
+
"load_rubric",
|
|
91
|
+
"blend_rubrics",
|
|
92
|
+
"score_events_against_rubric",
|
|
93
|
+
"score_outcome_against_rubric",
|
|
94
|
+
"TaskAppClient",
|
|
95
|
+
"error_payload",
|
|
96
|
+
"http_exception",
|
|
97
|
+
"json_error_response",
|
|
98
|
+
"run_task_app",
|
|
99
|
+
"create_task_app",
|
|
100
|
+
"RubricBundle",
|
|
101
|
+
"ProxyConfig",
|
|
102
|
+
"TaskAppConfig",
|
|
10
103
|
]
|