synth-ai 0.2.9.dev11__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

Files changed (909) hide show
  1. synth_ai/__init__.py +44 -45
  2. synth_ai/__main__.py +30 -3
  3. synth_ai/cli/__init__.py +104 -78
  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/baseline/__init__.py +12 -0
  25. synth_ai/cli/commands/baseline/core.py +636 -0
  26. synth_ai/cli/commands/baseline/list.py +94 -0
  27. synth_ai/cli/commands/demo/__init__.py +3 -0
  28. synth_ai/cli/commands/demo/core.py +153 -0
  29. synth_ai/cli/commands/eval/__init__.py +19 -0
  30. synth_ai/cli/commands/eval/core.py +1113 -0
  31. synth_ai/cli/commands/eval/errors.py +81 -0
  32. synth_ai/cli/commands/eval/validation.py +133 -0
  33. synth_ai/cli/commands/filter/__init__.py +12 -0
  34. synth_ai/cli/commands/filter/core.py +424 -0
  35. synth_ai/cli/commands/filter/errors.py +55 -0
  36. synth_ai/cli/commands/filter/validation.py +77 -0
  37. synth_ai/cli/commands/help/__init__.py +185 -0
  38. synth_ai/cli/commands/help/core.py +72 -0
  39. synth_ai/cli/commands/scan/__init__.py +19 -0
  40. synth_ai/cli/commands/scan/cloudflare_scanner.py +403 -0
  41. synth_ai/cli/commands/scan/core.py +344 -0
  42. synth_ai/cli/commands/scan/health_checker.py +242 -0
  43. synth_ai/cli/commands/scan/local_scanner.py +278 -0
  44. synth_ai/cli/commands/scan/models.py +83 -0
  45. synth_ai/cli/commands/smoke/__init__.py +7 -0
  46. synth_ai/cli/commands/smoke/core.py +1438 -0
  47. synth_ai/cli/commands/status/__init__.py +66 -0
  48. synth_ai/cli/commands/status/client.py +192 -0
  49. synth_ai/cli/commands/status/config.py +92 -0
  50. synth_ai/cli/commands/status/errors.py +20 -0
  51. synth_ai/cli/commands/status/formatters.py +164 -0
  52. synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
  53. synth_ai/cli/commands/status/subcommands/files.py +79 -0
  54. synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
  55. synth_ai/cli/commands/status/subcommands/models.py +79 -0
  56. synth_ai/cli/commands/status/subcommands/pricing.py +23 -0
  57. synth_ai/cli/commands/status/subcommands/runs.py +81 -0
  58. synth_ai/cli/commands/status/subcommands/session.py +182 -0
  59. synth_ai/cli/commands/status/subcommands/summary.py +47 -0
  60. synth_ai/cli/commands/status/subcommands/usage.py +203 -0
  61. synth_ai/cli/commands/status/utils.py +114 -0
  62. synth_ai/cli/commands/train/__init__.py +53 -0
  63. synth_ai/cli/commands/train/core.py +22 -0
  64. synth_ai/cli/commands/train/errors.py +117 -0
  65. synth_ai/cli/commands/train/judge_schemas.py +201 -0
  66. synth_ai/cli/commands/train/judge_validation.py +305 -0
  67. synth_ai/cli/commands/train/prompt_learning_validation.py +633 -0
  68. synth_ai/cli/commands/train/validation.py +392 -0
  69. synth_ai/cli/demo_apps/__init__.py +10 -0
  70. synth_ai/cli/demo_apps/core/__init__.py +28 -0
  71. synth_ai/cli/demo_apps/core/cli.py +1735 -0
  72. synth_ai/cli/demo_apps/crafter/crafter_fft_4b.toml +55 -0
  73. synth_ai/cli/demo_apps/crafter/grpo_crafter_task_app.py +186 -0
  74. synth_ai/cli/demo_apps/crafter/rl_from_base_qwen4b.toml +74 -0
  75. synth_ai/cli/demo_apps/demo_registry.py +176 -0
  76. synth_ai/cli/demo_apps/demo_task_apps/core.py +440 -0
  77. synth_ai/cli/demo_apps/demo_task_apps/crafter/__init__.py +1 -0
  78. synth_ai/cli/demo_apps/demo_task_apps/crafter/grpo_crafter_task_app.py +185 -0
  79. synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +742 -0
  80. synth_ai/cli/demo_apps/demo_task_apps/math/task_app_entry.py +39 -0
  81. synth_ai/cli/demo_apps/math/__init__.py +1 -0
  82. synth_ai/cli/demo_apps/math/_common.py +16 -0
  83. synth_ai/cli/demo_apps/math/app.py +38 -0
  84. synth_ai/cli/demo_apps/math/config.toml +76 -0
  85. synth_ai/cli/demo_apps/math/deploy_modal.py +54 -0
  86. synth_ai/cli/demo_apps/math/modal_task_app.py +702 -0
  87. synth_ai/cli/demo_apps/math/task_app_entry.py +53 -0
  88. synth_ai/cli/demo_apps/mipro/main.py +271 -0
  89. synth_ai/cli/demo_apps/mipro/task_app.py +933 -0
  90. synth_ai/cli/demo_apps/mipro/train_cfg.toml +92 -0
  91. synth_ai/cli/demos/__init__.py +12 -0
  92. synth_ai/cli/demos/demo.py +32 -0
  93. synth_ai/cli/demos/rl_demo.py +254 -0
  94. synth_ai/cli/deploy.py +216 -0
  95. synth_ai/cli/infra/__init__.py +14 -0
  96. synth_ai/cli/infra/balance.py +216 -0
  97. synth_ai/cli/infra/mcp.py +35 -0
  98. synth_ai/cli/infra/modal_app.py +36 -0
  99. synth_ai/cli/infra/setup.py +69 -0
  100. synth_ai/cli/infra/status.py +16 -0
  101. synth_ai/cli/infra/turso.py +77 -0
  102. synth_ai/cli/lib/__init__.py +10 -0
  103. synth_ai/cli/lib/agents.py +76 -0
  104. synth_ai/cli/lib/apps/modal_app.py +101 -0
  105. synth_ai/cli/lib/apps/task_app.py +643 -0
  106. synth_ai/cli/lib/bin.py +39 -0
  107. synth_ai/cli/lib/env.py +375 -0
  108. synth_ai/cli/lib/errors.py +85 -0
  109. synth_ai/cli/lib/modal.py +315 -0
  110. synth_ai/cli/lib/plotting.py +126 -0
  111. synth_ai/cli/lib/prompt_args.py +39 -0
  112. synth_ai/cli/lib/prompts.py +284 -0
  113. synth_ai/cli/lib/sqld.py +122 -0
  114. synth_ai/cli/lib/task_app_discovery.py +884 -0
  115. synth_ai/cli/lib/task_app_env.py +295 -0
  116. synth_ai/cli/lib/train_cfgs.py +300 -0
  117. synth_ai/cli/lib/tunnel_records.py +207 -0
  118. synth_ai/cli/local/__init__.py +14 -0
  119. synth_ai/cli/local/experiment_queue/__init__.py +72 -0
  120. synth_ai/cli/local/experiment_queue/api_schemas.py +221 -0
  121. synth_ai/cli/local/experiment_queue/celery_app.py +208 -0
  122. synth_ai/cli/local/experiment_queue/config.py +128 -0
  123. synth_ai/cli/local/experiment_queue/config_utils.py +272 -0
  124. synth_ai/cli/local/experiment_queue/database.py +175 -0
  125. synth_ai/cli/local/experiment_queue/dispatcher.py +119 -0
  126. synth_ai/cli/local/experiment_queue/models.py +231 -0
  127. synth_ai/cli/local/experiment_queue/progress_info.py +160 -0
  128. synth_ai/cli/local/experiment_queue/results.py +373 -0
  129. synth_ai/cli/local/experiment_queue/schemas.py +131 -0
  130. synth_ai/cli/local/experiment_queue/service.py +344 -0
  131. synth_ai/cli/local/experiment_queue/status.py +372 -0
  132. synth_ai/cli/local/experiment_queue/status_tracker.py +360 -0
  133. synth_ai/cli/local/experiment_queue/tasks.py +1984 -0
  134. synth_ai/cli/local/experiment_queue/trace_storage.py +65 -0
  135. synth_ai/cli/local/experiment_queue/validation.py +157 -0
  136. synth_ai/cli/local/session/__init__.py +92 -0
  137. synth_ai/cli/local/session/client.py +383 -0
  138. synth_ai/cli/local/session/constants.py +63 -0
  139. synth_ai/cli/local/session/exceptions.py +105 -0
  140. synth_ai/cli/local/session/manager.py +139 -0
  141. synth_ai/cli/local/session/models.py +89 -0
  142. synth_ai/cli/local/session/query.py +110 -0
  143. synth_ai/cli/root.py +30 -103
  144. synth_ai/cli/task_apps/__init__.py +26 -0
  145. synth_ai/cli/task_apps/commands.py +3153 -0
  146. synth_ai/cli/task_apps/deploy.py +7 -0
  147. synth_ai/cli/task_apps/list.py +26 -0
  148. synth_ai/cli/task_apps/main.py +36 -0
  149. synth_ai/cli/task_apps/modal_serve.py +11 -0
  150. synth_ai/cli/task_apps/serve.py +11 -0
  151. synth_ai/cli/training/__init__.py +8 -0
  152. synth_ai/cli/training/train.py +5 -0
  153. synth_ai/cli/training/train_cfg.py +34 -0
  154. synth_ai/cli/training/watch.py +506 -0
  155. synth_ai/cli/turso.py +34 -55
  156. synth_ai/cli/usage.py +159 -0
  157. synth_ai/cli/utils/__init__.py +8 -0
  158. synth_ai/cli/utils/experiments.py +235 -0
  159. synth_ai/cli/utils/queue.py +504 -0
  160. synth_ai/cli/utils/recent.py +133 -0
  161. synth_ai/cli/utils/traces.py +164 -0
  162. synth_ai/contracts/__init__.py +67 -0
  163. synth_ai/core/__init__.py +100 -0
  164. synth_ai/core/_utils/__init__.py +54 -0
  165. synth_ai/core/_utils/base_url.py +10 -0
  166. synth_ai/core/_utils/http.py +10 -0
  167. synth_ai/core/_utils/prompts.py +14 -0
  168. synth_ai/core/_utils/task_app_state.py +12 -0
  169. synth_ai/core/_utils/user_config.py +10 -0
  170. synth_ai/core/apps/common.py +116 -0
  171. synth_ai/core/auth.py +95 -0
  172. synth_ai/core/cfgs.py +240 -0
  173. synth_ai/core/config/__init__.py +16 -0
  174. synth_ai/core/config/base.py +168 -0
  175. synth_ai/core/config/resolver.py +89 -0
  176. synth_ai/core/env.py +220 -0
  177. synth_ai/core/errors.py +126 -0
  178. synth_ai/core/http.py +230 -0
  179. synth_ai/core/integrations/__init__.py +11 -0
  180. synth_ai/core/integrations/cloudflare.py +1710 -0
  181. synth_ai/core/integrations/mcp/__init__.py +6 -0
  182. synth_ai/core/integrations/mcp/__main__.py +8 -0
  183. synth_ai/core/integrations/mcp/claude.py +36 -0
  184. synth_ai/core/integrations/mcp/main.py +254 -0
  185. synth_ai/core/integrations/mcp/setup.py +100 -0
  186. synth_ai/core/integrations/modal.py +277 -0
  187. synth_ai/core/json.py +72 -0
  188. synth_ai/core/log_filter.py +99 -0
  189. synth_ai/core/logging.py +82 -0
  190. synth_ai/core/paths.py +107 -0
  191. synth_ai/core/pricing.py +109 -0
  192. synth_ai/core/process.py +233 -0
  193. synth_ai/core/ssl.py +25 -0
  194. synth_ai/core/storage/__init__.py +71 -0
  195. synth_ai/core/task_app_state.py +318 -0
  196. synth_ai/core/telemetry.py +282 -0
  197. synth_ai/core/tracing_v3/__init__.py +99 -0
  198. synth_ai/core/tracing_v3/config.py +229 -0
  199. synth_ai/core/tracing_v3/constants.py +21 -0
  200. synth_ai/core/tracing_v3/db_config.py +182 -0
  201. synth_ai/core/tracing_v3/decorators.py +401 -0
  202. synth_ai/core/tracing_v3/examples/basic_usage.py +194 -0
  203. synth_ai/core/tracing_v3/llm_call_record_helpers.py +437 -0
  204. synth_ai/core/tracing_v3/migration_helper.py +119 -0
  205. synth_ai/core/tracing_v3/replica_sync.py +262 -0
  206. synth_ai/core/tracing_v3/serialization.py +130 -0
  207. synth_ai/core/tracing_v3/session_tracer.py +542 -0
  208. synth_ai/core/tracing_v3/storage/base.py +211 -0
  209. synth_ai/core/tracing_v3/storage/config.py +109 -0
  210. synth_ai/core/tracing_v3/storage/factory.py +39 -0
  211. synth_ai/core/tracing_v3/storage/utils.py +206 -0
  212. synth_ai/core/tracing_v3/trace_utils.py +326 -0
  213. synth_ai/core/tracing_v3/turso/__init__.py +12 -0
  214. synth_ai/core/tracing_v3/turso/daemon.py +278 -0
  215. synth_ai/core/tracing_v3/turso/models.py +470 -0
  216. synth_ai/core/tracing_v3/turso/native_manager.py +1385 -0
  217. synth_ai/core/tracing_v3/utils.py +108 -0
  218. synth_ai/core/urls.py +18 -0
  219. synth_ai/core/user_config.py +137 -0
  220. synth_ai/core/uvicorn.py +222 -0
  221. synth_ai/data/__init__.py +110 -0
  222. synth_ai/data/enums.py +141 -0
  223. synth_ai/data/rewards.py +152 -0
  224. synth_ai/data/specs.py +36 -0
  225. synth_ai/data/traces.py +35 -0
  226. synth_ai/products/__init__.py +6 -0
  227. synth_ai/products/graph_evolve/__init__.py +46 -0
  228. synth_ai/products/graph_evolve/client.py +226 -0
  229. synth_ai/products/graph_evolve/config.py +591 -0
  230. synth_ai/products/graph_evolve/converters/__init__.py +42 -0
  231. synth_ai/products/graph_evolve/converters/openai_sft.py +484 -0
  232. synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +109 -0
  233. synth_ai/products/graph_evolve/run.py +222 -0
  234. synth_ai/sdk/__init__.py +119 -0
  235. synth_ai/sdk/api/__init__.py +1 -0
  236. synth_ai/sdk/api/models/supported.py +514 -0
  237. synth_ai/sdk/api/research_agent/__init__.py +86 -0
  238. synth_ai/sdk/api/research_agent/cli.py +428 -0
  239. synth_ai/sdk/api/research_agent/config.py +357 -0
  240. synth_ai/sdk/api/research_agent/job.py +717 -0
  241. synth_ai/sdk/api/train/__init__.py +85 -0
  242. synth_ai/sdk/api/train/builders.py +895 -0
  243. synth_ai/sdk/api/train/cli.py +2188 -0
  244. synth_ai/sdk/api/train/config_finder.py +267 -0
  245. synth_ai/sdk/api/train/configs/__init__.py +65 -0
  246. synth_ai/sdk/api/train/configs/prompt_learning.py +1706 -0
  247. synth_ai/sdk/api/train/configs/rl.py +188 -0
  248. synth_ai/sdk/api/train/configs/sft.py +99 -0
  249. synth_ai/sdk/api/train/configs/shared.py +81 -0
  250. synth_ai/sdk/api/train/context_learning.py +312 -0
  251. synth_ai/sdk/api/train/env_resolver.py +418 -0
  252. synth_ai/sdk/api/train/graph_validators.py +216 -0
  253. synth_ai/sdk/api/train/graphgen.py +984 -0
  254. synth_ai/sdk/api/train/graphgen_models.py +823 -0
  255. synth_ai/sdk/api/train/graphgen_validators.py +109 -0
  256. synth_ai/sdk/api/train/pollers.py +124 -0
  257. synth_ai/sdk/api/train/progress/__init__.py +97 -0
  258. synth_ai/sdk/api/train/progress/dataclasses.py +569 -0
  259. synth_ai/sdk/api/train/progress/events.py +326 -0
  260. synth_ai/sdk/api/train/progress/results.py +428 -0
  261. synth_ai/sdk/api/train/progress/tracker.py +641 -0
  262. synth_ai/sdk/api/train/prompt_learning.py +470 -0
  263. synth_ai/sdk/api/train/rl.py +442 -0
  264. synth_ai/sdk/api/train/sft.py +396 -0
  265. synth_ai/sdk/api/train/summary.py +522 -0
  266. synth_ai/sdk/api/train/supported_algos.py +147 -0
  267. synth_ai/sdk/api/train/task_app.py +331 -0
  268. synth_ai/sdk/api/train/utils.py +279 -0
  269. synth_ai/sdk/api/train/validators.py +2424 -0
  270. synth_ai/sdk/baseline/__init__.py +25 -0
  271. synth_ai/sdk/baseline/config.py +209 -0
  272. synth_ai/sdk/baseline/discovery.py +216 -0
  273. synth_ai/sdk/baseline/execution.py +154 -0
  274. synth_ai/sdk/graphs/__init__.py +15 -0
  275. synth_ai/sdk/graphs/completions.py +570 -0
  276. synth_ai/sdk/inference/__init__.py +6 -0
  277. synth_ai/sdk/inference/client.py +128 -0
  278. synth_ai/sdk/jobs/__init__.py +16 -0
  279. synth_ai/sdk/jobs/client.py +371 -0
  280. synth_ai/sdk/judging/__init__.py +15 -0
  281. synth_ai/sdk/judging/base.py +24 -0
  282. synth_ai/sdk/judging/client.py +191 -0
  283. synth_ai/sdk/judging/schemas.py +222 -0
  284. synth_ai/sdk/judging/types.py +42 -0
  285. synth_ai/sdk/learning/__init__.py +69 -0
  286. synth_ai/sdk/learning/client.py +240 -0
  287. synth_ai/sdk/learning/ft_client.py +7 -0
  288. synth_ai/sdk/learning/health.py +49 -0
  289. synth_ai/sdk/learning/jobs.py +202 -0
  290. synth_ai/sdk/learning/prompt_extraction.py +334 -0
  291. synth_ai/sdk/learning/prompt_learning_client.py +455 -0
  292. synth_ai/sdk/learning/prompt_learning_types.py +185 -0
  293. synth_ai/sdk/learning/rl/client.py +268 -0
  294. synth_ai/sdk/learning/rl/contracts.py +27 -0
  295. synth_ai/sdk/learning/rl/env_keys.py +166 -0
  296. synth_ai/sdk/learning/rl/secrets.py +13 -0
  297. synth_ai/sdk/learning/sft/client.py +95 -0
  298. synth_ai/sdk/learning/sft/config.py +270 -0
  299. synth_ai/sdk/learning/sft/data.py +698 -0
  300. synth_ai/sdk/learning/validators.py +52 -0
  301. synth_ai/sdk/research_agent/__init__.py +34 -0
  302. synth_ai/sdk/research_agent/container_builder.py +328 -0
  303. synth_ai/sdk/research_agent/container_spec.py +198 -0
  304. synth_ai/sdk/research_agent/defaults.py +34 -0
  305. synth_ai/sdk/research_agent/results_collector.py +69 -0
  306. synth_ai/sdk/specs/__init__.py +46 -0
  307. synth_ai/sdk/specs/dataclasses.py +149 -0
  308. synth_ai/sdk/specs/loader.py +144 -0
  309. synth_ai/sdk/specs/serializer.py +199 -0
  310. synth_ai/sdk/specs/validation.py +250 -0
  311. synth_ai/sdk/streaming/__init__.py +35 -0
  312. synth_ai/sdk/streaming/config.py +94 -0
  313. synth_ai/sdk/streaming/handlers.py +1997 -0
  314. synth_ai/sdk/streaming/streamer.py +704 -0
  315. synth_ai/sdk/streaming/types.py +112 -0
  316. synth_ai/sdk/task/__init__.py +151 -0
  317. synth_ai/sdk/task/apps/__init__.py +133 -0
  318. synth_ai/sdk/task/config.py +261 -0
  319. synth_ai/sdk/task/contracts.py +298 -0
  320. synth_ai/sdk/task/datasets.py +108 -0
  321. synth_ai/sdk/task/in_process.py +1190 -0
  322. synth_ai/sdk/task/in_process_runner.py +309 -0
  323. synth_ai/sdk/task/inference_api.py +299 -0
  324. synth_ai/sdk/task/proxy.py +287 -0
  325. synth_ai/sdk/task/rubrics/__init__.py +55 -0
  326. synth_ai/sdk/task/rubrics/loaders.py +156 -0
  327. synth_ai/sdk/task/rubrics/models.py +57 -0
  328. synth_ai/sdk/task/rubrics/scoring.py +116 -0
  329. synth_ai/sdk/task/rubrics/strict.py +149 -0
  330. synth_ai/sdk/task/server.py +580 -0
  331. synth_ai/sdk/task/trace_correlation_helpers.py +506 -0
  332. synth_ai/sdk/task/tracing_utils.py +95 -0
  333. synth_ai/sdk/task/validators.py +456 -0
  334. synth_ai/sdk/tracing/__init__.py +39 -0
  335. synth_ai/sdk/training/__init__.py +102 -0
  336. synth_ai/sdk/usage/__init__.py +37 -0
  337. synth_ai/sdk/usage/client.py +171 -0
  338. synth_ai/sdk/usage/models.py +261 -0
  339. synth_ai/utils/__init__.py +213 -0
  340. synth_ai-0.4.1.dist-info/METADATA +195 -0
  341. synth_ai-0.4.1.dist-info/RECORD +379 -0
  342. synth_ai-0.4.1.dist-info/entry_points.txt +2 -0
  343. synth_ai-0.4.1.dist-info/top_level.txt +1 -0
  344. examples/__init__.py +0 -16
  345. examples/analyze_semantic_words.sh +0 -17
  346. examples/crafter_debug_render.py +0 -186
  347. examples/qwen_coder/README.md +0 -102
  348. examples/qwen_coder/_shared.py +0 -113
  349. examples/qwen_coder/configs/coder_lora_30b.toml +0 -61
  350. examples/qwen_coder/configs/coder_lora_4b.toml +0 -57
  351. examples/qwen_coder/configs/coder_lora_small.toml +0 -58
  352. examples/qwen_coder/generate_dataset.py +0 -98
  353. examples/qwen_coder/infer_ft_smoke.py +0 -64
  354. examples/qwen_coder/infer_prod_proxy.py +0 -73
  355. examples/qwen_coder/infer_via_synth.py +0 -87
  356. examples/qwen_coder/scripts/infer_coder.sh +0 -18
  357. examples/qwen_coder/scripts/train_coder_30b.sh +0 -21
  358. examples/qwen_coder/sft_full_17b.py +0 -103
  359. examples/qwen_coder/sft_lora_30b.py +0 -110
  360. examples/qwen_coder/subset_jsonl.py +0 -38
  361. examples/qwen_coder/validate_jsonl.py +0 -59
  362. examples/rl/README.md +0 -169
  363. examples/rl/configs/eval_base_qwen.toml +0 -15
  364. examples/rl/configs/eval_rl_qwen.toml +0 -11
  365. examples/rl/configs/rl_from_base_qwen.toml +0 -35
  366. examples/rl/configs/rl_from_base_qwen17.toml +0 -74
  367. examples/rl/configs/rl_from_ft_qwen.toml +0 -35
  368. examples/rl/download_dataset.py +0 -80
  369. examples/rl/run_eval.py +0 -436
  370. examples/rl/run_rl_and_save.py +0 -111
  371. examples/rl/task_app/README.md +0 -22
  372. examples/rl/task_app/math_single_step.py +0 -991
  373. examples/rl/task_app/math_task_app.py +0 -115
  374. examples/run_crafter_demo.sh +0 -10
  375. examples/sft/README.md +0 -139
  376. examples/sft/configs/crafter_fft_qwen0p6b.toml +0 -44
  377. examples/sft/configs/crafter_lora_qwen0p6b.toml +0 -45
  378. examples/sft/evaluate.py +0 -117
  379. examples/sft/export_dataset.py +0 -117
  380. examples/sft/generate_traces.py +0 -162
  381. examples/swe/__init__.py +0 -12
  382. examples/swe/task_app/README.md +0 -105
  383. examples/swe/task_app/__init__.py +0 -2
  384. examples/swe/task_app/grpo_swe_mini.py +0 -571
  385. examples/swe/task_app/grpo_swe_mini_task_app.py +0 -136
  386. examples/swe/task_app/hosted/README.md +0 -173
  387. examples/swe/task_app/hosted/__init__.py +0 -5
  388. examples/swe/task_app/hosted/branching.py +0 -143
  389. examples/swe/task_app/hosted/environment_routes.py +0 -1289
  390. examples/swe/task_app/hosted/envs/__init__.py +0 -1
  391. examples/swe/task_app/hosted/envs/crafter/__init__.py +0 -6
  392. examples/swe/task_app/hosted/envs/crafter/app.py +0 -1
  393. examples/swe/task_app/hosted/envs/crafter/environment.py +0 -522
  394. examples/swe/task_app/hosted/envs/crafter/policy.py +0 -478
  395. examples/swe/task_app/hosted/envs/crafter/react_agent.py +0 -108
  396. examples/swe/task_app/hosted/envs/crafter/shared.py +0 -305
  397. examples/swe/task_app/hosted/envs/crafter/tools.py +0 -47
  398. examples/swe/task_app/hosted/envs/mini_swe/__init__.py +0 -8
  399. examples/swe/task_app/hosted/envs/mini_swe/environment.py +0 -1164
  400. examples/swe/task_app/hosted/envs/mini_swe/policy.py +0 -355
  401. examples/swe/task_app/hosted/envs/mini_swe/shared.py +0 -83
  402. examples/swe/task_app/hosted/envs/mini_swe/tools.py +0 -96
  403. examples/swe/task_app/hosted/hosted_app.py +0 -204
  404. examples/swe/task_app/hosted/inference/__init__.py +0 -5
  405. examples/swe/task_app/hosted/inference/openai_client.py +0 -618
  406. examples/swe/task_app/hosted/main.py +0 -100
  407. examples/swe/task_app/hosted/policy_routes.py +0 -1079
  408. examples/swe/task_app/hosted/registry.py +0 -195
  409. examples/swe/task_app/hosted/rollout.py +0 -1869
  410. examples/swe/task_app/hosted/storage/__init__.py +0 -5
  411. examples/swe/task_app/hosted/storage/volume.py +0 -211
  412. examples/swe/task_app/hosted/test_agents.py +0 -161
  413. examples/swe/task_app/hosted/test_service.py +0 -137
  414. examples/swe/task_app/hosted/utils.py +0 -62
  415. examples/vlm/README.md +0 -68
  416. examples/vlm/configs/crafter_vlm_gpt4o.toml +0 -44
  417. examples/vlm/crafter_image_only_agent.py +0 -207
  418. examples/vlm/crafter_openai_vlm_agent.py +0 -277
  419. examples/vlm/filter_image_rows.py +0 -63
  420. examples/vlm/run_crafter_vlm_benchmark.py +0 -316
  421. examples/warming_up_to_rl/analyze_trace_db.py +0 -422
  422. examples/warming_up_to_rl/configs/crafter_fft.toml +0 -48
  423. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +0 -54
  424. examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +0 -20
  425. examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +0 -13
  426. examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +0 -23
  427. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +0 -83
  428. examples/warming_up_to_rl/configs/rl_from_ft.toml +0 -56
  429. examples/warming_up_to_rl/export_trace_sft.py +0 -723
  430. examples/warming_up_to_rl/groq_test.py +0 -95
  431. examples/warming_up_to_rl/manage_secrets.py +0 -131
  432. examples/warming_up_to_rl/readme.md +0 -179
  433. examples/warming_up_to_rl/run_eval.py +0 -510
  434. examples/warming_up_to_rl/run_fft_and_save.py +0 -380
  435. examples/warming_up_to_rl/run_local_rollout.py +0 -237
  436. examples/warming_up_to_rl/run_local_rollout_modal.py +0 -246
  437. examples/warming_up_to_rl/run_local_rollout_parallel.py +0 -403
  438. examples/warming_up_to_rl/run_local_rollout_traced.py +0 -475
  439. examples/warming_up_to_rl/run_rl_and_save.py +0 -124
  440. examples/warming_up_to_rl/run_rollout_remote.py +0 -154
  441. examples/warming_up_to_rl/task_app/README.md +0 -42
  442. examples/warming_up_to_rl/task_app/grpo_crafter.py +0 -700
  443. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +0 -146
  444. examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +0 -173
  445. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +0 -5
  446. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +0 -143
  447. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +0 -1226
  448. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +0 -1
  449. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -6
  450. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +0 -1
  451. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +0 -522
  452. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +0 -478
  453. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +0 -108
  454. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -305
  455. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -47
  456. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +0 -204
  457. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +0 -5
  458. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +0 -618
  459. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +0 -100
  460. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +0 -1083
  461. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +0 -195
  462. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +0 -1869
  463. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +0 -5
  464. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +0 -211
  465. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +0 -161
  466. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +0 -137
  467. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +0 -62
  468. synth/__init__.py +0 -14
  469. synth_ai/api/models/supported.py +0 -376
  470. synth_ai/api/train/__init__.py +0 -5
  471. synth_ai/api/train/builders.py +0 -296
  472. synth_ai/api/train/cli.py +0 -606
  473. synth_ai/api/train/config_finder.py +0 -228
  474. synth_ai/api/train/env_resolver.py +0 -347
  475. synth_ai/api/train/pollers.py +0 -75
  476. synth_ai/api/train/supported_algos.py +0 -139
  477. synth_ai/api/train/task_app.py +0 -195
  478. synth_ai/api/train/utils.py +0 -217
  479. synth_ai/cli/_modal_wrapper.py +0 -28
  480. synth_ai/cli/_typer_patch.py +0 -49
  481. synth_ai/cli/balance.py +0 -203
  482. synth_ai/cli/calc.py +0 -69
  483. synth_ai/cli/demo.py +0 -159
  484. synth_ai/cli/legacy_root_backup.py +0 -470
  485. synth_ai/cli/man.py +0 -106
  486. synth_ai/cli/recent.py +0 -127
  487. synth_ai/cli/rl_demo.py +0 -274
  488. synth_ai/cli/status.py +0 -133
  489. synth_ai/cli/task_apps.py +0 -2782
  490. synth_ai/cli/traces.py +0 -163
  491. synth_ai/cli/watch.py +0 -505
  492. synth_ai/config/base_url.py +0 -107
  493. synth_ai/core/experiment.py +0 -13
  494. synth_ai/core/system.py +0 -15
  495. synth_ai/demo_registry.py +0 -295
  496. synth_ai/demos/core/__init__.py +0 -1
  497. synth_ai/demos/core/cli.py +0 -1756
  498. synth_ai/demos/demo_task_apps/core.py +0 -440
  499. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +0 -172
  500. synth_ai/demos/demo_task_apps/math/deploy_task_app.sh +0 -22
  501. synth_ai/demos/demo_task_apps/math/modal_task_app.py +0 -739
  502. synth_ai/demos/demo_task_apps/math/task_app_entry.py +0 -37
  503. synth_ai/environments/__init__.py +0 -31
  504. synth_ai/environments/environment/__init__.py +0 -1
  505. synth_ai/environments/environment/artifacts/__init__.py +0 -1
  506. synth_ai/environments/environment/artifacts/base.py +0 -52
  507. synth_ai/environments/environment/core.py +0 -67
  508. synth_ai/environments/environment/db/__init__.py +0 -1
  509. synth_ai/environments/environment/db/sqlite.py +0 -45
  510. synth_ai/environments/environment/registry.py +0 -233
  511. synth_ai/environments/environment/resources/sqlite.py +0 -45
  512. synth_ai/environments/environment/results.py +0 -1
  513. synth_ai/environments/environment/rewards/__init__.py +0 -1
  514. synth_ai/environments/environment/rewards/core.py +0 -29
  515. synth_ai/environments/environment/shared_engine.py +0 -26
  516. synth_ai/environments/environment/tools/__init__.py +0 -200
  517. synth_ai/environments/examples/__init__.py +0 -1
  518. synth_ai/environments/examples/bandit/__init__.py +0 -33
  519. synth_ai/environments/examples/bandit/engine.py +0 -302
  520. synth_ai/environments/examples/bandit/environment.py +0 -194
  521. synth_ai/environments/examples/bandit/taskset.py +0 -200
  522. synth_ai/environments/examples/crafter_classic/__init__.py +0 -8
  523. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +0 -250
  524. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_comprehensive_evaluation.py +0 -59
  525. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_browser.py +0 -152
  526. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_config.toml +0 -24
  527. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_framework.py +0 -1194
  528. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/crafter_synth_config.toml +0 -56
  529. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_config_modal.toml +0 -32
  530. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_modal.py +0 -384
  531. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_action_results.py +0 -53
  532. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_agent_actions.py +0 -178
  533. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_latest_run.py +0 -222
  534. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_lm_traces.py +0 -183
  535. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_no_rewards.py +0 -210
  536. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_trace_issue.py +0 -206
  537. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_db_schema.py +0 -49
  538. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_latest_results.py +0 -64
  539. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/debug_agent_responses.py +0 -88
  540. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/quick_trace_check.py +0 -77
  541. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/compare_experiments.py +0 -324
  542. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/kick_off_ft_oai.py +0 -362
  543. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/multi_model_config.toml +0 -49
  544. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_enhanced_hooks.py +0 -332
  545. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_events.py +0 -97
  546. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_results.py +0 -217
  547. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_hook_storage.py +0 -87
  548. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_seeds.py +0 -88
  549. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/compare_seed_performance.py +0 -195
  550. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/custom_eval_pipelines.py +0 -400
  551. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/plot_hook_frequency.py +0 -195
  552. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/seed_analysis_summary.py +0 -56
  553. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v3.py +0 -858
  554. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_quick_evaluation.py +0 -52
  555. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_react_agent.py +0 -874
  556. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +0 -1412
  557. synth_ai/environments/examples/crafter_classic/agent_demos/example_v3_usage.py +0 -216
  558. synth_ai/environments/examples/crafter_classic/agent_demos/old/compare_traces.py +0 -296
  559. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_comprehensive_evaluation.py +0 -58
  560. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_env_serialization.py +0 -464
  561. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_evaluation_browser.py +0 -152
  562. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_quick_evaluation.py +0 -51
  563. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_trace_evaluation.py +0 -1412
  564. synth_ai/environments/examples/crafter_classic/agent_demos/old/debug_player_loss.py +0 -112
  565. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_service.py +0 -203
  566. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_slowness.py +0 -305
  567. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_by_difficulty.py +0 -126
  568. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_example.py +0 -94
  569. synth_ai/environments/examples/crafter_classic/agent_demos/old/explore_saved_states.py +0 -142
  570. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft.py +0 -26
  571. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft_OLD.py +0 -984
  572. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_gemini.py +0 -724
  573. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_modal.py +0 -386
  574. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_metadata.py +0 -205
  575. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_gemini.py +0 -150
  576. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_modal.py +0 -283
  577. synth_ai/environments/examples/crafter_classic/agent_demos/old/prepare_vertex_ft.py +0 -280
  578. synth_ai/environments/examples/crafter_classic/agent_demos/old/profile_env_slowness.py +0 -456
  579. synth_ai/environments/examples/crafter_classic/agent_demos/old/replicate_issue.py +0 -166
  580. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_and_eval.py +0 -102
  581. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_comparison.py +0 -128
  582. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_qwen_rollouts.py +0 -655
  583. synth_ai/environments/examples/crafter_classic/agent_demos/old/trace_eval_OLD.py +0 -202
  584. synth_ai/environments/examples/crafter_classic/agent_demos/old/validate_openai_format.py +0 -166
  585. synth_ai/environments/examples/crafter_classic/config_logging.py +0 -111
  586. synth_ai/environments/examples/crafter_classic/debug_translation.py +0 -0
  587. synth_ai/environments/examples/crafter_classic/engine.py +0 -579
  588. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +0 -64
  589. synth_ai/environments/examples/crafter_classic/engine_helpers/action_map.py +0 -6
  590. synth_ai/environments/examples/crafter_classic/engine_helpers/serialization.py +0 -75
  591. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +0 -267
  592. synth_ai/environments/examples/crafter_classic/environment.py +0 -479
  593. synth_ai/environments/examples/crafter_classic/taskset.py +0 -233
  594. synth_ai/environments/examples/crafter_classic/trace_hooks_v3.py +0 -228
  595. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +0 -299
  596. synth_ai/environments/examples/crafter_custom/__init__.py +0 -4
  597. synth_ai/environments/examples/crafter_custom/agent_demos/__init__.py +0 -1
  598. synth_ai/environments/examples/crafter_custom/agent_demos/trace_eval.py +0 -202
  599. synth_ai/environments/examples/crafter_custom/crafter/__init__.py +0 -7
  600. synth_ai/environments/examples/crafter_custom/crafter/config.py +0 -182
  601. synth_ai/environments/examples/crafter_custom/crafter/constants.py +0 -8
  602. synth_ai/environments/examples/crafter_custom/crafter/engine.py +0 -269
  603. synth_ai/environments/examples/crafter_custom/crafter/env.py +0 -262
  604. synth_ai/environments/examples/crafter_custom/crafter/objects.py +0 -417
  605. synth_ai/environments/examples/crafter_custom/crafter/recorder.py +0 -187
  606. synth_ai/environments/examples/crafter_custom/crafter/worldgen.py +0 -118
  607. synth_ai/environments/examples/crafter_custom/dataset_builder.py +0 -373
  608. synth_ai/environments/examples/crafter_custom/environment.py +0 -312
  609. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_issue.py +0 -159
  610. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_spawning.py +0 -158
  611. synth_ai/environments/examples/crafter_custom/old/compare_worlds.py +0 -71
  612. synth_ai/environments/examples/crafter_custom/old/dataset_stats.py +0 -105
  613. synth_ai/environments/examples/crafter_custom/old/diamond_spawning_summary.py +0 -119
  614. synth_ai/environments/examples/crafter_custom/old/example_dataset_usage.py +0 -52
  615. synth_ai/environments/examples/crafter_custom/run_dataset.py +0 -305
  616. synth_ai/environments/examples/enron/art_helpers/email_search_tools.py +0 -156
  617. synth_ai/environments/examples/enron/art_helpers/local_email_db.py +0 -281
  618. synth_ai/environments/examples/enron/art_helpers/types_enron.py +0 -25
  619. synth_ai/environments/examples/enron/engine.py +0 -295
  620. synth_ai/environments/examples/enron/environment.py +0 -166
  621. synth_ai/environments/examples/enron/taskset.py +0 -112
  622. synth_ai/environments/examples/enron/units/keyword_stats.py +0 -112
  623. synth_ai/environments/examples/minigrid/__init__.py +0 -48
  624. synth_ai/environments/examples/minigrid/agent_demos/minigrid_evaluation_framework.py +0 -1188
  625. synth_ai/environments/examples/minigrid/agent_demos/minigrid_quick_evaluation.py +0 -48
  626. synth_ai/environments/examples/minigrid/agent_demos/minigrid_react_agent.py +0 -562
  627. synth_ai/environments/examples/minigrid/agent_demos/minigrid_trace_evaluation.py +0 -221
  628. synth_ai/environments/examples/minigrid/engine.py +0 -589
  629. synth_ai/environments/examples/minigrid/environment.py +0 -274
  630. synth_ai/environments/examples/minigrid/environment_mapping.py +0 -242
  631. synth_ai/environments/examples/minigrid/puzzle_loader.py +0 -417
  632. synth_ai/environments/examples/minigrid/taskset.py +0 -583
  633. synth_ai/environments/examples/nethack/__init__.py +0 -7
  634. synth_ai/environments/examples/nethack/achievements.py +0 -337
  635. synth_ai/environments/examples/nethack/agent_demos/nethack_evaluation_framework.py +0 -981
  636. synth_ai/environments/examples/nethack/agent_demos/nethack_quick_evaluation.py +0 -74
  637. synth_ai/environments/examples/nethack/agent_demos/nethack_react_agent.py +0 -831
  638. synth_ai/environments/examples/nethack/engine.py +0 -739
  639. synth_ai/environments/examples/nethack/environment.py +0 -256
  640. synth_ai/environments/examples/nethack/helpers/__init__.py +0 -41
  641. synth_ai/environments/examples/nethack/helpers/action_mapping.py +0 -301
  642. synth_ai/environments/examples/nethack/helpers/nle_wrapper.py +0 -402
  643. synth_ai/environments/examples/nethack/helpers/observation_utils.py +0 -433
  644. synth_ai/environments/examples/nethack/helpers/recording_wrapper.py +0 -200
  645. synth_ai/environments/examples/nethack/helpers/trajectory_recorder.py +0 -269
  646. synth_ai/environments/examples/nethack/helpers/visualization/replay_viewer.py +0 -308
  647. synth_ai/environments/examples/nethack/helpers/visualization/visualizer.py +0 -431
  648. synth_ai/environments/examples/nethack/taskset.py +0 -323
  649. synth_ai/environments/examples/red/__init__.py +0 -7
  650. synth_ai/environments/examples/red/agent_demos/__init__.py +0 -1
  651. synth_ai/environments/examples/red/config_logging.py +0 -110
  652. synth_ai/environments/examples/red/engine.py +0 -694
  653. synth_ai/environments/examples/red/engine_helpers/__init__.py +0 -1
  654. synth_ai/environments/examples/red/engine_helpers/memory_map.py +0 -28
  655. synth_ai/environments/examples/red/engine_helpers/reward_components.py +0 -276
  656. synth_ai/environments/examples/red/engine_helpers/reward_library/__init__.py +0 -142
  657. synth_ai/environments/examples/red/engine_helpers/reward_library/adaptive_rewards.py +0 -57
  658. synth_ai/environments/examples/red/engine_helpers/reward_library/battle_rewards.py +0 -284
  659. synth_ai/environments/examples/red/engine_helpers/reward_library/composite_rewards.py +0 -150
  660. synth_ai/environments/examples/red/engine_helpers/reward_library/economy_rewards.py +0 -138
  661. synth_ai/environments/examples/red/engine_helpers/reward_library/efficiency_rewards.py +0 -57
  662. synth_ai/environments/examples/red/engine_helpers/reward_library/exploration_rewards.py +0 -331
  663. synth_ai/environments/examples/red/engine_helpers/reward_library/novelty_rewards.py +0 -121
  664. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_rewards.py +0 -559
  665. synth_ai/environments/examples/red/engine_helpers/reward_library/pokemon_rewards.py +0 -313
  666. synth_ai/environments/examples/red/engine_helpers/reward_library/social_rewards.py +0 -148
  667. synth_ai/environments/examples/red/engine_helpers/reward_library/story_rewards.py +0 -247
  668. synth_ai/environments/examples/red/engine_helpers/screen_analysis.py +0 -368
  669. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +0 -140
  670. synth_ai/environments/examples/red/environment.py +0 -238
  671. synth_ai/environments/examples/red/taskset.py +0 -79
  672. synth_ai/environments/examples/red/units/__init__.py +0 -1
  673. synth_ai/environments/examples/sokoban/__init__.py +0 -1
  674. synth_ai/environments/examples/sokoban/agent_demos/sokoban_full_eval.py +0 -899
  675. synth_ai/environments/examples/sokoban/engine.py +0 -678
  676. synth_ai/environments/examples/sokoban/engine_helpers/__init__.py +0 -1
  677. synth_ai/environments/examples/sokoban/engine_helpers/room_utils.py +0 -657
  678. synth_ai/environments/examples/sokoban/engine_helpers/vendored/__init__.py +0 -18
  679. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/__init__.py +0 -3
  680. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/boxoban_env.py +0 -131
  681. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/render_utils.py +0 -370
  682. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/room_utils.py +0 -332
  683. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env.py +0 -306
  684. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_fixed_targets.py +0 -67
  685. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_pull.py +0 -115
  686. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_two_player.py +0 -123
  687. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_variations.py +0 -394
  688. synth_ai/environments/examples/sokoban/environment.py +0 -229
  689. synth_ai/environments/examples/sokoban/generate_verified_puzzles.py +0 -440
  690. synth_ai/environments/examples/sokoban/puzzle_loader.py +0 -312
  691. synth_ai/environments/examples/sokoban/taskset.py +0 -428
  692. synth_ai/environments/examples/tictactoe/__init__.py +0 -1
  693. synth_ai/environments/examples/tictactoe/engine.py +0 -368
  694. synth_ai/environments/examples/tictactoe/environment.py +0 -240
  695. synth_ai/environments/examples/tictactoe/taskset.py +0 -215
  696. synth_ai/environments/examples/verilog/__init__.py +0 -10
  697. synth_ai/environments/examples/verilog/engine.py +0 -329
  698. synth_ai/environments/examples/verilog/environment.py +0 -350
  699. synth_ai/environments/examples/verilog/taskset.py +0 -420
  700. synth_ai/environments/examples/wordle/__init__.py +0 -29
  701. synth_ai/environments/examples/wordle/engine.py +0 -398
  702. synth_ai/environments/examples/wordle/environment.py +0 -159
  703. synth_ai/environments/examples/wordle/helpers/generate_instances_wordfreq.py +0 -75
  704. synth_ai/environments/examples/wordle/taskset.py +0 -230
  705. synth_ai/environments/reproducibility/core.py +0 -42
  706. synth_ai/environments/reproducibility/helpers.py +0 -0
  707. synth_ai/environments/reproducibility/tree.py +0 -363
  708. synth_ai/environments/service/app.py +0 -97
  709. synth_ai/environments/service/core_routes.py +0 -1021
  710. synth_ai/environments/service/external_registry.py +0 -56
  711. synth_ai/environments/service/registry.py +0 -9
  712. synth_ai/environments/stateful/__init__.py +0 -1
  713. synth_ai/environments/stateful/core.py +0 -163
  714. synth_ai/environments/stateful/engine.py +0 -21
  715. synth_ai/environments/stateful/state.py +0 -7
  716. synth_ai/environments/tasks/api.py +0 -19
  717. synth_ai/environments/tasks/core.py +0 -81
  718. synth_ai/environments/tasks/filters.py +0 -40
  719. synth_ai/environments/tasks/utils.py +0 -90
  720. synth_ai/environments/v0_observability/history.py +0 -3
  721. synth_ai/environments/v0_observability/log.py +0 -2
  722. synth_ai/evals/base.py +0 -13
  723. synth_ai/handshake.py +0 -109
  724. synth_ai/http.py +0 -26
  725. synth_ai/http_client.py +0 -136
  726. synth_ai/inference/__init__.py +0 -5
  727. synth_ai/inference/client.py +0 -34
  728. synth_ai/jobs/client.py +0 -271
  729. synth_ai/learning/__init__.py +0 -59
  730. synth_ai/learning/client.py +0 -241
  731. synth_ai/learning/ft_client.py +0 -7
  732. synth_ai/learning/health.py +0 -49
  733. synth_ai/learning/jobs.py +0 -201
  734. synth_ai/learning/rl/client.py +0 -267
  735. synth_ai/learning/rl/contracts.py +0 -27
  736. synth_ai/learning/rl/env_keys.py +0 -166
  737. synth_ai/learning/rl/secrets.py +0 -13
  738. synth_ai/learning/sft/client.py +0 -68
  739. synth_ai/learning/sft/config.py +0 -270
  740. synth_ai/learning/sft/data.py +0 -295
  741. synth_ai/learning/validators.py +0 -49
  742. synth_ai/lm/__init__.py +0 -25
  743. synth_ai/main.py +0 -6
  744. synth_ai/task/__init__.py +0 -102
  745. synth_ai/task/apps/__init__.py +0 -128
  746. synth_ai/task/contracts.py +0 -137
  747. synth_ai/task/datasets.py +0 -108
  748. synth_ai/task/proxy.py +0 -259
  749. synth_ai/task/server.py +0 -424
  750. synth_ai/task/tracing_utils.py +0 -84
  751. synth_ai/task/validators.py +0 -11
  752. synth_ai/tracing_v3/__init__.py +0 -97
  753. synth_ai/tracing_v3/config.py +0 -84
  754. synth_ai/tracing_v3/db_config.py +0 -194
  755. synth_ai/tracing_v3/decorators.py +0 -369
  756. synth_ai/tracing_v3/examples/basic_usage.py +0 -189
  757. synth_ai/tracing_v3/llm_call_record_helpers.py +0 -337
  758. synth_ai/tracing_v3/migration_helper.py +0 -120
  759. synth_ai/tracing_v3/replica_sync.py +0 -258
  760. synth_ai/tracing_v3/session_tracer.py +0 -530
  761. synth_ai/tracing_v3/storage/base.py +0 -210
  762. synth_ai/tracing_v3/storage/config.py +0 -75
  763. synth_ai/tracing_v3/storage/factory.py +0 -39
  764. synth_ai/tracing_v3/storage/utils.py +0 -204
  765. synth_ai/tracing_v3/turso/daemon.py +0 -149
  766. synth_ai/tracing_v3/turso/models.py +0 -469
  767. synth_ai/tracing_v3/turso/native_manager.py +0 -1173
  768. synth_ai/tracing_v3/utils.py +0 -108
  769. synth_ai/v0/api/__init__.py +0 -8
  770. synth_ai/v0/api/models/__init__.py +0 -8
  771. synth_ai/v0/api/models/supported.py +0 -8
  772. synth_ai/v0/config/__init__.py +0 -15
  773. synth_ai/v0/config/base_url.py +0 -12
  774. synth_ai/v0/lm/__init__.py +0 -51
  775. synth_ai/v0/lm/caching/constants.py +0 -6
  776. synth_ai/v0/lm/caching/dbs.py +0 -0
  777. synth_ai/v0/lm/caching/ephemeral.py +0 -100
  778. synth_ai/v0/lm/caching/handler.py +0 -137
  779. synth_ai/v0/lm/caching/initialize.py +0 -11
  780. synth_ai/v0/lm/caching/persistent.py +0 -114
  781. synth_ai/v0/lm/config.py +0 -115
  782. synth_ai/v0/lm/constants.py +0 -32
  783. synth_ai/v0/lm/core/__init__.py +0 -8
  784. synth_ai/v0/lm/core/all.py +0 -73
  785. synth_ai/v0/lm/core/exceptions.py +0 -5
  786. synth_ai/v0/lm/core/main.py +0 -331
  787. synth_ai/v0/lm/core/main_v3.py +0 -594
  788. synth_ai/v0/lm/core/synth_models.py +0 -35
  789. synth_ai/v0/lm/core/vendor_clients.py +0 -190
  790. synth_ai/v0/lm/cost/__init__.py +0 -0
  791. synth_ai/v0/lm/cost/monitor.py +0 -1
  792. synth_ai/v0/lm/cost/statefulness.py +0 -1
  793. synth_ai/v0/lm/injection.py +0 -80
  794. synth_ai/v0/lm/overrides.py +0 -206
  795. synth_ai/v0/lm/provider_support/__init__.py +0 -8
  796. synth_ai/v0/lm/provider_support/anthropic.py +0 -972
  797. synth_ai/v0/lm/provider_support/openai.py +0 -1139
  798. synth_ai/v0/lm/provider_support/suppress_logging.py +0 -31
  799. synth_ai/v0/lm/structured_outputs/__init__.py +0 -0
  800. synth_ai/v0/lm/structured_outputs/handler.py +0 -440
  801. synth_ai/v0/lm/structured_outputs/inject.py +0 -297
  802. synth_ai/v0/lm/structured_outputs/rehabilitate.py +0 -185
  803. synth_ai/v0/lm/tools/__init__.py +0 -3
  804. synth_ai/v0/lm/tools/base.py +0 -172
  805. synth_ai/v0/lm/unified_interface.py +0 -202
  806. synth_ai/v0/lm/vendors/__init__.py +0 -0
  807. synth_ai/v0/lm/vendors/base.py +0 -81
  808. synth_ai/v0/lm/vendors/core/__init__.py +0 -0
  809. synth_ai/v0/lm/vendors/core/anthropic_api.py +0 -387
  810. synth_ai/v0/lm/vendors/core/gemini_api.py +0 -292
  811. synth_ai/v0/lm/vendors/core/mistral_api.py +0 -322
  812. synth_ai/v0/lm/vendors/core/openai_api.py +0 -227
  813. synth_ai/v0/lm/vendors/core/synth_dev_api.py +0 -0
  814. synth_ai/v0/lm/vendors/local/__init__.py +0 -0
  815. synth_ai/v0/lm/vendors/local/ollama.py +0 -0
  816. synth_ai/v0/lm/vendors/openai_standard.py +0 -782
  817. synth_ai/v0/lm/vendors/openai_standard_responses.py +0 -259
  818. synth_ai/v0/lm/vendors/retries.py +0 -22
  819. synth_ai/v0/lm/vendors/supported/__init__.py +0 -0
  820. synth_ai/v0/lm/vendors/supported/custom_endpoint.py +0 -415
  821. synth_ai/v0/lm/vendors/supported/deepseek.py +0 -69
  822. synth_ai/v0/lm/vendors/supported/grok.py +0 -75
  823. synth_ai/v0/lm/vendors/supported/groq.py +0 -16
  824. synth_ai/v0/lm/vendors/supported/ollama.py +0 -15
  825. synth_ai/v0/lm/vendors/supported/openrouter.py +0 -74
  826. synth_ai/v0/lm/vendors/supported/together.py +0 -11
  827. synth_ai/v0/lm/vendors/synth_client.py +0 -835
  828. synth_ai/v0/lm/warmup.py +0 -186
  829. synth_ai/v0/tracing/__init__.py +0 -0
  830. synth_ai/v0/tracing/abstractions.py +0 -224
  831. synth_ai/v0/tracing/base_client.py +0 -91
  832. synth_ai/v0/tracing/client_manager.py +0 -131
  833. synth_ai/v0/tracing/config.py +0 -142
  834. synth_ai/v0/tracing/context.py +0 -146
  835. synth_ai/v0/tracing/decorators.py +0 -682
  836. synth_ai/v0/tracing/events/__init__.py +0 -0
  837. synth_ai/v0/tracing/events/manage.py +0 -147
  838. synth_ai/v0/tracing/events/scope.py +0 -86
  839. synth_ai/v0/tracing/events/store.py +0 -228
  840. synth_ai/v0/tracing/immediate_client.py +0 -151
  841. synth_ai/v0/tracing/local.py +0 -18
  842. synth_ai/v0/tracing/log_client_base.py +0 -73
  843. synth_ai/v0/tracing/retry_queue.py +0 -186
  844. synth_ai/v0/tracing/trackers.py +0 -515
  845. synth_ai/v0/tracing/upload.py +0 -409
  846. synth_ai/v0/tracing/utils.py +0 -9
  847. synth_ai/v0/tracing_v1/__init__.py +0 -16
  848. synth_ai/v0/tracing_v1/abstractions.py +0 -224
  849. synth_ai/v0/tracing_v1/base_client.py +0 -91
  850. synth_ai/v0/tracing_v1/client_manager.py +0 -131
  851. synth_ai/v0/tracing_v1/config.py +0 -142
  852. synth_ai/v0/tracing_v1/context.py +0 -146
  853. synth_ai/v0/tracing_v1/decorators.py +0 -703
  854. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  855. synth_ai/v0/tracing_v1/events/manage.py +0 -147
  856. synth_ai/v0/tracing_v1/events/scope.py +0 -86
  857. synth_ai/v0/tracing_v1/events/store.py +0 -228
  858. synth_ai/v0/tracing_v1/immediate_client.py +0 -151
  859. synth_ai/v0/tracing_v1/local.py +0 -18
  860. synth_ai/v0/tracing_v1/log_client_base.py +0 -73
  861. synth_ai/v0/tracing_v1/retry_queue.py +0 -186
  862. synth_ai/v0/tracing_v1/trackers.py +0 -515
  863. synth_ai/v0/tracing_v1/upload.py +0 -527
  864. synth_ai/v0/tracing_v1/utils.py +0 -9
  865. synth_ai/v0/tracing_v3/__init__.py +0 -10
  866. synth_ai/v0/tracing_v3/abstractions.py +0 -3
  867. synth_ai/v0/tracing_v3/decorators.py +0 -3
  868. synth_ai/v0/tracing_v3/llm_call_record_helpers.py +0 -3
  869. synth_ai/v0/tracing_v3/session_tracer.py +0 -3
  870. synth_ai-0.2.9.dev11.dist-info/METADATA +0 -191
  871. synth_ai-0.2.9.dev11.dist-info/RECORD +0 -571
  872. synth_ai-0.2.9.dev11.dist-info/entry_points.txt +0 -3
  873. synth_ai-0.2.9.dev11.dist-info/top_level.txt +0 -3
  874. /synth_ai/{demos/demo_task_apps → cli/demo_apps}/crafter/__init__.py +0 -0
  875. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/__init__.py +0 -0
  876. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/crafter/configs/crafter_fft_4b.toml +0 -0
  877. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/crafter/configs/rl_from_base_qwen4b.toml +0 -0
  878. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/__init__.py +0 -0
  879. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/_common.py +0 -0
  880. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/app.py +0 -0
  881. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/config.toml +0 -0
  882. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/deploy_modal.py +0 -0
  883. /synth_ai/{v0/lm/caching → core/apps}/__init__.py +0 -0
  884. /synth_ai/{tracing_v3 → core/tracing_v3}/abstractions.py +0 -0
  885. /synth_ai/{tracing_v3 → core/tracing_v3}/hooks.py +0 -0
  886. /synth_ai/{tracing_v3 → core/tracing_v3}/lm_call_record_abstractions.py +0 -0
  887. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/__init__.py +0 -0
  888. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/exceptions.py +0 -0
  889. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/types.py +0 -0
  890. /synth_ai/{compound/cais.py → py.typed} +0 -0
  891. /synth_ai/{learning → sdk/learning}/algorithms.py +0 -0
  892. /synth_ai/{learning → sdk/learning}/config.py +0 -0
  893. /synth_ai/{learning → sdk/learning}/constants.py +0 -0
  894. /synth_ai/{learning → sdk/learning}/core.py +0 -0
  895. /synth_ai/{learning → sdk/learning}/gateway.py +0 -0
  896. /synth_ai/{learning → sdk/learning}/rl/__init__.py +0 -0
  897. /synth_ai/{learning → sdk/learning}/rl/config.py +0 -0
  898. /synth_ai/{learning → sdk/learning}/rl_client.py +0 -0
  899. /synth_ai/{learning → sdk/learning}/sft/__init__.py +0 -0
  900. /synth_ai/{learning → sdk/learning}/sse.py +0 -0
  901. /synth_ai/{task → sdk/task}/auth.py +0 -0
  902. /synth_ai/{task → sdk/task}/client.py +0 -0
  903. /synth_ai/{task → sdk/task}/errors.py +0 -0
  904. /synth_ai/{task → sdk/task}/health.py +0 -0
  905. /synth_ai/{task → sdk/task}/json.py +0 -0
  906. /synth_ai/{task → sdk/task}/rubrics.py +0 -0
  907. /synth_ai/{task → sdk/task}/vendors.py +0 -0
  908. {synth_ai-0.2.9.dev11.dist-info → synth_ai-0.4.1.dist-info}/WHEEL +0 -0
  909. {synth_ai-0.2.9.dev11.dist-info → synth_ai-0.4.1.dist-info}/licenses/LICENSE +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")