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.

Files changed (36) 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/task/__init__.py +94 -1
  14. synth_ai/task/apps/__init__.py +88 -0
  15. synth_ai/task/apps/grpo_crafter.py +438 -0
  16. synth_ai/task/apps/math_single_step.py +852 -0
  17. synth_ai/task/auth.py +132 -0
  18. synth_ai/task/client.py +148 -0
  19. synth_ai/task/contracts.py +29 -14
  20. synth_ai/task/datasets.py +105 -0
  21. synth_ai/task/errors.py +49 -0
  22. synth_ai/task/json.py +77 -0
  23. synth_ai/task/proxy.py +258 -0
  24. synth_ai/task/rubrics.py +212 -0
  25. synth_ai/task/server.py +398 -0
  26. synth_ai/task/tracing_utils.py +79 -0
  27. synth_ai/task/vendors.py +61 -0
  28. synth_ai/tracing_v3/session_tracer.py +13 -5
  29. synth_ai/tracing_v3/storage/base.py +10 -12
  30. synth_ai/tracing_v3/turso/manager.py +20 -6
  31. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/METADATA +3 -2
  32. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/RECORD +36 -14
  33. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/WHEEL +0 -0
  34. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/entry_points.txt +0 -0
  35. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.8.dev13.dist-info}/licenses/LICENSE +0 -0
  36. {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 TaskAppContract, TaskAppEndpoints
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
  ]