synth-ai 0.2.0__py3-none-any.whl → 0.2.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. synth_ai/__init__.py +28 -2
  2. synth_ai/core/system.py +4 -0
  3. synth_ai/environments/__init__.py +35 -0
  4. synth_ai/environments/environment/__init__.py +1 -0
  5. synth_ai/environments/environment/artifacts/__init__.py +1 -0
  6. synth_ai/environments/environment/artifacts/base.py +50 -0
  7. synth_ai/environments/environment/core.py +22 -0
  8. synth_ai/environments/environment/db/__init__.py +1 -0
  9. synth_ai/environments/environment/db/sqlite.py +45 -0
  10. synth_ai/environments/environment/registry.py +24 -0
  11. synth_ai/environments/environment/resources/sqlite.py +46 -0
  12. synth_ai/environments/environment/results.py +1 -0
  13. synth_ai/environments/environment/rewards/__init__.py +1 -0
  14. synth_ai/environments/environment/rewards/core.py +28 -0
  15. synth_ai/environments/environment/shared_engine.py +26 -0
  16. synth_ai/environments/environment/tools/__init__.py +34 -0
  17. synth_ai/environments/examples/__init__.py +1 -0
  18. synth_ai/environments/examples/crafter_classic/__init__.py +8 -0
  19. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_comprehensive_evaluation.py +58 -0
  20. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_browser.py +152 -0
  21. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_framework.py +1194 -0
  22. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_quick_evaluation.py +51 -0
  23. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_react_agent.py +872 -0
  24. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +1412 -0
  25. synth_ai/environments/examples/crafter_classic/agent_demos/test_crafter_react_agent.py +1110 -0
  26. synth_ai/environments/examples/crafter_classic/config_logging.py +111 -0
  27. synth_ai/environments/examples/crafter_classic/engine.py +502 -0
  28. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +63 -0
  29. synth_ai/environments/examples/crafter_classic/engine_helpers/action_map.py +5 -0
  30. synth_ai/environments/examples/crafter_classic/engine_helpers/serialization.py +74 -0
  31. synth_ai/environments/examples/crafter_classic/environment.py +255 -0
  32. synth_ai/environments/examples/crafter_classic/taskset.py +228 -0
  33. synth_ai/environments/examples/enron/agent_demos/test_synth_react.py +535 -0
  34. synth_ai/environments/examples/enron/art_helpers/email_search_tools.py +156 -0
  35. synth_ai/environments/examples/enron/art_helpers/local_email_db.py +280 -0
  36. synth_ai/environments/examples/enron/art_helpers/types_enron.py +24 -0
  37. synth_ai/environments/examples/enron/engine.py +291 -0
  38. synth_ai/environments/examples/enron/environment.py +165 -0
  39. synth_ai/environments/examples/enron/taskset.py +112 -0
  40. synth_ai/environments/examples/enron/units/keyword_stats.py +111 -0
  41. synth_ai/environments/examples/enron/units/test_email_index.py +8 -0
  42. synth_ai/environments/examples/minigrid/__init__.py +48 -0
  43. synth_ai/environments/examples/minigrid/agent_demos/minigrid_evaluation_framework.py +1188 -0
  44. synth_ai/environments/examples/minigrid/agent_demos/minigrid_quick_evaluation.py +47 -0
  45. synth_ai/environments/examples/minigrid/agent_demos/minigrid_react_agent.py +562 -0
  46. synth_ai/environments/examples/minigrid/agent_demos/minigrid_trace_evaluation.py +220 -0
  47. synth_ai/environments/examples/minigrid/agent_demos/test_minigrid_react_agent.py +393 -0
  48. synth_ai/environments/examples/minigrid/engine.py +589 -0
  49. synth_ai/environments/examples/minigrid/environment.py +274 -0
  50. synth_ai/environments/examples/minigrid/environment_mapping.py +242 -0
  51. synth_ai/environments/examples/minigrid/puzzle_loader.py +416 -0
  52. synth_ai/environments/examples/minigrid/taskset.py +583 -0
  53. synth_ai/environments/examples/minigrid/units/test_action_behavior.py +226 -0
  54. synth_ai/environments/examples/minigrid/units/test_debug_messages.py +83 -0
  55. synth_ai/environments/examples/minigrid/units/test_exploration.py +120 -0
  56. synth_ai/environments/examples/minigrid/units/test_minigrid_engine.py +214 -0
  57. synth_ai/environments/examples/minigrid/units/test_minigrid_environment.py +238 -0
  58. synth_ai/environments/examples/minigrid/units/test_minigrid_environment_mapping.py +301 -0
  59. synth_ai/environments/examples/minigrid/units/test_minigrid_taskset.py +210 -0
  60. synth_ai/environments/examples/nethack/__init__.py +7 -0
  61. synth_ai/environments/examples/nethack/achievements.py +337 -0
  62. synth_ai/environments/examples/nethack/agent_demos/nethack_evaluation_framework.py +981 -0
  63. synth_ai/environments/examples/nethack/agent_demos/nethack_quick_evaluation.py +74 -0
  64. synth_ai/environments/examples/nethack/agent_demos/nethack_react_agent.py +832 -0
  65. synth_ai/environments/examples/nethack/agent_demos/test_nethack_react_agent.py +1112 -0
  66. synth_ai/environments/examples/nethack/engine.py +738 -0
  67. synth_ai/environments/examples/nethack/environment.py +255 -0
  68. synth_ai/environments/examples/nethack/helpers/__init__.py +42 -0
  69. synth_ai/environments/examples/nethack/helpers/action_mapping.py +301 -0
  70. synth_ai/environments/examples/nethack/helpers/nle_wrapper.py +401 -0
  71. synth_ai/environments/examples/nethack/helpers/observation_utils.py +433 -0
  72. synth_ai/environments/examples/nethack/helpers/recording_wrapper.py +201 -0
  73. synth_ai/environments/examples/nethack/helpers/trajectory_recorder.py +268 -0
  74. synth_ai/environments/examples/nethack/helpers/visualization/replay_viewer.py +308 -0
  75. synth_ai/environments/examples/nethack/helpers/visualization/visualizer.py +430 -0
  76. synth_ai/environments/examples/nethack/taskset.py +323 -0
  77. synth_ai/environments/examples/nethack/units/test_nethack_engine.py +277 -0
  78. synth_ai/environments/examples/nethack/units/test_nethack_environment.py +281 -0
  79. synth_ai/environments/examples/nethack/units/test_nethack_taskset.py +213 -0
  80. synth_ai/environments/examples/nethack/units/test_recording.py +307 -0
  81. synth_ai/environments/examples/red/__init__.py +7 -0
  82. synth_ai/environments/examples/red/agent_demos/__init__.py +1 -0
  83. synth_ai/environments/examples/red/agent_demos/test_synth_react.py +1471 -0
  84. synth_ai/environments/examples/red/config_logging.py +110 -0
  85. synth_ai/environments/examples/red/engine.py +693 -0
  86. synth_ai/environments/examples/red/engine_helpers/__init__.py +1 -0
  87. synth_ai/environments/examples/red/engine_helpers/memory_map.py +28 -0
  88. synth_ai/environments/examples/red/engine_helpers/reward_components.py +275 -0
  89. synth_ai/environments/examples/red/engine_helpers/reward_library/__init__.py +142 -0
  90. synth_ai/environments/examples/red/engine_helpers/reward_library/adaptive_rewards.py +56 -0
  91. synth_ai/environments/examples/red/engine_helpers/reward_library/battle_rewards.py +283 -0
  92. synth_ai/environments/examples/red/engine_helpers/reward_library/composite_rewards.py +149 -0
  93. synth_ai/environments/examples/red/engine_helpers/reward_library/economy_rewards.py +137 -0
  94. synth_ai/environments/examples/red/engine_helpers/reward_library/efficiency_rewards.py +56 -0
  95. synth_ai/environments/examples/red/engine_helpers/reward_library/exploration_rewards.py +330 -0
  96. synth_ai/environments/examples/red/engine_helpers/reward_library/novelty_rewards.py +120 -0
  97. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_rewards.py +558 -0
  98. synth_ai/environments/examples/red/engine_helpers/reward_library/pokemon_rewards.py +312 -0
  99. synth_ai/environments/examples/red/engine_helpers/reward_library/social_rewards.py +147 -0
  100. synth_ai/environments/examples/red/engine_helpers/reward_library/story_rewards.py +246 -0
  101. synth_ai/environments/examples/red/engine_helpers/screen_analysis.py +367 -0
  102. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +139 -0
  103. synth_ai/environments/examples/red/environment.py +235 -0
  104. synth_ai/environments/examples/red/taskset.py +77 -0
  105. synth_ai/environments/examples/red/test_fixes.py +125 -0
  106. synth_ai/environments/examples/red/test_fixes_mock.py +148 -0
  107. synth_ai/environments/examples/red/units/__init__.py +1 -0
  108. synth_ai/environments/examples/red/units/test_basic_functionality.py +97 -0
  109. synth_ai/environments/examples/red/units/test_button_press_requirements.py +217 -0
  110. synth_ai/environments/examples/red/units/test_engine.py +192 -0
  111. synth_ai/environments/examples/red/units/test_environment.py +455 -0
  112. synth_ai/environments/examples/red/units/test_exploration_strategy.py +227 -0
  113. synth_ai/environments/examples/red/units/test_integration.py +217 -0
  114. synth_ai/environments/examples/red/units/test_memory_extraction.py +111 -0
  115. synth_ai/environments/examples/red/units/test_menu_bug_reproduction.py +1100 -0
  116. synth_ai/environments/examples/red/units/test_movement_debug.py +255 -0
  117. synth_ai/environments/examples/red/units/test_pokemon_mcts_debug.py +163 -0
  118. synth_ai/environments/examples/red/units/test_pokemon_mcts_verbose.py +117 -0
  119. synth_ai/environments/examples/red/units/test_red_basic.py +145 -0
  120. synth_ai/environments/examples/red/units/test_red_comprehensive.py +323 -0
  121. synth_ai/environments/examples/red/units/test_retry_movement.py +195 -0
  122. synth_ai/environments/examples/red/units/test_reward_components.py +186 -0
  123. synth_ai/environments/examples/red/units/test_rom_integration.py +260 -0
  124. synth_ai/environments/examples/red/units/test_taskset.py +116 -0
  125. synth_ai/environments/examples/red/units/test_tree.py +448 -0
  126. synth_ai/environments/examples/sokoban/__init__.py +1 -0
  127. synth_ai/environments/examples/sokoban/agent_demos/sokoban_full_eval.py +900 -0
  128. synth_ai/environments/examples/sokoban/agent_demos/test_dspy_react.py +1 -0
  129. synth_ai/environments/examples/sokoban/agent_demos/test_sokoban_react_agent.py +498 -0
  130. synth_ai/environments/examples/sokoban/agent_demos/test_synth_lats.py +1 -0
  131. synth_ai/environments/examples/sokoban/agent_demos/test_synth_react_locally.py +748 -0
  132. synth_ai/environments/examples/sokoban/agent_demos/test_synth_react_service.py +296 -0
  133. synth_ai/environments/examples/sokoban/engine.py +675 -0
  134. synth_ai/environments/examples/sokoban/engine_helpers/__init__.py +1 -0
  135. synth_ai/environments/examples/sokoban/engine_helpers/room_utils.py +656 -0
  136. synth_ai/environments/examples/sokoban/engine_helpers/vendored/__init__.py +17 -0
  137. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/__init__.py +3 -0
  138. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/boxoban_env.py +129 -0
  139. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/render_utils.py +370 -0
  140. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/room_utils.py +331 -0
  141. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env.py +305 -0
  142. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_fixed_targets.py +66 -0
  143. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_pull.py +114 -0
  144. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_two_player.py +122 -0
  145. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_variations.py +394 -0
  146. synth_ai/environments/examples/sokoban/environment.py +228 -0
  147. synth_ai/environments/examples/sokoban/generate_verified_puzzles.py +438 -0
  148. synth_ai/environments/examples/sokoban/puzzle_loader.py +311 -0
  149. synth_ai/environments/examples/sokoban/taskset.py +425 -0
  150. synth_ai/environments/examples/sokoban/units/astar_common.py +94 -0
  151. synth_ai/environments/examples/sokoban/units/test_building_task_set.py +49 -0
  152. synth_ai/environments/examples/sokoban/units/test_false_positive.py +120 -0
  153. synth_ai/environments/examples/sokoban/units/test_simple_run_through_environment.py +119 -0
  154. synth_ai/environments/examples/sokoban/units/test_sokoban_environment.py +98 -0
  155. synth_ai/environments/examples/sokoban/units/test_tree.py +364 -0
  156. synth_ai/environments/examples/tictactoe/__init__.py +1 -0
  157. synth_ai/environments/examples/tictactoe/agent_demos/test_synth_react.py +266 -0
  158. synth_ai/environments/examples/tictactoe/agent_demos/test_tictactoe_react_agent.py +470 -0
  159. synth_ai/environments/examples/tictactoe/engine.py +368 -0
  160. synth_ai/environments/examples/tictactoe/environment.py +239 -0
  161. synth_ai/environments/examples/tictactoe/taskset.py +214 -0
  162. synth_ai/environments/examples/tictactoe/units/test_tictactoe_engine.py +393 -0
  163. synth_ai/environments/examples/tictactoe/units/test_tictactoe_environment.py +493 -0
  164. synth_ai/environments/examples/tictactoe/units/test_tictactoe_taskset.py +191 -0
  165. synth_ai/environments/examples/verilog/__init__.py +10 -0
  166. synth_ai/environments/examples/verilog/agent_demos/test_synth_react.py +520 -0
  167. synth_ai/environments/examples/verilog/engine.py +328 -0
  168. synth_ai/environments/examples/verilog/environment.py +349 -0
  169. synth_ai/environments/examples/verilog/taskset.py +418 -0
  170. synth_ai/environments/examples/verilog/units/test_verilog_engine.py +466 -0
  171. synth_ai/environments/examples/verilog/units/test_verilog_environment.py +585 -0
  172. synth_ai/environments/examples/verilog/units/test_verilog_integration.py +383 -0
  173. synth_ai/environments/examples/verilog/units/test_verilog_taskset.py +457 -0
  174. synth_ai/environments/reproducibility/core.py +42 -0
  175. synth_ai/environments/reproducibility/tree.py +364 -0
  176. synth_ai/environments/service/app.py +78 -0
  177. synth_ai/environments/service/core_routes.py +775 -0
  178. synth_ai/environments/service/external_registry.py +57 -0
  179. synth_ai/environments/service/registry.py +9 -0
  180. synth_ai/environments/stateful/__init__.py +1 -0
  181. synth_ai/environments/stateful/core.py +28 -0
  182. synth_ai/environments/stateful/engine.py +21 -0
  183. synth_ai/environments/stateful/state.py +7 -0
  184. synth_ai/environments/tasks/api.py +19 -0
  185. synth_ai/environments/tasks/core.py +78 -0
  186. synth_ai/environments/tasks/filters.py +39 -0
  187. synth_ai/environments/tasks/utils.py +89 -0
  188. synth_ai/environments/v0_observability/history.py +3 -0
  189. synth_ai/environments/v0_observability/log.py +2 -0
  190. synth_ai/lm/caching/constants.py +1 -0
  191. synth_ai/{zyk/lms → lm}/caching/ephemeral.py +4 -8
  192. synth_ai/{zyk/lms → lm}/caching/handler.py +15 -15
  193. synth_ai/{zyk/lms → lm}/caching/initialize.py +2 -4
  194. synth_ai/{zyk/lms → lm}/caching/persistent.py +4 -10
  195. synth_ai/{zyk/lms → lm}/config.py +2 -1
  196. synth_ai/{zyk/lms → lm}/constants.py +2 -2
  197. synth_ai/{zyk/lms → lm}/core/all.py +10 -10
  198. synth_ai/{zyk/lms → lm}/core/main.py +57 -33
  199. synth_ai/{zyk/lms → lm}/core/vendor_clients.py +12 -10
  200. synth_ai/lm/cost/monitor.py +1 -0
  201. synth_ai/lm/cost/statefulness.py +1 -0
  202. synth_ai/lm/provider_support/__init__.py +8 -0
  203. synth_ai/lm/provider_support/anthropic.py +945 -0
  204. synth_ai/lm/provider_support/openai.py +1115 -0
  205. synth_ai/lm/provider_support/suppress_logging.py +31 -0
  206. synth_ai/{zyk/lms → lm}/structured_outputs/handler.py +58 -80
  207. synth_ai/{zyk/lms → lm}/structured_outputs/inject.py +6 -20
  208. synth_ai/{zyk/lms → lm}/structured_outputs/rehabilitate.py +6 -12
  209. synth_ai/{zyk/lms → lm}/vendors/core/anthropic_api.py +21 -30
  210. synth_ai/{zyk/lms → lm}/vendors/core/gemini_api.py +35 -32
  211. synth_ai/{zyk/lms → lm}/vendors/core/mistral_api.py +19 -28
  212. synth_ai/{zyk/lms → lm}/vendors/core/openai_api.py +26 -36
  213. synth_ai/{zyk/lms → lm}/vendors/openai_standard.py +29 -33
  214. synth_ai/{zyk/lms → lm}/vendors/retries.py +1 -1
  215. synth_ai/lm/vendors/supported/__init__.py +0 -0
  216. synth_ai/{zyk/lms → lm}/vendors/supported/custom_endpoint.py +131 -118
  217. synth_ai/{zyk/lms → lm}/vendors/supported/deepseek.py +4 -8
  218. synth_ai/{zyk/lms → lm}/vendors/supported/grok.py +6 -8
  219. synth_ai/{zyk/lms → lm}/vendors/supported/groq.py +1 -1
  220. synth_ai/{zyk/lms → lm}/vendors/supported/ollama.py +2 -2
  221. synth_ai/{zyk/lms → lm}/vendors/supported/openrouter.py +18 -16
  222. synth_ai/{zyk/lms → lm}/vendors/supported/together.py +1 -1
  223. synth_ai/tracing/__init__.py +0 -0
  224. synth_ai/tracing/abstractions.py +224 -0
  225. synth_ai/tracing/base_client.py +91 -0
  226. synth_ai/tracing/client_manager.py +131 -0
  227. synth_ai/tracing/config.py +140 -0
  228. synth_ai/tracing/context.py +146 -0
  229. synth_ai/tracing/decorators.py +679 -0
  230. synth_ai/tracing/events/__init__.py +0 -0
  231. synth_ai/tracing/events/manage.py +147 -0
  232. synth_ai/tracing/events/scope.py +86 -0
  233. synth_ai/tracing/events/store.py +227 -0
  234. synth_ai/tracing/immediate_client.py +152 -0
  235. synth_ai/tracing/local.py +18 -0
  236. synth_ai/tracing/log_client_base.py +74 -0
  237. synth_ai/tracing/retry_queue.py +187 -0
  238. synth_ai/tracing/trackers.py +515 -0
  239. synth_ai/tracing/upload.py +504 -0
  240. synth_ai/tracing/utils.py +9 -0
  241. synth_ai/zyk/__init__.py +28 -2
  242. synth_ai-0.2.1.dev0.dist-info/METADATA +349 -0
  243. synth_ai-0.2.1.dev0.dist-info/RECORD +261 -0
  244. {synth_ai-0.2.0.dist-info → synth_ai-0.2.1.dev0.dist-info}/WHEEL +1 -1
  245. synth_ai/zyk/lms/caching/constants.py +0 -1
  246. synth_ai/zyk/lms/cost/monitor.py +0 -1
  247. synth_ai/zyk/lms/cost/statefulness.py +0 -1
  248. synth_ai-0.2.0.dist-info/METADATA +0 -36
  249. synth_ai-0.2.0.dist-info/RECORD +0 -50
  250. /synth_ai/{zyk/lms/__init__.py → environments/reproducibility/helpers.py} +0 -0
  251. /synth_ai/{zyk/lms/caching → lm}/__init__.py +0 -0
  252. /synth_ai/{zyk/lms/core → lm/caching}/__init__.py +0 -0
  253. /synth_ai/{zyk/lms → lm}/caching/dbs.py +0 -0
  254. /synth_ai/{zyk/lms/cost → lm/core}/__init__.py +0 -0
  255. /synth_ai/{zyk/lms → lm}/core/exceptions.py +0 -0
  256. /synth_ai/{zyk/lms/structured_outputs → lm/cost}/__init__.py +0 -0
  257. /synth_ai/{zyk/lms/vendors → lm/structured_outputs}/__init__.py +0 -0
  258. /synth_ai/{zyk/lms → lm}/tools/__init__.py +0 -0
  259. /synth_ai/{zyk/lms → lm}/tools/base.py +0 -0
  260. /synth_ai/{zyk/lms/vendors/core → lm/vendors}/__init__.py +0 -0
  261. /synth_ai/{zyk/lms → lm}/vendors/base.py +0 -0
  262. /synth_ai/{zyk/lms/vendors/local → lm/vendors/core}/__init__.py +0 -0
  263. /synth_ai/{zyk/lms/vendors/supported → lm/vendors/local}/__init__.py +0 -0
  264. /synth_ai/{zyk/lms → lm}/vendors/local/ollama.py +0 -0
  265. {synth_ai-0.2.0.dist-info → synth_ai-0.2.1.dev0.dist-info/licenses}/LICENSE +0 -0
  266. {synth_ai-0.2.0.dist-info → synth_ai-0.2.1.dev0.dist-info}/top_level.txt +0 -0
@@ -11,122 +11,142 @@ from urllib3.util.retry import Retry
11
11
  import random
12
12
  from urllib.parse import urlparse
13
13
 
14
- from synth_ai.zyk.lms.vendors.base import BaseLMResponse, VendorBase
15
- from synth_ai.zyk.lms.tools.base import BaseTool
16
- from synth_ai.zyk.lms.caching.initialize import get_cache_handler
14
+ from synth_ai.lm.vendors.base import BaseLMResponse, VendorBase
15
+ from synth_ai.lm.tools.base import BaseTool
16
+ from synth_ai.lm.caching.initialize import get_cache_handler
17
17
 
18
18
  # Exception types for retry
19
19
  CUSTOM_ENDPOINT_EXCEPTIONS_TO_RETRY: Tuple[Type[Exception], ...] = (
20
- requests.RequestException,
20
+ requests.RequestException,
21
21
  requests.Timeout,
22
22
  httpx.RequestError,
23
- httpx.TimeoutException
23
+ httpx.TimeoutException,
24
24
  )
25
25
 
26
+
26
27
  class CustomEndpointAPI(VendorBase):
27
28
  """Generic vendor client for custom OpenAI-compatible endpoints."""
28
-
29
+
29
30
  used_for_structured_outputs: bool = False
30
31
  exceptions_to_retry: List = list(CUSTOM_ENDPOINT_EXCEPTIONS_TO_RETRY)
31
-
32
+
32
33
  def __init__(self, endpoint_url: str):
33
34
  # Validate and sanitize URL
34
35
  self._validate_endpoint_url(endpoint_url)
35
36
  self.endpoint_url = endpoint_url
36
-
37
+
37
38
  # Construct full chat completions URL
38
- if endpoint_url.endswith('/'):
39
+ if endpoint_url.endswith("/"):
39
40
  endpoint_url = endpoint_url[:-1]
40
41
  self.chat_completions_url = f"https://{endpoint_url}/chat/completions"
41
42
  self.health_url = f"https://{endpoint_url}/health"
42
-
43
+
43
44
  # Setup session with connection pooling and retries
44
45
  self.session = self._create_session()
45
46
  self.async_client = None # Lazy init
46
-
47
+
47
48
  # Get auth token from environment (generic support for any auth)
48
49
  self.auth_token = os.environ.get("CUSTOM_ENDPOINT_API_TOKEN")
49
-
50
+
50
51
  def _validate_endpoint_url(self, url: str) -> None:
51
52
  """Validate endpoint URL format and prevent SSRF."""
52
53
  # Block dangerous URL patterns
53
54
  dangerous_patterns = [
54
- "file://", "ftp://", "gopher://",
55
- "localhost", "127.", "0.0.0.0",
56
- "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
57
- "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
58
- "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
55
+ "file://",
56
+ "ftp://",
57
+ "gopher://",
58
+ "localhost",
59
+ "127.",
60
+ "0.0.0.0",
61
+ "10.",
62
+ "192.168.",
63
+ "172.16.",
64
+ "172.17.",
65
+ "172.18.",
66
+ "172.19.",
67
+ "172.20.",
68
+ "172.21.",
69
+ "172.22.",
70
+ "172.23.",
71
+ "172.24.",
72
+ "172.25.",
73
+ "172.26.",
74
+ "172.27.",
75
+ "172.28.",
76
+ "172.29.",
77
+ "172.30.",
78
+ "172.31.",
59
79
  "169.254.", # link-local
60
- "::1", "fc00:", "fd00:", "fe80:", # IPv6 private
80
+ "::1",
81
+ "fc00:",
82
+ "fd00:",
83
+ "fe80:", # IPv6 private
61
84
  ]
62
-
85
+
63
86
  for pattern in dangerous_patterns:
64
87
  if pattern in url.lower():
65
88
  raise ValueError(f"Blocked URL pattern for security: {pattern}")
66
-
89
+
67
90
  # Limit URL length
68
91
  if len(url) > 256:
69
92
  raise ValueError(f"Endpoint URL too long (max 256 chars)")
70
-
93
+
71
94
  # Basic URL format check
72
- if not re.match(r'^[a-zA-Z0-9\-._~:/?#\[\]@!$&\'()*+,;=]+$', url):
95
+ if not re.match(r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&\'()*+,;=]+$", url):
73
96
  raise ValueError(f"Invalid URL format: {url}")
74
-
97
+
75
98
  def _create_session(self) -> requests.Session:
76
99
  """Create session with retry strategy and connection pooling."""
77
100
  session = requests.Session()
78
-
101
+
79
102
  # Exponential backoff with jitter
80
103
  retry_strategy = Retry(
81
104
  total=3,
82
105
  backoff_factor=1,
83
106
  status_forcelist=[429, 500, 502, 503, 504],
84
- allowed_methods=["POST", "GET"]
107
+ allowed_methods=["POST", "GET"],
85
108
  )
86
-
87
- adapter = HTTPAdapter(
88
- max_retries=retry_strategy,
89
- pool_connections=10,
90
- pool_maxsize=20
91
- )
92
-
109
+
110
+ adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=10, pool_maxsize=20)
111
+
93
112
  session.mount("http://", adapter)
94
113
  session.mount("https://", adapter)
95
-
114
+
96
115
  return session
97
-
116
+
98
117
  async def _get_async_client(self) -> httpx.AsyncClient:
99
118
  """Lazy init async client with shared retry logic."""
100
119
  if self.async_client is None:
101
120
  self.async_client = httpx.AsyncClient(
102
121
  timeout=httpx.Timeout(30.0),
103
- limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
122
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
104
123
  )
105
124
  return self.async_client
106
-
125
+
107
126
  def _get_timeout(self, lm_config: Dict[str, Any]) -> float:
108
127
  """Get timeout with per-call override support."""
109
- return lm_config.get("timeout",
110
- float(os.environ.get("CUSTOM_ENDPOINT_REQUEST_TIMEOUT", "30")))
111
-
128
+ return lm_config.get(
129
+ "timeout", float(os.environ.get("CUSTOM_ENDPOINT_REQUEST_TIMEOUT", "30"))
130
+ )
131
+
112
132
  def _get_temperature_override(self) -> Optional[float]:
113
133
  """Get temperature override from environment for this specific endpoint."""
114
134
  # Create a safe env var key from the endpoint URL
115
135
  # e.g., "example.com/api" -> "CUSTOM_ENDPOINT_TEMP_EXAMPLE_COM_API"
116
- safe_key = re.sub(r'[^A-Za-z0-9]', '_', self.endpoint_url).upper()
136
+ safe_key = re.sub(r"[^A-Za-z0-9]", "_", self.endpoint_url).upper()
117
137
  safe_key = safe_key[:64] # Limit length
118
-
138
+
119
139
  env_key = f"CUSTOM_ENDPOINT_TEMP_{safe_key}"
120
140
  temp_str = os.environ.get(env_key)
121
141
  return float(temp_str) if temp_str else None
122
-
142
+
123
143
  def _compress_tool_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
124
144
  """Compress JSON schema to reduce token usage."""
125
145
  if isinstance(schema, dict):
126
146
  # Remove verbose keys
127
147
  compressed = {
128
- k: self._compress_tool_schema(v)
129
- for k, v in schema.items()
148
+ k: self._compress_tool_schema(v)
149
+ for k, v in schema.items()
130
150
  if k not in ["title", "$ref", "$schema"]
131
151
  }
132
152
  # Shorten descriptions
@@ -136,22 +156,22 @@ class CustomEndpointAPI(VendorBase):
136
156
  elif isinstance(schema, list):
137
157
  return [self._compress_tool_schema(item) for item in schema]
138
158
  return schema
139
-
159
+
140
160
  def _inject_tools_into_prompt(self, system_message: str, tools: List[BaseTool]) -> str:
141
161
  """Inject tool definitions with compressed schemas and clear output format."""
142
162
  if not tools:
143
163
  return system_message
144
-
164
+
145
165
  tool_descriptions = []
146
166
  for tool in tools:
147
167
  schema = tool.arguments.model_json_schema()
148
168
  compressed_schema = self._compress_tool_schema(schema)
149
-
169
+
150
170
  tool_desc = f"Tool: {tool.name}\nDesc: {tool.description}\nParams: {json.dumps(compressed_schema, separators=(',', ':'))}"
151
171
  tool_descriptions.append(tool_desc)
152
-
172
+
153
173
  tools_text = "\n".join(tool_descriptions)
154
-
174
+
155
175
  return f"""{system_message}
156
176
 
157
177
  Available tools:
@@ -164,50 +184,53 @@ IMPORTANT: To use a tool, respond with JSON wrapped in ```json fences:
164
184
 
165
185
  For regular responses, just respond normally without JSON fences."""
166
186
 
167
- def _extract_tool_calls(self, content: str, tools: List[BaseTool]) -> tuple[Optional[List], str]:
187
+ def _extract_tool_calls(
188
+ self, content: str, tools: List[BaseTool]
189
+ ) -> tuple[Optional[List], str]:
168
190
  """Extract and validate tool calls from response."""
169
191
  # Look for JSON fenced blocks
170
- json_pattern = r'```json\s*(\{.*?\})\s*```'
192
+ json_pattern = r"```json\s*(\{.*?\})\s*```"
171
193
  matches = re.findall(json_pattern, content, re.DOTALL)
172
-
194
+
173
195
  if not matches:
174
196
  return None, content
175
-
197
+
176
198
  tool_calls = []
177
199
  cleaned_content = content
178
-
200
+
179
201
  for match in matches:
180
202
  try:
181
203
  tool_data = json.loads(match)
182
204
  if "tool_call" in tool_data:
183
205
  call_data = tool_data["tool_call"]
184
206
  tool_name = call_data.get("name")
185
-
207
+
186
208
  # Validate against available tools
187
209
  matching_tool = next((t for t in tools if t.name == tool_name), None)
188
210
  if matching_tool:
189
211
  # Validate arguments with pydantic
190
212
  validated_args = matching_tool.arguments(**call_data.get("arguments", {}))
191
- tool_calls.append({
192
- "name": tool_name,
193
- "arguments": validated_args.model_dump()
194
- })
195
-
213
+ tool_calls.append(
214
+ {"name": tool_name, "arguments": validated_args.model_dump()}
215
+ )
216
+
196
217
  # Remove tool call from content
197
- cleaned_content = cleaned_content.replace(f"```json\n{match}\n```", "").strip()
198
-
218
+ cleaned_content = cleaned_content.replace(
219
+ f"```json\n{match}\n```", ""
220
+ ).strip()
221
+
199
222
  except (json.JSONDecodeError, Exception):
200
223
  # Fall back to treating as normal text if validation fails
201
224
  continue
202
-
225
+
203
226
  return tool_calls if tool_calls else None, cleaned_content
204
-
227
+
205
228
  def _exponential_backoff_with_jitter(self, attempt: int) -> float:
206
229
  """Calculate backoff time with jitter to prevent thundering herd."""
207
- base_delay = min(2 ** attempt, 32) # Cap at 32 seconds
230
+ base_delay = min(2**attempt, 32) # Cap at 32 seconds
208
231
  jitter = random.uniform(0, 1)
209
232
  return base_delay + jitter
210
-
233
+
211
234
  def _handle_rate_limit(self, response: requests.Response) -> None:
212
235
  """Extract and propagate rate limit information."""
213
236
  if response.status_code == 429:
@@ -215,7 +238,7 @@ For regular responses, just respond normally without JSON fences."""
215
238
  if retry_after:
216
239
  # Bubble up to synth-ai scheduler
217
240
  raise requests.exceptions.RetryError(f"Rate limited. Retry after {retry_after}s")
218
-
241
+
219
242
  async def _hit_api_async(
220
243
  self,
221
244
  model: str,
@@ -226,7 +249,7 @@ For regular responses, just respond normally without JSON fences."""
226
249
  tools: Optional[List[BaseTool]] = None,
227
250
  ) -> BaseLMResponse:
228
251
  """Async API call with comprehensive error handling and streaming support."""
229
-
252
+
230
253
  # Cache integration - check first
231
254
  used_cache_handler = get_cache_handler(use_ephemeral_cache_only)
232
255
  cache_result = used_cache_handler.hit_managed_cache(
@@ -234,7 +257,7 @@ For regular responses, just respond normally without JSON fences."""
234
257
  )
235
258
  if cache_result:
236
259
  return cache_result
237
-
260
+
238
261
  # Apply tool injection
239
262
  if tools and messages:
240
263
  messages = messages.copy()
@@ -242,65 +265,60 @@ For regular responses, just respond normally without JSON fences."""
242
265
  messages[0]["content"] = self._inject_tools_into_prompt(
243
266
  messages[0]["content"], tools
244
267
  )
245
-
268
+
246
269
  # Prepare request
247
270
  headers = {"Content-Type": "application/json"}
248
271
  if self.auth_token:
249
272
  headers["Authorization"] = f"Bearer {self.auth_token}"
250
-
273
+
251
274
  # Apply temperature override
252
275
  temp_override = self._get_temperature_override()
253
276
  request_temp = temp_override if temp_override else lm_config.get("temperature", 0.7)
254
-
277
+
255
278
  payload = {
256
279
  "model": model, # Pass through the model name
257
280
  "messages": messages,
258
281
  "temperature": request_temp,
259
- "stream": lm_config.get("stream", False)
282
+ "stream": lm_config.get("stream", False),
260
283
  }
261
-
284
+
262
285
  timeout = self._get_timeout(lm_config)
263
286
  client = await self._get_async_client()
264
-
287
+
265
288
  # Make request with retry logic
266
289
  for attempt in range(3):
267
290
  try:
268
291
  response = await client.post(
269
- self.chat_completions_url,
270
- json=payload,
271
- headers=headers,
272
- timeout=timeout
292
+ self.chat_completions_url, json=payload, headers=headers, timeout=timeout
273
293
  )
274
-
294
+
275
295
  if response.status_code == 429:
276
296
  self._handle_rate_limit(response)
277
-
297
+
278
298
  response.raise_for_status()
279
-
299
+
280
300
  response_data = response.json()
281
301
  content = response_data["choices"][0]["message"]["content"]
282
-
302
+
283
303
  # Extract tool calls
284
304
  tool_calls, clean_content = self._extract_tool_calls(content, tools or [])
285
-
305
+
286
306
  lm_response = BaseLMResponse(
287
- raw_response=clean_content,
288
- structured_output=None,
289
- tool_calls=tool_calls
307
+ raw_response=clean_content, structured_output=None, tool_calls=tool_calls
290
308
  )
291
-
309
+
292
310
  # Add to cache
293
311
  used_cache_handler.add_to_managed_cache(
294
312
  model, messages, lm_config=lm_config, output=lm_response, tools=tools
295
313
  )
296
-
314
+
297
315
  return lm_response
298
-
316
+
299
317
  except (httpx.RequestError, httpx.TimeoutException) as e:
300
318
  if attempt == 2: # Last attempt
301
319
  raise
302
320
  await asyncio.sleep(self._exponential_backoff_with_jitter(attempt))
303
-
321
+
304
322
  def _hit_api_sync(
305
323
  self,
306
324
  model: str,
@@ -311,7 +329,7 @@ For regular responses, just respond normally without JSON fences."""
311
329
  tools: Optional[List[BaseTool]] = None,
312
330
  ) -> BaseLMResponse:
313
331
  """Sync version with same logic as async."""
314
-
332
+
315
333
  # Cache integration - check first
316
334
  used_cache_handler = get_cache_handler(use_ephemeral_cache_only)
317
335
  cache_result = used_cache_handler.hit_managed_cache(
@@ -319,7 +337,7 @@ For regular responses, just respond normally without JSON fences."""
319
337
  )
320
338
  if cache_result:
321
339
  return cache_result
322
-
340
+
323
341
  # Apply tool injection
324
342
  if tools and messages:
325
343
  messages = messages.copy()
@@ -327,68 +345,63 @@ For regular responses, just respond normally without JSON fences."""
327
345
  messages[0]["content"] = self._inject_tools_into_prompt(
328
346
  messages[0]["content"], tools
329
347
  )
330
-
348
+
331
349
  # Prepare request
332
350
  headers = {"Content-Type": "application/json"}
333
351
  if self.auth_token:
334
352
  headers["Authorization"] = f"Bearer {self.auth_token}"
335
-
353
+
336
354
  # Apply temperature override
337
355
  temp_override = self._get_temperature_override()
338
356
  request_temp = temp_override if temp_override else lm_config.get("temperature", 0.7)
339
-
357
+
340
358
  payload = {
341
359
  "model": model, # Pass through the model name
342
360
  "messages": messages,
343
361
  "temperature": request_temp,
344
- "stream": lm_config.get("stream", False)
362
+ "stream": lm_config.get("stream", False),
345
363
  }
346
-
364
+
347
365
  timeout = self._get_timeout(lm_config)
348
-
366
+
349
367
  # Make request with retry logic
350
368
  for attempt in range(3):
351
369
  try:
352
370
  response = self.session.post(
353
- self.chat_completions_url,
354
- json=payload,
355
- headers=headers,
356
- timeout=timeout
371
+ self.chat_completions_url, json=payload, headers=headers, timeout=timeout
357
372
  )
358
-
373
+
359
374
  if response.status_code == 429:
360
375
  self._handle_rate_limit(response)
361
-
376
+
362
377
  response.raise_for_status()
363
-
378
+
364
379
  response_data = response.json()
365
380
  content = response_data["choices"][0]["message"]["content"]
366
-
381
+
367
382
  # Extract tool calls
368
383
  tool_calls, clean_content = self._extract_tool_calls(content, tools or [])
369
-
384
+
370
385
  lm_response = BaseLMResponse(
371
- raw_response=clean_content,
372
- structured_output=None,
373
- tool_calls=tool_calls
386
+ raw_response=clean_content, structured_output=None, tool_calls=tool_calls
374
387
  )
375
-
388
+
376
389
  # Add to cache
377
390
  used_cache_handler.add_to_managed_cache(
378
391
  model, messages, lm_config=lm_config, output=lm_response, tools=tools
379
392
  )
380
-
393
+
381
394
  return lm_response
382
-
395
+
383
396
  except (requests.RequestException, requests.Timeout) as e:
384
397
  if attempt == 2: # Last attempt
385
398
  raise
386
399
  time.sleep(self._exponential_backoff_with_jitter(attempt))
387
-
400
+
388
401
  def __del__(self):
389
402
  """Cleanup resources."""
390
- if hasattr(self, 'session'):
403
+ if hasattr(self, "session"):
391
404
  self.session.close()
392
- if hasattr(self, 'async_client') and self.async_client:
405
+ if hasattr(self, "async_client") and self.async_client:
393
406
  # Schedule cleanup for async client
394
- pass
407
+ pass
@@ -3,8 +3,8 @@ from typing import Any, Dict, List, Optional, Tuple
3
3
 
4
4
  from openai import AsyncOpenAI, OpenAI
5
5
 
6
- from synth_ai.zyk.lms.tools.base import BaseTool
7
- from synth_ai.zyk.lms.vendors.openai_standard import OpenAIStandard
6
+ from synth_ai.lm.tools.base import BaseTool
7
+ from synth_ai.lm.vendors.openai_standard import OpenAIStandard
8
8
 
9
9
 
10
10
  class DeepSeekAPI(OpenAIStandard):
@@ -43,9 +43,7 @@ class DeepSeekAPI(OpenAIStandard):
43
43
  response = await self.async_client.chat.completions.create(**request_params)
44
44
  message = response.choices[0].message
45
45
 
46
- return message.content, message.tool_calls if hasattr(
47
- message, "tool_calls"
48
- ) else None
46
+ return message.content, message.tool_calls if hasattr(message, "tool_calls") else None
49
47
 
50
48
  def _private_request_sync(
51
49
  self,
@@ -68,6 +66,4 @@ class DeepSeekAPI(OpenAIStandard):
68
66
  response = self.sync_client.chat.completions.create(**request_params)
69
67
  message = response.choices[0].message
70
68
 
71
- return message.content, message.tool_calls if hasattr(
72
- message, "tool_calls"
73
- ) else None
69
+ return message.content, message.tool_calls if hasattr(message, "tool_calls") else None
@@ -3,8 +3,8 @@ from typing import Any, Dict, List, Optional
3
3
 
4
4
  from openai import AsyncOpenAI, OpenAI
5
5
 
6
- from synth_ai.zyk.lms.tools.base import BaseTool
7
- from synth_ai.zyk.lms.vendors.openai_standard import OpenAIStandard
6
+ from synth_ai.lm.tools.base import BaseTool
7
+ from synth_ai.lm.vendors.openai_standard import OpenAIStandard
8
8
 
9
9
 
10
10
  class GrokAPI(OpenAIStandard):
@@ -24,9 +24,7 @@ class GrokAPI(OpenAIStandard):
24
24
  ) -> None:
25
25
  api_key = api_key or os.getenv("XAI_API_KEY")
26
26
  if not api_key:
27
- raise ValueError(
28
- "Set the XAI_API_KEY environment variable or pass api_key explicitly."
29
- )
27
+ raise ValueError("Set the XAI_API_KEY environment variable or pass api_key explicitly.")
30
28
 
31
29
  super().__init__(
32
30
  sync_client=OpenAI(api_key=api_key, base_url=base_url),
@@ -45,7 +43,7 @@ class GrokAPI(OpenAIStandard):
45
43
  ):
46
44
  if not model:
47
45
  raise ValueError("Model name is required for Grok API calls")
48
-
46
+
49
47
  return await super()._hit_api_async(
50
48
  model,
51
49
  messages,
@@ -66,7 +64,7 @@ class GrokAPI(OpenAIStandard):
66
64
  ):
67
65
  if not model:
68
66
  raise ValueError("Model name is required for Grok API calls")
69
-
67
+
70
68
  return super()._hit_api_sync(
71
69
  model,
72
70
  messages,
@@ -74,4 +72,4 @@ class GrokAPI(OpenAIStandard):
74
72
  use_ephemeral_cache_only=use_ephemeral_cache_only,
75
73
  reasoning_effort=reasoning_effort,
76
74
  tools=tools,
77
- )
75
+ )
@@ -3,7 +3,7 @@ import os
3
3
  from dotenv import load_dotenv
4
4
  from groq import AsyncGroq, Groq
5
5
 
6
- from synth_ai.zyk.lms.vendors.openai_standard import OpenAIStandard
6
+ from synth_ai.lm.vendors.openai_standard import OpenAIStandard
7
7
 
8
8
  load_dotenv()
9
9
 
@@ -1,5 +1,5 @@
1
1
  from openai import OpenAI, AsyncOpenAI
2
- from synth_ai.zyk.lms.vendors.openai_standard import OpenAIStandard
2
+ from synth_ai.lm.vendors.openai_standard import OpenAIStandard
3
3
 
4
4
 
5
5
  class OllamaAPI(OpenAIStandard):
@@ -11,4 +11,4 @@ class OllamaAPI(OpenAIStandard):
11
11
  self.async_client = AsyncOpenAI(
12
12
  base_url="http://localhost:11434/v1",
13
13
  api_key="ollama", # required, but unused
14
- )
14
+ )
@@ -1,44 +1,46 @@
1
1
  import os
2
2
  from typing import Any, Dict, List, Optional
3
3
  from openai import AsyncOpenAI, OpenAI
4
- from synth_ai.zyk.lms.vendors.openai_standard import OpenAIStandard
5
- from synth_ai.zyk.lms.vendors.base import BaseLMResponse
6
- from synth_ai.zyk.lms.tools.base import BaseTool
4
+ from synth_ai.lm.vendors.openai_standard import OpenAIStandard
5
+ from synth_ai.lm.vendors.base import BaseLMResponse
6
+ from synth_ai.lm.tools.base import BaseTool
7
7
 
8
8
 
9
9
  class OpenRouterAPI(OpenAIStandard):
10
10
  """OpenRouter API client for accessing various models through OpenRouter's unified API."""
11
-
11
+
12
12
  def __init__(self):
13
13
  api_key = os.getenv("OPENROUTER_API_KEY")
14
14
  if not api_key:
15
15
  raise ValueError("OPENROUTER_API_KEY environment variable is not set")
16
-
16
+
17
17
  # OpenRouter requires specific headers
18
18
  default_headers = {
19
- "HTTP-Referer": os.getenv("OPENROUTER_APP_URL", "https://github.com/synth-laboratories/synth-ai"),
20
- "X-Title": os.getenv("OPENROUTER_APP_TITLE", "synth-ai")
19
+ "HTTP-Referer": os.getenv(
20
+ "OPENROUTER_APP_URL", "https://github.com/synth-laboratories/synth-ai"
21
+ ),
22
+ "X-Title": os.getenv("OPENROUTER_APP_TITLE", "synth-ai"),
21
23
  }
22
-
24
+
23
25
  super().__init__(
24
26
  sync_client=OpenAI(
25
27
  api_key=api_key,
26
28
  base_url="https://openrouter.ai/api/v1",
27
- default_headers=default_headers
29
+ default_headers=default_headers,
28
30
  ),
29
31
  async_client=AsyncOpenAI(
30
32
  api_key=api_key,
31
33
  base_url="https://openrouter.ai/api/v1",
32
- default_headers=default_headers
33
- )
34
+ default_headers=default_headers,
35
+ ),
34
36
  )
35
-
37
+
36
38
  def _strip_prefix(self, model: str) -> str:
37
39
  """Remove the 'openrouter/' prefix from model names."""
38
40
  if model.startswith("openrouter/"):
39
- return model[len("openrouter/"):]
41
+ return model[len("openrouter/") :]
40
42
  return model
41
-
43
+
42
44
  async def _hit_api_async(
43
45
  self,
44
46
  model: str,
@@ -53,7 +55,7 @@ class OpenRouterAPI(OpenAIStandard):
53
55
  return await super()._hit_api_async(
54
56
  model, messages, lm_config, use_ephemeral_cache_only, reasoning_effort, tools
55
57
  )
56
-
58
+
57
59
  def _hit_api_sync(
58
60
  self,
59
61
  model: str,
@@ -67,4 +69,4 @@ class OpenRouterAPI(OpenAIStandard):
67
69
  model = self._strip_prefix(model)
68
70
  return super()._hit_api_sync(
69
71
  model, messages, lm_config, use_ephemeral_cache_only, reasoning_effort, tools
70
- )
72
+ )