webrouter 0.11.0__tar.gz

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 (481) hide show
  1. webrouter-0.11.0/.gitignore +74 -0
  2. webrouter-0.11.0/CHANGELOG.md +212 -0
  3. webrouter-0.11.0/LICENSE +201 -0
  4. webrouter-0.11.0/PKG-INFO +2533 -0
  5. webrouter-0.11.0/README.md +2459 -0
  6. webrouter-0.11.0/benchmarks/agent/README.md +269 -0
  7. webrouter-0.11.0/benchmarks/providers/README.md +569 -0
  8. webrouter-0.11.0/contracts/answer/brave.json +68 -0
  9. webrouter-0.11.0/contracts/answer/exa.json +60 -0
  10. webrouter-0.11.0/contracts/answer/linkup.json +88 -0
  11. webrouter-0.11.0/contracts/answer/parallel.json +61 -0
  12. webrouter-0.11.0/contracts/answer/perplexity.json +146 -0
  13. webrouter-0.11.0/contracts/answer/you.json +86 -0
  14. webrouter-0.11.0/contracts/browse/browser-use.json +125 -0
  15. webrouter-0.11.0/contracts/browse/browserbase.json +57 -0
  16. webrouter-0.11.0/contracts/browse/skyvern.json +133 -0
  17. webrouter-0.11.0/contracts/browser/anchor.json +114 -0
  18. webrouter-0.11.0/contracts/browser/browserbase.json +123 -0
  19. webrouter-0.11.0/contracts/browser/hyperbrowser.json +231 -0
  20. webrouter-0.11.0/contracts/browser/kernel.json +161 -0
  21. webrouter-0.11.0/contracts/browser/steel.json +171 -0
  22. webrouter-0.11.0/contracts/extract/brightdata.json +69 -0
  23. webrouter-0.11.0/contracts/extract/browserbase.json +61 -0
  24. webrouter-0.11.0/contracts/extract/deepseek.json +47 -0
  25. webrouter-0.11.0/contracts/extract/exa.json +106 -0
  26. webrouter-0.11.0/contracts/extract/firecrawl.json +165 -0
  27. webrouter-0.11.0/contracts/extract/jina.json +276 -0
  28. webrouter-0.11.0/contracts/extract/keenable.json +57 -0
  29. webrouter-0.11.0/contracts/extract/linkup.json +71 -0
  30. webrouter-0.11.0/contracts/extract/nimble.json +167 -0
  31. webrouter-0.11.0/contracts/extract/oxylabs.json +92 -0
  32. webrouter-0.11.0/contracts/extract/tavily.json +73 -0
  33. webrouter-0.11.0/contracts/extract/webiq.json +74 -0
  34. webrouter-0.11.0/contracts/research/linkup.json +79 -0
  35. webrouter-0.11.0/contracts/research/nimble.json +110 -0
  36. webrouter-0.11.0/contracts/research/parallel.json +95 -0
  37. webrouter-0.11.0/contracts/research/perplexity.json +129 -0
  38. webrouter-0.11.0/contracts/research/you.json +60 -0
  39. webrouter-0.11.0/contracts/search/agentcore.json +77 -0
  40. webrouter-0.11.0/contracts/search/brave.json +183 -0
  41. webrouter-0.11.0/contracts/search/deepseek.json +94 -0
  42. webrouter-0.11.0/contracts/search/exa-mcp.json +181 -0
  43. webrouter-0.11.0/contracts/search/exa.json +170 -0
  44. webrouter-0.11.0/contracts/search/keenable.json +114 -0
  45. webrouter-0.11.0/contracts/search/linkup.json +109 -0
  46. webrouter-0.11.0/contracts/search/nimble.json +126 -0
  47. webrouter-0.11.0/contracts/search/parallel-mcp.json +67 -0
  48. webrouter-0.11.0/contracts/search/parallel.json +138 -0
  49. webrouter-0.11.0/contracts/search/perplexity.json +137 -0
  50. webrouter-0.11.0/contracts/search/tavily.json +155 -0
  51. webrouter-0.11.0/contracts/search/webiq.json +87 -0
  52. webrouter-0.11.0/contracts/search/you-mcp.json +127 -0
  53. webrouter-0.11.0/contracts/search/you.json +126 -0
  54. webrouter-0.11.0/contracts/template/nimble.json +48 -0
  55. webrouter-0.11.0/examples/README.md +68 -0
  56. webrouter-0.11.0/pyproject.toml +243 -0
  57. webrouter-0.11.0/scripts/__init__.py +0 -0
  58. webrouter-0.11.0/scripts/benchmark_loader.py +49 -0
  59. webrouter-0.11.0/scripts/check_credentials.sh +26 -0
  60. webrouter-0.11.0/scripts/contracts.py +400 -0
  61. webrouter-0.11.0/scripts/latency.py +178 -0
  62. webrouter-0.11.0/scripts/prices.py +135 -0
  63. webrouter-0.11.0/scripts/shape.py +81 -0
  64. webrouter-0.11.0/src/web_router/__init__.py +293 -0
  65. webrouter-0.11.0/src/web_router/_benchmark.py +205 -0
  66. webrouter-0.11.0/src/web_router/_chains.py +35 -0
  67. webrouter-0.11.0/src/web_router/_hosts.py +105 -0
  68. webrouter-0.11.0/src/web_router/_urls.py +77 -0
  69. webrouter-0.11.0/src/web_router/_version.py +1 -0
  70. webrouter-0.11.0/src/web_router/client.py +896 -0
  71. webrouter-0.11.0/src/web_router/client_answer.py +121 -0
  72. webrouter-0.11.0/src/web_router/client_benchmark.py +325 -0
  73. webrouter-0.11.0/src/web_router/client_budget.py +45 -0
  74. webrouter-0.11.0/src/web_router/client_plan.py +192 -0
  75. webrouter-0.11.0/src/web_router/client_requests.py +162 -0
  76. webrouter-0.11.0/src/web_router/client_scope.py +134 -0
  77. webrouter-0.11.0/src/web_router/client_template.py +125 -0
  78. webrouter-0.11.0/src/web_router/constants.py +4332 -0
  79. webrouter-0.11.0/src/web_router/constants_agentcore.py +80 -0
  80. webrouter-0.11.0/src/web_router/constants_answer.py +119 -0
  81. webrouter-0.11.0/src/web_router/constants_brave.py +86 -0
  82. webrouter-0.11.0/src/web_router/constants_browserbase.py +160 -0
  83. webrouter-0.11.0/src/web_router/constants_budget.py +48 -0
  84. webrouter-0.11.0/src/web_router/constants_candidates.py +36 -0
  85. webrouter-0.11.0/src/web_router/constants_deepseek.py +82 -0
  86. webrouter-0.11.0/src/web_router/constants_exa.py +143 -0
  87. webrouter-0.11.0/src/web_router/constants_invoke.py +32 -0
  88. webrouter-0.11.0/src/web_router/constants_keenable.py +51 -0
  89. webrouter-0.11.0/src/web_router/constants_kernel.py +72 -0
  90. webrouter-0.11.0/src/web_router/constants_languages.py +52 -0
  91. webrouter-0.11.0/src/web_router/constants_linkup.py +57 -0
  92. webrouter-0.11.0/src/web_router/constants_mcp.py +174 -0
  93. webrouter-0.11.0/src/web_router/constants_mcp_hints.py +37 -0
  94. webrouter-0.11.0/src/web_router/constants_merge.py +70 -0
  95. webrouter-0.11.0/src/web_router/constants_parallel.py +173 -0
  96. webrouter-0.11.0/src/web_router/constants_params.py +57 -0
  97. webrouter-0.11.0/src/web_router/constants_perplexity.py +105 -0
  98. webrouter-0.11.0/src/web_router/constants_prices.py +52 -0
  99. webrouter-0.11.0/src/web_router/constants_routing.py +23 -0
  100. webrouter-0.11.0/src/web_router/constants_search.py +88 -0
  101. webrouter-0.11.0/src/web_router/constants_tavily.py +78 -0
  102. webrouter-0.11.0/src/web_router/constants_urls.py +65 -0
  103. webrouter-0.11.0/src/web_router/constants_webiq.py +49 -0
  104. webrouter-0.11.0/src/web_router/constants_you.py +136 -0
  105. webrouter-0.11.0/src/web_router/data/chains.json +183 -0
  106. webrouter-0.11.0/src/web_router/data/efforts.json +108 -0
  107. webrouter-0.11.0/src/web_router/data/prices.json +1668 -0
  108. webrouter-0.11.0/src/web_router/evals/__init__.py +29 -0
  109. webrouter-0.11.0/src/web_router/evals/datasets.py +357 -0
  110. webrouter-0.11.0/src/web_router/evals/judge.py +340 -0
  111. webrouter-0.11.0/src/web_router/evals/prompts/LICENSE-simple-evals +21 -0
  112. webrouter-0.11.0/src/web_router/evals/prompts/simpleqa_grader.txt +78 -0
  113. webrouter-0.11.0/src/web_router/evals/run.py +224 -0
  114. webrouter-0.11.0/src/web_router/evals/types.py +116 -0
  115. webrouter-0.11.0/src/web_router/exceptions.py +379 -0
  116. webrouter-0.11.0/src/web_router/exceptions_budget.py +28 -0
  117. webrouter-0.11.0/src/web_router/integrations/__init__.py +6 -0
  118. webrouter-0.11.0/src/web_router/integrations/_langchain_base.py +76 -0
  119. webrouter-0.11.0/src/web_router/integrations/_options.py +67 -0
  120. webrouter-0.11.0/src/web_router/integrations/_render.py +81 -0
  121. webrouter-0.11.0/src/web_router/integrations/_specs.py +82 -0
  122. webrouter-0.11.0/src/web_router/integrations/_tools.py +241 -0
  123. webrouter-0.11.0/src/web_router/integrations/anthropic.py +78 -0
  124. webrouter-0.11.0/src/web_router/integrations/langchain.py +227 -0
  125. webrouter-0.11.0/src/web_router/integrations/openai.py +128 -0
  126. webrouter-0.11.0/src/web_router/mcp/__init__.py +33 -0
  127. webrouter-0.11.0/src/web_router/mcp/__main__.py +15 -0
  128. webrouter-0.11.0/src/web_router/mcp/browsers.py +400 -0
  129. webrouter-0.11.0/src/web_router/mcp/budget.py +187 -0
  130. webrouter-0.11.0/src/web_router/mcp/chains.py +44 -0
  131. webrouter-0.11.0/src/web_router/mcp/env.py +263 -0
  132. webrouter-0.11.0/src/web_router/mcp/env_credentials.py +86 -0
  133. webrouter-0.11.0/src/web_router/mcp/messages.py +30 -0
  134. webrouter-0.11.0/src/web_router/mcp/methods.py +178 -0
  135. webrouter-0.11.0/src/web_router/mcp/navigation.py +293 -0
  136. webrouter-0.11.0/src/web_router/mcp/routing.py +152 -0
  137. webrouter-0.11.0/src/web_router/mcp/routing_config.py +130 -0
  138. webrouter-0.11.0/src/web_router/mcp/schema.py +102 -0
  139. webrouter-0.11.0/src/web_router/mcp/server.py +239 -0
  140. webrouter-0.11.0/src/web_router/mcp/shaping.py +507 -0
  141. webrouter-0.11.0/src/web_router/mcp/shaping_templates.py +167 -0
  142. webrouter-0.11.0/src/web_router/mcp/tools.py +634 -0
  143. webrouter-0.11.0/src/web_router/mcp/urls.py +76 -0
  144. webrouter-0.11.0/src/web_router/prices.py +196 -0
  145. webrouter-0.11.0/src/web_router/prices_latency.py +27 -0
  146. webrouter-0.11.0/src/web_router/providers/__init__.py +49 -0
  147. webrouter-0.11.0/src/web_router/providers/_auto_extract.py +74 -0
  148. webrouter-0.11.0/src/web_router/providers/_chain.py +341 -0
  149. webrouter-0.11.0/src/web_router/providers/_chain_deadline.py +78 -0
  150. webrouter-0.11.0/src/web_router/providers/_clamp.py +45 -0
  151. webrouter-0.11.0/src/web_router/providers/_clock.py +40 -0
  152. webrouter-0.11.0/src/web_router/providers/_deadline.py +371 -0
  153. webrouter-0.11.0/src/web_router/providers/_error_pages.py +27 -0
  154. webrouter-0.11.0/src/web_router/providers/_http.py +604 -0
  155. webrouter-0.11.0/src/web_router/providers/_language.py +40 -0
  156. webrouter-0.11.0/src/web_router/providers/_mcp.py +118 -0
  157. webrouter-0.11.0/src/web_router/providers/_page.py +801 -0
  158. webrouter-0.11.0/src/web_router/providers/_png.py +30 -0
  159. webrouter-0.11.0/src/web_router/providers/_race.py +145 -0
  160. webrouter-0.11.0/src/web_router/providers/_search_text.py +79 -0
  161. webrouter-0.11.0/src/web_router/providers/_utils.py +259 -0
  162. webrouter-0.11.0/src/web_router/providers/agentcore.py +316 -0
  163. webrouter-0.11.0/src/web_router/providers/agentcore_sigv4.py +75 -0
  164. webrouter-0.11.0/src/web_router/providers/anchor.py +123 -0
  165. webrouter-0.11.0/src/web_router/providers/answer.py +68 -0
  166. webrouter-0.11.0/src/web_router/providers/auto.py +817 -0
  167. webrouter-0.11.0/src/web_router/providers/auto_answer.py +221 -0
  168. webrouter-0.11.0/src/web_router/providers/auto_browser.py +233 -0
  169. webrouter-0.11.0/src/web_router/providers/base.py +1883 -0
  170. webrouter-0.11.0/src/web_router/providers/brave.py +133 -0
  171. webrouter-0.11.0/src/web_router/providers/brave_answer.py +121 -0
  172. webrouter-0.11.0/src/web_router/providers/brightdata.py +106 -0
  173. webrouter-0.11.0/src/web_router/providers/browser_use.py +176 -0
  174. webrouter-0.11.0/src/web_router/providers/browserbase.py +275 -0
  175. webrouter-0.11.0/src/web_router/providers/browserbase_extract.py +117 -0
  176. webrouter-0.11.0/src/web_router/providers/deepseek.py +170 -0
  177. webrouter-0.11.0/src/web_router/providers/exa.py +168 -0
  178. webrouter-0.11.0/src/web_router/providers/exa_answer.py +69 -0
  179. webrouter-0.11.0/src/web_router/providers/exa_mcp.py +86 -0
  180. webrouter-0.11.0/src/web_router/providers/firecrawl.py +110 -0
  181. webrouter-0.11.0/src/web_router/providers/hyperbrowser.py +116 -0
  182. webrouter-0.11.0/src/web_router/providers/jina.py +99 -0
  183. webrouter-0.11.0/src/web_router/providers/keenable.py +120 -0
  184. webrouter-0.11.0/src/web_router/providers/keenable_extract.py +124 -0
  185. webrouter-0.11.0/src/web_router/providers/kernel.py +97 -0
  186. webrouter-0.11.0/src/web_router/providers/linkup.py +329 -0
  187. webrouter-0.11.0/src/web_router/providers/local.py +600 -0
  188. webrouter-0.11.0/src/web_router/providers/max.py +235 -0
  189. webrouter-0.11.0/src/web_router/providers/nimble.py +530 -0
  190. webrouter-0.11.0/src/web_router/providers/nimble_template.py +259 -0
  191. webrouter-0.11.0/src/web_router/providers/oxylabs.py +102 -0
  192. webrouter-0.11.0/src/web_router/providers/parallel.py +257 -0
  193. webrouter-0.11.0/src/web_router/providers/parallel_mcp.py +59 -0
  194. webrouter-0.11.0/src/web_router/providers/parallel_search.py +162 -0
  195. webrouter-0.11.0/src/web_router/providers/perplexity.py +219 -0
  196. webrouter-0.11.0/src/web_router/providers/perplexity_answer.py +160 -0
  197. webrouter-0.11.0/src/web_router/providers/skyvern.py +114 -0
  198. webrouter-0.11.0/src/web_router/providers/steel.py +128 -0
  199. webrouter-0.11.0/src/web_router/providers/tavily.py +152 -0
  200. webrouter-0.11.0/src/web_router/providers/tavily_extract.py +175 -0
  201. webrouter-0.11.0/src/web_router/providers/webiq.py +182 -0
  202. webrouter-0.11.0/src/web_router/providers/you.py +133 -0
  203. webrouter-0.11.0/src/web_router/providers/you_answer.py +88 -0
  204. webrouter-0.11.0/src/web_router/providers/you_mcp.py +85 -0
  205. webrouter-0.11.0/src/web_router/providers/you_research.py +86 -0
  206. webrouter-0.11.0/src/web_router/py.typed +0 -0
  207. webrouter-0.11.0/src/web_router/registry.py +453 -0
  208. webrouter-0.11.0/src/web_router/registry_answer.py +121 -0
  209. webrouter-0.11.0/src/web_router/routing/__init__.py +34 -0
  210. webrouter-0.11.0/src/web_router/routing/_forms.py +36 -0
  211. webrouter-0.11.0/src/web_router/routing/_runner.py +154 -0
  212. webrouter-0.11.0/src/web_router/routing/attempts.py +125 -0
  213. webrouter-0.11.0/src/web_router/routing/budget.py +225 -0
  214. webrouter-0.11.0/src/web_router/routing/candidates.py +148 -0
  215. webrouter-0.11.0/src/web_router/routing/candidates_priced.py +87 -0
  216. webrouter-0.11.0/src/web_router/routing/contract.py +173 -0
  217. webrouter-0.11.0/src/web_router/routing/decide.py +94 -0
  218. webrouter-0.11.0/src/web_router/routing/effort_call.py +77 -0
  219. webrouter-0.11.0/src/web_router/routing/effort_table.py +65 -0
  220. webrouter-0.11.0/src/web_router/routing/eligible.py +109 -0
  221. webrouter-0.11.0/src/web_router/routing/invoke.py +199 -0
  222. webrouter-0.11.0/src/web_router/routing/ledger.py +214 -0
  223. webrouter-0.11.0/src/web_router/routing/merge.py +174 -0
  224. webrouter-0.11.0/src/web_router/routing/merge_extract.py +69 -0
  225. webrouter-0.11.0/src/web_router/routing/merge_params.py +177 -0
  226. webrouter-0.11.0/src/web_router/routing/merge_shape.py +61 -0
  227. webrouter-0.11.0/src/web_router/routing/merge_types.py +99 -0
  228. webrouter-0.11.0/src/web_router/routing/merge_urls.py +34 -0
  229. webrouter-0.11.0/src/web_router/routing/params.py +179 -0
  230. webrouter-0.11.0/src/web_router/routing/pool.py +193 -0
  231. webrouter-0.11.0/src/web_router/routing/pool_bind.py +43 -0
  232. webrouter-0.11.0/src/web_router/routing/pool_status.py +28 -0
  233. webrouter-0.11.0/src/web_router/routing/pricing.py +151 -0
  234. webrouter-0.11.0/src/web_router/routing/record.py +41 -0
  235. webrouter-0.11.0/src/web_router/routing/routed.py +188 -0
  236. webrouter-0.11.0/src/web_router/routing/rules.py +21 -0
  237. webrouter-0.11.0/src/web_router/routing/submitted.py +59 -0
  238. webrouter-0.11.0/src/web_router/routing/trail.py +75 -0
  239. webrouter-0.11.0/src/web_router/routing/url_policy.py +165 -0
  240. webrouter-0.11.0/src/web_router/types.py +935 -0
  241. webrouter-0.11.0/src/web_router/types_answer.py +72 -0
  242. webrouter-0.11.0/src/web_router/types_benchmark.py +158 -0
  243. webrouter-0.11.0/src/web_router/types_decision.py +108 -0
  244. webrouter-0.11.0/src/web_router/types_options.py +86 -0
  245. webrouter-0.11.0/src/web_router/types_page.py +69 -0
  246. webrouter-0.11.0/tests/__init__.py +0 -0
  247. webrouter-0.11.0/tests/benchmarks/__init__.py +0 -0
  248. webrouter-0.11.0/tests/benchmarks/_tools_effort_fakes.py +155 -0
  249. webrouter-0.11.0/tests/benchmarks/conftest.py +312 -0
  250. webrouter-0.11.0/tests/benchmarks/test_agent_benchmark.py +2053 -0
  251. webrouter-0.11.0/tests/benchmarks/test_agent_page.py +145 -0
  252. webrouter-0.11.0/tests/benchmarks/test_answer_scan.py +189 -0
  253. webrouter-0.11.0/tests/benchmarks/test_answer_scan_baseline.py +112 -0
  254. webrouter-0.11.0/tests/benchmarks/test_benchmark_loaders.py +42 -0
  255. webrouter-0.11.0/tests/benchmarks/test_chain_generation.py +108 -0
  256. webrouter-0.11.0/tests/benchmarks/test_effort_generation.py +69 -0
  257. webrouter-0.11.0/tests/benchmarks/test_extract_scan.py +189 -0
  258. webrouter-0.11.0/tests/benchmarks/test_extract_scan_files.py +187 -0
  259. webrouter-0.11.0/tests/benchmarks/test_frozen.py +213 -0
  260. webrouter-0.11.0/tests/benchmarks/test_frozen_checks.py +114 -0
  261. webrouter-0.11.0/tests/benchmarks/test_provider_scan.py +279 -0
  262. webrouter-0.11.0/tests/benchmarks/test_recommended_order.py +190 -0
  263. webrouter-0.11.0/tests/benchmarks/test_route_eval.py +211 -0
  264. webrouter-0.11.0/tests/benchmarks/test_route_eval_efforts.py +47 -0
  265. webrouter-0.11.0/tests/benchmarks/test_route_eval_floor.py +36 -0
  266. webrouter-0.11.0/tests/benchmarks/test_route_eval_merge_paths.py +129 -0
  267. webrouter-0.11.0/tests/benchmarks/test_route_eval_paths.py +143 -0
  268. webrouter-0.11.0/tests/benchmarks/test_route_eval_rank.py +123 -0
  269. webrouter-0.11.0/tests/benchmarks/test_route_eval_replay.py +155 -0
  270. webrouter-0.11.0/tests/benchmarks/test_route_eval_replay_merge.py +68 -0
  271. webrouter-0.11.0/tests/benchmarks/test_tools_effort_budget.py +134 -0
  272. webrouter-0.11.0/tests/benchmarks/test_tools_effort_freeze.py +95 -0
  273. webrouter-0.11.0/tests/benchmarks/test_tools_effort_run.py +226 -0
  274. webrouter-0.11.0/tests/budget/__init__.py +0 -0
  275. webrouter-0.11.0/tests/budget/conftest.py +92 -0
  276. webrouter-0.11.0/tests/budget/test_cost.py +174 -0
  277. webrouter-0.11.0/tests/budget/test_estimates.py +73 -0
  278. webrouter-0.11.0/tests/budget/test_no_budget.py +87 -0
  279. webrouter-0.11.0/tests/budget/test_routed.py +73 -0
  280. webrouter-0.11.0/tests/budget/test_shared.py +137 -0
  281. webrouter-0.11.0/tests/budget/test_shared_clock.py +145 -0
  282. webrouter-0.11.0/tests/budget/test_shared_nested.py +177 -0
  283. webrouter-0.11.0/tests/budget/test_shared_nested_cap.py +104 -0
  284. webrouter-0.11.0/tests/budget/test_shared_nested_detached.py +141 -0
  285. webrouter-0.11.0/tests/budget/test_shared_parallel.py +174 -0
  286. webrouter-0.11.0/tests/budget/test_shared_races.py +111 -0
  287. webrouter-0.11.0/tests/budget/test_shared_routing.py +80 -0
  288. webrouter-0.11.0/tests/budget/test_shared_scope.py +176 -0
  289. webrouter-0.11.0/tests/budget/test_submitted.py +105 -0
  290. webrouter-0.11.0/tests/budget/test_time.py +110 -0
  291. webrouter-0.11.0/tests/candidates_helpers.py +51 -0
  292. webrouter-0.11.0/tests/conftest.py +557 -0
  293. webrouter-0.11.0/tests/deadline_helpers.py +132 -0
  294. webrouter-0.11.0/tests/examples/__init__.py +0 -0
  295. webrouter-0.11.0/tests/examples/helpers.py +43 -0
  296. webrouter-0.11.0/tests/examples/test_router_jev.py +162 -0
  297. webrouter-0.11.0/tests/examples/test_router_llm.py +123 -0
  298. webrouter-0.11.0/tests/examples/test_router_rules_classifier.py +130 -0
  299. webrouter-0.11.0/tests/fixtures/browser/actions.html +33 -0
  300. webrouter-0.11.0/tests/fixtures/browser/next.html +2 -0
  301. webrouter-0.11.0/tests/image_helpers.py +15 -0
  302. webrouter-0.11.0/tests/integrations/__init__.py +0 -0
  303. webrouter-0.11.0/tests/integrations/test_answer_tool.py +183 -0
  304. webrouter-0.11.0/tests/integrations/test_anthropic.py +167 -0
  305. webrouter-0.11.0/tests/integrations/test_langchain.py +197 -0
  306. webrouter-0.11.0/tests/integrations/test_langchain_answer.py +44 -0
  307. webrouter-0.11.0/tests/integrations/test_openai.py +291 -0
  308. webrouter-0.11.0/tests/invoke_helpers.py +81 -0
  309. webrouter-0.11.0/tests/max_extract/__init__.py +0 -0
  310. webrouter-0.11.0/tests/max_extract/conftest.py +111 -0
  311. webrouter-0.11.0/tests/max_extract/test_async.py +91 -0
  312. webrouter-0.11.0/tests/max_extract/test_close.py +84 -0
  313. webrouter-0.11.0/tests/max_extract/test_hedge.py +90 -0
  314. webrouter-0.11.0/tests/max_extract/test_many_urls.py +58 -0
  315. webrouter-0.11.0/tests/max_extract/test_race.py +112 -0
  316. webrouter-0.11.0/tests/max_extract/test_wiring.py +121 -0
  317. webrouter-0.11.0/tests/mcp/__init__.py +0 -0
  318. webrouter-0.11.0/tests/mcp/conftest.py +163 -0
  319. webrouter-0.11.0/tests/mcp/env_helpers.py +32 -0
  320. webrouter-0.11.0/tests/mcp/test_arguments.py +101 -0
  321. webrouter-0.11.0/tests/mcp/test_browser_tools.py +1225 -0
  322. webrouter-0.11.0/tests/mcp/test_env.py +341 -0
  323. webrouter-0.11.0/tests/mcp/test_env_credentials.py +52 -0
  324. webrouter-0.11.0/tests/mcp/test_env_stdio.py +207 -0
  325. webrouter-0.11.0/tests/mcp/test_env_tables.py +46 -0
  326. webrouter-0.11.0/tests/mcp/test_navigation.py +571 -0
  327. webrouter-0.11.0/tests/mcp/test_protocol.py +34 -0
  328. webrouter-0.11.0/tests/mcp/test_routing_env.py +149 -0
  329. webrouter-0.11.0/tests/mcp/test_routing_jobs.py +165 -0
  330. webrouter-0.11.0/tests/mcp/test_routing_limits.py +162 -0
  331. webrouter-0.11.0/tests/mcp/test_routing_tools.py +199 -0
  332. webrouter-0.11.0/tests/mcp/test_routing_url_policy.py +23 -0
  333. webrouter-0.11.0/tests/mcp/test_search_depth_enum.py +41 -0
  334. webrouter-0.11.0/tests/mcp/test_shaping.py +322 -0
  335. webrouter-0.11.0/tests/mcp/test_shaping_templates.py +222 -0
  336. webrouter-0.11.0/tests/mcp/test_stdio.py +178 -0
  337. webrouter-0.11.0/tests/mcp/test_tools.py +924 -0
  338. webrouter-0.11.0/tests/mcp/test_tools_extract_profile.py +21 -0
  339. webrouter-0.11.0/tests/mcp/test_url_screen.py +85 -0
  340. webrouter-0.11.0/tests/merge_helpers.py +39 -0
  341. webrouter-0.11.0/tests/plan_helpers.py +68 -0
  342. webrouter-0.11.0/tests/providers/__init__.py +0 -0
  343. webrouter-0.11.0/tests/providers/_agentcore.py +85 -0
  344. webrouter-0.11.0/tests/providers/_answer_stub.py +42 -0
  345. webrouter-0.11.0/tests/providers/_browser_live.py +111 -0
  346. webrouter-0.11.0/tests/providers/_browserbase_fetch.py +47 -0
  347. webrouter-0.11.0/tests/providers/_perplexity_answer_payload.py +67 -0
  348. webrouter-0.11.0/tests/providers/_research_stub.py +49 -0
  349. webrouter-0.11.0/tests/providers/conftest.py +18 -0
  350. webrouter-0.11.0/tests/providers/test_agentcore.py +400 -0
  351. webrouter-0.11.0/tests/providers/test_agentcore_aws.py +56 -0
  352. webrouter-0.11.0/tests/providers/test_agentcore_options.py +85 -0
  353. webrouter-0.11.0/tests/providers/test_anchor_browser.py +433 -0
  354. webrouter-0.11.0/tests/providers/test_auto_answer.py +150 -0
  355. webrouter-0.11.0/tests/providers/test_brave.py +165 -0
  356. webrouter-0.11.0/tests/providers/test_brave_answer.py +176 -0
  357. webrouter-0.11.0/tests/providers/test_brightdata.py +168 -0
  358. webrouter-0.11.0/tests/providers/test_browser_use_browse.py +444 -0
  359. webrouter-0.11.0/tests/providers/test_browserbase_browse.py +302 -0
  360. webrouter-0.11.0/tests/providers/test_browserbase_browser.py +525 -0
  361. webrouter-0.11.0/tests/providers/test_browserbase_extract.py +152 -0
  362. webrouter-0.11.0/tests/providers/test_browserbase_extract_refusals.py +78 -0
  363. webrouter-0.11.0/tests/providers/test_deepseek.py +305 -0
  364. webrouter-0.11.0/tests/providers/test_deepseek_options.py +74 -0
  365. webrouter-0.11.0/tests/providers/test_exa.py +173 -0
  366. webrouter-0.11.0/tests/providers/test_exa_answer.py +149 -0
  367. webrouter-0.11.0/tests/providers/test_exa_mcp.py +166 -0
  368. webrouter-0.11.0/tests/providers/test_exa_options.py +124 -0
  369. webrouter-0.11.0/tests/providers/test_firecrawl.py +137 -0
  370. webrouter-0.11.0/tests/providers/test_http_base.py +468 -0
  371. webrouter-0.11.0/tests/providers/test_hyperbrowser_browser.py +428 -0
  372. webrouter-0.11.0/tests/providers/test_jina.py +148 -0
  373. webrouter-0.11.0/tests/providers/test_keenable.py +223 -0
  374. webrouter-0.11.0/tests/providers/test_keenable_options.py +100 -0
  375. webrouter-0.11.0/tests/providers/test_kernel_browser.py +343 -0
  376. webrouter-0.11.0/tests/providers/test_linkup.py +312 -0
  377. webrouter-0.11.0/tests/providers/test_linkup_answer.py +194 -0
  378. webrouter-0.11.0/tests/providers/test_linkup_options.py +35 -0
  379. webrouter-0.11.0/tests/providers/test_linkup_research.py +286 -0
  380. webrouter-0.11.0/tests/providers/test_local_browser.py +898 -0
  381. webrouter-0.11.0/tests/providers/test_nimble.py +355 -0
  382. webrouter-0.11.0/tests/providers/test_nimble_options.py +30 -0
  383. webrouter-0.11.0/tests/providers/test_nimble_research.py +481 -0
  384. webrouter-0.11.0/tests/providers/test_nimble_template.py +485 -0
  385. webrouter-0.11.0/tests/providers/test_oxylabs.py +119 -0
  386. webrouter-0.11.0/tests/providers/test_parallel.py +80 -0
  387. webrouter-0.11.0/tests/providers/test_parallel_answer.py +140 -0
  388. webrouter-0.11.0/tests/providers/test_parallel_mcp.py +75 -0
  389. webrouter-0.11.0/tests/providers/test_parallel_options.py +150 -0
  390. webrouter-0.11.0/tests/providers/test_parallel_research.py +298 -0
  391. webrouter-0.11.0/tests/providers/test_perplexity.py +77 -0
  392. webrouter-0.11.0/tests/providers/test_perplexity_answer.py +165 -0
  393. webrouter-0.11.0/tests/providers/test_perplexity_options.py +123 -0
  394. webrouter-0.11.0/tests/providers/test_perplexity_research.py +208 -0
  395. webrouter-0.11.0/tests/providers/test_site_domains.py +48 -0
  396. webrouter-0.11.0/tests/providers/test_skyvern_browse.py +335 -0
  397. webrouter-0.11.0/tests/providers/test_steel_browser.py +399 -0
  398. webrouter-0.11.0/tests/providers/test_tavily.py +189 -0
  399. webrouter-0.11.0/tests/providers/test_tavily_options.py +137 -0
  400. webrouter-0.11.0/tests/providers/test_utils.py +57 -0
  401. webrouter-0.11.0/tests/providers/test_webiq.py +216 -0
  402. webrouter-0.11.0/tests/providers/test_webiq_options.py +86 -0
  403. webrouter-0.11.0/tests/providers/test_you.py +118 -0
  404. webrouter-0.11.0/tests/providers/test_you_answer.py +184 -0
  405. webrouter-0.11.0/tests/providers/test_you_mcp.py +188 -0
  406. webrouter-0.11.0/tests/providers/test_you_options.py +124 -0
  407. webrouter-0.11.0/tests/providers/test_you_research.py +199 -0
  408. webrouter-0.11.0/tests/routing_helpers.py +168 -0
  409. webrouter-0.11.0/tests/shape_baseline.json +43 -0
  410. webrouter-0.11.0/tests/test_answer_contract.py +220 -0
  411. webrouter-0.11.0/tests/test_auto.py +1142 -0
  412. webrouter-0.11.0/tests/test_auto_browse.py +513 -0
  413. webrouter-0.11.0/tests/test_auto_browser.py +414 -0
  414. webrouter-0.11.0/tests/test_auto_extract_options.py +81 -0
  415. webrouter-0.11.0/tests/test_auto_research.py +493 -0
  416. webrouter-0.11.0/tests/test_auto_seam.py +86 -0
  417. webrouter-0.11.0/tests/test_benchmark.py +413 -0
  418. webrouter-0.11.0/tests/test_benchmark_extract.py +368 -0
  419. webrouter-0.11.0/tests/test_browse_contract.py +495 -0
  420. webrouter-0.11.0/tests/test_browser_actions.py +1056 -0
  421. webrouter-0.11.0/tests/test_browser_contract.py +1242 -0
  422. webrouter-0.11.0/tests/test_candidates.py +153 -0
  423. webrouter-0.11.0/tests/test_candidates_broken.py +45 -0
  424. webrouter-0.11.0/tests/test_candidates_budget.py +160 -0
  425. webrouter-0.11.0/tests/test_candidates_effort.py +49 -0
  426. webrouter-0.11.0/tests/test_candidates_effort_refused.py +26 -0
  427. webrouter-0.11.0/tests/test_candidates_repr.py +19 -0
  428. webrouter-0.11.0/tests/test_chains.py +72 -0
  429. webrouter-0.11.0/tests/test_client.py +609 -0
  430. webrouter-0.11.0/tests/test_constants.py +15 -0
  431. webrouter-0.11.0/tests/test_contract.py +302 -0
  432. webrouter-0.11.0/tests/test_deadline_auto.py +199 -0
  433. webrouter-0.11.0/tests/test_deadline_jobs.py +257 -0
  434. webrouter-0.11.0/tests/test_deadline_transport.py +323 -0
  435. webrouter-0.11.0/tests/test_decide_priced.py +25 -0
  436. webrouter-0.11.0/tests/test_decided_repr.py +16 -0
  437. webrouter-0.11.0/tests/test_decision_fill.py +172 -0
  438. webrouter-0.11.0/tests/test_decision_hops.py +199 -0
  439. webrouter-0.11.0/tests/test_decision_record.py +76 -0
  440. webrouter-0.11.0/tests/test_effort_refused_budget.py +77 -0
  441. webrouter-0.11.0/tests/test_error_pages.py +53 -0
  442. webrouter-0.11.0/tests/test_evals.py +1044 -0
  443. webrouter-0.11.0/tests/test_exceptions.py +16 -0
  444. webrouter-0.11.0/tests/test_extract_contract.py +568 -0
  445. webrouter-0.11.0/tests/test_import_graph.py +45 -0
  446. webrouter-0.11.0/tests/test_integration_answer.py +109 -0
  447. webrouter-0.11.0/tests/test_integration_surface.py +244 -0
  448. webrouter-0.11.0/tests/test_job_core.py +433 -0
  449. webrouter-0.11.0/tests/test_plan_run.py +112 -0
  450. webrouter-0.11.0/tests/test_plan_run_errors.py +125 -0
  451. webrouter-0.11.0/tests/test_png.py +32 -0
  452. webrouter-0.11.0/tests/test_prices.py +159 -0
  453. webrouter-0.11.0/tests/test_prices_latency.py +206 -0
  454. webrouter-0.11.0/tests/test_pricing_rule.py +71 -0
  455. webrouter-0.11.0/tests/test_provider_contracts.py +101 -0
  456. webrouter-0.11.0/tests/test_registry.py +117 -0
  457. webrouter-0.11.0/tests/test_research_contract.py +615 -0
  458. webrouter-0.11.0/tests/test_routed_calls.py +175 -0
  459. webrouter-0.11.0/tests/test_routed_params.py +198 -0
  460. webrouter-0.11.0/tests/test_routed_params_runnable.py +27 -0
  461. webrouter-0.11.0/tests/test_router_failures.py +119 -0
  462. webrouter-0.11.0/tests/test_router_forms.py +94 -0
  463. webrouter-0.11.0/tests/test_router_invoke_edges.py +159 -0
  464. webrouter-0.11.0/tests/test_router_threads.py +77 -0
  465. webrouter-0.11.0/tests/test_routing_contract.py +156 -0
  466. webrouter-0.11.0/tests/test_routing_merge.py +200 -0
  467. webrouter-0.11.0/tests/test_routing_merge_extract.py +43 -0
  468. webrouter-0.11.0/tests/test_routing_merge_extract_only.py +47 -0
  469. webrouter-0.11.0/tests/test_routing_merge_params.py +179 -0
  470. webrouter-0.11.0/tests/test_routing_merge_priced.py +59 -0
  471. webrouter-0.11.0/tests/test_routing_merge_urls.py +123 -0
  472. webrouter-0.11.0/tests/test_routing_params.py +128 -0
  473. webrouter-0.11.0/tests/test_search_depth_levels.py +158 -0
  474. webrouter-0.11.0/tests/test_search_options.py +81 -0
  475. webrouter-0.11.0/tests/test_shape.py +118 -0
  476. webrouter-0.11.0/tests/test_template_contract.py +246 -0
  477. webrouter-0.11.0/tests/test_types.py +33 -0
  478. webrouter-0.11.0/tests/test_types_benchmark.py +28 -0
  479. webrouter-0.11.0/tests/test_url_policy.py +218 -0
  480. webrouter-0.11.0/tests/test_url_policy_router.py +93 -0
  481. webrouter-0.11.0/tests/test_urls.py +50 -0
@@ -0,0 +1,74 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ sdist/
16
+ wheels/
17
+ share/python-wheels/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+ MANIFEST
22
+
23
+ # Unit test / coverage reports
24
+ htmlcov/
25
+ .tox/
26
+ .nox/
27
+ .coverage
28
+ .coverage.*
29
+ .cache
30
+ coverage.xml
31
+ *.cover
32
+ .hypothesis/
33
+ .pytest_cache/
34
+ junit.xml
35
+
36
+ # Type checkers / linters
37
+ .mypy_cache/
38
+ .dmypy.json
39
+ dmypy.json
40
+ .pyre/
41
+ .pytype/
42
+ .ruff_cache/
43
+
44
+ # Environments
45
+ .env
46
+ .env.*
47
+ !.env.example
48
+ .venv
49
+ venv/
50
+ ENV/
51
+ env/
52
+
53
+ # Editors
54
+ .idea/
55
+ .vscode/
56
+ *.swp
57
+ *.swo
58
+
59
+ # OS
60
+ .DS_Store
61
+ Thumbs.db
62
+
63
+ # Jupyter
64
+ .ipynb_checkpoints
65
+
66
+ # Local scratch
67
+ .scratch/
68
+ scratch/
69
+ .claude/
70
+
71
+ # Agent benchmark runs (benchmarks/agent/run.py)
72
+ benchmarks/agent/results/
73
+ benchmarks/agent/.managed-agents.json
74
+ benchmarks/providers/results/
@@ -0,0 +1,212 @@
1
+ # Changelog
2
+
3
+ All notable changes to `web-router` are recorded here, newest first. Versions
4
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html); while on
5
+ `0.x`, a breaking change bumps the minor version.
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ ### Fixed
12
+
13
+ ## [0.11.0] - 2026-11-09
14
+
15
+ ### Changed
16
+
17
+ - The repository moved to [github.com/Nimbleway/webrouter](https://github.com/Nimbleway/webrouter). Links to the old location redirect, and the package metadata now points to the new one. Security reports go through GitHub's private vulnerability reporting (see SECURITY.md).
18
+ - **Breaking (install name):** the package is now named `webrouter` and is published to [PyPI](https://pypi.org/project/webrouter/): install it with `pip install "webrouter[all]"` or `uv add "webrouter[all]"`. Earlier releases were named `web-router` and published only to a private index. The import is unchanged (`import web_router`), as are the `web-router-mcp` command and the MCP server's name.
19
+
20
+ ## [0.10.0] - 2026-11-09
21
+
22
+ ### Added
23
+
24
+ - `extract(profile=...)`: a saved identity on the backend whose cookies are sent with each fetch, the same `profile` a browser session starts from, so a login made in a session serves the fetches that follow. `browserbase` is the first extract backend to map it, through a new `BrowserbaseExtractProvider` over the Fetch API (`POST /v1/fetch`, a plain fetch that never renders the page; `markdown` and `html`, the latter Fetch's `raw`), with `profile` sent as `contextId`; it joins extract `auto` and `max` last, after DeepSeek, so no call that was already being served moves. Verified live by hand on 2026-10-29, and by the automated live tests on 2026-11-08: a cookie set in a persisting session came back from a fetch naming the same Context, and from none without it. A backend without saved profiles refuses `profile` rather than fetching anonymously, and extract `auto` and `max` now pass over a member that does not map an option the request sets, as search `auto` does, raising `UnsupportedParameterError` when none is left. The MCP `extract` tool never offers `profile`, as `browser_open` never has.
25
+ - web-router is open source under the Apache License 2.0: `LICENSE` at the root, and `license = "Apache-2.0"` in the package metadata.
26
+ - Routers (experimental): `router=` on `WebRouter` and `AsyncWebRouter` attaches your own router, `RouteRequest -> RouteDecision`: a function, method, partial or coroutine function, or an object with `route` or `aroute` (one that is only callable is refused) (typed as `RouterLike`, exported from `web_router`). It is shown the prompt and its priced, timed candidates, and answers the method and the order of `auto`'s backends. `route_tools=` limits the methods it orders; a router that fails leaves the static order, or with `fail_on_router_error=True` raises `RouterError`. An extract you did not pin, left with no URL to read, searches instead when a search backend is offered, and otherwise `plan()` raises `MissingParameterError`. With no router nothing changes. See the README's [Smart routing](README.md#smart-routing-experimental).
27
+ - `plan(prompt, budget=, tool=)` and `run(plan_or_prompt, budget=, tool=)`, sync and async (experimental): `plan` works out a prompt's method, options and backends without running anything and returns a `Plan`; `run` runs it and returns that method's own response. A `Plan`'s `repr` shows neither its prompt nor a whole URL. `run` on a plan left with no backend calls nothing and raises. See [`plan()` and `run()`](README.md#plan-and-run).
28
+ - Budgets (experimental): `budget=Budget(max_cost_usd=, time_limit_s=, strict=)` on `search`, `extract`, `answer`, `research`, `browse`, `plan` and `run`, and `with wr.budget(Budget(...)) as b:` for that router's calls to share one (another router's calls inside the block are untouched), with `plan()`, `run()`, `submit_research()` and `submit_browse()` on `BudgetScope` and `AsyncBudgetScope` too, and `status()` returning a `BudgetStatus` (exported from `web_router`) of what is spent, reserved and left; a cap that is not a finite number of 0 or more raises `ValueError`. Costs are list-price estimates, and under a cost cap an unknown price never runs, a price marked as a floor, a request with `provider_options` or a search asking for more results than its price assumes (`results_assumed`) included; the cap holds estimates, not the bill. A router's options are priced on the request the call runs -- `effort`, `search_depth`, `max_results` past `results_assumed`, extract's URLs per page -- and one that does not fit is set aside in `ignored`. A budgeted research call runs at one effort, chosen once before the router is asked and only from efforts each backend takes for the request (a Nimble enrichment under a $0.10 cap runs at `medium`, not `low`), on `auto`, a named backend, `plan`/`run` and `submit_research` alike. A call inside a shared budget is capped at what is left, and the router is shown that; a call spends from every scope of its router in force, so a scope made earlier cannot escape an outer block's cap. A call a backend makes inside a budgeted call, on its own router or another, is reserved once, by the call it is made in, and still settles in the shared budget when it runs in another thread or task or after its parent ended. A call where nothing fits raises `BudgetExceededError`; a time cap stops search, extract and answer with `DeadlineExceededError`. See [Budgets](README.md#budgets).
29
+ - `url_policy="public_only"` on `WebRouter` and `AsyncWebRouter` (experimental): a plan or a router does not send a loopback, private or cloud-metadata URL to a backend as a URL (it stays in the prompt text). It reads the host as written, in the spellings a browser reads, NAT64 and IPv4-compatible IPv6 included; a numeric host resolvers read differently (`010.0.0.1`) is refused outright. It resolves no DNS and follows no redirect. Off by default; a URL with no host is refused even then. See [`url_policy`](README.md#url_policy). A bare `0x` part of an IPv4 host reads as 0, as a browser reads it, so `http://127.0x.0x.1/` is refused as loopback; the MCP browser tools' navigation guard reads hosts the same way. A 6to4 host (`2002::/16`) is judged by the IPv4 it carries when that address is not public, the same on every Python.
30
+ - The decision record (experimental): a `DecisionRecord` in a new `decision` field on every routed or budgeted call's response and on every `Plan` -- what was offered, ranked, tried and skipped, and the estimated spend. It holds no prompt text: `prompt_sha256` is an unsalted SHA-256 of what the router reads (the query or task, an extract's whole URLs, a plan's whole prompt), a pseudonymous id, not anonymisation: a prompt or URL that can be guessed can be confirmed from it. Elsewhere a URL, scheme-relative ones included, shows only its scheme and host, a non-ASCII host as IDNA. A research or browse job started inside a budget keeps one in `Job.decision`, with the effort the budget chose and what it charged the start (`estimated_cost_usd`, `attempts`), and the answer `result()` returns carries it. `DecisionHop` and `DecisionAttempt` import from `web_router` and `web_router.types`. See [The decision record](README.md#the-decision-record).
31
+ - The MCP server's `web_run`, `budget_start` and `budget_status` tools (experimental): `web_run` plans and runs a prompt, through the built-in keyless rules router (`web_router.routing.rules`) unless `WEB_ROUTER_MCP_ROUTER`, operator code set only in the server's process environment, names another; set empty, it stops the server. Every billed tool spends from one budget per connection, under the operator's ceiling (`WEB_ROUTER_MCP_MAX_COST_USD`, `WEB_ROUTER_MCP_TIME_LIMIT_S`, read once from the process environment; a value it cannot read, empty included, stops the server) and the task `budget_start` opens; `budget_status` reports the task, the connection's totals and the ceiling. A browser session is priced per whole minute of its `timeout`, a browser option that can bill an extra makes a paid backend's price unknown, Anchor's browser price is a floor, and extract's `max` is refused under a cost cap. `web_run`'s own caps must be finite and positive, and its time cap covers planning too; `url_policy` holds for its plans only, which the capabilities' `url_policy_note` says. When it picks research or browse, it waits 25 seconds, or less if its time cap or the budget has less left, then hands back a handle as those tools do, its note giving the wait it used. The capabilities resource gains a `routing` section, which names an operator's router `operator`, and the browser tools' navigation guard reads NAT64 and IPv4-compatible addresses as the IPv4 they carry. See [docs/mcp.md](docs/mcp.md#routing-and-budgets-web_run-budget_start-budget_status). An operator's router shows as `operator`, in the capabilities resource and in `web_run`'s `decision.router`, never by its import path or name.
32
+ - Four example routers in `examples/` (experimental): `router_rules.py`, rules on the prompt; `router_classifier.py`, a scikit-learn classifier (`uv run --with scikit-learn`); `router_llm.py`, any OpenAI-compatible model; and `router_jev.py`, TypeSafe's Jev. The rules router takes the 50 ms default; the other three declare `timeout_s` of 0.05, 3.0 and 1.0. The model routers send the prompt to their server, and the Jev router refuses a probability outside 0 to 1. All four are tested offline.
33
+ - `web_router.prices` (experimental): the estimated list price of every (provider, method, mode) web-router calls, from `web_router/data/prices.json`, with `estimate()`, `price()` and `prices()`, and the measured p50 and p90 latency of search and answer rows (`estimate_latency()`). `make prices` writes `PRICING.md`'s tables from it, and `make latency` the latency from the frozen results.
34
+ - `benchmarks/providers/frozen/`: the per-question results route-eval and the latency figures rest on, committed with no question or answer text; `frozen.py join` puts them back from the public sets. Experimental until 1.0.
35
+ - `benchmarks/providers/tools_effort.py` (experimental): the tool and effort set, 60 questions each run through search then read, extract, `answer()` and `research()` at each effort, under a hard `--budget`. Its first run is frozen as `tools-effort-60`.
36
+ - route-eval, `benchmarks/providers/route_eval.py` (experimental): replays a router -- `static`, `oracle`, `always:<backend>` or your own `RouteRequest` → `RouteDecision` callable -- over the frozen results, offline, through the library's merge rule, and scores accuracy, cost, latency and regret against the static order, held out and in-sample, against each method's noise floor. It covers search and answer backend order and, on `tools-effort-60`, the method and research effort.
37
+ - Search options: `recency` (`day` | `week` | `month` | `year`) and `effort` (`low` | `medium` | `high` | `max`) on `search()` and `asearch()`, `SearchRequest`, the benchmark, the MCP `search` tool and the Claude, OpenAI and LangChain tools. `effort` is how hard the backend looks, anchored on its own tiers: `medium` is what it does unset, `low` its fastest tier, `max` its most thorough; a backend with fewer tiers serves several levels with one setting. Higher effort costs more and takes longer, by a vendor-specific amount. Each `search_depth` level is now a size: `lite` a snippet of up to about 500 characters, `fast` a truncated body of a few thousand, `deep` the whole page. A backend that maps an option but cannot serve a value of it (a depth level it lacks, a country off its list) refuses it before anything is sent, `auto` passes it over, and a router's default option is dropped when a backend in the order would refuse it. The MCP `search` tool offers `search_depth` only at the levels a reachable backend serves. `recency` now reads "sources the backend dates within the window" on `answer()` too. `Recency` and the depth and effort levels live in `web_router.types_options`; `web_router.types` and `web_router` still export them. See the README's [Search options](README.md#search-options).
38
+ - Internals (experimental, for router authors and contributors): `web_router.routing.candidates.build_candidates()` (a research backend that refuses the effort the budget maps for the request, such as Nimble's enrichment at `low`, is left out with its reason), `web_router.routing.merge.merge()`, `web_router.routing.params.map_effort()` (from `data/efforts.json`, `make efforts`), `web_router.routing.pricing`, and the per-call deadline under every method, which clamps each request to the time left. Their docstrings hold the rules.
39
+ - Every MCP tool now carries a `title` and MCP behaviour hints (read-only, destructive, idempotent, open-world), so a client can let free lookups through and ask before a billed run or a page action.
40
+ - MCP tool results now carry `structuredContent`, the same object as their JSON text, and reading an unknown resource returns MCP's resource-not-found error (-32002) instead of an internal error.
41
+
42
+ ### Changed
43
+
44
+ - Search backends map the shared options their APIs document, each refusing before the call a value it cannot serve (README's [Search options](README.md#search-options) has the tables; each `contracts/search/<name>.json` the fields sent):
45
+ - Exa and Exa MCP: every option but `locale`. Exa MCP now calls Exa's keyless `web_search_advanced_exa` tool (named in `?tools=` on the server URL) instead of `web_search_exa`, and no longer sends `objective`, which that tool refuses. Unset depth still asks Exa for the whole text and Exa MCP for highlights. Exa's highlights cap is soft, so `lite` is cut to a snippet of about 500 characters by web-router.
46
+ - Perplexity: every option; `search_depth` at `lite` (cut to about 500 characters by web-router) and `fast` only, one domain list of at most 20, and `locale` as a bare two-letter language.
47
+ - Tavily: every option but `effort`. `country` becomes the country name Tavily takes (a ranking boost, general topic only); `locale` and `recency` are filters, and `recency` drops pages Tavily cannot date. `lite` is `basic` cut to about 500 characters by web-router, since `basic` returns up to three chunks of about 500.
48
+ - You.com and You.com MCP: every option but `effort`, `country` from its 36 codes and `locale` from its 51-language list (`pt` or `zh` alone refused), one domain list at a time. You.com MCP sends `site:`, `lang:` and `loc:` in the query, which its tool applies. `lite` is its snippets cut to about 500 characters by web-router.
49
+ - Brave: every option but `effort`, `search_depth` at `lite` alone, its description and extra snippets cut to about 500 characters by web-router, since Brave has no size setting; domains as `site:` operators (Brave marks them experimental), `country` and `locale` from its lists, and a query past 600 characters or 75 words with the operators refused.
50
+ - Parallel: every option but `locale`; `search_depth` at `lite` and `fast`, one domain list of at most 200, `country` from the 37 it geo-targets, and `effort` picking the mode (`low` `turbo`, `high` and `max` `advanced`). `lite` is cut to about 500 characters by web-router, since Parallel's excerpt cap returned about 1,400 live.
51
+ - WebIQ: every option but `recency` and `effort`; domains as `site:` operators, `country` as a two-letter region code and `locale` as an ISO 639-1 language (an unlisted one such as `zz` is refused), `deep` as each page's whole text.
52
+ - Keenable: `search_depth` at `lite` and `fast`, one `include_domains` entry, `recency` and `effort`.
53
+ - Linkup: `recency` and `effort` (as its `depth`: `low` `flash`, `medium` `fast`, still sent when unset, `high` `standard`, `max` `deep`, which Linkup bills at ten times the others).
54
+ - DeepSeek: `recency` as `after:` in the query, and `search_depth` at `lite` (its summary cut to about 500 characters by web-router) and `deep` (the page body).
55
+ - A domain filter that goes into the query as `site:` (Brave, WebIQ and DeepSeek, and You.com MCP's `include_domains`) must be a host name, optionally with a path; anything else is refused before the call with `UnsupportedParameterError` (and `auto` passes the backend over), so a value cannot add its own operators.
56
+ - AgentCore Web Search: `recency`, on connector 1.2.0 or later.
57
+ - Nimble: `recency` as its `time_range`; it refuses `effort`, and no longer takes a new shared option unmapped.
58
+ - Parallel search moves from `/v1beta/search` to `/v1/search`, with `mode=fast` pinned (unset, `/v1` runs `advanced`, the old `agentic`, at five times the price). `max_results` now goes under `advanced_settings`, and an `advanced_settings` object in `provider_options` is merged with it rather than replacing it. The beta-only `processor`, `excerpts` and top-level `max_chars_per_result` are rejected by `/v1`.
59
+ - `PageState`, `PageSnapshot`, `Screenshot`, `PageLoadState` and `PageFormat` move from `web_router.types` to `web_router.types_page`. `from web_router import ...` and `from web_router.types import ...` work as before.
60
+ - `BenchmarkRun`, `BenchmarkReport`, `ExtractBenchmarkRun` and `ExtractBenchmarkReport` move from `web_router.types` to `web_router.types_benchmark`. `from web_router import ...` and `from web_router.types import ...` work as before.
61
+ - `auto`'s default `search` (keyed) and `answer()` orders are now generated by `make chains`, ranked by accuracy on the frozen `sealqa-hard-254` and `answer-simpleqa-300` sets (method in `benchmarks/providers/README.md`). Search: perplexity, nimble, webiq, linkup, tavily, exa, parallel, brave, you (was perplexity, nimble, linkup, tavily, brave, webiq, parallel, exa, you). Answer: perplexity, you, linkup, parallel, exa, brave (was perplexity, exa, you, linkup, parallel, brave). The previous search order came from a SimpleQA scan; search is now ranked on SealQA hard because on SimpleQA every keyed backend scores 81-95%, too close to tell them apart, while on SealQA hard they differ. With every key, Perplexity still serves first; with some, another may, and some cost more: with Brave and Exa keys, a search now goes to Exa ($0.017) before Brave ($0.005). Keep the old order with `provider_config={"auto": {"chain": {...}}}`.
62
+ - The provider scan's recommended order now uses route-eval's ranking: within the noise floor the cheaper backend goes first (a row's stated cost, else its list price), and the keyless tier last. A search that failed on a rate-limit or quota error is left out, not counted a miss, and shown in the *Rate limit or quota* column.
63
+ - Contributors: `make check` now limits size. Ruff holds a function to 50 statements (`PLR0915`) and a complexity of 10 (`C901`); the few over either today carry a `noqa`. `tests/test_shape.py` holds a file to 400 lines, test files included; files already longer are listed in `tests/shape_baseline.json` and may only shrink, and `make shape-baseline` records a shrink and refuses a new or grown entry. `CHANGELOG.md` merges with git's union driver, so parallel pull requests that each add a line no longer conflict.
64
+
65
+ ### Fixed
66
+
67
+ - Tavily: a `session` (`requests.Session`) or `client` (`httpx.AsyncClient`) passed to the Tavily search or extract provider goes only to the SDK client that takes it. Before, either one raised `TypeError` in the other client.
68
+ - The MCP server now loads `BRAVE_ANSWER_API_KEY` and `JINA_API_KEY` from a `.env` file, at startup and through `load_env`. Before, both were ignored, so a Brave Answers key could not turn on `answer` the way the server's own hints said.
69
+
70
+ - The MCP server reads every `.env` it finds at startup, not just the first one that sets a key: `WEB_ROUTER_ENV_FILE`, then your user config directory's `.env`, then a path `load_env(remember=true)` recorded, then the working directory's `.env`. An earlier file wins per variable and a later one only fills what is still unset, so a project's `.env` or a remembered file can add keys but never replace yours. Before, a project `.env` with any one key hid your user config `.env` entirely. The capabilities resource's `env.loaded` now maps each variable to the file it came from (it replaces `env.file`), and a file whose keys were all set already reports `nothing new`. `load_env` now tells the model to use only a path you gave and to set `remember` only when you ask.
71
+
72
+ - An MCP tool call with an argument the tool does not declare, such as a typo or a guessed `recency`, is now refused with the names the tool takes, instead of running with the argument silently dropped.
73
+
74
+ - An MCP result that is over its size budget now cuts every result's text further before it drops any result, so `max_results=3` returns three results; before, it dropped whole results first.
75
+
76
+ - The `mcp` extra now requires `mcp>=2.2,<3`. It allowed 1.x, which cannot import the server.
77
+
78
+ - The MCP tool list is about a sixth smaller (31 KB to 26 KB with no keys): each rule is said once, in the server instructions or the one tool it belongs to, and `browser_open`'s description is cut from 1,769 to 623 characters. The no-key hint is in the instructions, not repeated on `search`.
79
+
80
+ - The MCP server refuses, before sending anything, a URL passed to `extract`, `browse` (`start_url`) or `run_template` (a URL among its `inputs`) that is not a public `http(s)` URL: `file:`, loopback, private and cloud-metadata hosts are named with the reason, by scheme and host only. Before, they went to every backend in the chain. The server instructions now also say that results are untrusted web content.
81
+
82
+ - `extract()` counts a page that came back with a 4xx or 5xx status and no content as a failure, named in `failures` with its status, instead of a successful empty document. A call with nothing else raises, so `auto` tries its next backend. A page with text, such as a site's own "not found" page, is still returned.
83
+
84
+ - MCP errors for a missing key no longer tell the model to pass `provider_config`, which a conversation cannot do; they say to put the key in the user's `.env` and call `load_env`. With no template backend configured, `templates`, `template` and `run_template` say which key would add one, instead of asking for a `provider` argument their schema does not offer.
85
+
86
+ ### Removed
87
+
88
+ - The decider spike: `WebRouter(decider=, decider_config=)`, `WebRouter.classify()`, `WebRouter.execute()`, `Route` and the `web_router.decisions` package (`DecisionProvider`, `register_decider()` and the `keywords`, `jev` and `llm` deciders). It was a proof of concept for routing; `router=`, `plan()` and `run()` replace it. To keep using it, pin `web-router==0.9.0`, the last version that has it.
89
+
90
+
91
+
92
+ ## [0.9.0] - 2026-11-04
93
+
94
+ ### Added
95
+
96
+ - `Template.samples`: the runs a backend recorded for a template, each a `TemplateSample` (importable from `web_router`) with the `input` it accepted and the `output` record it returned -- the shortest route to a first run of a template you have not run before. Nimble publishes one per template, and its catalogue rows carry the published version whole, so a `templates()` page describes each template as fully as `template()` does, schemas and samples included. The MCP `template` tool returns `samples` beside the schemas, whole or not at all, as the schemas go; a listing row from `templates` now carries `required_inputs`, the schema's own `required` list, and `sample_inputs`, the first recorded input that names any, left out whole when it is too large for the listing, so a URL-keyed template is told from an id-keyed one without a second call. `run_template`'s description says what a run bills: one template run on the vendor's account, on Nimble at one flat rate for every template, with the dated figure in `benchmarks/providers/PRICING.md` and never in code.
97
+ - `answer()`, a seventh method, and its async twin: `WebRouter.answer(query, provider=...)` asks one question and returns the backend's own written answer with the sources it rests on, in one call -- between `search()`, which returns pages, and `research()`, a job of minutes. `AnswerResponse` carries `answer`, `citations` (the shared `Citation`; an inline marker in the answer goes in `field`), `outcome` (`partial` only when the backend says it stopped early), `request_id` and `raw`, one payload per call. An empty answer is returned as `""`, never raised. The options are `include_domains`, `exclude_domains`, `country`, `locale` and `recency` (`day`, `week`, `month` or `year`); a backend refuses each one it does not map with `UnsupportedParameterError`, before anything is sent. With `provider` unset the router's default serves the call when it is an answer backend, else the one configured answer backend, and a key passed in `provider_config` counts, and otherwise it raises `NoAnswerProviderError`, a `ProviderNotFoundError`, naming the configured backends when there are several and the variables to set when there are none. On the usual default, `auto`, that is answer()'s own `auto`: every answer backend you hold a key for, cheapest first at list price (Perplexity, Exa, You.com, Linkup, Parallel, then Brave). A backend that does not map an option the request sets is passed over without being built, one that refuses a value before sending or fails or answers with nothing is passed over for the next, each one tried bills, and with no key it raises `NoAnswerProviderError` naming the variables to set. `provider_config={"auto": {"chain": {"answer": [...]}}}` sets the order, and `fallback_on_empty=False` returns the first answer, empty or not. `AnswerProvider` and `HttpAnswerProvider` to add a backend, `register_answer_provider()`, `available_answer_providers()` and `configured_answer_providers()` beside the other registries, and an `answer` extra, which `mcp` and `all` include.
98
+ - `benchmarks/providers/answer.py`, the answer scan: every configured answer backend on the same questions as the search scan, each backend's own answer graded by the same judge, with `--baseline` setting a finished search scan of the same questions and judge beside it. A row whose sources include one of SealQA's own pages, cited or only read, is marked leaked and left out of accuracy; cost is shown only where a backend states it. The latencies quoted for each answer backend are the medians from its run on 300 SimpleQA questions on 2026-11-01, graded by `openai:gpt-4o`. `PRICING.md` gains an Answer section.
99
+ - `answer()` as a tool for an agent framework. The Claude and OpenAI `WebRouterTools` take `"answer"` in `methods` and serve `web_router_answer`, sent to `answer_provider`, else to `provider` when it answers, else to the router's default answer backend; with none to use, building the tools raises `NoAnswerProviderError`. LangChain gains `WebRouterAnswer` (`web_answer`). The model sends only the question and reads the answer whole with its citations, each snippet cut to `result_chars`; `routing_guide()` gains a paragraph for it when it is exposed. It is never exposed by default, since every answer backend needs a key and bills each call, so the default tools, the guide and the agent benchmark are unchanged.
100
+ - The MCP server's `answer` tool, its twenty-first: the same call as `answer()`, shaped to fit a context window with the answer first, each citation's `field` marker kept, and a handle for `fetch_raw`. `provider` is optional, and only the configured answer backends are offered; unnamed, the router's `auto` tries them cheapest first. The capabilities resource keys its backends as `answer`, and `unlock` lists the ones a key would add.
101
+ - Perplexity as an answer backend (`perplexity`): its Agent API at the `fast` effort, one call to `/v1/responses` answering in about two seconds. Maps `include_domains` or `exclude_domains` (its `search_domain_filter`, an excluded domain prefixed `-`; both together are refused before the call), `recency` and `country` (`user_location`) through the `web_search` tool, set only when a filter is; `locale` is refused. The answer cites inline as `[N]`, and each source it cites carries its marker in `Citation.field`. An `incomplete` response is `partial`. Verified live.
102
+ - You.com as an answer backend (`you`): its Answer API on `api.you.com`, answering in about two seconds. Maps `include_domains`, `exclude_domains` and `country` to its own fields and `recency` to `freshness`; it takes one domain list, so asking for both raises before the call, and `locale` is refused. Its citations are only the sources the answer cites, marked `[[1, 2]]`; each carries its marker in `Citation.field` and its title from the pages considered. Verified live.
103
+ - Parallel as an answer backend (`parallel`): its Chat API (beta) with the `lite` model, answering in about six seconds, the fastest model whose `basis` ties its sources to the answer. Each citation carries the basis's `confidence` in `extra`; a `finish_reason` of `length` is `partial`. The Chat API takes no source filter, so every option is refused. Verified live.
104
+ - Exa as an answer backend (`exa`): its Answer API, answering in about 1.3 seconds with its stated cost in `raw`. The answer cites inline as `[N]`, numbering its citations from 1, and each cited source carries its marker in `Citation.field`. No option is mapped: the API has no source filter, and `userLocation` showed no clear effect, so `country` is refused. Verified live.
105
+ - Linkup as an answer backend (`linkup`): its Search API with `outputType: sourcedAnswer`, answering in about a second. Maps `include_domains`, `exclude_domains` and `recency`, sent as `fromDate` that far back from today; `country` and `locale` have no Linkup field and are refused. Its sources are every page read; `includeInlineCitations` is always sent, so the answer marks the ones it rests on as `[N]`, and each carries its marker in `Citation.field`. Verified live.
106
+ - Brave as an answer backend (`brave`): its Answers API, `POST /res/v1/chat/completions` with `model: brave`, answering in two to three seconds. It returns its sources only on a streamed call with `enable_citations`, so the router streams it and reads the whole stream; the answer is the text with Brave's `<citation>` and `<usage>` tags taken out, and each citation tag becomes a `Citation`, in order, its `number` and its span in the answer in `extra`. `raw` keeps the stream's events, the `<usage>` tag with the call's stated cost among them. It reads `BRAVE_ANSWER_API_KEY`, a key on Brave's Answers plan, separate from `BRAVE_SEARCH_API_KEY`. No option is mapped: the API has no source filter, and `language` showed no effect, so `locale` is refused. Verified live.
107
+
108
+ ### Changed
109
+
110
+ - The `evals` extra now allows openai 3 (`openai>=1.0,<4`), and the lock moves to openai 3.45.0 and pyarrow 26.0.1 (25.0.1 stays for Python 3.10, which pyarrow 26 no longer supports). openai 3 uses HTTPX2 and no longer installs `httpx`; web-router still declares `httpx` itself, so nothing else changes. The Parallel answer backend's docs link points at its Chat Completions reference, since the old Chat API guide is gone.
111
+ - The `max_results` description, in `SearchRequest`'s JSON schema and the MCP `search` tool, now steers an agent to 5 to 10 results instead of one. The README's "Wrapping `search` for your own agent" shows how to pin the number instead.
112
+ - `auto` now moves to the next backend when one answers with nothing usable, as it does on an error: a search with no results and no `answer`, or an extract whose every document is blank. A document's page status is not read, since only some backends report one. When every member answers empty, the first empty answer comes back, naming its backend, rather than an error. A call that ends empty has tried every backend that can serve it, one after another, and each one you hold a key for bills for it. `provider_config={"auto": {"fallback_on_empty": False}}` returns the first answer, empty or not; the MCP server has no such switch. `max` is unchanged.
113
+ - Nimble's `samples` moved out of `Template.extra` into the typed `Template.samples`; a caller reading `template.extra["samples"]` reads `template.samples` now.
114
+
115
+ ### Fixed
116
+
117
+ - An error whose exception has no text -- httpx's read and connect timeouts -- now names its type instead of ending blank: a timed-out answer read `answer failed: ` and now reads `answer failed: ReadTimeout`. The same for every method's wrapped failure and the judge's.
118
+
119
+ ## [0.8.0] - 2026-10-28
120
+
121
+ ### Added
122
+
123
+ - AWS AgentCore Web Search as a search backend (`agentcore`), through the caller's own AgentCore Gateway, set in `AGENTCORE_GATEWAY_URL`. Requests are SigV4-signed with botocore's usual credential chain, for the region in the gateway's hostname; botocore comes with a new `aws` extra, which `all` includes. Maps `max_results` (at most 25), `include_domains` and `exclude_domains`; on a target older than connector 1.2.0, which takes no filters, the domain options raise rather than being dropped. A date range goes through `provider_options["filters"]["publishedDateFilter"]` and merges with the mapped domains. `auto` tries it last, after DeepSeek, so no existing call changes backend. Setup and teardown: `docs/agentcore-setup.md`.
124
+
125
+ ### Fixed
126
+
127
+ - Screenshots on Anchor are PNG, as on every other backend. Anchor's browser answers a PNG request with JPEG, even over raw CDP, so `Screenshot.png` held JPEG bytes, `width` and `height` were read from the wrong header (65536 by 4293001688), and the MCP server sent them as `image/png`. A capture that is not PNG is now converted, with Pillow, which the `browser` extra now installs. Checked live on Anchor.
128
+
129
+ ## [0.7.0] - 2026-10-26
130
+
131
+ ### Added
132
+
133
+ - Templates, a sixth method: `WebRouter.run_template(name, inputs, provider=...)` runs one of a vendor's saved, site-specific extractions (Nimble's Extract Templates today) on one page and returns the record it parsed in `TemplateResponse.data`, `templates(provider=...)` lists one page of the vendor's catalogue as a `TemplatePage`, and `template(name, provider=...)` describes one as a `Template` with the JSON Schema of the inputs a run takes and the record it returns; each has its async twin. A template's inputs are its own -- a URL for some, an id, a slug or a search term for others -- which is why this is a method rather than an option on `extract`, and its name belongs to one vendor, which is why `provider` is required and there is no template `auto`. `format` asks for the page beside the record as `markdown` or `html`; unset, no page comes back. A run the backend could not complete, a page the site answered with 4xx or 5xx, or a parse that failed raises `ProviderError` with the reason, never an empty record; an unknown name raises the new `TemplateNotFoundError`. Nimble is the first backend (`NimbleTemplateProvider`, installed by `[extract]`, verified live), with `TemplateProvider` to add another and `register_template_provider()`, `available_template_providers()` and `configured_template_providers()` beside the other registries. The MCP server adds `templates`, `template` and `run_template`, which alone require `provider`, carry handles for `fetch_raw`, and are keyed as `template` in the capabilities resource.
134
+ - `contracts/<method>/<provider>.json`: for every provider, the wire fields each option value sends, read from the provider's own code by `make contracts`, beside the vendor's reference pages, where each mapping was verified (live or in the docs) and the vendor's options no mapping sends. CI fails when a contract no longer matches its provider or leaves a hand-written field empty, so a mapping cannot change without its contract. Extract Template backends have one too. [AGENTS.md](AGENTS.md#provider-contracts) says how to start one.
135
+ - `max`, an extract provider that sends the request to every backend `auto` would try, all at once, and returns the first document back -- `WebRouter(Provider.MAX).extract(url)`, with `response.provider` naming the backend that answered. Every fetch it starts is billed by its backend, so it costs up to one extract per configured backend per URL -- most backends take one URL per request, and a loser keeps fetching after the winner returns -- and is never the default. Only a document wins: a backend that refused, could not be built or returned nothing is its own loss, and when every one loses the error names each reason. `provider_config={"max": {"hedge": 0.8}}` runs the first member alone for that long and starts the rest only if it has not answered, so most one-URL calls stay at one fetch; a first member that fails early, or answers only part of the list, starts the rest at once. The hedge covers the first member's whole call, so it does not cap the cost of a multi-URL request. The members are `auto`'s: a `chain` or `members_config` set under `"auto"` in `provider_config` applies to `max` too, unless its own `"max"` entry sets one. `format` routes the same way, and the sync router's `close()` waits for a race's losers before shutting their clients while the async router cancels them. `max` joins `auto` in `BENCHMARK_EXCLUDED`, and the MCP `extract` tool offers it under `provider` with its cost in the description.
136
+ - `WebRouter.benchmark_extract()` and its async twin: the same URLs fanned out across several extract backends at once, through the public `extract` path, one `ExtractBenchmarkRun` per provider in an `ExtractBenchmarkReport` -- measured facts only, as `benchmark()` reports for search. A backend that fails, cannot return the format, has no credentials or runs out of the comparison's `timeout` (`BENCHMARK_EXTRACT_TIMEOUT`, 120 seconds) is its own row's error, and a call that fetched some URLs and not others is an answered row whose response says which. `BenchmarkRun` and `BenchmarkReport` now share a base with the extract pair; their fields and behaviour are unchanged, except that a row for a backend that could not be built reports a latency of exactly zero on the async router too.
137
+ - `benchmarks/providers/extract.py`: every configured extract backend on the same URLs, tabulated per backend (pages returned with text, empty, failed, timed out, refused, latency, median length) and per category, with a suggested `auto` extract order. Two URL sets ship under `benchmarks/providers/urls/` -- `general` (455 URLs) and `social-pdf` (908 URLs), URL lists sampled from public pages -- and `--urls` takes a CSV of your own. The scan writes `run.json` before its first call and each URL's rows as it finishes, so one that dies keeps what it paid for, and `--combine` on that folder alone reports the rows it fetched against the URLs asked. `examples/compare_extract_providers.py` shows the method on one URL.
138
+ - `WebRouter.classify()` and `WebRouter.execute()`, an opt-in spike (unchanged unless you pass `decider=`). `classify(query)` asks a decider one set of questions (tool, recent, coverage, well_scoped) and turns the labels into a `Route`: the method and the auto chain to run it on, searching nothing. `execute(route)` runs it. Passing `urls` routes to extract and `output_schema` to research without asking; a decider that fails, or answers below 0.5 confidence, gives today's default, search on the default chain, with `Route.fallback` saying why (`decider_error`, `low_confidence`, `no_url`, `no_backend_for_method`). The decider is swappable: `WebRouter(decider=...)` takes `"keywords"` (simple keyword rules, keyless, with fixed confidences that are not measured; a research or browse pick sits below the threshold, so it falls back to search), `"jev"` (TypeSafe's Jev, reading `TYPESAFE_API_KEY`), `"llm"` (a chat model with structured outputs over any OpenAI-compatible API, with the `evals` extra, taking `api_key=` or reading `OPENAI_API_KEY`; its version carries a hash of its prompt and schema, `gpt-4.1-mini+p<8 hex>`), a name given to `register_decider()`, or any object with `decide()` and `close()`. `decider_config=` passes constructor kwargs to a decider given by name, as `provider_config=` does for backends. Every built-in decider reports confidence on Jev's normalized scale, `(p - 1/n)/(1 - 1/n)` (`normalized_confidence()`), and the llm decider scales its probabilities to sum to 1 before they are checked; an answer it cannot read takes the `decider_error` fallback. A decider that raises anything, not only `ProviderError`, takes the `decider_error` fallback. Exception types a decider names in its `passthrough` attribute propagate instead of becoming a default route.
139
+ - Browser Use now maps `output_schema`, `max_steps` and `allowed_domains`, each checked against a live task on 2026-10-21; they were refused before. The schema is sent as Browser Use's `structuredOutput` and the answer comes back as `structured`; an answer that is not JSON stays in `answer`. `auto` can now serve a browse call that sets any of the three, through Browser Use.
140
+
141
+ ### Fixed
142
+
143
+ - `benchmarks/providers/run.py` writes `run.json` before its first search and each depth's rows to `rows.jsonl` as that depth finishes, so a scan that dies keeps what it paid for; it wrote everything at the end, so a dropped connection or a Ctrl-C an hour in lost every row. `--combine` on that folder alone reports it, naming the backends whose depth never finished.
144
+ - Nimble enrichments (`research` with `records`) work as documented. Nimble takes a table schema for one, so the row schema you pass as `output_schema` is now sent as an array of rows; it was sent as is and refused with a 422. `records` without an `output_schema`, or at `effort="low"`, which Nimble also refuses, now raise before a run starts. Checked live.
145
+ - Browser Use steps now carry a `detail` and an `action`. `detail` came from `nextGoal`, which live tasks always return empty, so it was always `None`; it now reads the step's `memory`. A step that took a single action, which every step in four live tasks did, now has it as `action`. `nextGoal` moves to `extra`.
146
+
147
+ ## [0.6.0] - 2026-10-20
148
+
149
+ ### Added
150
+
151
+ - Logging in once and reusing the login on vendor browser sessions. `profile` and `persist_profile` are now mapped on Browserbase (a context), Steel, Hyperbrowser and Anchor (a profile name), as they already were on Kernel, so a session opened on a profile with `persist_profile=True` saves its cookies and storage when released, and the next session on that profile starts logged in. The handle's new `control_url()` returns a page where a person can see and drive the session to log in: Browserbase's live view, fetched on first call, and the interactive live view each other vendor returns on open. It is a credential like `cdp_url`, kept as a `SecretStr` on the new `BrowserSession.control_url` and out of `repr` and serialization; `live_view_url` stays for pages that grant no control. All of it was checked live on 2026-10-20 on all five vendors. `persist_profile` without a `profile` raises. `auto` refuses `profile` and `persist_profile`, since a profile is saved on one backend; name that backend. The MCP server's `browser_open` still takes no profile, and never shows a control URL.
152
+ - Kernel as a browser session backend (`kernel`), over its Browsers API, reading `KERNEL_API_KEY`. It maps `idle_timeout`, `stealth`, `headless`, `viewport`, `profile` and `persist_profile`, each checked against a live session. Kernel has an idle limit and no lifetime, so `timeout` raises and no default lifetime is sent; Kernel's own 60-second idle limit applies unless you set `idle_timeout`. `stealth` also brings Kernel's CAPTCHA solver and stealth proxy, so `solve_captchas` raises, and `proxy_country` raises because Kernel needs a proxy created first; pass an existing one through `provider_options`. `auto` tries Kernel after the other vendors and before `local`, so no existing call changes backend.
153
+
154
+ ### Changed
155
+
156
+ - Steel's `debugUrl`, Hyperbrowser's `liveUrl` and Anchor's `live_view_url`, which were only in `raw`, are now returned by `control_url()`. Each grants control of the browser, so none of them becomes `live_view_url`, which is unchanged.
157
+
158
+ ## [0.5.0] - 2026-10-19
159
+
160
+ ### Added
161
+
162
+ - DeepSeek as a search and an extract backend (`deepseek`), reading `DEEPSEEK_API_KEY`. Search maps `max_results`, `include_domains`, `exclude_domains` and `locale`; the filters go inside the query, and a query over DeepSeek's 128-character limit is refused, not cut. `search_depth` and `country` raise. A result's content is DeepSeek's summary unless `need_content` is sent through `provider_options`. Extract returns `markdown`, starting at most 5 fetches a second, DeepSeek's account limit; `ExtractProvider.min_start_interval` lets any backend space its fetches this way. `auto` tries DeepSeek last in both chains, so no existing call changes backend.
163
+ - You.com as a research backend (`you`), over its Research API on `api.you.com`, reading the same `YOU_API_KEY` as search. A run is a background task, polled until it finishes; the answer is You.com's markdown and its sources become citations. `effort` maps `low`, `medium`, `high` and `max` onto `lite`, `standard`, `deep` and `exhaustive`; `frontier` is reachable through `provider_options`. `output_schema` is not mapped yet, and You.com offers no cancel. `auto` tries You.com last in the research chain when you hold its key, so no call that was already being served moves. If `YOU_API_KEY` was the only key you held, `research()` through `auto` now starts a billed You.com run instead of raising `ConfigurationError`.
164
+
165
+ ### Fixed
166
+
167
+ - Jina: an empty `JINA_API_KEY` is treated as no key. It was sent as a bare `Bearer ` header, which httpx rejects, so every Jina call failed and `auto` quietly moved on to the next extract backend. Every provider now strips its API keys and settings, so a whitespace-only value counts as missing, a padded one such as a key with a trailing newline is sent without the padding, and `configured_*_providers()` no longer lists a provider whose only key is blank.
168
+ - A research run or browse task whose status request stalled until the caller's `timeout` ran out raised `ProviderError`, which carries no job id, instead of `ResearchTimeoutError` / `BrowseTimeoutError`, so a run that was still going, and billing, could not be come back to. A poll whose request timed out once the deadline has passed now raises the method's timeout error with the job id, the failure as its cause. Any other poll failure -- one with time left, or an auth error or unrecognized status after the deadline -- still raises `ProviderError`.
169
+ - Bright Data treats a URL its unlocker could not fetch, such as an unresolvable host, as a failure with Bright Data's reason and error code, instead of returning it as an empty document with status 502. A single-URL `extract()` raises `ProviderError`, and `auto` moves on to the next extract backend; in a multi-URL call the URL is listed in `ExtractResponse.failures` beside the pages that were fetched. A page the target site served, an error page included, is still a document.
170
+
171
+ ## [0.4.0] - 2026-10-14
172
+
173
+ ### Added
174
+
175
+ - Keenable as a search and an extract backend (`keenable`), over its Search and Fetch APIs, reading `KEENABLE_API_KEY`. Search maps `max_results`; extract returns `markdown` and asks for up to 500,000 characters, where Keenable's own default cuts a page at 50,000. With `allow_keyless=True` both run on Keenable's keyless `/public` endpoints, rate-limited per IP. `auto` tries Keenable after the keyless tier when you hold its key, and its keyless tier last of all, so no call that was already being served moves.
176
+
177
+ ## [0.3.0] - 2026-10-12
178
+
179
+ ### Added
180
+
181
+ - `NimbleProvider`, `NimbleExtractProvider` and `NimbleResearchProvider` accept the caller's own `AsyncNimble` as `async_client`, directly or through `provider_config={"nimble": {"async_client": client}}`. Such a provider needs no key, builds no sync client (a sync call raises `ConfigurationError`, and so does a synchronous `WebRouter` handed a client in `provider_config`), and leaves the client open on `aclose()`. An `api_key` passed beside the client raises `ConfigurationError` rather than replace its credential. Other options passed beside it apply to a copy that shares its connection pool, and research keeps the client's own timeouts, clamping only the read limit to the run's budget. `auto` counts Nimble as configured when it is handed a client, as it does for an `api_key`.
182
+
183
+ ### Changed
184
+
185
+ - A missing extra's error now tells you to run `uv sync --extra <name>` in your clone, or `make setup` for every extra, instead of a `pip install` line.
186
+ - The MCP server's `web-router://capabilities` resource adds `unlock`: for each method, the backends no key has reached yet, and the environment variables that would add each (names only).
187
+ - Project links point at this repository, and the README opens with the alpha notice. The package carries no license metadata during the alpha; the code is shared under your agreement.
188
+
189
+ ## Alpha snapshot - 2026-10-08
190
+
191
+ The first alpha snapshot, shared with alpha users. Not a published release:
192
+ the next published version will be above it.
193
+
194
+ - **Five methods behind one interface:** `search`, `extract`, `research`
195
+ (agentic, minutes rather than seconds), `open_browser` (a browser session you
196
+ drive, with page actions built in) and `browse` (a hosted browser agent's
197
+ task). Each call behaves the same whichever backend serves it.
198
+ - **Many backends,** from Nimble, Tavily, Exa, Brave, Perplexity, Parallel,
199
+ Linkup, You.com, WebIQ, Firecrawl, Oxylabs, Bright Data and Jina Reader to
200
+ Steel, Browserbase, Hyperbrowser, Anchor, Browser Use, Skyvern and a local
201
+ Chromium. Several need no key at all.
202
+ - **`auto`,** the default for every method: it tries the backends you hold keys
203
+ for, then the keyless ones, and routes each request to a backend that can
204
+ serve it. Pass a `chain` to choose your own order, per method.
205
+ - **An MCP server** (`web-router-mcp`) exposing every method as tools, including
206
+ `browser_*` tools that drive a session, for Claude Code, Claude Desktop,
207
+ Cursor and other MCP clients.
208
+ - **Agent integrations:** tools for the Claude and OpenAI APIs, and for
209
+ LangChain, with a routing guide that tells a model which tool fits which need.
210
+ - **Benchmarking and evaluation:** `benchmark()` compares backends on the same
211
+ query, `evaluate()` scores their accuracy on a question set with an LLM judge,
212
+ and `benchmarks/` holds the scripts to run both at scale.
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Nimble
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.