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.

Files changed (153) hide show
  1. synth_ai/__init__.py +13 -13
  2. synth_ai/cli/__init__.py +6 -15
  3. synth_ai/cli/commands/eval/__init__.py +6 -15
  4. synth_ai/cli/commands/eval/config.py +338 -0
  5. synth_ai/cli/commands/eval/core.py +236 -1091
  6. synth_ai/cli/commands/eval/runner.py +704 -0
  7. synth_ai/cli/commands/eval/validation.py +44 -117
  8. synth_ai/cli/commands/filter/core.py +7 -7
  9. synth_ai/cli/commands/filter/validation.py +2 -2
  10. synth_ai/cli/commands/smoke/core.py +7 -17
  11. synth_ai/cli/commands/status/__init__.py +1 -64
  12. synth_ai/cli/commands/status/client.py +50 -151
  13. synth_ai/cli/commands/status/config.py +3 -83
  14. synth_ai/cli/commands/status/errors.py +4 -13
  15. synth_ai/cli/commands/status/subcommands/__init__.py +2 -8
  16. synth_ai/cli/commands/status/subcommands/config.py +13 -0
  17. synth_ai/cli/commands/status/subcommands/files.py +18 -63
  18. synth_ai/cli/commands/status/subcommands/jobs.py +28 -311
  19. synth_ai/cli/commands/status/subcommands/models.py +18 -62
  20. synth_ai/cli/commands/status/subcommands/runs.py +16 -63
  21. synth_ai/cli/commands/status/subcommands/session.py +67 -172
  22. synth_ai/cli/commands/status/subcommands/summary.py +24 -32
  23. synth_ai/cli/commands/status/subcommands/utils.py +41 -0
  24. synth_ai/cli/commands/status/utils.py +16 -107
  25. synth_ai/cli/commands/train/__init__.py +18 -20
  26. synth_ai/cli/commands/train/errors.py +3 -3
  27. synth_ai/cli/commands/train/prompt_learning_validation.py +15 -16
  28. synth_ai/cli/commands/train/validation.py +7 -7
  29. synth_ai/cli/commands/train/{judge_schemas.py → verifier_schemas.py} +33 -34
  30. synth_ai/cli/commands/train/verifier_validation.py +235 -0
  31. synth_ai/cli/demo_apps/demo_task_apps/math/config.toml +0 -1
  32. synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +2 -6
  33. synth_ai/cli/demo_apps/math/config.toml +0 -1
  34. synth_ai/cli/demo_apps/math/modal_task_app.py +2 -6
  35. synth_ai/cli/demo_apps/mipro/task_app.py +25 -47
  36. synth_ai/cli/lib/apps/task_app.py +12 -13
  37. synth_ai/cli/lib/task_app_discovery.py +6 -6
  38. synth_ai/cli/lib/train_cfgs.py +10 -10
  39. synth_ai/cli/task_apps/__init__.py +11 -0
  40. synth_ai/cli/task_apps/commands.py +7 -15
  41. synth_ai/core/env.py +12 -1
  42. synth_ai/core/errors.py +1 -2
  43. synth_ai/core/integrations/cloudflare.py +209 -33
  44. synth_ai/core/tracing_v3/abstractions.py +46 -0
  45. synth_ai/data/__init__.py +3 -30
  46. synth_ai/data/enums.py +1 -20
  47. synth_ai/data/rewards.py +100 -3
  48. synth_ai/products/graph_evolve/__init__.py +1 -2
  49. synth_ai/products/graph_evolve/config.py +16 -16
  50. synth_ai/products/graph_evolve/converters/__init__.py +3 -3
  51. synth_ai/products/graph_evolve/converters/openai_sft.py +7 -7
  52. synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +1 -1
  53. synth_ai/products/graph_gepa/__init__.py +23 -0
  54. synth_ai/products/graph_gepa/converters/__init__.py +19 -0
  55. synth_ai/products/graph_gepa/converters/openai_sft.py +29 -0
  56. synth_ai/sdk/__init__.py +45 -35
  57. synth_ai/sdk/api/eval/__init__.py +33 -0
  58. synth_ai/sdk/api/eval/job.py +732 -0
  59. synth_ai/sdk/api/research_agent/__init__.py +276 -66
  60. synth_ai/sdk/api/train/builders.py +181 -0
  61. synth_ai/sdk/api/train/cli.py +41 -33
  62. synth_ai/sdk/api/train/configs/__init__.py +6 -4
  63. synth_ai/sdk/api/train/configs/prompt_learning.py +127 -33
  64. synth_ai/sdk/api/train/configs/rl.py +264 -16
  65. synth_ai/sdk/api/train/configs/sft.py +165 -1
  66. synth_ai/sdk/api/train/graph_validators.py +12 -12
  67. synth_ai/sdk/api/train/graphgen.py +169 -51
  68. synth_ai/sdk/api/train/graphgen_models.py +95 -45
  69. synth_ai/sdk/api/train/local_api.py +10 -0
  70. synth_ai/sdk/api/train/pollers.py +36 -0
  71. synth_ai/sdk/api/train/prompt_learning.py +390 -60
  72. synth_ai/sdk/api/train/rl.py +41 -5
  73. synth_ai/sdk/api/train/sft.py +2 -0
  74. synth_ai/sdk/api/train/task_app.py +20 -0
  75. synth_ai/sdk/api/train/validators.py +17 -17
  76. synth_ai/sdk/graphs/completions.py +239 -33
  77. synth_ai/sdk/{judging/schemas.py → graphs/verifier_schemas.py} +23 -23
  78. synth_ai/sdk/learning/__init__.py +35 -5
  79. synth_ai/sdk/learning/context_learning_client.py +531 -0
  80. synth_ai/sdk/learning/context_learning_types.py +294 -0
  81. synth_ai/sdk/learning/prompt_learning_client.py +1 -1
  82. synth_ai/sdk/learning/prompt_learning_types.py +2 -1
  83. synth_ai/sdk/learning/rl/__init__.py +0 -4
  84. synth_ai/sdk/learning/rl/contracts.py +0 -4
  85. synth_ai/sdk/localapi/__init__.py +40 -0
  86. synth_ai/sdk/localapi/apps/__init__.py +28 -0
  87. synth_ai/sdk/localapi/client.py +10 -0
  88. synth_ai/sdk/localapi/contracts.py +10 -0
  89. synth_ai/sdk/localapi/helpers.py +519 -0
  90. synth_ai/sdk/localapi/rollouts.py +93 -0
  91. synth_ai/sdk/localapi/server.py +29 -0
  92. synth_ai/sdk/localapi/template.py +49 -0
  93. synth_ai/sdk/streaming/handlers.py +6 -6
  94. synth_ai/sdk/streaming/streamer.py +10 -6
  95. synth_ai/sdk/task/__init__.py +18 -5
  96. synth_ai/sdk/task/apps/__init__.py +37 -1
  97. synth_ai/sdk/task/client.py +9 -1
  98. synth_ai/sdk/task/config.py +6 -11
  99. synth_ai/sdk/task/contracts.py +137 -95
  100. synth_ai/sdk/task/in_process.py +32 -22
  101. synth_ai/sdk/task/in_process_runner.py +9 -4
  102. synth_ai/sdk/task/rubrics/__init__.py +2 -3
  103. synth_ai/sdk/task/rubrics/loaders.py +4 -4
  104. synth_ai/sdk/task/rubrics/strict.py +3 -4
  105. synth_ai/sdk/task/server.py +76 -16
  106. synth_ai/sdk/task/trace_correlation_helpers.py +190 -139
  107. synth_ai/sdk/task/validators.py +34 -49
  108. synth_ai/sdk/training/__init__.py +7 -16
  109. synth_ai/sdk/tunnels/__init__.py +118 -0
  110. synth_ai/sdk/tunnels/cleanup.py +83 -0
  111. synth_ai/sdk/tunnels/ports.py +120 -0
  112. synth_ai/sdk/tunnels/tunneled_api.py +363 -0
  113. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/METADATA +71 -4
  114. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/RECORD +118 -128
  115. synth_ai/cli/commands/baseline/__init__.py +0 -12
  116. synth_ai/cli/commands/baseline/core.py +0 -636
  117. synth_ai/cli/commands/baseline/list.py +0 -94
  118. synth_ai/cli/commands/eval/errors.py +0 -81
  119. synth_ai/cli/commands/status/formatters.py +0 -164
  120. synth_ai/cli/commands/status/subcommands/pricing.py +0 -23
  121. synth_ai/cli/commands/status/subcommands/usage.py +0 -203
  122. synth_ai/cli/commands/train/judge_validation.py +0 -305
  123. synth_ai/cli/usage.py +0 -159
  124. synth_ai/data/specs.py +0 -36
  125. synth_ai/sdk/api/research_agent/cli.py +0 -428
  126. synth_ai/sdk/api/research_agent/config.py +0 -357
  127. synth_ai/sdk/api/research_agent/job.py +0 -717
  128. synth_ai/sdk/baseline/__init__.py +0 -25
  129. synth_ai/sdk/baseline/config.py +0 -209
  130. synth_ai/sdk/baseline/discovery.py +0 -216
  131. synth_ai/sdk/baseline/execution.py +0 -154
  132. synth_ai/sdk/judging/__init__.py +0 -15
  133. synth_ai/sdk/judging/base.py +0 -24
  134. synth_ai/sdk/judging/client.py +0 -191
  135. synth_ai/sdk/judging/types.py +0 -42
  136. synth_ai/sdk/research_agent/__init__.py +0 -34
  137. synth_ai/sdk/research_agent/container_builder.py +0 -328
  138. synth_ai/sdk/research_agent/container_spec.py +0 -198
  139. synth_ai/sdk/research_agent/defaults.py +0 -34
  140. synth_ai/sdk/research_agent/results_collector.py +0 -69
  141. synth_ai/sdk/specs/__init__.py +0 -46
  142. synth_ai/sdk/specs/dataclasses.py +0 -149
  143. synth_ai/sdk/specs/loader.py +0 -144
  144. synth_ai/sdk/specs/serializer.py +0 -199
  145. synth_ai/sdk/specs/validation.py +0 -250
  146. synth_ai/sdk/tracing/__init__.py +0 -39
  147. synth_ai/sdk/usage/__init__.py +0 -37
  148. synth_ai/sdk/usage/client.py +0 -171
  149. synth_ai/sdk/usage/models.py +0 -261
  150. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/WHEEL +0 -0
  151. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/entry_points.txt +0 -0
  152. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/licenses/LICENSE +0 -0
  153. {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"]