synth-ai 0.4.1__py3-none-any.whl → 0.4.4__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/__init__.py +13 -13
- synth_ai/cli/__init__.py +6 -15
- synth_ai/cli/commands/eval/__init__.py +6 -15
- synth_ai/cli/commands/eval/config.py +338 -0
- synth_ai/cli/commands/eval/core.py +236 -1091
- synth_ai/cli/commands/eval/runner.py +704 -0
- synth_ai/cli/commands/eval/validation.py +44 -117
- synth_ai/cli/commands/filter/core.py +7 -7
- synth_ai/cli/commands/filter/validation.py +2 -2
- synth_ai/cli/commands/smoke/core.py +7 -17
- synth_ai/cli/commands/status/__init__.py +1 -64
- synth_ai/cli/commands/status/client.py +50 -151
- synth_ai/cli/commands/status/config.py +3 -83
- synth_ai/cli/commands/status/errors.py +4 -13
- synth_ai/cli/commands/status/subcommands/__init__.py +2 -8
- synth_ai/cli/commands/status/subcommands/config.py +13 -0
- synth_ai/cli/commands/status/subcommands/files.py +18 -63
- synth_ai/cli/commands/status/subcommands/jobs.py +28 -311
- synth_ai/cli/commands/status/subcommands/models.py +18 -62
- synth_ai/cli/commands/status/subcommands/runs.py +16 -63
- synth_ai/cli/commands/status/subcommands/session.py +67 -172
- synth_ai/cli/commands/status/subcommands/summary.py +24 -32
- synth_ai/cli/commands/status/subcommands/utils.py +41 -0
- synth_ai/cli/commands/status/utils.py +16 -107
- synth_ai/cli/commands/train/__init__.py +18 -20
- synth_ai/cli/commands/train/errors.py +3 -3
- synth_ai/cli/commands/train/prompt_learning_validation.py +15 -16
- synth_ai/cli/commands/train/validation.py +7 -7
- synth_ai/cli/commands/train/{judge_schemas.py → verifier_schemas.py} +33 -34
- synth_ai/cli/commands/train/verifier_validation.py +235 -0
- synth_ai/cli/demo_apps/demo_task_apps/math/config.toml +0 -1
- synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +2 -6
- synth_ai/cli/demo_apps/math/config.toml +0 -1
- synth_ai/cli/demo_apps/math/modal_task_app.py +2 -6
- synth_ai/cli/demo_apps/mipro/task_app.py +25 -47
- synth_ai/cli/lib/apps/task_app.py +12 -13
- synth_ai/cli/lib/task_app_discovery.py +6 -6
- synth_ai/cli/lib/train_cfgs.py +10 -10
- synth_ai/cli/task_apps/__init__.py +11 -0
- synth_ai/cli/task_apps/commands.py +7 -15
- synth_ai/core/env.py +12 -1
- synth_ai/core/errors.py +1 -2
- synth_ai/core/integrations/cloudflare.py +209 -33
- synth_ai/core/tracing_v3/abstractions.py +46 -0
- synth_ai/data/__init__.py +3 -30
- synth_ai/data/enums.py +1 -20
- synth_ai/data/rewards.py +100 -3
- synth_ai/products/graph_evolve/__init__.py +1 -2
- synth_ai/products/graph_evolve/config.py +16 -16
- synth_ai/products/graph_evolve/converters/__init__.py +3 -3
- synth_ai/products/graph_evolve/converters/openai_sft.py +7 -7
- synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +1 -1
- synth_ai/products/graph_gepa/__init__.py +23 -0
- synth_ai/products/graph_gepa/converters/__init__.py +19 -0
- synth_ai/products/graph_gepa/converters/openai_sft.py +29 -0
- synth_ai/sdk/__init__.py +45 -35
- synth_ai/sdk/api/eval/__init__.py +33 -0
- synth_ai/sdk/api/eval/job.py +732 -0
- synth_ai/sdk/api/research_agent/__init__.py +276 -66
- synth_ai/sdk/api/train/builders.py +181 -0
- synth_ai/sdk/api/train/cli.py +41 -33
- synth_ai/sdk/api/train/configs/__init__.py +6 -4
- synth_ai/sdk/api/train/configs/prompt_learning.py +127 -33
- synth_ai/sdk/api/train/configs/rl.py +264 -16
- synth_ai/sdk/api/train/configs/sft.py +165 -1
- synth_ai/sdk/api/train/graph_validators.py +12 -12
- synth_ai/sdk/api/train/graphgen.py +169 -51
- synth_ai/sdk/api/train/graphgen_models.py +95 -45
- synth_ai/sdk/api/train/local_api.py +10 -0
- synth_ai/sdk/api/train/pollers.py +36 -0
- synth_ai/sdk/api/train/prompt_learning.py +390 -60
- synth_ai/sdk/api/train/rl.py +41 -5
- synth_ai/sdk/api/train/sft.py +2 -0
- synth_ai/sdk/api/train/task_app.py +20 -0
- synth_ai/sdk/api/train/validators.py +17 -17
- synth_ai/sdk/graphs/completions.py +239 -33
- synth_ai/sdk/{judging/schemas.py → graphs/verifier_schemas.py} +23 -23
- synth_ai/sdk/learning/__init__.py +35 -5
- synth_ai/sdk/learning/context_learning_client.py +531 -0
- synth_ai/sdk/learning/context_learning_types.py +294 -0
- synth_ai/sdk/learning/prompt_learning_client.py +1 -1
- synth_ai/sdk/learning/prompt_learning_types.py +2 -1
- synth_ai/sdk/learning/rl/__init__.py +0 -4
- synth_ai/sdk/learning/rl/contracts.py +0 -4
- synth_ai/sdk/localapi/__init__.py +40 -0
- synth_ai/sdk/localapi/apps/__init__.py +28 -0
- synth_ai/sdk/localapi/client.py +10 -0
- synth_ai/sdk/localapi/contracts.py +10 -0
- synth_ai/sdk/localapi/helpers.py +519 -0
- synth_ai/sdk/localapi/rollouts.py +93 -0
- synth_ai/sdk/localapi/server.py +29 -0
- synth_ai/sdk/localapi/template.py +49 -0
- synth_ai/sdk/streaming/handlers.py +6 -6
- synth_ai/sdk/streaming/streamer.py +10 -6
- synth_ai/sdk/task/__init__.py +18 -5
- synth_ai/sdk/task/apps/__init__.py +37 -1
- synth_ai/sdk/task/client.py +9 -1
- synth_ai/sdk/task/config.py +6 -11
- synth_ai/sdk/task/contracts.py +137 -95
- synth_ai/sdk/task/in_process.py +32 -22
- synth_ai/sdk/task/in_process_runner.py +9 -4
- synth_ai/sdk/task/rubrics/__init__.py +2 -3
- synth_ai/sdk/task/rubrics/loaders.py +4 -4
- synth_ai/sdk/task/rubrics/strict.py +3 -4
- synth_ai/sdk/task/server.py +76 -16
- synth_ai/sdk/task/trace_correlation_helpers.py +190 -139
- synth_ai/sdk/task/validators.py +34 -49
- synth_ai/sdk/training/__init__.py +7 -16
- synth_ai/sdk/tunnels/__init__.py +118 -0
- synth_ai/sdk/tunnels/cleanup.py +83 -0
- synth_ai/sdk/tunnels/ports.py +120 -0
- synth_ai/sdk/tunnels/tunneled_api.py +363 -0
- {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/METADATA +71 -4
- {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/RECORD +118 -128
- synth_ai/cli/commands/baseline/__init__.py +0 -12
- synth_ai/cli/commands/baseline/core.py +0 -636
- synth_ai/cli/commands/baseline/list.py +0 -94
- synth_ai/cli/commands/eval/errors.py +0 -81
- synth_ai/cli/commands/status/formatters.py +0 -164
- synth_ai/cli/commands/status/subcommands/pricing.py +0 -23
- synth_ai/cli/commands/status/subcommands/usage.py +0 -203
- synth_ai/cli/commands/train/judge_validation.py +0 -305
- synth_ai/cli/usage.py +0 -159
- synth_ai/data/specs.py +0 -36
- synth_ai/sdk/api/research_agent/cli.py +0 -428
- synth_ai/sdk/api/research_agent/config.py +0 -357
- synth_ai/sdk/api/research_agent/job.py +0 -717
- synth_ai/sdk/baseline/__init__.py +0 -25
- synth_ai/sdk/baseline/config.py +0 -209
- synth_ai/sdk/baseline/discovery.py +0 -216
- synth_ai/sdk/baseline/execution.py +0 -154
- synth_ai/sdk/judging/__init__.py +0 -15
- synth_ai/sdk/judging/base.py +0 -24
- synth_ai/sdk/judging/client.py +0 -191
- synth_ai/sdk/judging/types.py +0 -42
- synth_ai/sdk/research_agent/__init__.py +0 -34
- synth_ai/sdk/research_agent/container_builder.py +0 -328
- synth_ai/sdk/research_agent/container_spec.py +0 -198
- synth_ai/sdk/research_agent/defaults.py +0 -34
- synth_ai/sdk/research_agent/results_collector.py +0 -69
- synth_ai/sdk/specs/__init__.py +0 -46
- synth_ai/sdk/specs/dataclasses.py +0 -149
- synth_ai/sdk/specs/loader.py +0 -144
- synth_ai/sdk/specs/serializer.py +0 -199
- synth_ai/sdk/specs/validation.py +0 -250
- synth_ai/sdk/tracing/__init__.py +0 -39
- synth_ai/sdk/usage/__init__.py +0 -37
- synth_ai/sdk/usage/client.py +0 -171
- synth_ai/sdk/usage/models.py +0 -261
- {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/WHEEL +0 -0
- {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Cloudflare tunnel helpers for exposing local APIs.
|
|
2
|
+
|
|
3
|
+
This module provides high-level and low-level tunnel management for
|
|
4
|
+
exposing local task apps to the internet via Cloudflare tunnels.
|
|
5
|
+
|
|
6
|
+
**Recommended:** Use `TunneledLocalAPI` for a clean, one-liner experience:
|
|
7
|
+
|
|
8
|
+
from synth_ai.sdk.tunnels import TunneledLocalAPI, TunnelBackend
|
|
9
|
+
|
|
10
|
+
# Managed tunnel (stable subdomain, requires API key)
|
|
11
|
+
tunnel = await TunneledLocalAPI.create(
|
|
12
|
+
local_port=8001,
|
|
13
|
+
backend=TunnelBackend.CloudflareManagedTunnel,
|
|
14
|
+
api_key="sk_live_...",
|
|
15
|
+
env_api_key="env_key_...",
|
|
16
|
+
progress=True, # Print status updates
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Quick tunnel (random subdomain, no API key needed)
|
|
20
|
+
tunnel = await TunneledLocalAPI.create(
|
|
21
|
+
local_port=8001,
|
|
22
|
+
backend=TunnelBackend.CloudflareQuickTunnel,
|
|
23
|
+
progress=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
print(f"Local API exposed at: {tunnel.url}")
|
|
27
|
+
|
|
28
|
+
# Use the URL for remote jobs
|
|
29
|
+
job = PromptLearningJob.from_dict(config, task_app_url=tunnel.url)
|
|
30
|
+
|
|
31
|
+
# Clean up when done
|
|
32
|
+
tunnel.close()
|
|
33
|
+
|
|
34
|
+
**Low-level:** For more control, use the individual functions:
|
|
35
|
+
|
|
36
|
+
from synth_ai.sdk.tunnels import (
|
|
37
|
+
rotate_tunnel,
|
|
38
|
+
open_managed_tunnel,
|
|
39
|
+
track_process,
|
|
40
|
+
verify_tunnel_dns_resolution,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Get a managed tunnel from backend
|
|
44
|
+
tunnel = await rotate_tunnel(API_KEY, port=8001, reason="demo")
|
|
45
|
+
|
|
46
|
+
# Start cloudflared with the token
|
|
47
|
+
proc = track_process(open_managed_tunnel(tunnel['tunnel_token']))
|
|
48
|
+
|
|
49
|
+
# Verify the tunnel is ready
|
|
50
|
+
await verify_tunnel_dns_resolution(f"https://{tunnel['hostname']}")
|
|
51
|
+
|
|
52
|
+
Note:
|
|
53
|
+
Processes registered with track_process() are automatically cleaned up
|
|
54
|
+
when Python exits (via atexit). You can also call cleanup_all() manually.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
# Re-export from cloudflare.py (no wrappers - these are the actual functions)
|
|
60
|
+
from synth_ai.core.integrations.cloudflare import (
|
|
61
|
+
# Tunnel lifecycle
|
|
62
|
+
create_tunnel,
|
|
63
|
+
open_managed_tunnel,
|
|
64
|
+
open_quick_tunnel,
|
|
65
|
+
open_quick_tunnel_with_dns_verification,
|
|
66
|
+
rotate_tunnel,
|
|
67
|
+
stop_tunnel,
|
|
68
|
+
# Verification
|
|
69
|
+
verify_tunnel_dns_resolution,
|
|
70
|
+
wait_for_health_check,
|
|
71
|
+
# Installation
|
|
72
|
+
ensure_cloudflared_installed,
|
|
73
|
+
get_cloudflared_path,
|
|
74
|
+
require_cloudflared,
|
|
75
|
+
# Discovery
|
|
76
|
+
fetch_managed_tunnels,
|
|
77
|
+
ManagedTunnelRecord,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# New: process tracking with atexit cleanup
|
|
81
|
+
from synth_ai.sdk.tunnels.cleanup import cleanup_all, track_process, tracked_processes
|
|
82
|
+
|
|
83
|
+
# New: port management utilities (was private in in_process.py)
|
|
84
|
+
from synth_ai.sdk.tunnels.ports import find_available_port, is_port_available, kill_port
|
|
85
|
+
|
|
86
|
+
# New: high-level tunnel abstraction
|
|
87
|
+
from synth_ai.sdk.tunnels.tunneled_api import TunneledLocalAPI, TunnelBackend
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
# High-level (RECOMMENDED)
|
|
91
|
+
"TunneledLocalAPI",
|
|
92
|
+
"TunnelBackend",
|
|
93
|
+
# Tunnel lifecycle
|
|
94
|
+
"rotate_tunnel",
|
|
95
|
+
"create_tunnel",
|
|
96
|
+
"open_managed_tunnel",
|
|
97
|
+
"open_quick_tunnel",
|
|
98
|
+
"open_quick_tunnel_with_dns_verification",
|
|
99
|
+
"stop_tunnel",
|
|
100
|
+
# Verification
|
|
101
|
+
"verify_tunnel_dns_resolution",
|
|
102
|
+
"wait_for_health_check",
|
|
103
|
+
# Installation
|
|
104
|
+
"require_cloudflared",
|
|
105
|
+
"ensure_cloudflared_installed",
|
|
106
|
+
"get_cloudflared_path",
|
|
107
|
+
# Discovery
|
|
108
|
+
"fetch_managed_tunnels",
|
|
109
|
+
"ManagedTunnelRecord",
|
|
110
|
+
# Process tracking (NEW)
|
|
111
|
+
"track_process",
|
|
112
|
+
"cleanup_all",
|
|
113
|
+
"tracked_processes",
|
|
114
|
+
# Port management (NEW - was private)
|
|
115
|
+
"kill_port",
|
|
116
|
+
"is_port_available",
|
|
117
|
+
"find_available_port",
|
|
118
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Process tracking with automatic atexit cleanup.
|
|
2
|
+
|
|
3
|
+
This module provides a simple way to track cloudflared processes and ensure
|
|
4
|
+
they are terminated when Python exits (via atexit) or when cleanup_all() is
|
|
5
|
+
called explicitly.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from synth_ai.sdk.tunnels import open_managed_tunnel, track_process
|
|
9
|
+
|
|
10
|
+
# Start a cloudflared process and track it
|
|
11
|
+
proc = track_process(open_managed_tunnel(tunnel_token))
|
|
12
|
+
|
|
13
|
+
# Process will be automatically terminated when Python exits
|
|
14
|
+
# Or you can clean up early:
|
|
15
|
+
# cleanup_all()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import atexit
|
|
21
|
+
import subprocess
|
|
22
|
+
from typing import List
|
|
23
|
+
|
|
24
|
+
# Global state - tracked processes for cleanup
|
|
25
|
+
_tracked: List[subprocess.Popen] = []
|
|
26
|
+
_cleanup_registered = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def tracked_processes() -> List[subprocess.Popen]:
|
|
30
|
+
"""Return list of currently tracked processes (read-only copy).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
List of subprocess.Popen objects being tracked
|
|
34
|
+
"""
|
|
35
|
+
return list(_tracked)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def track_process(proc: subprocess.Popen) -> subprocess.Popen:
|
|
39
|
+
"""Track a cloudflared process for automatic cleanup on exit.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
proc: Process returned by open_managed_tunnel() or similar
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The same process (for chaining)
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
from synth_ai.sdk.tunnels import open_managed_tunnel, track_process
|
|
49
|
+
|
|
50
|
+
proc = track_process(open_managed_tunnel(token))
|
|
51
|
+
# proc will be terminated automatically when Python exits
|
|
52
|
+
"""
|
|
53
|
+
global _cleanup_registered
|
|
54
|
+
_tracked.append(proc)
|
|
55
|
+
|
|
56
|
+
if not _cleanup_registered:
|
|
57
|
+
atexit.register(cleanup_all)
|
|
58
|
+
_cleanup_registered = True
|
|
59
|
+
|
|
60
|
+
return proc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cleanup_all() -> None:
|
|
64
|
+
"""Stop all tracked cloudflared processes.
|
|
65
|
+
|
|
66
|
+
This is called automatically on Python exit via atexit.
|
|
67
|
+
You can also call it manually to clean up early.
|
|
68
|
+
|
|
69
|
+
Processes are terminated gracefully (SIGTERM), with a fallback
|
|
70
|
+
to SIGKILL if they don't exit within 5 seconds.
|
|
71
|
+
"""
|
|
72
|
+
for proc in _tracked:
|
|
73
|
+
try:
|
|
74
|
+
if proc.poll() is None: # Still running
|
|
75
|
+
proc.terminate()
|
|
76
|
+
try:
|
|
77
|
+
proc.wait(timeout=5)
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
proc.kill()
|
|
80
|
+
proc.wait()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass # Best effort cleanup
|
|
83
|
+
_tracked.clear()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Port management utilities.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for checking port availability and
|
|
4
|
+
killing processes that are using specific ports.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from synth_ai.sdk.tunnels import kill_port, is_port_available, find_available_port
|
|
8
|
+
|
|
9
|
+
# Check if port is available
|
|
10
|
+
if not is_port_available(8001):
|
|
11
|
+
kill_port(8001) # Free the port
|
|
12
|
+
|
|
13
|
+
# Or find an available port automatically
|
|
14
|
+
port = find_available_port(8001)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_port_available(port: int, host: str = "127.0.0.1") -> bool:
|
|
25
|
+
"""Check if a port is available for binding.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
port: Port number to check
|
|
29
|
+
host: Host to check (default: 127.0.0.1)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
True if the port is available, False if in use
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
36
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
37
|
+
sock.bind((host, port))
|
|
38
|
+
return True
|
|
39
|
+
except OSError:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_available_port(
|
|
44
|
+
start_port: int, host: str = "127.0.0.1", max_attempts: int = 100
|
|
45
|
+
) -> int:
|
|
46
|
+
"""Find an available port starting from start_port.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
start_port: Port number to start searching from
|
|
50
|
+
host: Host to check (default: 127.0.0.1)
|
|
51
|
+
max_attempts: Maximum number of ports to try
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
First available port number
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
RuntimeError: If no available port found within max_attempts
|
|
58
|
+
"""
|
|
59
|
+
for offset in range(max_attempts):
|
|
60
|
+
port = start_port + offset
|
|
61
|
+
if is_port_available(port, host):
|
|
62
|
+
return port
|
|
63
|
+
raise RuntimeError(f"No available port found starting from {start_port}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def kill_port(port: int, host: str = "127.0.0.1") -> bool:
|
|
67
|
+
"""Kill any process using the specified port.
|
|
68
|
+
|
|
69
|
+
This is a best-effort operation that attempts to free a port by
|
|
70
|
+
terminating whatever process is using it. Use with caution.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
port: Port number to free
|
|
74
|
+
host: Host (unused, for API consistency)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if a process was killed, False if port was already free
|
|
78
|
+
|
|
79
|
+
Note:
|
|
80
|
+
This may not work on all systems or require elevated privileges.
|
|
81
|
+
Prefer using find_available_port() instead when possible.
|
|
82
|
+
"""
|
|
83
|
+
if is_port_available(port, host):
|
|
84
|
+
return False # Already free
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
if sys.platform == "win32":
|
|
88
|
+
# Windows: use netstat + taskkill
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["netstat", "-ano"], capture_output=True, text=True, check=False
|
|
91
|
+
)
|
|
92
|
+
for line in result.stdout.splitlines():
|
|
93
|
+
if f":{port}" in line and "LISTENING" in line:
|
|
94
|
+
parts = line.split()
|
|
95
|
+
if len(parts) > 4:
|
|
96
|
+
pid = parts[-1]
|
|
97
|
+
subprocess.run(
|
|
98
|
+
["taskkill", "/F", "/PID", pid],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
check=False,
|
|
101
|
+
)
|
|
102
|
+
return True
|
|
103
|
+
else:
|
|
104
|
+
# Unix-like (macOS, Linux): use lsof + kill
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
["lsof", "-ti", f":{port}"],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
if result.stdout.strip():
|
|
112
|
+
for pid in result.stdout.strip().split():
|
|
113
|
+
subprocess.run(
|
|
114
|
+
["kill", "-9", pid], capture_output=True, check=False
|
|
115
|
+
)
|
|
116
|
+
return True
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
return False
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""High-level tunnel management for exposing local APIs.
|
|
2
|
+
|
|
3
|
+
This module provides a clean abstraction for setting up Cloudflare tunnels
|
|
4
|
+
to expose local APIs to the internet.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from synth_ai.sdk.tunnels import TunneledLocalAPI, TunnelBackend
|
|
8
|
+
|
|
9
|
+
# Managed tunnel (stable subdomain, requires API key)
|
|
10
|
+
tunnel = await TunneledLocalAPI.create(
|
|
11
|
+
local_port=8001,
|
|
12
|
+
backend=TunnelBackend.CloudflareManagedTunnel,
|
|
13
|
+
api_key="sk_live_...",
|
|
14
|
+
env_api_key="env_key_...",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Quick tunnel (random subdomain, no API key needed)
|
|
18
|
+
tunnel = await TunneledLocalAPI.create(
|
|
19
|
+
local_port=8001,
|
|
20
|
+
backend=TunnelBackend.CloudflareQuickTunnel,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
print(f"Local API exposed at: {tunnel.url}")
|
|
24
|
+
|
|
25
|
+
# Use the URL for remote jobs
|
|
26
|
+
job = PromptLearningJob.from_dict(
|
|
27
|
+
config_dict={...},
|
|
28
|
+
task_app_url=tunnel.url,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Clean up when done
|
|
32
|
+
tunnel.close()
|
|
33
|
+
|
|
34
|
+
See Also:
|
|
35
|
+
- `synth_ai.sdk.tunnels`: Lower-level tunnel functions
|
|
36
|
+
- `synth_ai.core.integrations.cloudflare`: Core tunnel implementation
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import asyncio
|
|
42
|
+
import subprocess
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from enum import Enum
|
|
45
|
+
from typing import Any, Optional
|
|
46
|
+
|
|
47
|
+
from synth_ai.core.telemetry import log_info
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TunnelBackend(str, Enum):
|
|
51
|
+
"""Supported tunnel backends for exposing local APIs.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
CloudflareManagedTunnel: Managed tunnel via Synth backend.
|
|
55
|
+
- Stable subdomains (e.g., task-1234-5678.usesynth.ai)
|
|
56
|
+
- Requires Synth API key
|
|
57
|
+
- Associated with your organization
|
|
58
|
+
- Best for production jobs
|
|
59
|
+
|
|
60
|
+
CloudflareQuickTunnel: Anonymous tunnel via trycloudflare.com.
|
|
61
|
+
- Random subdomains that change each time
|
|
62
|
+
- No API key required
|
|
63
|
+
- Not associated with any organization
|
|
64
|
+
- Best for quick local testing
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
CloudflareManagedTunnel = "cloudflare_managed"
|
|
68
|
+
CloudflareQuickTunnel = "cloudflare_quick"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class TunneledLocalAPI:
|
|
73
|
+
"""A managed tunnel exposing a local API to the internet.
|
|
74
|
+
|
|
75
|
+
This class provides a clean interface for:
|
|
76
|
+
1. Provisioning a Cloudflare tunnel (managed or quick)
|
|
77
|
+
2. Starting the cloudflared process
|
|
78
|
+
3. Verifying DNS resolution and connectivity
|
|
79
|
+
4. Tracking the process for cleanup
|
|
80
|
+
|
|
81
|
+
Use `TunneledLocalAPI.create()` with a `TunnelBackend` to provision a tunnel.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
url: Public HTTPS URL for the tunnel (e.g., "https://task-1234-5678.usesynth.ai")
|
|
85
|
+
hostname: Hostname without protocol (e.g., "task-1234-5678.usesynth.ai")
|
|
86
|
+
local_port: Local port being tunneled
|
|
87
|
+
backend: The tunnel backend used (CloudflareManagedTunnel or CloudflareQuickTunnel)
|
|
88
|
+
process: The cloudflared subprocess (for advanced use)
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> from synth_ai.sdk.tunnels import TunneledLocalAPI, TunnelBackend
|
|
92
|
+
>>> tunnel = await TunneledLocalAPI.create(
|
|
93
|
+
... local_port=8001,
|
|
94
|
+
... backend=TunnelBackend.CloudflareManagedTunnel,
|
|
95
|
+
... api_key="sk_live_...",
|
|
96
|
+
... env_api_key="env_key_...",
|
|
97
|
+
... )
|
|
98
|
+
>>> print(tunnel.url)
|
|
99
|
+
https://task-1234-5678.usesynth.ai
|
|
100
|
+
>>> tunnel.close()
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
url: str
|
|
104
|
+
hostname: str
|
|
105
|
+
local_port: int
|
|
106
|
+
backend: TunnelBackend
|
|
107
|
+
process: Optional[subprocess.Popen] = None
|
|
108
|
+
tunnel_token: Optional[str] = field(default=None, repr=False)
|
|
109
|
+
_raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
async def create(
|
|
113
|
+
cls,
|
|
114
|
+
local_port: int,
|
|
115
|
+
backend: TunnelBackend = TunnelBackend.CloudflareManagedTunnel,
|
|
116
|
+
*,
|
|
117
|
+
api_key: Optional[str] = None,
|
|
118
|
+
env_api_key: Optional[str] = None,
|
|
119
|
+
reason: Optional[str] = None,
|
|
120
|
+
backend_url: Optional[str] = None,
|
|
121
|
+
verify_dns: bool = True,
|
|
122
|
+
progress: bool = False,
|
|
123
|
+
) -> "TunneledLocalAPI":
|
|
124
|
+
"""Create a tunnel to expose a local API.
|
|
125
|
+
|
|
126
|
+
This is the main entry point for creating tunnels. It handles:
|
|
127
|
+
1. Requesting a tunnel (from Synth backend for managed, or trycloudflare for quick)
|
|
128
|
+
2. Starting the cloudflared process
|
|
129
|
+
3. Waiting for DNS propagation
|
|
130
|
+
4. Verifying HTTP connectivity
|
|
131
|
+
5. Registering for automatic cleanup
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
local_port: Local port to tunnel (e.g., 8001)
|
|
135
|
+
backend: Tunnel backend to use. Defaults to CloudflareManagedTunnel.
|
|
136
|
+
- CloudflareManagedTunnel: Stable subdomain, requires api_key
|
|
137
|
+
- CloudflareQuickTunnel: Random subdomain, no api_key needed
|
|
138
|
+
api_key: Synth API key for authentication (required for managed tunnels)
|
|
139
|
+
env_api_key: API key for the local task app (for health checks).
|
|
140
|
+
Defaults to ENVIRONMENT_API_KEY env var.
|
|
141
|
+
reason: Optional reason for creating tunnel (for logging, managed only)
|
|
142
|
+
backend_url: Optional backend URL (defaults to production, managed only)
|
|
143
|
+
verify_dns: Whether to verify DNS resolution after creating tunnel.
|
|
144
|
+
Set to False if you're sure DNS will work (e.g., reusing subdomain).
|
|
145
|
+
progress: If True, print status updates during setup
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
TunneledLocalAPI instance with .url, .hostname, .close(), etc.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If api_key is missing for managed tunnels
|
|
152
|
+
RuntimeError: If tunnel creation or verification fails
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> # Managed tunnel (stable subdomain)
|
|
156
|
+
>>> tunnel = await TunneledLocalAPI.create(
|
|
157
|
+
... local_port=8001,
|
|
158
|
+
... backend=TunnelBackend.CloudflareManagedTunnel,
|
|
159
|
+
... api_key="sk_live_...",
|
|
160
|
+
... progress=True,
|
|
161
|
+
... )
|
|
162
|
+
Provisioning managed tunnel for port 8001...
|
|
163
|
+
Starting cloudflared...
|
|
164
|
+
Tunnel ready: https://task-1234-5678.usesynth.ai
|
|
165
|
+
|
|
166
|
+
>>> # Quick tunnel (random subdomain)
|
|
167
|
+
>>> tunnel = await TunneledLocalAPI.create(
|
|
168
|
+
... local_port=8001,
|
|
169
|
+
... backend=TunnelBackend.CloudflareQuickTunnel,
|
|
170
|
+
... progress=True,
|
|
171
|
+
... )
|
|
172
|
+
Starting quick tunnel for port 8001...
|
|
173
|
+
Tunnel ready: https://random-words.trycloudflare.com
|
|
174
|
+
"""
|
|
175
|
+
import os
|
|
176
|
+
|
|
177
|
+
from .cleanup import track_process
|
|
178
|
+
|
|
179
|
+
# Resolve env_api_key from environment if not provided
|
|
180
|
+
if env_api_key is None:
|
|
181
|
+
env_api_key = os.environ.get("ENVIRONMENT_API_KEY")
|
|
182
|
+
|
|
183
|
+
if backend == TunnelBackend.CloudflareManagedTunnel:
|
|
184
|
+
return await cls._create_managed(
|
|
185
|
+
local_port=local_port,
|
|
186
|
+
api_key=api_key,
|
|
187
|
+
env_api_key=env_api_key,
|
|
188
|
+
reason=reason,
|
|
189
|
+
backend_url=backend_url,
|
|
190
|
+
verify_dns=verify_dns,
|
|
191
|
+
progress=progress,
|
|
192
|
+
track_process=track_process,
|
|
193
|
+
)
|
|
194
|
+
elif backend == TunnelBackend.CloudflareQuickTunnel:
|
|
195
|
+
return await cls._create_quick(
|
|
196
|
+
local_port=local_port,
|
|
197
|
+
env_api_key=env_api_key,
|
|
198
|
+
progress=progress,
|
|
199
|
+
track_process=track_process,
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
raise ValueError(f"Unsupported tunnel backend: {backend}")
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
async def _create_managed(
|
|
206
|
+
cls,
|
|
207
|
+
local_port: int,
|
|
208
|
+
api_key: Optional[str],
|
|
209
|
+
env_api_key: Optional[str],
|
|
210
|
+
reason: Optional[str],
|
|
211
|
+
backend_url: Optional[str],
|
|
212
|
+
verify_dns: bool,
|
|
213
|
+
progress: bool,
|
|
214
|
+
track_process,
|
|
215
|
+
) -> "TunneledLocalAPI":
|
|
216
|
+
"""Internal: Create a managed tunnel via Synth backend."""
|
|
217
|
+
from synth_ai.core.integrations.cloudflare import (
|
|
218
|
+
open_managed_tunnel_with_connection_wait,
|
|
219
|
+
rotate_tunnel,
|
|
220
|
+
verify_tunnel_dns_resolution,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if not api_key:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"api_key is required for CloudflareManagedTunnel. "
|
|
226
|
+
"Use CloudflareQuickTunnel for anonymous tunnels."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Step 1: Provision tunnel from backend
|
|
230
|
+
if progress:
|
|
231
|
+
print(f"Provisioning managed tunnel for port {local_port}...")
|
|
232
|
+
|
|
233
|
+
tunnel_data = await rotate_tunnel(
|
|
234
|
+
api_key,
|
|
235
|
+
local_port,
|
|
236
|
+
reason=reason,
|
|
237
|
+
backend_url=backend_url,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
hostname = tunnel_data["hostname"]
|
|
241
|
+
tunnel_token = tunnel_data["tunnel_token"]
|
|
242
|
+
url = f"https://{hostname}"
|
|
243
|
+
|
|
244
|
+
log_info(
|
|
245
|
+
"TunneledLocalAPI.create: managed tunnel provisioned",
|
|
246
|
+
ctx={"hostname": hostname, "local_port": local_port},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Step 2: Start cloudflared and WAIT for it to connect
|
|
250
|
+
# This is critical - DNS only resolves after cloudflared connects to Cloudflare's edge
|
|
251
|
+
if progress:
|
|
252
|
+
print(f"Starting cloudflared for {hostname}...")
|
|
253
|
+
print("Waiting for cloudflared to connect to Cloudflare edge...")
|
|
254
|
+
|
|
255
|
+
proc = await open_managed_tunnel_with_connection_wait(
|
|
256
|
+
tunnel_token,
|
|
257
|
+
timeout_seconds=30.0,
|
|
258
|
+
)
|
|
259
|
+
track_process(proc)
|
|
260
|
+
|
|
261
|
+
log_info(
|
|
262
|
+
"TunneledLocalAPI.create: cloudflared connected",
|
|
263
|
+
ctx={"hostname": hostname},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Step 3: Verify DNS resolution and connectivity (if requested)
|
|
267
|
+
# DNS should now resolve quickly since cloudflared is connected
|
|
268
|
+
if verify_dns:
|
|
269
|
+
dns_verified = tunnel_data.get("dns_verified", False)
|
|
270
|
+
if not dns_verified:
|
|
271
|
+
if progress:
|
|
272
|
+
print("Verifying DNS propagation...")
|
|
273
|
+
|
|
274
|
+
await verify_tunnel_dns_resolution(
|
|
275
|
+
url,
|
|
276
|
+
name="tunnel",
|
|
277
|
+
timeout_seconds=60.0, # Reduced from 90s since cloudflared is already connected
|
|
278
|
+
api_key=env_api_key,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if progress:
|
|
282
|
+
print(f"Tunnel ready: {url}")
|
|
283
|
+
|
|
284
|
+
return cls(
|
|
285
|
+
url=url,
|
|
286
|
+
hostname=hostname,
|
|
287
|
+
local_port=local_port,
|
|
288
|
+
backend=TunnelBackend.CloudflareManagedTunnel,
|
|
289
|
+
process=proc,
|
|
290
|
+
tunnel_token=tunnel_token,
|
|
291
|
+
_raw=tunnel_data,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
async def _create_quick(
|
|
296
|
+
cls,
|
|
297
|
+
local_port: int,
|
|
298
|
+
env_api_key: Optional[str],
|
|
299
|
+
progress: bool,
|
|
300
|
+
track_process,
|
|
301
|
+
) -> "TunneledLocalAPI":
|
|
302
|
+
"""Internal: Create a quick (anonymous) tunnel via trycloudflare.com."""
|
|
303
|
+
from synth_ai.core.integrations.cloudflare import (
|
|
304
|
+
open_quick_tunnel_with_dns_verification,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if progress:
|
|
308
|
+
print(f"Starting quick tunnel for port {local_port}...")
|
|
309
|
+
|
|
310
|
+
url, proc = await open_quick_tunnel_with_dns_verification(
|
|
311
|
+
port=local_port,
|
|
312
|
+
api_key=env_api_key,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
track_process(proc)
|
|
316
|
+
|
|
317
|
+
# Extract hostname from URL
|
|
318
|
+
hostname = url.replace("https://", "").replace("http://", "").rstrip("/")
|
|
319
|
+
|
|
320
|
+
log_info(
|
|
321
|
+
"TunneledLocalAPI.create: quick tunnel ready",
|
|
322
|
+
ctx={"hostname": hostname, "local_port": local_port},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if progress:
|
|
326
|
+
print(f"Tunnel ready: {url}")
|
|
327
|
+
|
|
328
|
+
return cls(
|
|
329
|
+
url=url,
|
|
330
|
+
hostname=hostname,
|
|
331
|
+
local_port=local_port,
|
|
332
|
+
backend=TunnelBackend.CloudflareQuickTunnel,
|
|
333
|
+
process=proc,
|
|
334
|
+
tunnel_token=None,
|
|
335
|
+
_raw={},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def close(self) -> None:
|
|
339
|
+
"""Close the tunnel and terminate the cloudflared process.
|
|
340
|
+
|
|
341
|
+
This is called automatically when the process exits (via atexit),
|
|
342
|
+
but you can call it explicitly for earlier cleanup.
|
|
343
|
+
"""
|
|
344
|
+
from synth_ai.core.integrations.cloudflare import stop_tunnel
|
|
345
|
+
|
|
346
|
+
if self.process:
|
|
347
|
+
stop_tunnel(self.process)
|
|
348
|
+
self.process = None
|
|
349
|
+
log_info(
|
|
350
|
+
"TunneledLocalAPI.close: tunnel closed",
|
|
351
|
+
ctx={"hostname": self.hostname, "backend": self.backend.value},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def __enter__(self) -> "TunneledLocalAPI":
|
|
355
|
+
"""Context manager entry (for sync use after async creation)."""
|
|
356
|
+
return self
|
|
357
|
+
|
|
358
|
+
def __exit__(self, *args: Any) -> None:
|
|
359
|
+
"""Context manager exit - closes tunnel."""
|
|
360
|
+
self.close()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = ["TunneledLocalAPI", "TunnelBackend"]
|