synth-ai 0.2.6.dev1__py3-none-any.whl → 0.4.3__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.
Files changed (738) hide show
  1. synth_ai/__init__.py +44 -24
  2. synth_ai/__main__.py +30 -3
  3. synth_ai/cli/__init__.py +103 -48
  4. synth_ai/cli/__main__.py +42 -0
  5. synth_ai/cli/_internal/__init__.py +5 -0
  6. synth_ai/cli/_internal/modal_wrapper.py +31 -0
  7. synth_ai/cli/_internal/storage.py +20 -0
  8. synth_ai/cli/_internal/typer_patch.py +47 -0
  9. synth_ai/cli/_internal/validate_task_app.py +29 -0
  10. synth_ai/cli/agents/__init__.py +17 -0
  11. synth_ai/cli/agents/claude.py +77 -0
  12. synth_ai/cli/agents/codex.py +265 -0
  13. synth_ai/cli/agents/opencode.py +253 -0
  14. synth_ai/cli/commands/__init__.py +18 -0
  15. synth_ai/cli/commands/artifacts/__init__.py +13 -0
  16. synth_ai/cli/commands/artifacts/client.py +119 -0
  17. synth_ai/cli/commands/artifacts/config.py +57 -0
  18. synth_ai/cli/commands/artifacts/core.py +24 -0
  19. synth_ai/cli/commands/artifacts/download.py +188 -0
  20. synth_ai/cli/commands/artifacts/export.py +186 -0
  21. synth_ai/cli/commands/artifacts/list.py +156 -0
  22. synth_ai/cli/commands/artifacts/parsing.py +250 -0
  23. synth_ai/cli/commands/artifacts/show.py +336 -0
  24. synth_ai/cli/commands/demo/__init__.py +3 -0
  25. synth_ai/cli/commands/demo/core.py +153 -0
  26. synth_ai/cli/commands/eval/__init__.py +10 -0
  27. synth_ai/cli/commands/eval/config.py +338 -0
  28. synth_ai/cli/commands/eval/core.py +256 -0
  29. synth_ai/cli/commands/eval/runner.py +704 -0
  30. synth_ai/cli/commands/eval/validation.py +60 -0
  31. synth_ai/cli/commands/filter/__init__.py +12 -0
  32. synth_ai/cli/commands/filter/core.py +424 -0
  33. synth_ai/cli/commands/filter/errors.py +55 -0
  34. synth_ai/cli/commands/filter/validation.py +77 -0
  35. synth_ai/cli/commands/help/__init__.py +185 -0
  36. synth_ai/cli/commands/help/core.py +72 -0
  37. synth_ai/cli/commands/scan/__init__.py +19 -0
  38. synth_ai/cli/commands/scan/cloudflare_scanner.py +403 -0
  39. synth_ai/cli/commands/scan/core.py +344 -0
  40. synth_ai/cli/commands/scan/health_checker.py +242 -0
  41. synth_ai/cli/commands/scan/local_scanner.py +278 -0
  42. synth_ai/cli/commands/scan/models.py +83 -0
  43. synth_ai/cli/commands/smoke/__init__.py +7 -0
  44. synth_ai/cli/commands/smoke/core.py +1428 -0
  45. synth_ai/cli/commands/status/__init__.py +3 -0
  46. synth_ai/cli/commands/status/client.py +91 -0
  47. synth_ai/cli/commands/status/config.py +12 -0
  48. synth_ai/cli/commands/status/errors.py +11 -0
  49. synth_ai/cli/commands/status/subcommands/__init__.py +3 -0
  50. synth_ai/cli/commands/status/subcommands/config.py +13 -0
  51. synth_ai/cli/commands/status/subcommands/files.py +34 -0
  52. synth_ai/cli/commands/status/subcommands/jobs.py +51 -0
  53. synth_ai/cli/commands/status/subcommands/models.py +35 -0
  54. synth_ai/cli/commands/status/subcommands/runs.py +34 -0
  55. synth_ai/cli/commands/status/subcommands/session.py +77 -0
  56. synth_ai/cli/commands/status/subcommands/summary.py +39 -0
  57. synth_ai/cli/commands/status/subcommands/utils.py +41 -0
  58. synth_ai/cli/commands/status/utils.py +23 -0
  59. synth_ai/cli/commands/train/__init__.py +53 -0
  60. synth_ai/cli/commands/train/core.py +22 -0
  61. synth_ai/cli/commands/train/errors.py +117 -0
  62. synth_ai/cli/commands/train/judge_schemas.py +201 -0
  63. synth_ai/cli/commands/train/judge_validation.py +305 -0
  64. synth_ai/cli/commands/train/prompt_learning_validation.py +633 -0
  65. synth_ai/cli/commands/train/validation.py +392 -0
  66. synth_ai/cli/demo_apps/__init__.py +10 -0
  67. synth_ai/cli/demo_apps/core/__init__.py +28 -0
  68. synth_ai/cli/demo_apps/core/cli.py +1735 -0
  69. synth_ai/cli/demo_apps/crafter/__init__.py +1 -0
  70. synth_ai/cli/demo_apps/crafter/crafter_fft_4b.toml +55 -0
  71. synth_ai/cli/demo_apps/crafter/grpo_crafter_task_app.py +186 -0
  72. synth_ai/cli/demo_apps/crafter/rl_from_base_qwen4b.toml +74 -0
  73. synth_ai/cli/demo_apps/demo_registry.py +176 -0
  74. synth_ai/cli/demo_apps/demo_task_apps/__init__.py +7 -0
  75. synth_ai/{demos → cli/demo_apps}/demo_task_apps/core.py +117 -51
  76. synth_ai/cli/demo_apps/demo_task_apps/crafter/__init__.py +1 -0
  77. synth_ai/cli/demo_apps/demo_task_apps/crafter/configs/crafter_fft_4b.toml +53 -0
  78. synth_ai/cli/demo_apps/demo_task_apps/crafter/configs/rl_from_base_qwen4b.toml +73 -0
  79. synth_ai/cli/demo_apps/demo_task_apps/crafter/grpo_crafter_task_app.py +185 -0
  80. synth_ai/cli/demo_apps/demo_task_apps/math/_common.py +16 -0
  81. synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/app.py +2 -1
  82. synth_ai/cli/demo_apps/demo_task_apps/math/config.toml +73 -0
  83. synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/deploy_modal.py +3 -6
  84. synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +738 -0
  85. synth_ai/cli/demo_apps/demo_task_apps/math/task_app_entry.py +39 -0
  86. synth_ai/cli/demo_apps/math/__init__.py +1 -0
  87. synth_ai/cli/demo_apps/math/_common.py +16 -0
  88. synth_ai/cli/demo_apps/math/app.py +38 -0
  89. synth_ai/cli/demo_apps/math/config.toml +75 -0
  90. synth_ai/cli/demo_apps/math/deploy_modal.py +54 -0
  91. synth_ai/cli/demo_apps/math/modal_task_app.py +698 -0
  92. synth_ai/cli/demo_apps/math/task_app_entry.py +53 -0
  93. synth_ai/cli/demo_apps/mipro/main.py +271 -0
  94. synth_ai/cli/demo_apps/mipro/task_app.py +922 -0
  95. synth_ai/cli/demo_apps/mipro/train_cfg.toml +92 -0
  96. synth_ai/cli/demos/__init__.py +12 -0
  97. synth_ai/cli/demos/demo.py +32 -0
  98. synth_ai/cli/demos/rl_demo.py +254 -0
  99. synth_ai/cli/deploy.py +216 -0
  100. synth_ai/cli/infra/__init__.py +14 -0
  101. synth_ai/cli/{balance.py → infra/balance.py} +21 -3
  102. synth_ai/cli/infra/mcp.py +35 -0
  103. synth_ai/cli/infra/modal_app.py +36 -0
  104. synth_ai/cli/infra/setup.py +69 -0
  105. synth_ai/cli/infra/status.py +16 -0
  106. synth_ai/cli/infra/turso.py +77 -0
  107. synth_ai/cli/lib/__init__.py +10 -0
  108. synth_ai/cli/lib/agents.py +76 -0
  109. synth_ai/cli/lib/apps/modal_app.py +101 -0
  110. synth_ai/cli/lib/apps/task_app.py +642 -0
  111. synth_ai/cli/lib/bin.py +39 -0
  112. synth_ai/cli/lib/env.py +375 -0
  113. synth_ai/cli/lib/errors.py +85 -0
  114. synth_ai/cli/lib/modal.py +315 -0
  115. synth_ai/cli/lib/plotting.py +126 -0
  116. synth_ai/cli/lib/prompt_args.py +39 -0
  117. synth_ai/cli/lib/prompts.py +284 -0
  118. synth_ai/cli/lib/sqld.py +122 -0
  119. synth_ai/cli/lib/task_app_discovery.py +884 -0
  120. synth_ai/cli/lib/task_app_env.py +295 -0
  121. synth_ai/cli/lib/train_cfgs.py +300 -0
  122. synth_ai/cli/lib/tunnel_records.py +207 -0
  123. synth_ai/cli/local/__init__.py +14 -0
  124. synth_ai/cli/local/experiment_queue/__init__.py +72 -0
  125. synth_ai/cli/local/experiment_queue/api_schemas.py +221 -0
  126. synth_ai/cli/local/experiment_queue/celery_app.py +208 -0
  127. synth_ai/cli/local/experiment_queue/config.py +128 -0
  128. synth_ai/cli/local/experiment_queue/config_utils.py +272 -0
  129. synth_ai/cli/local/experiment_queue/database.py +175 -0
  130. synth_ai/cli/local/experiment_queue/dispatcher.py +119 -0
  131. synth_ai/cli/local/experiment_queue/models.py +231 -0
  132. synth_ai/cli/local/experiment_queue/progress_info.py +160 -0
  133. synth_ai/cli/local/experiment_queue/results.py +373 -0
  134. synth_ai/cli/local/experiment_queue/schemas.py +131 -0
  135. synth_ai/cli/local/experiment_queue/service.py +344 -0
  136. synth_ai/cli/local/experiment_queue/status.py +372 -0
  137. synth_ai/cli/local/experiment_queue/status_tracker.py +360 -0
  138. synth_ai/cli/local/experiment_queue/tasks.py +1984 -0
  139. synth_ai/cli/local/experiment_queue/trace_storage.py +65 -0
  140. synth_ai/cli/local/experiment_queue/validation.py +157 -0
  141. synth_ai/cli/local/session/__init__.py +92 -0
  142. synth_ai/cli/local/session/client.py +383 -0
  143. synth_ai/cli/local/session/constants.py +63 -0
  144. synth_ai/cli/local/session/exceptions.py +105 -0
  145. synth_ai/cli/local/session/manager.py +139 -0
  146. synth_ai/cli/local/session/models.py +89 -0
  147. synth_ai/cli/local/session/query.py +110 -0
  148. synth_ai/cli/root.py +150 -102
  149. synth_ai/cli/task_apps/__init__.py +37 -0
  150. synth_ai/cli/task_apps/commands.py +3145 -0
  151. synth_ai/cli/task_apps/deploy.py +7 -0
  152. synth_ai/cli/task_apps/list.py +26 -0
  153. synth_ai/cli/task_apps/main.py +36 -0
  154. synth_ai/cli/task_apps/modal_serve.py +11 -0
  155. synth_ai/cli/task_apps/serve.py +11 -0
  156. synth_ai/cli/training/__init__.py +8 -0
  157. synth_ai/cli/training/train.py +5 -0
  158. synth_ai/cli/training/train_cfg.py +34 -0
  159. synth_ai/cli/{watch.py → training/watch.py} +13 -18
  160. synth_ai/cli/turso.py +52 -0
  161. synth_ai/cli/utils/__init__.py +8 -0
  162. synth_ai/cli/utils/experiments.py +235 -0
  163. synth_ai/cli/utils/queue.py +504 -0
  164. synth_ai/cli/{recent.py → utils/recent.py} +13 -7
  165. synth_ai/cli/{traces.py → utils/traces.py} +9 -5
  166. synth_ai/contracts/__init__.py +67 -0
  167. synth_ai/core/__init__.py +100 -0
  168. synth_ai/core/_utils/__init__.py +54 -0
  169. synth_ai/core/_utils/base_url.py +10 -0
  170. synth_ai/core/_utils/http.py +10 -0
  171. synth_ai/core/_utils/prompts.py +14 -0
  172. synth_ai/core/_utils/task_app_state.py +12 -0
  173. synth_ai/core/_utils/user_config.py +10 -0
  174. synth_ai/core/apps/common.py +116 -0
  175. synth_ai/core/auth.py +95 -0
  176. synth_ai/core/cfgs.py +240 -0
  177. synth_ai/core/config/__init__.py +16 -0
  178. synth_ai/core/config/base.py +168 -0
  179. synth_ai/core/config/resolver.py +89 -0
  180. synth_ai/core/env.py +231 -0
  181. synth_ai/core/errors.py +126 -0
  182. synth_ai/core/http.py +230 -0
  183. synth_ai/core/integrations/__init__.py +11 -0
  184. synth_ai/core/integrations/cloudflare.py +1710 -0
  185. synth_ai/core/integrations/mcp/__init__.py +6 -0
  186. synth_ai/core/integrations/mcp/__main__.py +8 -0
  187. synth_ai/core/integrations/mcp/claude.py +36 -0
  188. synth_ai/core/integrations/mcp/main.py +254 -0
  189. synth_ai/core/integrations/mcp/setup.py +100 -0
  190. synth_ai/core/integrations/modal.py +277 -0
  191. synth_ai/core/json.py +72 -0
  192. synth_ai/core/log_filter.py +99 -0
  193. synth_ai/core/logging.py +82 -0
  194. synth_ai/core/paths.py +107 -0
  195. synth_ai/core/pricing.py +109 -0
  196. synth_ai/core/process.py +233 -0
  197. synth_ai/core/ssl.py +25 -0
  198. synth_ai/core/storage/__init__.py +71 -0
  199. synth_ai/core/task_app_state.py +318 -0
  200. synth_ai/core/telemetry.py +282 -0
  201. synth_ai/{tracing_v3 → core/tracing_v3}/__init__.py +5 -1
  202. synth_ai/{tracing_v3 → core/tracing_v3}/abstractions.py +21 -4
  203. synth_ai/core/tracing_v3/config.py +229 -0
  204. synth_ai/core/tracing_v3/constants.py +21 -0
  205. synth_ai/{tracing_v3 → core/tracing_v3}/db_config.py +42 -29
  206. synth_ai/{tracing_v3 → core/tracing_v3}/decorators.py +80 -45
  207. synth_ai/{tracing_v3 → core/tracing_v3}/examples/basic_usage.py +15 -9
  208. synth_ai/{tracing_v3 → core/tracing_v3}/hooks.py +6 -4
  209. synth_ai/{tracing_v3 → core/tracing_v3}/llm_call_record_helpers.py +161 -61
  210. synth_ai/{tracing_v3 → core/tracing_v3}/migration_helper.py +1 -2
  211. synth_ai/{tracing_v3 → core/tracing_v3}/replica_sync.py +12 -7
  212. synth_ai/core/tracing_v3/serialization.py +130 -0
  213. synth_ai/{tracing_v3 → core/tracing_v3}/session_tracer.py +88 -21
  214. synth_ai/{tracing_v3 → core/tracing_v3}/storage/base.py +99 -12
  215. synth_ai/core/tracing_v3/storage/config.py +109 -0
  216. synth_ai/{tracing_v3 → core/tracing_v3}/storage/factory.py +11 -9
  217. synth_ai/{tracing_v3 → core/tracing_v3}/storage/utils.py +15 -11
  218. synth_ai/core/tracing_v3/trace_utils.py +326 -0
  219. synth_ai/core/tracing_v3/turso/__init__.py +12 -0
  220. synth_ai/core/tracing_v3/turso/daemon.py +278 -0
  221. synth_ai/{tracing_v3 → core/tracing_v3}/turso/models.py +7 -3
  222. synth_ai/core/tracing_v3/turso/native_manager.py +1385 -0
  223. synth_ai/{tracing_v3 → core/tracing_v3}/utils.py +5 -4
  224. synth_ai/core/urls.py +18 -0
  225. synth_ai/core/user_config.py +137 -0
  226. synth_ai/core/uvicorn.py +222 -0
  227. synth_ai/data/__init__.py +83 -0
  228. synth_ai/data/enums.py +123 -0
  229. synth_ai/data/rewards.py +152 -0
  230. synth_ai/data/traces.py +35 -0
  231. synth_ai/products/__init__.py +6 -0
  232. synth_ai/products/graph_evolve/__init__.py +46 -0
  233. synth_ai/products/graph_evolve/client.py +226 -0
  234. synth_ai/products/graph_evolve/config.py +591 -0
  235. synth_ai/products/graph_evolve/converters/__init__.py +42 -0
  236. synth_ai/products/graph_evolve/converters/openai_sft.py +484 -0
  237. synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +109 -0
  238. synth_ai/products/graph_evolve/run.py +222 -0
  239. synth_ai/products/graph_gepa/__init__.py +23 -0
  240. synth_ai/products/graph_gepa/converters/__init__.py +19 -0
  241. synth_ai/products/graph_gepa/converters/openai_sft.py +29 -0
  242. synth_ai/sdk/__init__.py +123 -0
  243. synth_ai/sdk/api/__init__.py +1 -0
  244. synth_ai/sdk/api/models/supported.py +514 -0
  245. synth_ai/sdk/api/research_agent/__init__.py +296 -0
  246. synth_ai/sdk/api/train/__init__.py +85 -0
  247. synth_ai/sdk/api/train/builders.py +895 -0
  248. synth_ai/sdk/api/train/cli.py +2199 -0
  249. synth_ai/sdk/api/train/config_finder.py +267 -0
  250. synth_ai/sdk/api/train/configs/__init__.py +65 -0
  251. synth_ai/sdk/api/train/configs/prompt_learning.py +1706 -0
  252. synth_ai/sdk/api/train/configs/rl.py +187 -0
  253. synth_ai/sdk/api/train/configs/sft.py +99 -0
  254. synth_ai/sdk/api/train/configs/shared.py +81 -0
  255. synth_ai/sdk/api/train/context_learning.py +312 -0
  256. synth_ai/sdk/api/train/env_resolver.py +418 -0
  257. synth_ai/sdk/api/train/graph_validators.py +216 -0
  258. synth_ai/sdk/api/train/graphgen.py +984 -0
  259. synth_ai/sdk/api/train/graphgen_models.py +823 -0
  260. synth_ai/sdk/api/train/graphgen_validators.py +109 -0
  261. synth_ai/sdk/api/train/local_api.py +10 -0
  262. synth_ai/sdk/api/train/pollers.py +124 -0
  263. synth_ai/sdk/api/train/progress/__init__.py +97 -0
  264. synth_ai/sdk/api/train/progress/dataclasses.py +569 -0
  265. synth_ai/sdk/api/train/progress/events.py +326 -0
  266. synth_ai/sdk/api/train/progress/results.py +428 -0
  267. synth_ai/sdk/api/train/progress/tracker.py +641 -0
  268. synth_ai/sdk/api/train/prompt_learning.py +469 -0
  269. synth_ai/sdk/api/train/rl.py +441 -0
  270. synth_ai/sdk/api/train/sft.py +396 -0
  271. synth_ai/sdk/api/train/summary.py +522 -0
  272. synth_ai/sdk/api/train/supported_algos.py +147 -0
  273. synth_ai/sdk/api/train/task_app.py +351 -0
  274. synth_ai/sdk/api/train/utils.py +279 -0
  275. synth_ai/sdk/api/train/validators.py +2424 -0
  276. synth_ai/sdk/graphs/__init__.py +15 -0
  277. synth_ai/sdk/graphs/completions.py +570 -0
  278. synth_ai/{inference → sdk/inference}/__init__.py +0 -1
  279. synth_ai/sdk/inference/client.py +128 -0
  280. synth_ai/sdk/jobs/__init__.py +16 -0
  281. synth_ai/sdk/jobs/client.py +371 -0
  282. synth_ai/sdk/judging/__init__.py +14 -0
  283. synth_ai/sdk/judging/base.py +24 -0
  284. synth_ai/sdk/judging/client.py +40 -0
  285. synth_ai/sdk/judging/schemas.py +222 -0
  286. synth_ai/sdk/judging/types.py +42 -0
  287. synth_ai/sdk/learning/__init__.py +99 -0
  288. synth_ai/sdk/learning/algorithms.py +14 -0
  289. synth_ai/{learning → sdk/learning}/client.py +121 -30
  290. synth_ai/sdk/learning/config.py +5 -0
  291. synth_ai/{learning → sdk/learning}/constants.py +0 -2
  292. synth_ai/sdk/learning/context_learning_client.py +531 -0
  293. synth_ai/sdk/learning/context_learning_types.py +292 -0
  294. synth_ai/sdk/learning/ft_client.py +7 -0
  295. synth_ai/{learning → sdk/learning}/health.py +15 -9
  296. synth_ai/{learning → sdk/learning}/jobs.py +44 -47
  297. synth_ai/sdk/learning/prompt_extraction.py +334 -0
  298. synth_ai/sdk/learning/prompt_learning_client.py +455 -0
  299. synth_ai/sdk/learning/prompt_learning_types.py +186 -0
  300. synth_ai/{rl → sdk/learning/rl}/__init__.py +13 -8
  301. synth_ai/{learning/rl_client.py → sdk/learning/rl/client.py} +89 -77
  302. synth_ai/sdk/learning/rl/config.py +31 -0
  303. synth_ai/{rl → sdk/learning/rl}/contracts.py +5 -14
  304. synth_ai/{rl → sdk/learning/rl}/env_keys.py +45 -16
  305. synth_ai/sdk/learning/rl/secrets.py +13 -0
  306. synth_ai/sdk/learning/rl_client.py +5 -0
  307. synth_ai/sdk/learning/sft/__init__.py +29 -0
  308. synth_ai/sdk/learning/sft/client.py +95 -0
  309. synth_ai/sdk/learning/sft/config.py +270 -0
  310. synth_ai/sdk/learning/sft/data.py +698 -0
  311. synth_ai/sdk/learning/sse.py +57 -0
  312. synth_ai/sdk/learning/validators.py +52 -0
  313. synth_ai/sdk/localapi/__init__.py +40 -0
  314. synth_ai/sdk/localapi/apps/__init__.py +28 -0
  315. synth_ai/sdk/localapi/client.py +10 -0
  316. synth_ai/sdk/localapi/contracts.py +10 -0
  317. synth_ai/sdk/localapi/helpers.py +519 -0
  318. synth_ai/sdk/localapi/rollouts.py +87 -0
  319. synth_ai/sdk/localapi/server.py +29 -0
  320. synth_ai/sdk/localapi/template.py +70 -0
  321. synth_ai/sdk/streaming/__init__.py +35 -0
  322. synth_ai/sdk/streaming/config.py +94 -0
  323. synth_ai/sdk/streaming/handlers.py +1997 -0
  324. synth_ai/sdk/streaming/streamer.py +713 -0
  325. synth_ai/sdk/streaming/types.py +112 -0
  326. synth_ai/sdk/task/__init__.py +164 -0
  327. synth_ai/sdk/task/apps/__init__.py +169 -0
  328. synth_ai/sdk/task/auth.py +165 -0
  329. synth_ai/sdk/task/client.py +175 -0
  330. synth_ai/sdk/task/config.py +257 -0
  331. synth_ai/sdk/task/contracts.py +219 -0
  332. synth_ai/sdk/task/datasets.py +108 -0
  333. synth_ai/sdk/task/errors.py +50 -0
  334. synth_ai/sdk/task/health.py +34 -0
  335. synth_ai/sdk/task/in_process.py +1190 -0
  336. synth_ai/sdk/task/in_process_runner.py +314 -0
  337. synth_ai/sdk/task/inference_api.py +299 -0
  338. synth_ai/sdk/task/json.py +111 -0
  339. synth_ai/sdk/task/proxy.py +287 -0
  340. synth_ai/sdk/task/rubrics/__init__.py +55 -0
  341. synth_ai/sdk/task/rubrics/loaders.py +156 -0
  342. synth_ai/sdk/task/rubrics/models.py +57 -0
  343. synth_ai/sdk/task/rubrics/scoring.py +116 -0
  344. synth_ai/sdk/task/rubrics/strict.py +149 -0
  345. synth_ai/sdk/task/rubrics.py +219 -0
  346. synth_ai/sdk/task/server.py +631 -0
  347. synth_ai/sdk/task/trace_correlation_helpers.py +539 -0
  348. synth_ai/sdk/task/tracing_utils.py +95 -0
  349. synth_ai/sdk/task/validators.py +441 -0
  350. synth_ai/sdk/task/vendors.py +59 -0
  351. synth_ai/sdk/training/__init__.py +102 -0
  352. synth_ai/sdk/tunnels/__init__.py +83 -0
  353. synth_ai/sdk/tunnels/cleanup.py +83 -0
  354. synth_ai/sdk/tunnels/ports.py +120 -0
  355. synth_ai/utils/__init__.py +213 -0
  356. synth_ai-0.4.3.dist-info/METADATA +262 -0
  357. synth_ai-0.4.3.dist-info/RECORD +370 -0
  358. {synth_ai-0.2.6.dev1.dist-info → synth_ai-0.4.3.dist-info}/entry_points.txt +0 -1
  359. synth_ai/cli/calc.py +0 -69
  360. synth_ai/cli/demo.py +0 -131
  361. synth_ai/cli/legacy_root_backup.py +0 -470
  362. synth_ai/cli/man.py +0 -106
  363. synth_ai/cli/rl_demo.py +0 -137
  364. synth_ai/cli/status.py +0 -133
  365. synth_ai/config/base_url.py +0 -98
  366. synth_ai/core/experiment.py +0 -15
  367. synth_ai/core/system.py +0 -15
  368. synth_ai/demos/core/__init__.py +0 -1
  369. synth_ai/demos/core/cli.py +0 -685
  370. synth_ai/demos/demo_task_apps/__init__.py +0 -1
  371. synth_ai/demos/demo_task_apps/math/config.toml +0 -44
  372. synth_ai/demos/demo_task_apps/math/deploy_task_app.sh +0 -22
  373. synth_ai/environments/__init__.py +0 -31
  374. synth_ai/environments/environment/__init__.py +0 -1
  375. synth_ai/environments/environment/artifacts/__init__.py +0 -1
  376. synth_ai/environments/environment/artifacts/base.py +0 -52
  377. synth_ai/environments/environment/core.py +0 -67
  378. synth_ai/environments/environment/db/__init__.py +0 -1
  379. synth_ai/environments/environment/db/sqlite.py +0 -45
  380. synth_ai/environments/environment/registry.py +0 -233
  381. synth_ai/environments/environment/resources/sqlite.py +0 -45
  382. synth_ai/environments/environment/results.py +0 -1
  383. synth_ai/environments/environment/rewards/__init__.py +0 -1
  384. synth_ai/environments/environment/rewards/core.py +0 -29
  385. synth_ai/environments/environment/shared_engine.py +0 -26
  386. synth_ai/environments/environment/tools/__init__.py +0 -200
  387. synth_ai/environments/examples/__init__.py +0 -1
  388. synth_ai/environments/examples/bandit/__init__.py +0 -33
  389. synth_ai/environments/examples/bandit/engine.py +0 -294
  390. synth_ai/environments/examples/bandit/environment.py +0 -194
  391. synth_ai/environments/examples/bandit/taskset.py +0 -200
  392. synth_ai/environments/examples/crafter_classic/__init__.py +0 -8
  393. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +0 -250
  394. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_comprehensive_evaluation.py +0 -59
  395. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_browser.py +0 -152
  396. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_config.toml +0 -24
  397. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_framework.py +0 -1194
  398. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/crafter_synth_config.toml +0 -56
  399. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_config_modal.toml +0 -32
  400. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +0 -724
  401. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_modal.py +0 -384
  402. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_action_results.py +0 -53
  403. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_agent_actions.py +0 -178
  404. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_latest_run.py +0 -222
  405. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_lm_traces.py +0 -183
  406. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_no_rewards.py +0 -210
  407. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_trace_issue.py +0 -206
  408. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_db_schema.py +0 -49
  409. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_latest_results.py +0 -64
  410. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/debug_agent_responses.py +0 -88
  411. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/quick_trace_check.py +0 -77
  412. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/compare_experiments.py +0 -324
  413. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +0 -580
  414. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/kick_off_ft_oai.py +0 -362
  415. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/multi_model_config.toml +0 -49
  416. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_enhanced_hooks.py +0 -332
  417. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_events.py +0 -97
  418. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_results.py +0 -217
  419. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_hook_storage.py +0 -87
  420. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_seeds.py +0 -88
  421. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/compare_seed_performance.py +0 -195
  422. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/custom_eval_pipelines.py +0 -400
  423. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/plot_hook_frequency.py +0 -195
  424. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/seed_analysis_summary.py +0 -56
  425. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v3.py +0 -858
  426. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_quick_evaluation.py +0 -52
  427. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_react_agent.py +0 -874
  428. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +0 -1412
  429. synth_ai/environments/examples/crafter_classic/agent_demos/example_v3_usage.py +0 -216
  430. synth_ai/environments/examples/crafter_classic/agent_demos/old/compare_traces.py +0 -296
  431. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_comprehensive_evaluation.py +0 -58
  432. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_env_serialization.py +0 -464
  433. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_evaluation_browser.py +0 -152
  434. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_quick_evaluation.py +0 -51
  435. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_trace_evaluation.py +0 -1412
  436. synth_ai/environments/examples/crafter_classic/agent_demos/old/debug_player_loss.py +0 -112
  437. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_service.py +0 -203
  438. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_slowness.py +0 -305
  439. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_by_difficulty.py +0 -126
  440. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_example.py +0 -94
  441. synth_ai/environments/examples/crafter_classic/agent_demos/old/explore_saved_states.py +0 -142
  442. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft.py +0 -26
  443. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft_OLD.py +0 -984
  444. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_gemini.py +0 -724
  445. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_modal.py +0 -386
  446. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_metadata.py +0 -205
  447. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_gemini.py +0 -150
  448. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_modal.py +0 -283
  449. synth_ai/environments/examples/crafter_classic/agent_demos/old/prepare_vertex_ft.py +0 -280
  450. synth_ai/environments/examples/crafter_classic/agent_demos/old/profile_env_slowness.py +0 -456
  451. synth_ai/environments/examples/crafter_classic/agent_demos/old/replicate_issue.py +0 -166
  452. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_and_eval.py +0 -102
  453. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_comparison.py +0 -128
  454. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_qwen_rollouts.py +0 -655
  455. synth_ai/environments/examples/crafter_classic/agent_demos/old/trace_eval_OLD.py +0 -202
  456. synth_ai/environments/examples/crafter_classic/agent_demos/old/validate_openai_format.py +0 -166
  457. synth_ai/environments/examples/crafter_classic/config_logging.py +0 -111
  458. synth_ai/environments/examples/crafter_classic/debug_translation.py +0 -0
  459. synth_ai/environments/examples/crafter_classic/engine.py +0 -579
  460. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +0 -64
  461. synth_ai/environments/examples/crafter_classic/engine_helpers/action_map.py +0 -6
  462. synth_ai/environments/examples/crafter_classic/engine_helpers/serialization.py +0 -75
  463. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +0 -267
  464. synth_ai/environments/examples/crafter_classic/environment.py +0 -404
  465. synth_ai/environments/examples/crafter_classic/taskset.py +0 -233
  466. synth_ai/environments/examples/crafter_classic/trace_hooks_v3.py +0 -228
  467. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +0 -299
  468. synth_ai/environments/examples/crafter_custom/__init__.py +0 -4
  469. synth_ai/environments/examples/crafter_custom/agent_demos/__init__.py +0 -1
  470. synth_ai/environments/examples/crafter_custom/agent_demos/trace_eval.py +0 -202
  471. synth_ai/environments/examples/crafter_custom/crafter/__init__.py +0 -7
  472. synth_ai/environments/examples/crafter_custom/crafter/config.py +0 -182
  473. synth_ai/environments/examples/crafter_custom/crafter/constants.py +0 -8
  474. synth_ai/environments/examples/crafter_custom/crafter/engine.py +0 -269
  475. synth_ai/environments/examples/crafter_custom/crafter/env.py +0 -262
  476. synth_ai/environments/examples/crafter_custom/crafter/objects.py +0 -417
  477. synth_ai/environments/examples/crafter_custom/crafter/recorder.py +0 -187
  478. synth_ai/environments/examples/crafter_custom/crafter/worldgen.py +0 -118
  479. synth_ai/environments/examples/crafter_custom/dataset_builder.py +0 -373
  480. synth_ai/environments/examples/crafter_custom/environment.py +0 -312
  481. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_issue.py +0 -159
  482. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_spawning.py +0 -158
  483. synth_ai/environments/examples/crafter_custom/old/compare_worlds.py +0 -71
  484. synth_ai/environments/examples/crafter_custom/old/dataset_stats.py +0 -105
  485. synth_ai/environments/examples/crafter_custom/old/diamond_spawning_summary.py +0 -119
  486. synth_ai/environments/examples/crafter_custom/old/example_dataset_usage.py +0 -52
  487. synth_ai/environments/examples/crafter_custom/run_dataset.py +0 -305
  488. synth_ai/environments/examples/enron/art_helpers/email_search_tools.py +0 -156
  489. synth_ai/environments/examples/enron/art_helpers/local_email_db.py +0 -281
  490. synth_ai/environments/examples/enron/art_helpers/types_enron.py +0 -25
  491. synth_ai/environments/examples/enron/engine.py +0 -295
  492. synth_ai/environments/examples/enron/environment.py +0 -166
  493. synth_ai/environments/examples/enron/taskset.py +0 -112
  494. synth_ai/environments/examples/enron/units/keyword_stats.py +0 -112
  495. synth_ai/environments/examples/minigrid/__init__.py +0 -48
  496. synth_ai/environments/examples/minigrid/agent_demos/minigrid_evaluation_framework.py +0 -1188
  497. synth_ai/environments/examples/minigrid/agent_demos/minigrid_quick_evaluation.py +0 -48
  498. synth_ai/environments/examples/minigrid/agent_demos/minigrid_react_agent.py +0 -562
  499. synth_ai/environments/examples/minigrid/agent_demos/minigrid_trace_evaluation.py +0 -221
  500. synth_ai/environments/examples/minigrid/engine.py +0 -589
  501. synth_ai/environments/examples/minigrid/environment.py +0 -274
  502. synth_ai/environments/examples/minigrid/environment_mapping.py +0 -242
  503. synth_ai/environments/examples/minigrid/puzzle_loader.py +0 -417
  504. synth_ai/environments/examples/minigrid/taskset.py +0 -583
  505. synth_ai/environments/examples/nethack/__init__.py +0 -7
  506. synth_ai/environments/examples/nethack/achievements.py +0 -337
  507. synth_ai/environments/examples/nethack/agent_demos/nethack_evaluation_framework.py +0 -981
  508. synth_ai/environments/examples/nethack/agent_demos/nethack_quick_evaluation.py +0 -74
  509. synth_ai/environments/examples/nethack/agent_demos/nethack_react_agent.py +0 -831
  510. synth_ai/environments/examples/nethack/engine.py +0 -739
  511. synth_ai/environments/examples/nethack/environment.py +0 -256
  512. synth_ai/environments/examples/nethack/helpers/__init__.py +0 -41
  513. synth_ai/environments/examples/nethack/helpers/action_mapping.py +0 -301
  514. synth_ai/environments/examples/nethack/helpers/nle_wrapper.py +0 -402
  515. synth_ai/environments/examples/nethack/helpers/observation_utils.py +0 -433
  516. synth_ai/environments/examples/nethack/helpers/recording_wrapper.py +0 -200
  517. synth_ai/environments/examples/nethack/helpers/trajectory_recorder.py +0 -269
  518. synth_ai/environments/examples/nethack/helpers/visualization/replay_viewer.py +0 -308
  519. synth_ai/environments/examples/nethack/helpers/visualization/visualizer.py +0 -431
  520. synth_ai/environments/examples/nethack/taskset.py +0 -323
  521. synth_ai/environments/examples/red/__init__.py +0 -7
  522. synth_ai/environments/examples/red/agent_demos/__init__.py +0 -1
  523. synth_ai/environments/examples/red/config_logging.py +0 -110
  524. synth_ai/environments/examples/red/engine.py +0 -694
  525. synth_ai/environments/examples/red/engine_helpers/__init__.py +0 -1
  526. synth_ai/environments/examples/red/engine_helpers/memory_map.py +0 -28
  527. synth_ai/environments/examples/red/engine_helpers/reward_components.py +0 -276
  528. synth_ai/environments/examples/red/engine_helpers/reward_library/__init__.py +0 -142
  529. synth_ai/environments/examples/red/engine_helpers/reward_library/adaptive_rewards.py +0 -57
  530. synth_ai/environments/examples/red/engine_helpers/reward_library/battle_rewards.py +0 -284
  531. synth_ai/environments/examples/red/engine_helpers/reward_library/composite_rewards.py +0 -150
  532. synth_ai/environments/examples/red/engine_helpers/reward_library/economy_rewards.py +0 -138
  533. synth_ai/environments/examples/red/engine_helpers/reward_library/efficiency_rewards.py +0 -57
  534. synth_ai/environments/examples/red/engine_helpers/reward_library/exploration_rewards.py +0 -331
  535. synth_ai/environments/examples/red/engine_helpers/reward_library/novelty_rewards.py +0 -121
  536. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_rewards.py +0 -559
  537. synth_ai/environments/examples/red/engine_helpers/reward_library/pokemon_rewards.py +0 -313
  538. synth_ai/environments/examples/red/engine_helpers/reward_library/social_rewards.py +0 -148
  539. synth_ai/environments/examples/red/engine_helpers/reward_library/story_rewards.py +0 -247
  540. synth_ai/environments/examples/red/engine_helpers/screen_analysis.py +0 -368
  541. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +0 -140
  542. synth_ai/environments/examples/red/environment.py +0 -238
  543. synth_ai/environments/examples/red/taskset.py +0 -79
  544. synth_ai/environments/examples/red/units/__init__.py +0 -1
  545. synth_ai/environments/examples/sokoban/__init__.py +0 -1
  546. synth_ai/environments/examples/sokoban/agent_demos/sokoban_full_eval.py +0 -899
  547. synth_ai/environments/examples/sokoban/engine.py +0 -678
  548. synth_ai/environments/examples/sokoban/engine_helpers/__init__.py +0 -1
  549. synth_ai/environments/examples/sokoban/engine_helpers/room_utils.py +0 -657
  550. synth_ai/environments/examples/sokoban/engine_helpers/vendored/__init__.py +0 -18
  551. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/__init__.py +0 -3
  552. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/boxoban_env.py +0 -131
  553. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/render_utils.py +0 -370
  554. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/room_utils.py +0 -332
  555. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env.py +0 -306
  556. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_fixed_targets.py +0 -67
  557. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_pull.py +0 -115
  558. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_two_player.py +0 -123
  559. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_variations.py +0 -394
  560. synth_ai/environments/examples/sokoban/environment.py +0 -229
  561. synth_ai/environments/examples/sokoban/generate_verified_puzzles.py +0 -440
  562. synth_ai/environments/examples/sokoban/puzzle_loader.py +0 -312
  563. synth_ai/environments/examples/sokoban/taskset.py +0 -428
  564. synth_ai/environments/examples/sokoban/units/astar_common.py +0 -95
  565. synth_ai/environments/examples/tictactoe/__init__.py +0 -1
  566. synth_ai/environments/examples/tictactoe/engine.py +0 -368
  567. synth_ai/environments/examples/tictactoe/environment.py +0 -240
  568. synth_ai/environments/examples/tictactoe/taskset.py +0 -215
  569. synth_ai/environments/examples/verilog/__init__.py +0 -10
  570. synth_ai/environments/examples/verilog/engine.py +0 -329
  571. synth_ai/environments/examples/verilog/environment.py +0 -350
  572. synth_ai/environments/examples/verilog/taskset.py +0 -420
  573. synth_ai/environments/examples/wordle/__init__.py +0 -29
  574. synth_ai/environments/examples/wordle/engine.py +0 -398
  575. synth_ai/environments/examples/wordle/environment.py +0 -159
  576. synth_ai/environments/examples/wordle/helpers/generate_instances_wordfreq.py +0 -75
  577. synth_ai/environments/examples/wordle/taskset.py +0 -230
  578. synth_ai/environments/reproducibility/core.py +0 -42
  579. synth_ai/environments/reproducibility/helpers.py +0 -0
  580. synth_ai/environments/reproducibility/tree.py +0 -364
  581. synth_ai/environments/service/app.py +0 -91
  582. synth_ai/environments/service/core_routes.py +0 -1020
  583. synth_ai/environments/service/external_registry.py +0 -56
  584. synth_ai/environments/service/registry.py +0 -9
  585. synth_ai/environments/stateful/__init__.py +0 -1
  586. synth_ai/environments/stateful/core.py +0 -163
  587. synth_ai/environments/stateful/engine.py +0 -21
  588. synth_ai/environments/stateful/state.py +0 -7
  589. synth_ai/environments/tasks/api.py +0 -19
  590. synth_ai/environments/tasks/core.py +0 -80
  591. synth_ai/environments/tasks/filters.py +0 -41
  592. synth_ai/environments/tasks/utils.py +0 -91
  593. synth_ai/environments/v0_observability/history.py +0 -3
  594. synth_ai/environments/v0_observability/log.py +0 -2
  595. synth_ai/evals/base.py +0 -15
  596. synth_ai/experimental/synth_oss.py +0 -446
  597. synth_ai/http.py +0 -102
  598. synth_ai/inference/client.py +0 -20
  599. synth_ai/install_sqld.sh +0 -40
  600. synth_ai/jobs/client.py +0 -246
  601. synth_ai/learning/__init__.py +0 -24
  602. synth_ai/learning/config.py +0 -43
  603. synth_ai/learning/filtering.py +0 -0
  604. synth_ai/learning/ft_client.py +0 -59
  605. synth_ai/learning/offline/dpo.py +0 -0
  606. synth_ai/learning/offline/providers.py +0 -7
  607. synth_ai/learning/offline/sft.py +0 -0
  608. synth_ai/learning/offline/shared.py +0 -0
  609. synth_ai/learning/online/grpo.py +0 -0
  610. synth_ai/learning/online/irft.py +0 -0
  611. synth_ai/learning/prompts/banking77_injection_eval.py +0 -168
  612. synth_ai/learning/prompts/gepa.py +0 -0
  613. synth_ai/learning/prompts/hello_world_in_context_injection_ex.py +0 -213
  614. synth_ai/learning/prompts/mipro.py +0 -289
  615. synth_ai/learning/prompts/random_search.py +0 -246
  616. synth_ai/learning/prompts/run_mipro_banking77.py +0 -172
  617. synth_ai/learning/prompts/run_random_search_banking77.py +0 -324
  618. synth_ai/learning/sse.py +0 -58
  619. synth_ai/learning/validators.py +0 -48
  620. synth_ai/lm/__init__.py +0 -51
  621. synth_ai/lm/caching/constants.py +0 -6
  622. synth_ai/lm/caching/dbs.py +0 -0
  623. synth_ai/lm/caching/ephemeral.py +0 -102
  624. synth_ai/lm/caching/handler.py +0 -137
  625. synth_ai/lm/caching/initialize.py +0 -11
  626. synth_ai/lm/caching/persistent.py +0 -114
  627. synth_ai/lm/config.py +0 -110
  628. synth_ai/lm/constants.py +0 -32
  629. synth_ai/lm/core/__init__.py +0 -8
  630. synth_ai/lm/core/all.py +0 -73
  631. synth_ai/lm/core/exceptions.py +0 -7
  632. synth_ai/lm/core/main.py +0 -319
  633. synth_ai/lm/core/main_v3.py +0 -594
  634. synth_ai/lm/core/synth_models.py +0 -48
  635. synth_ai/lm/core/vendor_clients.py +0 -188
  636. synth_ai/lm/cost/__init__.py +0 -0
  637. synth_ai/lm/cost/monitor.py +0 -1
  638. synth_ai/lm/cost/statefulness.py +0 -1
  639. synth_ai/lm/injection.py +0 -80
  640. synth_ai/lm/overrides.py +0 -206
  641. synth_ai/lm/provider_support/__init__.py +0 -8
  642. synth_ai/lm/provider_support/anthropic.py +0 -972
  643. synth_ai/lm/provider_support/openai.py +0 -1139
  644. synth_ai/lm/provider_support/suppress_logging.py +0 -31
  645. synth_ai/lm/structured_outputs/__init__.py +0 -0
  646. synth_ai/lm/structured_outputs/handler.py +0 -440
  647. synth_ai/lm/structured_outputs/inject.py +0 -297
  648. synth_ai/lm/structured_outputs/rehabilitate.py +0 -185
  649. synth_ai/lm/tools/__init__.py +0 -3
  650. synth_ai/lm/tools/base.py +0 -172
  651. synth_ai/lm/unified_interface.py +0 -202
  652. synth_ai/lm/vendors/__init__.py +0 -0
  653. synth_ai/lm/vendors/base.py +0 -81
  654. synth_ai/lm/vendors/core/__init__.py +0 -0
  655. synth_ai/lm/vendors/core/anthropic_api.py +0 -387
  656. synth_ai/lm/vendors/core/gemini_api.py +0 -292
  657. synth_ai/lm/vendors/core/mistral_api.py +0 -322
  658. synth_ai/lm/vendors/core/openai_api.py +0 -220
  659. synth_ai/lm/vendors/core/synth_dev_api.py +0 -0
  660. synth_ai/lm/vendors/local/__init__.py +0 -0
  661. synth_ai/lm/vendors/local/ollama.py +0 -0
  662. synth_ai/lm/vendors/openai_standard.py +0 -780
  663. synth_ai/lm/vendors/openai_standard_responses.py +0 -256
  664. synth_ai/lm/vendors/retries.py +0 -22
  665. synth_ai/lm/vendors/supported/__init__.py +0 -0
  666. synth_ai/lm/vendors/supported/custom_endpoint.py +0 -417
  667. synth_ai/lm/vendors/supported/deepseek.py +0 -69
  668. synth_ai/lm/vendors/supported/grok.py +0 -75
  669. synth_ai/lm/vendors/supported/groq.py +0 -16
  670. synth_ai/lm/vendors/supported/ollama.py +0 -15
  671. synth_ai/lm/vendors/supported/openrouter.py +0 -74
  672. synth_ai/lm/vendors/supported/together.py +0 -11
  673. synth_ai/lm/vendors/synth_client.py +0 -808
  674. synth_ai/lm/warmup.py +0 -186
  675. synth_ai/rl/secrets.py +0 -19
  676. synth_ai/scripts/verify_rewards.py +0 -100
  677. synth_ai/task/__init__.py +0 -10
  678. synth_ai/task/contracts.py +0 -120
  679. synth_ai/task/health.py +0 -28
  680. synth_ai/task/validators.py +0 -12
  681. synth_ai/tracing/__init__.py +0 -30
  682. synth_ai/tracing_v1/__init__.py +0 -33
  683. synth_ai/tracing_v3/config.py +0 -84
  684. synth_ai/tracing_v3/storage/config.py +0 -62
  685. synth_ai/tracing_v3/turso/__init__.py +0 -25
  686. synth_ai/tracing_v3/turso/daemon.py +0 -144
  687. synth_ai/tracing_v3/turso/manager.py +0 -760
  688. synth_ai/v0/tracing/__init__.py +0 -0
  689. synth_ai/v0/tracing/abstractions.py +0 -224
  690. synth_ai/v0/tracing/base_client.py +0 -91
  691. synth_ai/v0/tracing/client_manager.py +0 -131
  692. synth_ai/v0/tracing/config.py +0 -140
  693. synth_ai/v0/tracing/context.py +0 -146
  694. synth_ai/v0/tracing/decorators.py +0 -680
  695. synth_ai/v0/tracing/events/__init__.py +0 -0
  696. synth_ai/v0/tracing/events/manage.py +0 -147
  697. synth_ai/v0/tracing/events/scope.py +0 -86
  698. synth_ai/v0/tracing/events/store.py +0 -228
  699. synth_ai/v0/tracing/immediate_client.py +0 -151
  700. synth_ai/v0/tracing/local.py +0 -18
  701. synth_ai/v0/tracing/log_client_base.py +0 -73
  702. synth_ai/v0/tracing/retry_queue.py +0 -186
  703. synth_ai/v0/tracing/trackers.py +0 -515
  704. synth_ai/v0/tracing/upload.py +0 -510
  705. synth_ai/v0/tracing/utils.py +0 -9
  706. synth_ai/v0/tracing_v1/__init__.py +0 -16
  707. synth_ai/v0/tracing_v1/abstractions.py +0 -224
  708. synth_ai/v0/tracing_v1/base_client.py +0 -91
  709. synth_ai/v0/tracing_v1/client_manager.py +0 -131
  710. synth_ai/v0/tracing_v1/config.py +0 -140
  711. synth_ai/v0/tracing_v1/context.py +0 -146
  712. synth_ai/v0/tracing_v1/decorators.py +0 -701
  713. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  714. synth_ai/v0/tracing_v1/events/manage.py +0 -147
  715. synth_ai/v0/tracing_v1/events/scope.py +0 -86
  716. synth_ai/v0/tracing_v1/events/store.py +0 -228
  717. synth_ai/v0/tracing_v1/immediate_client.py +0 -151
  718. synth_ai/v0/tracing_v1/local.py +0 -18
  719. synth_ai/v0/tracing_v1/log_client_base.py +0 -73
  720. synth_ai/v0/tracing_v1/retry_queue.py +0 -186
  721. synth_ai/v0/tracing_v1/trackers.py +0 -515
  722. synth_ai/v0/tracing_v1/upload.py +0 -525
  723. synth_ai/v0/tracing_v1/utils.py +0 -9
  724. synth_ai/zyk/__init__.py +0 -30
  725. synth_ai-0.2.6.dev1.dist-info/METADATA +0 -106
  726. synth_ai-0.2.6.dev1.dist-info/RECORD +0 -416
  727. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/__init__.py +0 -0
  728. /synth_ai/{lm/caching → core/apps}/__init__.py +0 -0
  729. /synth_ai/{tracing_v3 → core/tracing_v3}/lm_call_record_abstractions.py +0 -0
  730. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/__init__.py +0 -0
  731. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/exceptions.py +0 -0
  732. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/types.py +0 -0
  733. /synth_ai/{compound/cais.py → py.typed} +0 -0
  734. /synth_ai/{learning → sdk/learning}/core.py +0 -0
  735. /synth_ai/{learning → sdk/learning}/gateway.py +0 -0
  736. {synth_ai-0.2.6.dev1.dist-info → synth_ai-0.4.3.dist-info}/WHEEL +0 -0
  737. {synth_ai-0.2.6.dev1.dist-info → synth_ai-0.4.3.dist-info}/licenses/LICENSE +0 -0
  738. {synth_ai-0.2.6.dev1.dist-info → synth_ai-0.4.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1710 @@
1
+ """Cloudflare CLI/bootstrap helpers and tunnel deployment utilities."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import platform
7
+ import re
8
+ import shutil
9
+ import signal
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+ import tempfile
15
+ import time
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Iterable, Optional, Tuple
19
+ from urllib.parse import urlparse
20
+
21
+ import click
22
+ import httpx
23
+ import requests
24
+ import uvicorn
25
+ from starlette.types import ASGIApp
26
+
27
+ from synth_ai.core.apps.common import get_asgi_app, load_module
28
+ from synth_ai.core.cfgs import CFDeployCfg
29
+ from synth_ai.core.paths import REPO_ROOT, configure_import_paths
30
+ from synth_ai.core.telemetry import log_error, log_event, log_info
31
+ from synth_ai.core.urls import BACKEND_URL_BASE
32
+
33
+
34
+ def __resolve_env_var(key: str) -> str:
35
+ """Lazy import to avoid circular dependency."""
36
+ from synth_ai.cli.lib.env import resolve_env_var
37
+ return resolve_env_var(key)
38
+
39
+
40
+ def __write_env_var_to_dotenv(key: str, value: str, **kwargs) -> None:
41
+ """Lazy import to avoid circular dependency."""
42
+ from synth_ai.cli.lib.env import write_env_var_to_dotenv
43
+ write_env_var_to_dotenv(key, value, **kwargs)
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Constants
48
+ CLOUDFLARED_BIN_NAME = "cloudflared"
49
+ CLOUDFLARED_RELEASES = "https://updatecloudflared.com/launcher"
50
+ CLOUDFLARE_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation"
51
+
52
+ # Regex for parsing quick tunnel URLs
53
+ # Match partial URLs too (in case they're split across lines)
54
+ _URL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com", re.I)
55
+ _URL_PARTIAL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudf", re.I) # Partial match for truncated lines (ends with trycloudf)
56
+ _URL_PARTIAL_RE2 = re.compile(r"https://[a-z0-9-]+\.tryclo", re.I) # Partial match for truncated lines (ends with tryclo)
57
+
58
+ # Global state - store tunnel process handles for cleanup
59
+ _TUNNEL_PROCESSES: dict[int, subprocess.Popen] = {}
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class ManagedTunnelRecord:
64
+ """Managed tunnel metadata returned by backend."""
65
+
66
+ id: str
67
+ hostname: str
68
+ org_id: str
69
+ org_name: Optional[str]
70
+ local_host: str
71
+ local_port: int
72
+ metadata: dict[str, Any]
73
+ raw: dict[str, Any]
74
+
75
+ @property
76
+ def url(self) -> str:
77
+ if self.hostname.startswith(("http://", "https://")):
78
+ return self.hostname
79
+ return f"https://{self.hostname}"
80
+
81
+ @property
82
+ def subdomain(self) -> str:
83
+ return self.hostname.split(".", 1)[0]
84
+
85
+ def credential(self, key: str) -> Optional[str]:
86
+ return _extract_credential(self.raw, key)
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Managed tunnel discovery helpers
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ async def fetch_managed_tunnels(synth_api_key: str) -> list[ManagedTunnelRecord]:
95
+ """
96
+ Fetch managed tunnels tied to the provided Synth API key.
97
+
98
+ Raises:
99
+ RuntimeError: If backend returns an error or unexpected payload.
100
+ """
101
+ url = f"{BACKEND_URL_BASE}/api/v1/tunnels/"
102
+ headers = {"Authorization": f"Bearer {synth_api_key}"}
103
+ try:
104
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
105
+ response = await client.get(url, headers=headers)
106
+ response.raise_for_status()
107
+ payload = response.json()
108
+ except httpx.HTTPStatusError as exc:
109
+ raise RuntimeError(
110
+ f"Failed to list managed tunnels (status {exc.response.status_code}): {exc.response.text}"
111
+ ) from exc
112
+ except httpx.RequestError as exc:
113
+ raise RuntimeError(f"Failed to reach Synth backend at {url}: {exc}") from exc
114
+
115
+ if not isinstance(payload, list):
116
+ raise RuntimeError("Unexpected tunnel API response: expected a list of tunnels.")
117
+
118
+ records: list[ManagedTunnelRecord] = []
119
+ for entry in payload:
120
+ if not isinstance(entry, dict):
121
+ continue
122
+ hostname = entry.get("hostname")
123
+ org_id = entry.get("org_id")
124
+ tunnel_id = entry.get("id")
125
+ if not hostname or not org_id or not tunnel_id:
126
+ continue
127
+ metadata = entry.get("metadata")
128
+ if not isinstance(metadata, dict):
129
+ metadata = {}
130
+ records.append(
131
+ ManagedTunnelRecord(
132
+ id=str(tunnel_id),
133
+ hostname=str(hostname),
134
+ org_id=str(org_id),
135
+ org_name=entry.get("org_name"),
136
+ local_host=str(entry.get("local_host") or "127.0.0.1"),
137
+ local_port=int(entry.get("local_port") or 8000),
138
+ metadata=metadata,
139
+ raw=entry,
140
+ )
141
+ )
142
+ return records
143
+
144
+
145
+ def _select_existing_tunnel(
146
+ tunnels: list[ManagedTunnelRecord],
147
+ desired_subdomain: Optional[str],
148
+ ) -> Optional[ManagedTunnelRecord]:
149
+ if not tunnels:
150
+ return None
151
+
152
+ if desired_subdomain:
153
+ target = _normalize_subdomain(desired_subdomain)
154
+ for tunnel in tunnels:
155
+ if _normalize_subdomain(tunnel.subdomain) == target or _normalize_subdomain(
156
+ tunnel.hostname
157
+ ) == target:
158
+ print(
159
+ f"ℹ️ Using managed tunnel {tunnel.url} "
160
+ f"(matched subdomain '{tunnel.subdomain}')"
161
+ )
162
+ return tunnel
163
+ _print_tunnel_choices(tunnels, header="Available managed tunnels:")
164
+ raise RuntimeError(
165
+ f"No managed tunnel matched subdomain '{desired_subdomain}'. "
166
+ "Re-run with a valid --tunnel-subdomain."
167
+ )
168
+
169
+ if len(tunnels) == 1:
170
+ tunnel = tunnels[0]
171
+ print(
172
+ f"ℹ️ Reusing existing managed tunnel for "
173
+ f"{tunnel.org_name or tunnel.org_id}: {tunnel.url}"
174
+ )
175
+ return tunnel
176
+
177
+ _print_tunnel_choices(
178
+ tunnels,
179
+ header=(
180
+ "Multiple managed tunnels found. Please re-run with "
181
+ "--tunnel-subdomain <subdomain> to choose one."
182
+ ),
183
+ )
184
+ raise RuntimeError("Multiple managed tunnels available; selection required.")
185
+
186
+
187
+ def _print_tunnel_choices(
188
+ tunnels: Iterable[ManagedTunnelRecord],
189
+ header: Optional[str] = None,
190
+ ) -> None:
191
+ if header:
192
+ print(header)
193
+ for idx, tunnel in enumerate(tunnels, 1):
194
+ label = tunnel.org_name or tunnel.org_id
195
+ print(f" {idx}. {label}: {tunnel.url} (subdomain '{tunnel.subdomain}')")
196
+
197
+
198
+ def _normalize_subdomain(value: str) -> str:
199
+ value = value.strip().lower()
200
+ if value.startswith("https://"):
201
+ value = value[len("https://") :]
202
+ elif value.startswith("http://"):
203
+ value = value[len("http://") :]
204
+ return value.split(".", 1)[0]
205
+
206
+
207
+ def _extract_credential(payload: dict[str, Any], key: str) -> Optional[str]:
208
+ """Extract secret from various nested metadata structures."""
209
+
210
+ def _dig(obj: Any, path: tuple[str, ...]) -> Optional[Any]:
211
+ current = obj
212
+ for part in path:
213
+ if isinstance(current, dict):
214
+ current = current.get(part)
215
+ else:
216
+ return None
217
+ return current
218
+
219
+ candidate_paths: tuple[tuple[str, ...], ...] = (
220
+ (key,),
221
+ ("metadata", key),
222
+ ("metadata", "secrets", key),
223
+ ("metadata", "credentials", key),
224
+ ("metadata", "cloudflare", key),
225
+ ("metadata", "cloudflare", "secrets", key),
226
+ )
227
+
228
+ for path in candidate_paths:
229
+ value = _dig(payload, path)
230
+ if isinstance(value, str) and value:
231
+ return value
232
+ return None
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Cloudflared binary management
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def get_cloudflared_path(prefer_system: bool = True) -> Optional[Path]:
241
+ """Locate the cloudflared binary (managed bin dir, PATH, or common dirs)."""
242
+ bin_dir = Path.home() / ".synth-ai" / "bin"
243
+ candidate = bin_dir / CLOUDFLARED_BIN_NAME
244
+ if candidate.exists() and os.access(candidate, os.X_OK):
245
+ return candidate
246
+
247
+ if prefer_system:
248
+ resolved = shutil.which(CLOUDFLARED_BIN_NAME)
249
+ if resolved:
250
+ return Path(resolved)
251
+
252
+ common = [
253
+ Path("/usr/local/bin/cloudflared"),
254
+ Path("/opt/homebrew/bin/cloudflared"),
255
+ Path.home() / "bin" / "cloudflared",
256
+ ]
257
+ for path in common:
258
+ if path.exists() and os.access(path, os.X_OK):
259
+ return path
260
+ return None
261
+
262
+
263
+ def ensure_cloudflared_installed(force: bool = False) -> Path:
264
+ """Ensure cloudflared is installed in synth-ai's managed bin directory."""
265
+ existing = get_cloudflared_path(prefer_system=not force)
266
+ if existing and not force:
267
+ return existing
268
+
269
+ target_dir = Path.home() / ".synth-ai" / "bin"
270
+ target_dir.mkdir(parents=True, exist_ok=True)
271
+
272
+ url = _resolve_cloudflared_download_url()
273
+ tmp_file = _download_file(url)
274
+
275
+ if tmp_file.suffixes[-2:] == [".tar", ".gz"]:
276
+ _extract_tarball(tmp_file, target_dir)
277
+ elif tmp_file.suffix == ".gz":
278
+ _extract_gzip(tmp_file, target_dir / CLOUDFLARED_BIN_NAME)
279
+ else:
280
+ shutil.move(str(tmp_file), str(target_dir / CLOUDFLARED_BIN_NAME))
281
+
282
+ bin_path = target_dir / CLOUDFLARED_BIN_NAME
283
+ bin_path.chmod(0o755)
284
+ log_event("info", "cloudflared installed", ctx={"path": str(bin_path)})
285
+ return bin_path
286
+
287
+
288
+ def require_cloudflared() -> Path:
289
+ """Return cloudflared binary or raise ClickException with guidance."""
290
+ path = get_cloudflared_path()
291
+ if path:
292
+ return path
293
+
294
+ extra = ""
295
+ if platform.system() == "Darwin":
296
+ extra = "Try `brew install cloudflare/cloudflare/cloudflared`."
297
+ elif platform.system() == "Linux":
298
+ extra = "See Cloudflare docs for Linux packages."
299
+ log_error("cloudflared not found", ctx={"hint": extra})
300
+ raise click.ClickException(
301
+ f"Cloudflared CLI missing. Install via Homebrew or follow {CLOUDFLARE_DOCS_URL}."
302
+ )
303
+
304
+
305
+ def run_cloudflared_cmd(args: list[str], *, env: Optional[dict[str, str]] = None) -> subprocess.Popen:
306
+ """Spawn cloudflared subprocess (mirrors synth_ai.core.integrations.modal.run_modal_cmd)."""
307
+ bin_path = require_cloudflared()
308
+ cmd = [str(bin_path), *args]
309
+ log_event("info", "starting cloudflared", ctx={"cmd": cmd})
310
+ try:
311
+ return subprocess.Popen(
312
+ cmd,
313
+ stdout=subprocess.PIPE,
314
+ stderr=subprocess.STDOUT,
315
+ text=True,
316
+ bufsize=1,
317
+ env=env or os.environ.copy(),
318
+ )
319
+ except FileNotFoundError as exc:
320
+ raise click.ClickException(f"cloudflared binary missing: {exc}") from exc
321
+ except Exception as exc:
322
+ raise click.ClickException(f"Failed to start cloudflared: {exc}") from exc
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Internal helpers
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def _resolve_cloudflared_download_url() -> str:
330
+ system = platform.system().lower()
331
+ arch = platform.machine().lower()
332
+ mapping = {"darwin": "macos", "linux": "linux", "windows": "windows"}
333
+ platform_key = mapping.get(system)
334
+ if not platform_key:
335
+ raise RuntimeError(f"Unsupported platform: {system}")
336
+
337
+ arch_key = "amd64"
338
+ if arch in ("arm64", "aarch64"):
339
+ arch_key = "arm64"
340
+
341
+ resp = requests.get(f"{CLOUDFLARED_RELEASES}/v1/{platform_key}/{arch_key}/versions/stable", timeout=30.0)
342
+ resp.raise_for_status()
343
+ data = resp.json()
344
+ url = data.get("url")
345
+ if not url:
346
+ raise RuntimeError("Cloudflared release metadata missing URL")
347
+ return url
348
+
349
+
350
+ def _download_file(url: str) -> Path:
351
+ resp = requests.get(url, timeout=60.0, stream=True)
352
+ resp.raise_for_status()
353
+ suffix = Path(url.split("?")[0]).suffix or ".tmp"
354
+ fd, tmp_path = tempfile.mkstemp(suffix=suffix)
355
+ with os.fdopen(fd, "wb") as fh:
356
+ for chunk in resp.iter_content(chunk_size=8192):
357
+ fh.write(chunk)
358
+ return Path(tmp_path)
359
+
360
+
361
+ def _extract_tarball(archive_path: Path, target_dir: Path) -> None:
362
+ with tarfile.open(archive_path, "r:gz") as tar:
363
+ tar.extractall(target_dir)
364
+ archive_path.unlink(missing_ok=True)
365
+
366
+
367
+ def _extract_gzip(gz_path: Path, target: Path) -> None:
368
+ import gzip
369
+
370
+ # gzip.open ensures the bytes are decompressed while copying to target
371
+ with gzip.open(gz_path, "rb") as gz_fh, open(target, "wb") as out_fh:
372
+ shutil.copyfileobj(gz_fh, out_fh)
373
+ gz_path.unlink(missing_ok=True)
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Tunnel process management
378
+ # ---------------------------------------------------------------------------
379
+
380
+
381
+ def open_quick_tunnel(port: int, wait_s: float = 10.0) -> Tuple[str, subprocess.Popen]:
382
+ """
383
+ Open a quick (ephemeral) Cloudflare tunnel.
384
+
385
+ Args:
386
+ port: Local port to tunnel to
387
+ wait_s: Maximum time to wait for URL in seconds
388
+
389
+ Returns:
390
+ Tuple of (public_url, process_handle)
391
+
392
+ Raises:
393
+ RuntimeError: If tunnel fails to start or URL cannot be parsed
394
+ """
395
+ bin_path = require_cloudflared()
396
+
397
+ # Verify cloudflared can run before attempting tunnel
398
+ try:
399
+ test_proc = subprocess.run(
400
+ [str(bin_path), "--version"],
401
+ capture_output=True,
402
+ text=True,
403
+ timeout=5.0,
404
+ )
405
+ if test_proc.returncode != 0:
406
+ raise RuntimeError(
407
+ f"cloudflared binary exists but fails to run (exit code {test_proc.returncode}). "
408
+ f"STDOUT: {test_proc.stdout[:500] if test_proc.stdout else 'none'}. "
409
+ f"STDERR: {test_proc.stderr[:500] if test_proc.stderr else 'none'}. "
410
+ f"Try reinstalling: cloudflared update or brew reinstall cloudflared"
411
+ )
412
+ except subprocess.TimeoutExpired as e:
413
+ raise RuntimeError(
414
+ "cloudflared binary hangs when running --version. "
415
+ "This suggests the binary is corrupted or incompatible with your system. "
416
+ "Try reinstalling: cloudflared update or brew reinstall cloudflared"
417
+ ) from e
418
+ except Exception as e:
419
+ raise RuntimeError(
420
+ f"Failed to verify cloudflared binary: {e}. "
421
+ f"Binary path: {bin_path}. "
422
+ f"Try reinstalling: cloudflared update or brew reinstall cloudflared"
423
+ ) from e
424
+
425
+ # Capture stderr separately for better error diagnostics
426
+ # Use --config /dev/null to prevent loading any user config file
427
+ # This fixes issues where ~/.cloudflared/config.yml has ingress rules
428
+ # for named tunnels that interfere with quick tunnels (returning 404)
429
+ proc = subprocess.Popen(
430
+ [str(bin_path), "tunnel", "--config", "/dev/null", "--url", f"http://127.0.0.1:{port}"],
431
+ stdout=subprocess.PIPE,
432
+ stderr=subprocess.PIPE, # Capture stderr separately
433
+ text=True,
434
+ bufsize=1,
435
+ )
436
+
437
+ start = time.time()
438
+ url: Optional[str] = None
439
+ output_lines: list[str] = []
440
+ stderr_lines: list[str] = []
441
+
442
+ # Use select for non-blocking I/O to avoid hanging on readline()
443
+ import select
444
+
445
+ # Stream both stdout and stderr to detect the trycloudflare URL
446
+ # Note: cloudflared prints the URL to stderr, not stdout!
447
+ while time.time() - start < wait_s:
448
+ elapsed = time.time() - start
449
+ remaining_time = wait_s - elapsed
450
+
451
+ if remaining_time <= 0:
452
+ break
453
+
454
+ if proc.poll() is not None:
455
+ # Process exited early - try to read all available output
456
+ try:
457
+ stdout, stderr = proc.communicate(timeout=1.0)
458
+ except subprocess.TimeoutExpired:
459
+ proc.kill()
460
+ stdout, stderr = proc.communicate()
461
+
462
+ # Combine stdout and stderr for error message
463
+ all_output = ""
464
+ if stdout:
465
+ all_output += f"STDOUT:\n{stdout}\n"
466
+ if stderr:
467
+ all_output += f"STDERR:\n{stderr}\n"
468
+ if output_lines:
469
+ all_output += f"Captured stdout:\n{''.join(output_lines)}\n"
470
+ if stderr_lines:
471
+ all_output += f"Captured stderr:\n{''.join(stderr_lines)}\n"
472
+
473
+ # Check for rate limiting (429 Too Many Requests)
474
+ is_rate_limited = False
475
+ if stderr and "429" in stderr and "Too Many Requests" in stderr or stderr and "rate limit" in stderr.lower():
476
+ is_rate_limited = True
477
+
478
+ # Add diagnostic info
479
+ if is_rate_limited:
480
+ error_msg = (
481
+ "❌ RATE LIMIT ERROR: Cloudflare is blocking quick tunnel creation due to rate limiting.\n"
482
+ f"\n"
483
+ f"Error Details:\n"
484
+ f" • Exit code: {proc.returncode}\n"
485
+ f" • Status: 429 Too Many Requests\n"
486
+ f" • Command: {' '.join([str(bin_path), 'tunnel', '--url', f'http://127.0.0.1:{port}'])}\n"
487
+ f"\n"
488
+ f"Why this happens:\n"
489
+ f" Cloudflare limits how many quick (ephemeral) tunnels can be created\n"
490
+ f" in a short time period. You've hit this limit.\n"
491
+ f"\n"
492
+ f"Solutions (in order of preference):\n"
493
+ f" 1. ⏰ WAIT: Wait 5-10 minutes for the rate limit to reset\n"
494
+ f" 2. 🔑 USE MANAGED TUNNEL: Set SYNTH_API_KEY env var to use managed tunnels (no rate limits)\n"
495
+ f" 3. ♻️ REUSE EXISTING: Set INTERCEPTOR_TUNNEL_URL env var to reuse an existing tunnel\n"
496
+ f"\n"
497
+ f"Full error output:\n"
498
+ f"{all_output[:1000]}"
499
+ )
500
+ else:
501
+ error_msg = (
502
+ f"cloudflared exited early with code {proc.returncode}.\n"
503
+ f"Command: {' '.join([str(bin_path), 'tunnel', '--url', f'http://127.0.0.1:{port}'])}\n"
504
+ f"Binary path: {bin_path}\n"
505
+ )
506
+ if all_output:
507
+ error_msg += f"Output:\n{all_output[:1000]}"
508
+ else:
509
+ error_msg += "No output captured. This usually means:\n"
510
+ error_msg += " 1. cloudflared binary is corrupted or wrong architecture\n"
511
+ error_msg += " 2. cloudflared needs to be updated (try: cloudflared update)\n"
512
+ error_msg += " 3. System-level issue preventing cloudflared from running\n"
513
+ error_msg += " 4. Port conflict or network issue\n"
514
+ error_msg += f"\nTry running manually: {bin_path} tunnel --url http://127.0.0.1:{port}"
515
+
516
+ raise RuntimeError(error_msg)
517
+
518
+ # Read from both stdout and stderr (cloudflared prints URL to stderr!)
519
+ fds_to_check = []
520
+ from contextlib import suppress
521
+
522
+ if proc.stdout:
523
+ with suppress(ValueError, OSError):
524
+ fds_to_check.append(("stdout", proc.stdout.fileno(), proc.stdout))
525
+ if proc.stderr:
526
+ with suppress(ValueError, OSError):
527
+ fds_to_check.append(("stderr", proc.stderr.fileno(), proc.stderr))
528
+
529
+ if not fds_to_check:
530
+ if time.time() - start >= wait_s:
531
+ break
532
+ time.sleep(0.05)
533
+ continue
534
+
535
+ # Use select to check if data is available (non-blocking)
536
+ try:
537
+ fds = [fd for _, fd, _ in fds_to_check]
538
+ ready, _, _ = select.select(fds, [], [], min(0.1, remaining_time))
539
+
540
+ if ready:
541
+ # Check which file descriptors are ready
542
+ for name, fd, stream in fds_to_check:
543
+ if fd in ready:
544
+ # Data is available, read a line
545
+ line = stream.readline()
546
+ if line:
547
+ # Capture output for diagnostics
548
+ if name == "stdout":
549
+ output_lines.append(line)
550
+ else:
551
+ stderr_lines.append(line)
552
+
553
+ # Check current line for URL
554
+ match = _URL_RE.search(line)
555
+ if match:
556
+ url = match.group(0)
557
+ break
558
+
559
+ # Check for partial URL (truncated line) - wait for more data
560
+ partial_match = _URL_PARTIAL_RE.search(line)
561
+ if partial_match:
562
+ # Found partial URL, wait a bit longer for the rest
563
+ # Read more lines to get the complete URL
564
+ for _ in range(5): # Try reading up to 5 more lines
565
+ if time.time() - start >= wait_s:
566
+ break
567
+ time.sleep(0.1)
568
+ if proc.poll() is not None:
569
+ break
570
+ # Try to read more
571
+ if stream in [s for _, _, s in fds_to_check]:
572
+ try:
573
+ more_line = stream.readline()
574
+ if more_line:
575
+ if name == "stdout":
576
+ output_lines.append(more_line)
577
+ else:
578
+ stderr_lines.append(more_line)
579
+ line += more_line
580
+ except (OSError, ValueError):
581
+ pass
582
+
583
+ # Now check accumulated output for full URL
584
+ all_accumulated = ''.join(output_lines + stderr_lines)
585
+ match = _URL_RE.search(all_accumulated)
586
+ if match:
587
+ url = match.group(0)
588
+ break
589
+
590
+ # Also check accumulated output (URL might be split across lines)
591
+ all_accumulated = ''.join(output_lines + stderr_lines)
592
+ match = _URL_RE.search(all_accumulated)
593
+ if match:
594
+ url = match.group(0)
595
+ break
596
+
597
+ if url:
598
+ break
599
+ else:
600
+ # No data available, check timeout and continue
601
+ if time.time() - start >= wait_s:
602
+ break
603
+ time.sleep(0.05)
604
+ continue
605
+ except (ValueError, OSError) as e:
606
+ # File descriptor not available or select failed - fall back to reading both streams
607
+ # This can happen on Windows or if the file is closed
608
+ _ = e # Suppress unused variable warning
609
+ if proc.stdout:
610
+ line = proc.stdout.readline()
611
+ if line:
612
+ output_lines.append(line)
613
+ match = _URL_RE.search(line)
614
+ if match:
615
+ url = match.group(0)
616
+ break
617
+ # Check for partial URL
618
+ partial_match = _URL_PARTIAL_RE.search(line)
619
+ if partial_match:
620
+ # Wait a bit and read more
621
+ time.sleep(0.2)
622
+ more_line = proc.stdout.readline()
623
+ if more_line:
624
+ output_lines.append(more_line)
625
+ line += more_line
626
+ if proc.stderr:
627
+ line = proc.stderr.readline()
628
+ if line:
629
+ stderr_lines.append(line)
630
+ match = _URL_RE.search(line)
631
+ if match:
632
+ url = match.group(0)
633
+ break
634
+ # Check for partial URL
635
+ partial_match = _URL_PARTIAL_RE.search(line)
636
+ if partial_match:
637
+ # Wait a bit and read more
638
+ time.sleep(0.2)
639
+ more_line = proc.stderr.readline()
640
+ if more_line:
641
+ stderr_lines.append(more_line)
642
+ line += more_line
643
+
644
+ # Check accumulated output
645
+ all_accumulated = ''.join(output_lines + stderr_lines)
646
+ match = _URL_RE.search(all_accumulated)
647
+ if match:
648
+ url = match.group(0)
649
+ break
650
+
651
+ if time.time() - start >= wait_s:
652
+ break
653
+ time.sleep(0.05)
654
+ continue
655
+
656
+ if not url:
657
+ proc.terminate()
658
+ try:
659
+ stdout, stderr = proc.communicate(timeout=2.0)
660
+ except subprocess.TimeoutExpired:
661
+ proc.kill()
662
+ stdout, stderr = proc.communicate()
663
+
664
+ all_output = ""
665
+ if stdout:
666
+ all_output += f"STDOUT:\n{stdout}\n"
667
+ if stderr:
668
+ all_output += f"STDERR:\n{stderr}\n"
669
+ if output_lines:
670
+ all_output += f"Captured stdout:\n{''.join(output_lines)}\n"
671
+ if stderr_lines:
672
+ all_output += f"Captured stderr:\n{''.join(stderr_lines)}\n"
673
+
674
+ # Try to extract URL from accumulated output even if timeout occurred
675
+ all_accumulated = ''.join(output_lines + stderr_lines)
676
+ if stdout:
677
+ all_accumulated += stdout
678
+ if stderr:
679
+ all_accumulated += stderr
680
+
681
+ # Check for partial URL and try to reconstruct
682
+ if not url:
683
+ # Try first partial pattern (ends with trycloudf)
684
+ partial_match = _URL_PARTIAL_RE.search(all_accumulated)
685
+ if partial_match:
686
+ # Found partial URL - try to complete it
687
+ partial_url = partial_match.group(0)
688
+ # Partial match ends with "trycloudf", so we need "lare.com"
689
+ test_url = partial_url + "lare.com"
690
+ if _URL_RE.match(test_url):
691
+ url = test_url
692
+ logger.info(f"Reconstructed URL from partial match (trycloudf): {url}")
693
+
694
+ # Try second partial pattern (ends with tryclo)
695
+ if not url:
696
+ partial_match2 = _URL_PARTIAL_RE2.search(all_accumulated)
697
+ if partial_match2:
698
+ partial_url = partial_match2.group(0)
699
+ # Partial match ends with "tryclo", so we need "udflare.com"
700
+ test_url = partial_url + "udflare.com"
701
+ if _URL_RE.match(test_url):
702
+ url = test_url
703
+ logger.info(f"Reconstructed URL from partial match (tryclo): {url}")
704
+
705
+ if url:
706
+ return url, proc
707
+
708
+ error_msg = (
709
+ f"Failed to parse trycloudflare URL from cloudflared output after {wait_s}s.\n"
710
+ f"Command: {' '.join([str(bin_path), 'tunnel', '--url', f'http://127.0.0.1:{port}'])}\n"
711
+ )
712
+ if all_output:
713
+ error_msg += f"Output:\n{all_output[:1000]}"
714
+ else:
715
+ error_msg += "No output captured."
716
+
717
+ raise RuntimeError(error_msg)
718
+
719
+ return url, proc
720
+
721
+
722
+ async def resolve_hostname_with_explicit_resolvers(hostname: str) -> str:
723
+ """
724
+ Resolve hostname using explicit resolvers (1.1.1.1, 8.8.8.8) first,
725
+ then fall back to system resolver.
726
+
727
+ This fixes resolver path issues where system DNS is slow or blocking.
728
+
729
+ Args:
730
+ hostname: Hostname to resolve
731
+
732
+ Returns:
733
+ Resolved IP address
734
+
735
+ Raises:
736
+ socket.gaierror: If resolution fails with all resolvers
737
+ """
738
+ timeout = float(os.getenv("SYNTH_TUNNEL_DNS_TIMEOUT_PER_ATTEMPT_SECS", "5"))
739
+ loop = asyncio.get_event_loop()
740
+
741
+ # Try Cloudflare / Google first via `dig`, then fall back to system resolver
742
+ for resolver_ip in ("1.1.1.1", "8.8.8.8"):
743
+ try:
744
+ result = await loop.run_in_executor(
745
+ None,
746
+ lambda ip=resolver_ip: subprocess.run(
747
+ ["dig", f"@{ip}", "+short", hostname],
748
+ capture_output=True,
749
+ text=True,
750
+ timeout=timeout,
751
+ ),
752
+ )
753
+ if result.returncode == 0 and result.stdout.strip():
754
+ first = result.stdout.strip().splitlines()[0].strip()
755
+ if first:
756
+ logger.debug(f"Resolved via {resolver_ip}: {hostname} -> {first}")
757
+ return first
758
+ except FileNotFoundError:
759
+ logger.debug(f"dig not found, skipping {resolver_ip}")
760
+ continue
761
+ except Exception as e:
762
+ logger.debug(f"Resolver {resolver_ip} failed: {e}")
763
+ continue
764
+
765
+ # Fallback: system resolver
766
+ logger.debug(f"Falling back to system resolver for {hostname}")
767
+ return await loop.run_in_executor(
768
+ None,
769
+ socket.gethostbyname,
770
+ hostname,
771
+ )
772
+
773
+
774
+ async def verify_tunnel_dns_resolution(
775
+ tunnel_url: str,
776
+ name: str = "tunnel",
777
+ timeout_seconds: float = 60.0,
778
+ api_key: Optional[str] = None,
779
+ ) -> None:
780
+ """
781
+ Verify that a tunnel URL's hostname can be resolved via DNS (using public
782
+ resolvers first) and that HTTP connectivity works by connecting directly
783
+ to the resolved IP with the original Host header.
784
+
785
+ This avoids depending on the system resolver for HTTP checks, which was
786
+ causing [Errno 8] errors even after DNS resolved via explicit resolvers.
787
+
788
+ Args:
789
+ tunnel_url: The tunnel URL to verify (e.g., https://xxx.trycloudflare.com/v1)
790
+ name: Human-readable name for logging
791
+ timeout_seconds: Maximum time to wait for DNS resolution
792
+ api_key: Optional API key for health check authentication (defaults to ENVIRONMENT_API_KEY env var)
793
+
794
+ Raises:
795
+ RuntimeError: If DNS resolution or HTTP connectivity fails after timeout
796
+ """
797
+ parsed = urlparse(tunnel_url)
798
+ hostname = parsed.hostname
799
+ if not hostname:
800
+ logger.warning(f"No hostname in {name} tunnel URL: {tunnel_url}")
801
+ return
802
+
803
+ # Skip DNS check for localhost
804
+ if hostname in ("localhost", "127.0.0.1"):
805
+ logger.debug(f"Skipping DNS check for localhost {name}")
806
+ return
807
+
808
+ max_delay = 3.0
809
+ delay = 0.5
810
+ loop = asyncio.get_event_loop()
811
+ deadline = loop.time() + timeout_seconds
812
+ attempt = 0
813
+
814
+ logger.info(f"Verifying DNS resolution for {name}: {hostname} (timeout {timeout_seconds:.0f}s)...")
815
+
816
+ last_exc: Optional[Exception] = None
817
+
818
+ while True:
819
+ attempt += 1
820
+ try:
821
+ # 1. Resolve via explicit resolvers (1.1.1.1 / 8.8.8.8) → IP
822
+ resolved_ip = await resolve_hostname_with_explicit_resolvers(hostname)
823
+ logger.info(f"DNS resolution successful (attempt {attempt}): {hostname} -> {resolved_ip}")
824
+
825
+ # 2. HTTP connectivity: hit the tunnel via the resolved IP, but keep Host header.
826
+ # This avoids depending on the system resolver, which is what gave you EAI_NONAME.
827
+ try:
828
+ scheme = parsed.scheme or "https"
829
+ test_url = f"{scheme}://{resolved_ip}/health"
830
+ headers = {"Host": hostname}
831
+
832
+ # Include API key if provided (or from env var)
833
+ if api_key is None:
834
+ # Try to load .env file if available
835
+ try:
836
+ from dotenv import load_dotenv
837
+ load_dotenv(override=False)
838
+ except ImportError:
839
+ pass
840
+ api_key = os.getenv("ENVIRONMENT_API_KEY")
841
+ if api_key:
842
+ headers["X-API-Key"] = api_key
843
+
844
+ # For Quick Tunnels, TLS cert is for *.trycloudflare.com, not the bare IP,
845
+ # so we disable verification here; this is just a readiness probe.
846
+ async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
847
+ resp = await client.get(test_url, headers=headers)
848
+ # Accept 200 (OK), 400/401 (auth required - server is reachable), 404/405 (not found/method not allowed)
849
+ # All of these indicate the tunnel is working and the server is responding
850
+ if resp.status_code in (200, 400, 401, 404, 405):
851
+ logger.info(f"HTTP connectivity verified via IP: {test_url} -> {resp.status_code}")
852
+ return
853
+ else:
854
+ # 530 errors are common when tunnel is still establishing - be lenient
855
+ if resp.status_code == 530:
856
+ logger.debug("HTTP 530 (tunnel establishing) - will retry")
857
+ last_exc = RuntimeError("tunnel not ready yet (HTTP 530)")
858
+ else:
859
+ logger.warning(f"HTTP check returned unexpected status: {resp.status_code}")
860
+ last_exc = RuntimeError(f"unexpected HTTP status {resp.status_code}")
861
+ except Exception as http_exc:
862
+ logger.warning(f"HTTP connectivity check failed (attempt {attempt}): {http_exc}")
863
+ last_exc = http_exc
864
+
865
+ # DNS resolved, but HTTP check failed - wait and retry until deadline
866
+ now = loop.time()
867
+ if now >= deadline:
868
+ break
869
+ delay = min(delay * 2 if attempt > 1 else delay, max_delay)
870
+ sleep_for = min(delay, max(0.0, deadline - now))
871
+ logger.debug(f"Waiting {sleep_for:.1f}s before retry...")
872
+ await asyncio.sleep(sleep_for)
873
+
874
+ except socket.gaierror as e:
875
+ logger.warning(f"DNS resolution failed (attempt {attempt}): {e}")
876
+ last_exc = e
877
+ now = loop.time()
878
+ if now >= deadline:
879
+ raise RuntimeError(
880
+ f"DNS resolution failed for {name} tunnel hostname {hostname} "
881
+ f"after {timeout_seconds:.0f}s. Tunnel URL: {tunnel_url}. Error: {e}"
882
+ ) from e
883
+ delay = min(delay * 2 if attempt > 1 else delay, max_delay)
884
+ sleep_for = min(delay, max(0.0, deadline - now))
885
+ logger.debug(f"Waiting {sleep_for:.1f}s before retry...")
886
+ await asyncio.sleep(sleep_for)
887
+ except Exception as e:
888
+ logger.error(f"Unexpected error during DNS verification (attempt {attempt}): {e}")
889
+ last_exc = e
890
+ now = loop.time()
891
+ if now >= deadline:
892
+ raise RuntimeError(
893
+ f"DNS verification failed for {hostname} after {timeout_seconds:.0f}s: {e}"
894
+ ) from e
895
+ delay = min(delay * 2 if attempt > 1 else delay, max_delay)
896
+ sleep_for = min(delay, max(0.0, deadline - now))
897
+ await asyncio.sleep(sleep_for)
898
+
899
+ # If we get here, we ran out of time with HTTP still failing
900
+ raise RuntimeError(
901
+ f"DNS succeeded but HTTP connectivity could not be confirmed for {hostname} "
902
+ f"within {timeout_seconds:.0f}s. Last error: {last_exc}"
903
+ )
904
+
905
+
906
+ async def open_quick_tunnel_with_dns_verification(
907
+ port: int,
908
+ *,
909
+ wait_s: float = 10.0,
910
+ max_retries: Optional[int] = None,
911
+ dns_timeout_s: Optional[float] = None,
912
+ api_key: Optional[str] = None,
913
+ ) -> Tuple[str, subprocess.Popen]:
914
+ """
915
+ Open a quick Cloudflare tunnel with DNS verification and retry logic.
916
+
917
+ This wraps open_quick_tunnel with DNS verification to ensure the tunnel
918
+ is actually reachable before returning.
919
+
920
+ Args:
921
+ port: Local port to tunnel to
922
+ wait_s: Maximum time to wait for URL in seconds
923
+ max_retries: Maximum number of tunnel creation retries (default: from SYNTH_TUNNEL_MAX_RETRIES env var, or 2)
924
+ dns_timeout_s: Maximum time to wait for DNS resolution (default: from SYNTH_TUNNEL_DNS_TIMEOUT_SECS env var, or 60)
925
+ api_key: Optional API key for health check authentication (defaults to ENVIRONMENT_API_KEY env var)
926
+
927
+ Returns:
928
+ Tuple of (public_url, process_handle)
929
+
930
+ Raises:
931
+ RuntimeError: If tunnel creation or DNS verification fails after retries
932
+ """
933
+ max_retries = max_retries or int(os.getenv("SYNTH_TUNNEL_MAX_RETRIES", "2"))
934
+ dns_timeout_s = dns_timeout_s or float(os.getenv("SYNTH_TUNNEL_DNS_TIMEOUT_SECS", "60"))
935
+
936
+ # Get API key from parameter or env var
937
+ if api_key is None:
938
+ # Try to load .env file if available
939
+ try:
940
+ from dotenv import load_dotenv
941
+ load_dotenv(override=False)
942
+ except ImportError:
943
+ pass
944
+ api_key = os.getenv("ENVIRONMENT_API_KEY")
945
+
946
+ last_err: Optional[Exception] = None
947
+ for attempt in range(1, max_retries + 1):
948
+ proc: Optional[subprocess.Popen] = None
949
+ try:
950
+ logger.info(f"Tunnel attempt {attempt}/{max_retries}")
951
+ url, proc = open_quick_tunnel(port, wait_s=wait_s)
952
+ logger.info(f"Tunnel URL obtained: {url}")
953
+
954
+ # Give tunnel a moment to establish before verification
955
+ # Cloudflare tunnels can take a few seconds to become fully ready
956
+ logger.debug("Waiting 3s for tunnel to establish before verification...")
957
+ await asyncio.sleep(3.0)
958
+
959
+ # Verify DNS (this is where failures usually happen)
960
+ await verify_tunnel_dns_resolution(url, timeout_seconds=dns_timeout_s, name=f"tunnel attempt {attempt}", api_key=api_key)
961
+
962
+ logger.info("Tunnel verified and ready!")
963
+ return url, proc
964
+ except Exception as e:
965
+ last_err = e
966
+ # Check if this is a rate limit error and make it clearer
967
+ error_str = str(e)
968
+ is_rate_limit = "429" in error_str and "Too Many Requests" in error_str
969
+ if is_rate_limit:
970
+ logger.error(
971
+ f"❌ RATE LIMIT: Tunnel attempt {attempt}/{max_retries} failed due to Cloudflare rate limiting. "
972
+ f"This means too many quick tunnels were created recently. "
973
+ f"Wait 5-10 minutes or use managed tunnels (set SYNTH_API_KEY)."
974
+ )
975
+ else:
976
+ logger.warning(f"Tunnel attempt {attempt} failed: {e}")
977
+ if proc is not None and proc.poll() is None:
978
+ proc.terminate()
979
+ try:
980
+ proc.wait(timeout=5.0)
981
+ except subprocess.TimeoutExpired:
982
+ proc.kill()
983
+ if attempt < max_retries:
984
+ logger.info("Retrying after 10s backoff...")
985
+ await asyncio.sleep(10.0)
986
+ else:
987
+ break
988
+
989
+ assert last_err is not None
990
+ raise last_err
991
+
992
+
993
+ async def check_rate_limit_status(test_port: int = 19999) -> dict[str, Any]:
994
+ """
995
+ Check if Cloudflare is currently rate-limiting quick tunnel creation.
996
+
997
+ This attempts to create a quick tunnel and checks for rate limit errors.
998
+
999
+ Args:
1000
+ test_port: Port to use for test tunnel (should be available)
1001
+
1002
+ Returns:
1003
+ dict with keys:
1004
+ - is_rate_limited: bool
1005
+ - exit_code: int | None
1006
+ - error_message: str | None
1007
+ - output: str
1008
+ """
1009
+ import http.server
1010
+ import socketserver
1011
+ import threading
1012
+
1013
+ bin_path = require_cloudflared()
1014
+
1015
+ # Start a dummy HTTP server
1016
+ server = None
1017
+ server_thread = None
1018
+
1019
+ try:
1020
+ handler = http.server.SimpleHTTPRequestHandler
1021
+ server = socketserver.TCPServer(("127.0.0.1", test_port), handler)
1022
+ server.allow_reuse_address = True
1023
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
1024
+ server_thread.start()
1025
+ await asyncio.sleep(0.5)
1026
+
1027
+ # Try to create a tunnel (use --config /dev/null to ignore user config)
1028
+ proc = subprocess.Popen(
1029
+ [str(bin_path), "tunnel", "--config", "/dev/null", "--url", f"http://127.0.0.1:{test_port}"],
1030
+ stdout=subprocess.PIPE,
1031
+ stderr=subprocess.PIPE,
1032
+ text=True,
1033
+ )
1034
+
1035
+ # Wait a few seconds
1036
+ start = time.time()
1037
+ output_lines = []
1038
+ stderr_lines = []
1039
+
1040
+ while time.time() - start < 3.0:
1041
+ if proc.poll() is not None:
1042
+ stdout, stderr = proc.communicate()
1043
+ if stdout:
1044
+ output_lines.extend(stdout.splitlines())
1045
+ if stderr:
1046
+ stderr_lines.extend(stderr.splitlines())
1047
+ break
1048
+ await asyncio.sleep(0.1)
1049
+
1050
+ # Clean up
1051
+ proc.terminate()
1052
+ try:
1053
+ proc.wait(timeout=2.0)
1054
+ except subprocess.TimeoutExpired:
1055
+ proc.kill()
1056
+
1057
+ all_output = "\n".join(stderr_lines + output_lines)
1058
+
1059
+ # Check for rate limit
1060
+ is_rate_limited = False
1061
+ if proc.returncode == 1 and (
1062
+ ("429" in all_output and "Too Many Requests" in all_output) or "rate limit" in all_output.lower()
1063
+ ):
1064
+ is_rate_limited = True
1065
+
1066
+ return {
1067
+ "is_rate_limited": is_rate_limited,
1068
+ "exit_code": proc.returncode,
1069
+ "error_message": all_output if is_rate_limited else None,
1070
+ "output": all_output,
1071
+ }
1072
+
1073
+ finally:
1074
+ if server:
1075
+ server.shutdown()
1076
+ server.server_close()
1077
+ if server_thread:
1078
+ server_thread.join(timeout=2.0)
1079
+
1080
+
1081
+ def open_managed_tunnel(tunnel_token: str) -> subprocess.Popen:
1082
+ """
1083
+ Open a managed (named) Cloudflare tunnel using a token.
1084
+
1085
+ Args:
1086
+ tunnel_token: Cloudflare tunnel token from backend API
1087
+
1088
+ Returns:
1089
+ Process handle for the tunnel
1090
+
1091
+ Raises:
1092
+ RuntimeError: If cloudflared is not installed
1093
+ """
1094
+ bin_path = require_cloudflared()
1095
+ # cloudflared v2023.4+ accepts --token for named tunnels
1096
+ return subprocess.Popen(
1097
+ [str(bin_path), "tunnel", "run", "--token", tunnel_token],
1098
+ stdout=subprocess.PIPE,
1099
+ stderr=subprocess.STDOUT,
1100
+ text=True,
1101
+ bufsize=1,
1102
+ )
1103
+
1104
+
1105
+ def stop_tunnel(proc: Optional[subprocess.Popen]) -> None:
1106
+ """
1107
+ Gracefully stop a tunnel process.
1108
+
1109
+ Args:
1110
+ proc: Process handle to terminate, or None
1111
+ """
1112
+ if proc is None:
1113
+ return
1114
+
1115
+ if proc.poll() is None:
1116
+ # Process is still running
1117
+ proc.terminate()
1118
+ try:
1119
+ proc.wait(timeout=5.0)
1120
+ except subprocess.TimeoutExpired:
1121
+ # Force kill if graceful termination fails
1122
+ proc.kill()
1123
+ proc.wait()
1124
+
1125
+
1126
+ def store_tunnel_credentials(
1127
+ tunnel_url: str,
1128
+ access_client_id: Optional[str] = None,
1129
+ access_client_secret: Optional[str] = None,
1130
+ env_file: Optional[Path] = None,
1131
+ ) -> None:
1132
+ """
1133
+ Store tunnel credentials in .env file for optimizer to use.
1134
+
1135
+ Writes:
1136
+ - TASK_APP_URL=<tunnel_url>
1137
+ - CF_ACCESS_CLIENT_ID=<client_id> (if Access enabled)
1138
+ - CF_ACCESS_CLIENT_SECRET=<client_secret> (if Access enabled)
1139
+
1140
+ Args:
1141
+ tunnel_url: Public tunnel URL (e.g., "https://cust-abc123.usesynth.ai")
1142
+ access_client_id: Cloudflare Access client ID (optional)
1143
+ access_client_secret: Cloudflare Access client secret (optional)
1144
+ env_file: Path to .env file (defaults to .env in current directory)
1145
+ """
1146
+ __write_env_var_to_dotenv(
1147
+ "TASK_APP_URL",
1148
+ tunnel_url,
1149
+ output_file_path=env_file,
1150
+ print_msg=True,
1151
+ mask_msg=False,
1152
+ )
1153
+
1154
+ if access_client_id:
1155
+ __write_env_var_to_dotenv(
1156
+ "CF_ACCESS_CLIENT_ID",
1157
+ access_client_id,
1158
+ output_file_path=env_file,
1159
+ print_msg=True,
1160
+ mask_msg=True,
1161
+ )
1162
+
1163
+ if access_client_secret:
1164
+ __write_env_var_to_dotenv(
1165
+ "CF_ACCESS_CLIENT_SECRET",
1166
+ access_client_secret,
1167
+ output_file_path=env_file,
1168
+ print_msg=True,
1169
+ mask_msg=True,
1170
+ )
1171
+
1172
+
1173
+ # ---------------------------------------------------------------------------
1174
+ # Tunnel deployment helpers
1175
+ # ---------------------------------------------------------------------------
1176
+
1177
+
1178
+ async def rotate_tunnel(
1179
+ synth_api_key: str,
1180
+ port: int,
1181
+ reason: Optional[str] = None,
1182
+ backend_url: Optional[str] = None,
1183
+ ) -> dict[str, Any]:
1184
+ """
1185
+ Rotate (delete + recreate) the org's managed tunnel via Synth backend API.
1186
+
1187
+ This is useful when a tunnel becomes stale or inaccessible. It will:
1188
+ 1. Delete any existing active tunnels for the org
1189
+ 2. Create a fresh tunnel with a new auto-generated subdomain
1190
+ 3. Wait for DNS propagation (up to 90s) before returning
1191
+
1192
+ Args:
1193
+ synth_api_key: Synth API key for authentication
1194
+ port: Local port the new tunnel will forward to
1195
+ reason: Optional reason for rotation (for logging)
1196
+ backend_url: Optional backend URL (defaults to get_backend_url())
1197
+
1198
+ Returns:
1199
+ Dict containing:
1200
+ - tunnel_token: Token for cloudflared
1201
+ - hostname: Public hostname (e.g., "task-8114-12345.usesynth.ai")
1202
+ - access_client_id: Cloudflare Access client ID (if Access enabled)
1203
+ - access_client_secret: Cloudflare Access client secret (if Access enabled)
1204
+ - dns_verified: True if backend verified DNS propagation (SDK can skip DNS verification)
1205
+ - metadata: Dict with dns_verified and dns_verified_at timestamp
1206
+
1207
+ Raises:
1208
+ RuntimeError: If API request fails
1209
+ """
1210
+ from synth_ai.core.env import get_backend_url
1211
+
1212
+ base_url = backend_url or get_backend_url()
1213
+ url = f"{base_url}/api/v1/tunnels/rotate"
1214
+
1215
+ def mask_key(key: str) -> str:
1216
+ if len(key) > 14:
1217
+ return f"{key[:10]}...{key[-4:]}"
1218
+ return f"{key[:6]}..."
1219
+
1220
+ try:
1221
+ # Backend now waits up to 90s for DNS propagation, so we need a longer timeout
1222
+ async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
1223
+ response = await client.post(
1224
+ url,
1225
+ headers={
1226
+ "X-API-Key": synth_api_key,
1227
+ "Authorization": f"Bearer {synth_api_key}",
1228
+ },
1229
+ json={
1230
+ "local_port": port,
1231
+ "local_host": "127.0.0.1",
1232
+ "reason": reason,
1233
+ },
1234
+ )
1235
+ response.raise_for_status()
1236
+ return response.json()
1237
+ except httpx.HTTPStatusError as exc:
1238
+ error_detail = exc.response.text
1239
+ try:
1240
+ import json
1241
+ error_json = json.loads(error_detail)
1242
+ error_detail = str(error_json.get("detail", error_detail))
1243
+ except Exception:
1244
+ pass
1245
+
1246
+ raise RuntimeError(
1247
+ f"Backend API returned {exc.response.status_code} when rotating tunnel:\n"
1248
+ f" Error: {error_detail}\n"
1249
+ f" URL: {url}\n"
1250
+ f" API Key: {mask_key(synth_api_key)}"
1251
+ ) from exc
1252
+ except httpx.ReadTimeout as exc:
1253
+ raise RuntimeError(
1254
+ f"Request timed out when rotating tunnel (backend waits for DNS propagation):\n"
1255
+ f" URL: {url}\n"
1256
+ f" Timeout: 180s\n"
1257
+ f" This is usually temporary - try again in a moment"
1258
+ ) from exc
1259
+ except httpx.RequestError as exc:
1260
+ raise RuntimeError(
1261
+ f"Failed to connect to backend when rotating tunnel:\n"
1262
+ f" URL: {url}\n"
1263
+ f" Error: {exc}"
1264
+ ) from exc
1265
+
1266
+
1267
+ async def create_tunnel(
1268
+ synth_api_key: str,
1269
+ port: int,
1270
+ subdomain: Optional[str] = None,
1271
+ ) -> dict[str, Any]:
1272
+ """
1273
+ Create a managed Cloudflare tunnel via Synth backend API.
1274
+
1275
+ The backend waits for DNS propagation (up to 90s) before returning.
1276
+
1277
+ Args:
1278
+ synth_api_key: Synth API key for authentication
1279
+ port: Local port the tunnel will forward to
1280
+ subdomain: Optional custom subdomain (e.g., "my-company")
1281
+
1282
+ Returns:
1283
+ Dict containing:
1284
+ - tunnel_token: Token for cloudflared
1285
+ - hostname: Public hostname (e.g., "cust-abc123.usesynth.ai")
1286
+ - access_client_id: Cloudflare Access client ID (if Access enabled)
1287
+ - access_client_secret: Cloudflare Access client secret (if Access enabled)
1288
+ - dns_verified: True if backend verified DNS propagation (SDK can skip DNS verification)
1289
+ - metadata: Dict with dns_verified and dns_verified_at timestamp
1290
+
1291
+ Raises:
1292
+ RuntimeError: If API request fails
1293
+ """
1294
+ url = f"{BACKEND_URL_BASE}/api/v1/tunnels/"
1295
+
1296
+ # Mask API key for error messages
1297
+ def mask_key(key: str) -> str:
1298
+ if len(key) > 14:
1299
+ return f"{key[:10]}...{key[-4:]}"
1300
+ return f"{key[:6]}..."
1301
+
1302
+ try:
1303
+ # Use X-API-Key header (backend expects this format)
1304
+ # Also support Authorization header as fallback
1305
+ # Backend now waits up to 90s for DNS propagation, so we need a longer timeout
1306
+ async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
1307
+ response = await client.post(
1308
+ url,
1309
+ headers={
1310
+ "X-API-Key": synth_api_key,
1311
+ "Authorization": f"Bearer {synth_api_key}", # Fallback
1312
+ },
1313
+ json={
1314
+ "subdomain": subdomain or f"tunnel-{port}",
1315
+ "local_port": port,
1316
+ "local_host": "127.0.0.1",
1317
+ },
1318
+ )
1319
+ response.raise_for_status()
1320
+ return response.json()
1321
+ except httpx.HTTPStatusError as exc:
1322
+ error_detail = exc.response.text
1323
+ try:
1324
+ import json
1325
+ error_json = json.loads(error_detail)
1326
+ error_detail = str(error_json.get("detail", error_detail))
1327
+ except Exception:
1328
+ pass
1329
+
1330
+ # Provide helpful error message
1331
+ if exc.response.status_code == 401:
1332
+ raise RuntimeError(
1333
+ f"Authentication failed when creating tunnel:\n"
1334
+ f" Status: {exc.response.status_code}\n"
1335
+ f" Error: {error_detail}\n"
1336
+ f" API Key used: {mask_key(synth_api_key)}\n"
1337
+ f" URL: {url}\n"
1338
+ f" This usually means:\n"
1339
+ f" - The API key is invalid or expired\n"
1340
+ f" - The backend is experiencing high load (PostgREST timeout)\n"
1341
+ f" - Network connectivity issues\n"
1342
+ f" Try:\n"
1343
+ f" - Verify SYNTH_API_KEY is set correctly\n"
1344
+ f" - Wait a moment and retry (backend may be under load)\n"
1345
+ f" - Use tunnel_mode='quick' as a workaround"
1346
+ ) from exc
1347
+ else:
1348
+ raise RuntimeError(
1349
+ f"Backend API returned {exc.response.status_code} when creating tunnel:\n"
1350
+ f" Error: {error_detail}\n"
1351
+ f" URL: {url}\n"
1352
+ f" API Key: {mask_key(synth_api_key)}"
1353
+ ) from exc
1354
+ except httpx.ReadTimeout as exc:
1355
+ raise RuntimeError(
1356
+ f"Request timed out when creating tunnel (backend waits for DNS propagation):\n"
1357
+ f" URL: {url}\n"
1358
+ f" API Key: {mask_key(synth_api_key)}\n"
1359
+ f" Timeout: 180s\n"
1360
+ f" This is usually temporary - try again in a moment"
1361
+ ) from exc
1362
+ except httpx.RequestError as exc:
1363
+ raise RuntimeError(
1364
+ f"Failed to connect to backend when creating tunnel:\n"
1365
+ f" URL: {url}\n"
1366
+ f" API Key: {mask_key(synth_api_key)}\n"
1367
+ f" Error: {exc}\n"
1368
+ f" Check network connectivity and backend availability"
1369
+ ) from exc
1370
+
1371
+
1372
+ async def wait_for_health_check(
1373
+ host: str,
1374
+ port: int,
1375
+ api_key: str,
1376
+ timeout: float = 30.0,
1377
+ ) -> None:
1378
+ """
1379
+ Wait for task app health endpoint to be ready.
1380
+
1381
+ Args:
1382
+ host: Host to check
1383
+ port: Port to check
1384
+ api_key: API key for authentication
1385
+ timeout: Maximum time to wait in seconds
1386
+
1387
+ Raises:
1388
+ RuntimeError: If health check fails or times out
1389
+ """
1390
+ health_url = f"http://{host}:{port}/health"
1391
+ headers = {"X-API-Key": api_key}
1392
+ start = time.time()
1393
+
1394
+ while time.time() - start < timeout:
1395
+ try:
1396
+ async with httpx.AsyncClient(timeout=5.0) as client:
1397
+ response = await client.get(health_url, headers=headers)
1398
+ # Accept both 200 (success) and 400 (auth error means server is up)
1399
+ if response.status_code in (200, 400):
1400
+ return
1401
+ except (httpx.RequestError, httpx.TimeoutException):
1402
+ pass
1403
+
1404
+ await asyncio.sleep(0.5)
1405
+
1406
+ raise RuntimeError(
1407
+ f"Health check failed: {health_url} not ready after {timeout}s. "
1408
+ "Make sure your task app has a /health endpoint."
1409
+ )
1410
+
1411
+
1412
+ def _start_uvicorn_background(
1413
+ app: ASGIApp,
1414
+ host: str,
1415
+ port: int,
1416
+ daemon: bool = True,
1417
+ ) -> None:
1418
+ """
1419
+ Start uvicorn server in a background thread.
1420
+
1421
+ Args:
1422
+ app: ASGI application
1423
+ host: Host to bind to
1424
+ port: Port to bind to
1425
+ daemon: If True, thread dies when main process exits. If False, thread keeps running.
1426
+ """
1427
+ import threading
1428
+
1429
+ def serve():
1430
+ try:
1431
+ uvicorn.run(
1432
+ app,
1433
+ host=host,
1434
+ port=port,
1435
+ reload=False,
1436
+ log_level="info",
1437
+ )
1438
+ except Exception as exc:
1439
+ # Log error but don't raise (background thread)
1440
+ print(f"Uvicorn error: {exc}", flush=True)
1441
+
1442
+ thread = threading.Thread(
1443
+ target=serve,
1444
+ name=f"synth-uvicorn-tunnel-{port}",
1445
+ daemon=daemon,
1446
+ )
1447
+ thread.start()
1448
+
1449
+
1450
+ async def deploy_app_tunnel(
1451
+ cfg: CFDeployCfg,
1452
+ env_file: Optional[Path] = None,
1453
+ keep_alive: bool = False,
1454
+ wait: bool = False,
1455
+ health_check_timeout: float = 30.0,
1456
+ ) -> str:
1457
+ """
1458
+ Deploy task app via Cloudflare Tunnel.
1459
+
1460
+ This function provides a clean abstraction that handles:
1461
+ 1. Starting the local task app (uvicorn) in background
1462
+ 2. Optionally waiting for health check (only if wait=True)
1463
+ 3. Opening tunnel (quick or managed)
1464
+ 4. Writing tunnel URL and Access credentials to .env
1465
+ 5. Optionally keeping processes alive (blocking vs non-blocking mode)
1466
+
1467
+ By default (wait=False), this function is non-blocking and returns immediately
1468
+ after starting the tunnel. This is designed for AI agent use to prevent indefinite stalls.
1469
+ Processes run in the background and will continue until explicitly stopped.
1470
+
1471
+ When `wait=True` or `keep_alive=True`, this function blocks and keeps the tunnel running
1472
+ until interrupted (Ctrl+C). Use this for interactive use or when you need to wait
1473
+ for the deployment to complete.
1474
+
1475
+ Args:
1476
+ cfg: Tunnel deployment configuration
1477
+ env_file: Optional path to .env file (defaults to .env in current directory)
1478
+ keep_alive: (Deprecated) If True, block and keep tunnel alive until interrupted.
1479
+ Use `wait` instead.
1480
+ wait: If True, wait for health check and block until interrupted.
1481
+ If False (default), return immediately after deployment (background mode).
1482
+ health_check_timeout: Maximum time to wait for health check (only used if wait=True)
1483
+
1484
+ Returns:
1485
+ Public tunnel URL
1486
+
1487
+ Raises:
1488
+ RuntimeError: If deployment fails at any step
1489
+
1490
+ Example:
1491
+ # Non-blocking (background mode, returns immediately) - DEFAULT
1492
+ url = await deploy_app_tunnel(cfg, wait=False)
1493
+
1494
+ # Blocking (waits for health check and keeps tunnel alive)
1495
+ url = await deploy_app_tunnel(cfg, wait=True)
1496
+ """
1497
+ ctx: dict[str, Any] = {
1498
+ "mode": cfg.mode,
1499
+ "host": cfg.host,
1500
+ "port": cfg.port,
1501
+ "task_app_path": str(cfg.task_app_path) if cfg.task_app_path else None,
1502
+ "wait": wait,
1503
+ }
1504
+ log_info("deploy_app_tunnel invoked", ctx=ctx)
1505
+
1506
+ ensure_cloudflared_installed()
1507
+
1508
+ selected_managed: Optional[ManagedTunnelRecord] = None
1509
+ synth_api_key: Optional[str] = None
1510
+
1511
+ if cfg.mode == "managed":
1512
+ synth_api_key = __resolve_env_var("SYNTH_API_KEY")
1513
+ tunnels = await fetch_managed_tunnels(synth_api_key)
1514
+ if tunnels:
1515
+ selected_managed = _select_existing_tunnel(tunnels, cfg.subdomain)
1516
+ if selected_managed:
1517
+ cfg.host = selected_managed.local_host or cfg.host
1518
+ cfg.port = selected_managed.local_port or cfg.port
1519
+ else:
1520
+ print("ℹ️ No managed tunnels found; provisioning a new managed tunnel.")
1521
+
1522
+ # Load environment variables from env_file before starting uvicorn
1523
+ # This ensures all env vars (HF cache paths, dataset names, etc.) are available to the task app
1524
+ if env_file and env_file.exists():
1525
+ try:
1526
+ from dotenv import load_dotenv
1527
+ load_dotenv(str(env_file), override=True)
1528
+ # Also explicitly set critical env vars to ensure they're available
1529
+ # Read the file directly to set vars even if dotenv fails
1530
+ try:
1531
+ with open(env_file) as f:
1532
+ for line in f:
1533
+ line = line.strip()
1534
+ if line and not line.startswith("#") and "=" in line:
1535
+ key, value = line.split("=", 1)
1536
+ # Remove quotes if present
1537
+ value = value.strip().strip('"').strip("'")
1538
+ os.environ[key.strip()] = value
1539
+ except Exception as file_exc:
1540
+ logger.debug(f"Could not read env_file directly: {file_exc}")
1541
+ logger.debug(f"Loaded environment from {env_file}")
1542
+ except ImportError:
1543
+ logger.warning("python-dotenv not available, skipping env_file load")
1544
+ # Fallback: read file directly
1545
+ try:
1546
+ with open(env_file) as f:
1547
+ for line in f:
1548
+ line = line.strip()
1549
+ if line and not line.startswith("#") and "=" in line:
1550
+ key, value = line.split("=", 1)
1551
+ value = value.strip().strip('"').strip("'")
1552
+ os.environ[key.strip()] = value
1553
+ except Exception as file_exc:
1554
+ logger.warning(f"Failed to read env_file directly: {file_exc}")
1555
+ except Exception as exc:
1556
+ logger.warning(f"Failed to load env_file {env_file}: {exc}")
1557
+
1558
+ os.environ["ENVIRONMENT_API_KEY"] = cfg.env_api_key
1559
+ if cfg.trace:
1560
+ os.environ["TASKAPP_TRACING_ENABLED"] = "1"
1561
+ else:
1562
+ os.environ.pop("TASKAPP_TRACING_ENABLED", None)
1563
+
1564
+ configure_import_paths(cfg.task_app_path, REPO_ROOT)
1565
+ module = load_module(cfg.task_app_path, f"_synth_tunnel_task_app_{cfg.task_app_path.stem}")
1566
+ app = get_asgi_app(module)
1567
+
1568
+ # Always use non-daemon thread so it survives when main process exits
1569
+ _start_uvicorn_background(app, cfg.host, cfg.port, daemon=False)
1570
+
1571
+ # Only wait for health check if wait mode is enabled (for AI agents, skip to avoid stalls)
1572
+ if wait or keep_alive:
1573
+ await wait_for_health_check(cfg.host, cfg.port, cfg.env_api_key, timeout=health_check_timeout)
1574
+ else:
1575
+ # In background mode, give it a short moment to start, but don't wait for full health check
1576
+ # This prevents indefinite stalls while still allowing the server to start
1577
+ import asyncio
1578
+ await asyncio.sleep(1.0) # Brief delay to let server start
1579
+
1580
+ tunnel_proc: Optional[subprocess.Popen] = None
1581
+ try:
1582
+ if cfg.mode == "quick":
1583
+ # Quick tunnel: ephemeral, no backend API call
1584
+ url, tunnel_proc = open_quick_tunnel(cfg.port)
1585
+ _TUNNEL_PROCESSES[cfg.port] = tunnel_proc
1586
+ store_tunnel_credentials(url, None, None, env_file)
1587
+ # Record tunnel for scan command
1588
+ try:
1589
+ from synth_ai.cli.lib.tunnel_records import record_tunnel
1590
+ record_tunnel(
1591
+ url=url,
1592
+ port=cfg.port,
1593
+ mode="quick",
1594
+ pid=tunnel_proc.pid if tunnel_proc else None,
1595
+ hostname=url.replace("https://", "").split("/")[0] if url.startswith("https://") else None,
1596
+ local_host=cfg.host,
1597
+ task_app_path=str(cfg.task_app_path) if cfg.task_app_path else None,
1598
+ )
1599
+ except Exception:
1600
+ pass # Fail silently - records are optional
1601
+ else:
1602
+ # Managed tunnel: either reuse or provision via backend API
1603
+ if selected_managed:
1604
+ tunnel_token = selected_managed.credential("tunnel_token")
1605
+ if not tunnel_token:
1606
+ raise RuntimeError(
1607
+ "Managed tunnel metadata missing tunnel_token. "
1608
+ "Delete the tunnel or contact Synth support."
1609
+ )
1610
+ hostname = selected_managed.hostname
1611
+ access_client_id = selected_managed.credential("access_client_id")
1612
+ access_client_secret = selected_managed.credential("access_client_secret")
1613
+ else:
1614
+ if not synth_api_key:
1615
+ synth_api_key = __resolve_env_var("SYNTH_API_KEY")
1616
+ data = await create_tunnel(synth_api_key, cfg.port, cfg.subdomain)
1617
+ tunnel_token = data["tunnel_token"]
1618
+ hostname = data["hostname"]
1619
+ access_client_id = data.get("access_client_id")
1620
+ access_client_secret = data.get("access_client_secret")
1621
+
1622
+ tunnel_proc = open_managed_tunnel(str(tunnel_token))
1623
+ _TUNNEL_PROCESSES[cfg.port] = tunnel_proc
1624
+
1625
+ url = hostname if hostname.startswith("http") else f"https://{hostname}"
1626
+ store_tunnel_credentials(url, access_client_id, access_client_secret, env_file)
1627
+ # Record tunnel for scan command
1628
+ try:
1629
+ from synth_ai.cli.lib.tunnel_records import record_tunnel
1630
+ record_tunnel(
1631
+ url=url,
1632
+ port=cfg.port,
1633
+ mode="managed",
1634
+ pid=tunnel_proc.pid if tunnel_proc else None,
1635
+ hostname=hostname,
1636
+ local_host=cfg.host,
1637
+ task_app_path=str(cfg.task_app_path) if cfg.task_app_path else None,
1638
+ )
1639
+ except Exception:
1640
+ pass # Fail silently - records are optional
1641
+
1642
+ # If wait or keep_alive is True, block and keep processes alive until interrupted
1643
+ if wait or keep_alive:
1644
+ _keep_tunnel_alive(cfg.port, url)
1645
+ else:
1646
+ # Background mode: print URL and return immediately
1647
+ # Processes will keep running in background
1648
+ print(f"✓ Tunnel ready: {url}")
1649
+ print(f"⏳ Tunnel running in background (PID: {tunnel_proc.pid if tunnel_proc else 'N/A'})")
1650
+ print(" Press Ctrl+C in this process to stop, or use: pkill -f cloudflared")
1651
+
1652
+ return url
1653
+
1654
+ except Exception as exc:
1655
+ # Clean up tunnel process on error
1656
+ if tunnel_proc:
1657
+ stop_tunnel(tunnel_proc)
1658
+ _TUNNEL_PROCESSES.pop(cfg.port, None)
1659
+ # Remove record if it was created
1660
+ try:
1661
+ from synth_ai.cli.lib.tunnel_records import remove_tunnel_record
1662
+ remove_tunnel_record(cfg.port)
1663
+ except Exception:
1664
+ pass
1665
+ raise RuntimeError(f"Failed to deploy tunnel: {exc}") from exc
1666
+
1667
+
1668
+ def _keep_tunnel_alive(port: int, url: str) -> None:
1669
+ """
1670
+ Keep tunnel processes alive until interrupted.
1671
+
1672
+ This function blocks and monitors the tunnel process, similar to how
1673
+ local deployments block. Users can interrupt with Ctrl+C to stop.
1674
+
1675
+ Args:
1676
+ port: Port the tunnel is running on
1677
+ url: Public tunnel URL (for display)
1678
+ """
1679
+
1680
+ def signal_handler(signum, frame): # noqa: ARG001
1681
+ """Handle SIGINT/SIGTERM to cleanup gracefully."""
1682
+ if port in _TUNNEL_PROCESSES:
1683
+ stop_tunnel(_TUNNEL_PROCESSES[port])
1684
+ _TUNNEL_PROCESSES.pop(port, None)
1685
+ sys.exit(0)
1686
+
1687
+ # Register signal handlers for graceful shutdown
1688
+ signal.signal(signal.SIGINT, signal_handler)
1689
+ signal.signal(signal.SIGTERM, signal_handler)
1690
+
1691
+ print(f"✓ Tunnel ready: {url}")
1692
+ print("⏳ Keeping tunnel running... (Press Ctrl+C to stop)")
1693
+
1694
+ try:
1695
+ # Monitor tunnel process and keep alive
1696
+ while True:
1697
+ if port in _TUNNEL_PROCESSES:
1698
+ proc = _TUNNEL_PROCESSES[port]
1699
+ if isinstance(proc, subprocess.Popen) and proc.poll() is not None:
1700
+ print(f"❌ Tunnel process exited with code {proc.returncode}")
1701
+ break
1702
+ time.sleep(1)
1703
+ except KeyboardInterrupt:
1704
+ pass
1705
+ finally:
1706
+ # Cleanup on exit
1707
+ if port in _TUNNEL_PROCESSES:
1708
+ stop_tunnel(_TUNNEL_PROCESSES[port])
1709
+ _TUNNEL_PROCESSES.pop(port, None)
1710
+ print("\n🛑 Tunnel stopped")