webrouter 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. web_router/__init__.py +293 -0
  2. web_router/_benchmark.py +205 -0
  3. web_router/_chains.py +35 -0
  4. web_router/_hosts.py +105 -0
  5. web_router/_urls.py +77 -0
  6. web_router/_version.py +1 -0
  7. web_router/client.py +896 -0
  8. web_router/client_answer.py +121 -0
  9. web_router/client_benchmark.py +325 -0
  10. web_router/client_budget.py +45 -0
  11. web_router/client_plan.py +192 -0
  12. web_router/client_requests.py +162 -0
  13. web_router/client_scope.py +134 -0
  14. web_router/client_template.py +125 -0
  15. web_router/constants.py +4332 -0
  16. web_router/constants_agentcore.py +80 -0
  17. web_router/constants_answer.py +119 -0
  18. web_router/constants_brave.py +86 -0
  19. web_router/constants_browserbase.py +160 -0
  20. web_router/constants_budget.py +48 -0
  21. web_router/constants_candidates.py +36 -0
  22. web_router/constants_deepseek.py +82 -0
  23. web_router/constants_exa.py +143 -0
  24. web_router/constants_invoke.py +32 -0
  25. web_router/constants_keenable.py +51 -0
  26. web_router/constants_kernel.py +72 -0
  27. web_router/constants_languages.py +52 -0
  28. web_router/constants_linkup.py +57 -0
  29. web_router/constants_mcp.py +174 -0
  30. web_router/constants_mcp_hints.py +37 -0
  31. web_router/constants_merge.py +70 -0
  32. web_router/constants_parallel.py +173 -0
  33. web_router/constants_params.py +57 -0
  34. web_router/constants_perplexity.py +105 -0
  35. web_router/constants_prices.py +52 -0
  36. web_router/constants_routing.py +23 -0
  37. web_router/constants_search.py +88 -0
  38. web_router/constants_tavily.py +78 -0
  39. web_router/constants_urls.py +65 -0
  40. web_router/constants_webiq.py +49 -0
  41. web_router/constants_you.py +136 -0
  42. web_router/data/chains.json +183 -0
  43. web_router/data/efforts.json +108 -0
  44. web_router/data/prices.json +1668 -0
  45. web_router/evals/__init__.py +29 -0
  46. web_router/evals/datasets.py +357 -0
  47. web_router/evals/judge.py +340 -0
  48. web_router/evals/prompts/LICENSE-simple-evals +21 -0
  49. web_router/evals/prompts/simpleqa_grader.txt +78 -0
  50. web_router/evals/run.py +224 -0
  51. web_router/evals/types.py +116 -0
  52. web_router/exceptions.py +379 -0
  53. web_router/exceptions_budget.py +28 -0
  54. web_router/integrations/__init__.py +6 -0
  55. web_router/integrations/_langchain_base.py +76 -0
  56. web_router/integrations/_options.py +67 -0
  57. web_router/integrations/_render.py +81 -0
  58. web_router/integrations/_specs.py +82 -0
  59. web_router/integrations/_tools.py +241 -0
  60. web_router/integrations/anthropic.py +78 -0
  61. web_router/integrations/langchain.py +227 -0
  62. web_router/integrations/openai.py +128 -0
  63. web_router/mcp/__init__.py +33 -0
  64. web_router/mcp/__main__.py +15 -0
  65. web_router/mcp/browsers.py +400 -0
  66. web_router/mcp/budget.py +187 -0
  67. web_router/mcp/chains.py +44 -0
  68. web_router/mcp/env.py +263 -0
  69. web_router/mcp/env_credentials.py +86 -0
  70. web_router/mcp/messages.py +30 -0
  71. web_router/mcp/methods.py +178 -0
  72. web_router/mcp/navigation.py +293 -0
  73. web_router/mcp/routing.py +152 -0
  74. web_router/mcp/routing_config.py +130 -0
  75. web_router/mcp/schema.py +102 -0
  76. web_router/mcp/server.py +239 -0
  77. web_router/mcp/shaping.py +507 -0
  78. web_router/mcp/shaping_templates.py +167 -0
  79. web_router/mcp/tools.py +634 -0
  80. web_router/mcp/urls.py +76 -0
  81. web_router/prices.py +196 -0
  82. web_router/prices_latency.py +27 -0
  83. web_router/providers/__init__.py +49 -0
  84. web_router/providers/_auto_extract.py +74 -0
  85. web_router/providers/_chain.py +341 -0
  86. web_router/providers/_chain_deadline.py +78 -0
  87. web_router/providers/_clamp.py +45 -0
  88. web_router/providers/_clock.py +40 -0
  89. web_router/providers/_deadline.py +371 -0
  90. web_router/providers/_error_pages.py +27 -0
  91. web_router/providers/_http.py +604 -0
  92. web_router/providers/_language.py +40 -0
  93. web_router/providers/_mcp.py +118 -0
  94. web_router/providers/_page.py +801 -0
  95. web_router/providers/_png.py +30 -0
  96. web_router/providers/_race.py +145 -0
  97. web_router/providers/_search_text.py +79 -0
  98. web_router/providers/_utils.py +259 -0
  99. web_router/providers/agentcore.py +316 -0
  100. web_router/providers/agentcore_sigv4.py +75 -0
  101. web_router/providers/anchor.py +123 -0
  102. web_router/providers/answer.py +68 -0
  103. web_router/providers/auto.py +817 -0
  104. web_router/providers/auto_answer.py +221 -0
  105. web_router/providers/auto_browser.py +233 -0
  106. web_router/providers/base.py +1883 -0
  107. web_router/providers/brave.py +133 -0
  108. web_router/providers/brave_answer.py +121 -0
  109. web_router/providers/brightdata.py +106 -0
  110. web_router/providers/browser_use.py +176 -0
  111. web_router/providers/browserbase.py +275 -0
  112. web_router/providers/browserbase_extract.py +117 -0
  113. web_router/providers/deepseek.py +170 -0
  114. web_router/providers/exa.py +168 -0
  115. web_router/providers/exa_answer.py +69 -0
  116. web_router/providers/exa_mcp.py +86 -0
  117. web_router/providers/firecrawl.py +110 -0
  118. web_router/providers/hyperbrowser.py +116 -0
  119. web_router/providers/jina.py +99 -0
  120. web_router/providers/keenable.py +120 -0
  121. web_router/providers/keenable_extract.py +124 -0
  122. web_router/providers/kernel.py +97 -0
  123. web_router/providers/linkup.py +329 -0
  124. web_router/providers/local.py +600 -0
  125. web_router/providers/max.py +235 -0
  126. web_router/providers/nimble.py +530 -0
  127. web_router/providers/nimble_template.py +259 -0
  128. web_router/providers/oxylabs.py +102 -0
  129. web_router/providers/parallel.py +257 -0
  130. web_router/providers/parallel_mcp.py +59 -0
  131. web_router/providers/parallel_search.py +162 -0
  132. web_router/providers/perplexity.py +219 -0
  133. web_router/providers/perplexity_answer.py +160 -0
  134. web_router/providers/skyvern.py +114 -0
  135. web_router/providers/steel.py +128 -0
  136. web_router/providers/tavily.py +152 -0
  137. web_router/providers/tavily_extract.py +175 -0
  138. web_router/providers/webiq.py +182 -0
  139. web_router/providers/you.py +133 -0
  140. web_router/providers/you_answer.py +88 -0
  141. web_router/providers/you_mcp.py +85 -0
  142. web_router/providers/you_research.py +86 -0
  143. web_router/py.typed +0 -0
  144. web_router/registry.py +453 -0
  145. web_router/registry_answer.py +121 -0
  146. web_router/routing/__init__.py +34 -0
  147. web_router/routing/_forms.py +36 -0
  148. web_router/routing/_runner.py +154 -0
  149. web_router/routing/attempts.py +125 -0
  150. web_router/routing/budget.py +225 -0
  151. web_router/routing/candidates.py +148 -0
  152. web_router/routing/candidates_priced.py +87 -0
  153. web_router/routing/contract.py +173 -0
  154. web_router/routing/decide.py +94 -0
  155. web_router/routing/effort_call.py +77 -0
  156. web_router/routing/effort_table.py +65 -0
  157. web_router/routing/eligible.py +109 -0
  158. web_router/routing/invoke.py +199 -0
  159. web_router/routing/ledger.py +214 -0
  160. web_router/routing/merge.py +174 -0
  161. web_router/routing/merge_extract.py +69 -0
  162. web_router/routing/merge_params.py +177 -0
  163. web_router/routing/merge_shape.py +61 -0
  164. web_router/routing/merge_types.py +99 -0
  165. web_router/routing/merge_urls.py +34 -0
  166. web_router/routing/params.py +179 -0
  167. web_router/routing/pool.py +193 -0
  168. web_router/routing/pool_bind.py +43 -0
  169. web_router/routing/pool_status.py +28 -0
  170. web_router/routing/pricing.py +151 -0
  171. web_router/routing/record.py +41 -0
  172. web_router/routing/routed.py +188 -0
  173. web_router/routing/rules.py +21 -0
  174. web_router/routing/submitted.py +59 -0
  175. web_router/routing/trail.py +75 -0
  176. web_router/routing/url_policy.py +165 -0
  177. web_router/types.py +935 -0
  178. web_router/types_answer.py +72 -0
  179. web_router/types_benchmark.py +158 -0
  180. web_router/types_decision.py +108 -0
  181. web_router/types_options.py +86 -0
  182. web_router/types_page.py +69 -0
  183. webrouter-0.11.0.dist-info/METADATA +2533 -0
  184. webrouter-0.11.0.dist-info/RECORD +188 -0
  185. webrouter-0.11.0.dist-info/WHEEL +4 -0
  186. webrouter-0.11.0.dist-info/entry_points.txt +2 -0
  187. webrouter-0.11.0.dist-info/licenses/LICENSE +201 -0
  188. webrouter-0.11.0.dist-info/licenses/src/web_router/evals/prompts/LICENSE-simple-evals +21 -0
web_router/__init__.py ADDED
@@ -0,0 +1,293 @@
1
+ """web-router: one interface over many web search, extraction, answer, research and browser APIs.
2
+
3
+ Point the router at a provider and call it; swapping backends is a one-word
4
+ change rather than a rewrite.
5
+
6
+ >>> from web_router import WebRouter
7
+ >>> router = WebRouter("nimble")
8
+ >>> response = router.search("what is retrieval augmented generation", max_results=5)
9
+ >>> pages = router.extract(response.urls[:3], format="markdown")
10
+
11
+ :meth:`~web_router.WebRouter.run_template` runs a backend's Extract Template --
12
+ a saved, site-specific extraction that turns one page into a record -- with
13
+ :meth:`~web_router.WebRouter.templates` and :meth:`~web_router.WebRouter.template`
14
+ to find one and read the inputs it takes:
15
+
16
+ >>> record = router.run_template("books_pdp", {"url": "https://books.example/..."}).data
17
+
18
+ :meth:`~web_router.WebRouter.answer` asks one question and returns the
19
+ backend's written answer with the sources it rests on, in a single call:
20
+
21
+ >>> reply = router.answer("who won the 2022 world cup final", provider="linkup")
22
+
23
+ :meth:`~web_router.WebRouter.research` runs an agentic backend
24
+ over several turns and answers in minutes rather than milliseconds:
25
+
26
+ >>> answer = router.research("how did nvidia's datacenter revenue mix shift in 2025")
27
+
28
+ :meth:`~web_router.WebRouter.browse` is the same shape with a hosted browser
29
+ agent doing the work -- Browser Use, Skyvern or Browserbase:
30
+
31
+ >>> response = router.browse("find the top story", start_url="https://news.ycombinator.com")
32
+
33
+ And :meth:`~web_router.WebRouter.open_browser` hands back a browser session --
34
+ a cloud vendor's, or a Chromium on this machine through ``local`` -- to drive
35
+ over CDP or through the handle's own page actions, released when the block
36
+ holding it ends:
37
+
38
+ >>> with router.open_browser() as browser:
39
+ ... browser.goto("https://example.com")
40
+ ... tree = browser.snapshot().tree
41
+ """
42
+
43
+ from web_router._version import __version__
44
+ from web_router.client import AsyncWebRouter, WebRouter
45
+ from web_router.client_scope import AsyncBudgetScope, BudgetScope
46
+ from web_router.constants import DEFAULT_PROVIDER
47
+ from web_router.exceptions import (
48
+ BenchmarkTimeoutError,
49
+ BrowserActionError,
50
+ BrowserActionTimeoutError,
51
+ BrowseTimeoutError,
52
+ ConfigurationError,
53
+ DeadlineExceededError,
54
+ ElementNotFoundError,
55
+ MissingParameterError,
56
+ NavigationError,
57
+ NoAnswerProviderError,
58
+ PageClosedError,
59
+ ProviderError,
60
+ ProviderNotFoundError,
61
+ ProviderNotInstalledError,
62
+ ResearchTimeoutError,
63
+ RouterError,
64
+ StaleElementError,
65
+ TemplateNotFoundError,
66
+ UnsupportedFormatError,
67
+ UnsupportedParameterError,
68
+ WebRouterError,
69
+ )
70
+ from web_router.exceptions_budget import BudgetExceededError
71
+ from web_router.providers.answer import AnswerProvider
72
+ from web_router.providers.base import (
73
+ AsyncBrowserHandle,
74
+ BrowseJob,
75
+ BrowseProvider,
76
+ BrowserHandle,
77
+ BrowserProvider,
78
+ ExtractProvider,
79
+ ResearchJob,
80
+ ResearchProvider,
81
+ SearchProvider,
82
+ TemplateProvider,
83
+ )
84
+ from web_router.registry import (
85
+ available_browse_providers,
86
+ available_browser_providers,
87
+ available_extract_providers,
88
+ available_providers,
89
+ available_research_providers,
90
+ available_template_providers,
91
+ configured_browse_providers,
92
+ configured_browser_providers,
93
+ configured_extract_providers,
94
+ configured_providers,
95
+ configured_research_providers,
96
+ configured_template_providers,
97
+ register_browse_provider,
98
+ register_browser_provider,
99
+ register_extract_provider,
100
+ register_provider,
101
+ register_research_provider,
102
+ register_template_provider,
103
+ unregister_browse_provider,
104
+ unregister_browser_provider,
105
+ unregister_extract_provider,
106
+ unregister_provider,
107
+ unregister_research_provider,
108
+ unregister_template_provider,
109
+ )
110
+ from web_router.registry_answer import (
111
+ available_answer_providers,
112
+ configured_answer_providers,
113
+ register_answer_provider,
114
+ unregister_answer_provider,
115
+ )
116
+ from web_router.routing import Budget, Candidate, Plan, RouteDecision, RouteRequest, RouterLike, ToolName
117
+ from web_router.routing.pool_status import BudgetStatus
118
+ from web_router.types import (
119
+ BROWSE_PARAMETERS,
120
+ BROWSER_PARAMETERS,
121
+ EXTRACT_PARAMETERS,
122
+ NORMALIZED_PARAMETERS,
123
+ RESEARCH_PARAMETERS,
124
+ TEMPLATE_PARAMETERS,
125
+ BrowseOutcome,
126
+ BrowseProgress,
127
+ BrowseRequest,
128
+ BrowseResponse,
129
+ BrowserRequest,
130
+ BrowserSession,
131
+ BrowserState,
132
+ BrowseStep,
133
+ Citation,
134
+ ExtractedDocument,
135
+ ExtractFailure,
136
+ ExtractFormat,
137
+ ExtractRequest,
138
+ ExtractResponse,
139
+ Provider,
140
+ ResearchEffort,
141
+ ResearchOutcome,
142
+ ResearchProgress,
143
+ ResearchRequest,
144
+ ResearchResponse,
145
+ ResearchState,
146
+ SearchDepth,
147
+ SearchRequest,
148
+ SearchResponse,
149
+ SearchResult,
150
+ StealthLevel,
151
+ Template,
152
+ TemplatePage,
153
+ TemplateRequest,
154
+ TemplateResponse,
155
+ TemplateSample,
156
+ Viewport,
157
+ )
158
+ from web_router.types_answer import ANSWER_PARAMETERS, AnswerRequest, AnswerResponse, Recency
159
+ from web_router.types_benchmark import BenchmarkReport, BenchmarkRun, ExtractBenchmarkReport, ExtractBenchmarkRun
160
+ from web_router.types_decision import DecisionAttempt, DecisionHop, DecisionRecord
161
+ from web_router.types_page import PageFormat, PageLoadState, PageSnapshot, PageState, Screenshot
162
+
163
+ __all__ = [
164
+ "ANSWER_PARAMETERS",
165
+ "BROWSER_PARAMETERS",
166
+ "BROWSE_PARAMETERS",
167
+ "DEFAULT_PROVIDER",
168
+ "EXTRACT_PARAMETERS",
169
+ "NORMALIZED_PARAMETERS",
170
+ "RESEARCH_PARAMETERS",
171
+ "TEMPLATE_PARAMETERS",
172
+ "AnswerProvider",
173
+ "AnswerRequest",
174
+ "AnswerResponse",
175
+ "AsyncBrowserHandle",
176
+ "AsyncBudgetScope",
177
+ "AsyncWebRouter",
178
+ "BenchmarkReport",
179
+ "BenchmarkRun",
180
+ "BenchmarkTimeoutError",
181
+ "BrowseJob",
182
+ "BrowseOutcome",
183
+ "BrowseProgress",
184
+ "BrowseProvider",
185
+ "BrowseRequest",
186
+ "BrowseResponse",
187
+ "BrowseStep",
188
+ "BrowseTimeoutError",
189
+ "BrowserActionError",
190
+ "BrowserActionTimeoutError",
191
+ "BrowserHandle",
192
+ "BrowserProvider",
193
+ "BrowserRequest",
194
+ "BrowserSession",
195
+ "BrowserState",
196
+ "Budget",
197
+ "BudgetExceededError",
198
+ "BudgetScope",
199
+ "BudgetStatus",
200
+ "Candidate",
201
+ "Citation",
202
+ "ConfigurationError",
203
+ "DeadlineExceededError",
204
+ "DecisionAttempt",
205
+ "DecisionHop",
206
+ "DecisionRecord",
207
+ "ElementNotFoundError",
208
+ "ExtractBenchmarkReport",
209
+ "ExtractBenchmarkRun",
210
+ "ExtractFailure",
211
+ "ExtractFormat",
212
+ "ExtractProvider",
213
+ "ExtractRequest",
214
+ "ExtractResponse",
215
+ "ExtractedDocument",
216
+ "MissingParameterError",
217
+ "NavigationError",
218
+ "NoAnswerProviderError",
219
+ "PageClosedError",
220
+ "PageFormat",
221
+ "PageLoadState",
222
+ "PageSnapshot",
223
+ "PageState",
224
+ "Plan",
225
+ "Provider",
226
+ "ProviderError",
227
+ "ProviderNotFoundError",
228
+ "ProviderNotInstalledError",
229
+ "Recency",
230
+ "ResearchEffort",
231
+ "ResearchJob",
232
+ "ResearchOutcome",
233
+ "ResearchProgress",
234
+ "ResearchProvider",
235
+ "ResearchRequest",
236
+ "ResearchResponse",
237
+ "ResearchState",
238
+ "ResearchTimeoutError",
239
+ "RouteDecision",
240
+ "RouteRequest",
241
+ "RouterError",
242
+ "RouterLike",
243
+ "Screenshot",
244
+ "SearchDepth",
245
+ "SearchProvider",
246
+ "SearchRequest",
247
+ "SearchResponse",
248
+ "SearchResult",
249
+ "StaleElementError",
250
+ "StealthLevel",
251
+ "Template",
252
+ "TemplateNotFoundError",
253
+ "TemplatePage",
254
+ "TemplateProvider",
255
+ "TemplateRequest",
256
+ "TemplateResponse",
257
+ "TemplateSample",
258
+ "ToolName",
259
+ "UnsupportedFormatError",
260
+ "UnsupportedParameterError",
261
+ "Viewport",
262
+ "WebRouter",
263
+ "WebRouterError",
264
+ "__version__",
265
+ "available_answer_providers",
266
+ "available_browse_providers",
267
+ "available_browser_providers",
268
+ "available_extract_providers",
269
+ "available_providers",
270
+ "available_research_providers",
271
+ "available_template_providers",
272
+ "configured_answer_providers",
273
+ "configured_browse_providers",
274
+ "configured_browser_providers",
275
+ "configured_extract_providers",
276
+ "configured_providers",
277
+ "configured_research_providers",
278
+ "configured_template_providers",
279
+ "register_answer_provider",
280
+ "register_browse_provider",
281
+ "register_browser_provider",
282
+ "register_extract_provider",
283
+ "register_provider",
284
+ "register_research_provider",
285
+ "register_template_provider",
286
+ "unregister_answer_provider",
287
+ "unregister_browse_provider",
288
+ "unregister_browser_provider",
289
+ "unregister_extract_provider",
290
+ "unregister_provider",
291
+ "unregister_research_provider",
292
+ "unregister_template_provider",
293
+ ]
@@ -0,0 +1,205 @@
1
+ """The fan-out behind :meth:`~web_router.WebRouter.benchmark` and :meth:`~web_router.WebRouter.benchmark_extract`.
2
+
3
+ A comparison is generic in the method it fans out: the provider it calls,
4
+ the response that call returns, and the row the report keeps per provider.
5
+ What the two methods share is here -- the members resolved before any row
6
+ starts, the threads or the tasks, the deadline and the rows that outlive
7
+ it -- and each router method passes in its own call and row builder. Private
8
+ to the routers: nothing here is a public name.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextvars
15
+ import threading
16
+ import time
17
+ from collections.abc import Awaitable, Callable, Sequence
18
+ from concurrent.futures import Future, ThreadPoolExecutor, wait
19
+ from typing import Any, TypeVar
20
+
21
+ from web_router.constants import MSG_BENCHMARK_ROW_FAILED
22
+ from web_router.exceptions import BenchmarkTimeoutError, ProviderError, WebRouterError
23
+ from web_router.providers.base import ExtractProvider, SearchProvider
24
+ from web_router.types import ExtractResponse, Provider, SearchResponse, provider_name
25
+ from web_router.types_benchmark import BenchmarkRun, ExtractBenchmarkRun
26
+
27
+ _MemberT = TypeVar("_MemberT", bound="SearchProvider | ExtractProvider")
28
+ _AnswerT = TypeVar("_AnswerT", bound="SearchResponse | ExtractResponse")
29
+ _RunT = TypeVar("_RunT", bound="BenchmarkRun | ExtractBenchmarkRun")
30
+
31
+
32
+ class Stragglers:
33
+ """Rows the sync fan-out gave up on whose calls still run.
34
+
35
+ The router's ``close()`` joins them, so it never shuts a provider under a
36
+ live call; each is dropped the moment it finishes.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._lock = threading.Lock()
41
+ self._futures: set[Future[Any]] = set()
42
+
43
+ def track(self, future: Future[Any]) -> None:
44
+ with self._lock:
45
+ self._futures.add(future)
46
+ future.add_done_callback(self._forget)
47
+
48
+ def _forget(self, future: Future[Any]) -> None:
49
+ with self._lock:
50
+ self._futures.discard(future)
51
+
52
+ def join(self) -> None:
53
+ """Block until every call still tracked has finished -- bounded by each provider's own HTTP timeout."""
54
+ with self._lock:
55
+ pending = tuple(self._futures)
56
+ if pending:
57
+ wait(pending)
58
+
59
+
60
+ def members_of(
61
+ providers: Sequence[str | Provider | _MemberT] | None,
62
+ *,
63
+ default: Callable[[], list[str]],
64
+ build: Callable[[str | Provider | _MemberT], _MemberT],
65
+ none_named: str,
66
+ ) -> list[tuple[str, _MemberT | WebRouterError]]:
67
+ """The rows to run, as ``(row name, the provider or what stopped it)``, each name once, in the order asked.
68
+
69
+ Resolved on the caller's thread and before any row starts, for two
70
+ reasons: a backend whose extra is not installed or whose key is missing
71
+ is that row's failure rather than the comparison's, and a provider built
72
+ inside a worker after the router was closed would be cached by a router
73
+ that will never close it. ``build`` is the method's own getter, so the
74
+ provider is the one a plain call would use.
75
+ """
76
+ members: list[tuple[str, _MemberT | WebRouterError]] = []
77
+ seen: set[str] = set()
78
+ for item in default() if providers is None else providers:
79
+ name = provider_name(item) if isinstance(item, (str, Provider)) else item.name
80
+ if name in seen:
81
+ continue
82
+ seen.add(name)
83
+ try:
84
+ members.append((name, build(item)))
85
+ except Exception as exc:
86
+ members.append((name, _failure(name, exc)))
87
+ if not members:
88
+ raise ValueError(none_named)
89
+ return members
90
+
91
+
92
+ def _failure(name: str, exc: Exception) -> WebRouterError:
93
+ """What a call raised, as its row's error: a router error as it is, anything else wrapped.
94
+
95
+ The public methods wrap every backend failure, so only a router error
96
+ should reach here; when something else does -- a provider bug, a caller's
97
+ own provider -- the report still comes back with this row saying what raised.
98
+ """
99
+ if isinstance(exc, WebRouterError):
100
+ return exc
101
+ wrapped = ProviderError(name, MSG_BENCHMARK_ROW_FAILED.format(error=exc), original=exc)
102
+ wrapped.__cause__ = exc # Returned, not raised, so ``raise ... from`` cannot set it; the traceback stays reachable.
103
+ return wrapped
104
+
105
+
106
+ def fan_out(
107
+ members: list[tuple[str, _MemberT | WebRouterError]],
108
+ call: Callable[[_MemberT], _AnswerT],
109
+ row_of: Callable[[str, float | None, _AnswerT | WebRouterError], _RunT],
110
+ timeout: float | None,
111
+ *,
112
+ stragglers: Stragglers,
113
+ ) -> list[_RunT]:
114
+ """Every member's row at once, each timed around its own ``call``, the whole bounded by ``timeout``.
115
+
116
+ ``row_of`` builds the method's own row from a name, when its call
117
+ started, and what it got or raised -- ``BenchmarkRun.of`` or
118
+ ``ExtractBenchmarkRun.of``. A row still unanswered at the
119
+ deadline is reported as timed out and its call left to finish on its
120
+ thread, tracked in ``stragglers`` until it does.
121
+ """
122
+ started = time.perf_counter()
123
+ # A member that could not be built is its row already, with nothing to time.
124
+ runs = {name: row_of(name, None, member) for name, member in members if isinstance(member, WebRouterError)}
125
+ futures = _start(members, call, row_of)
126
+ wait(futures.values(), timeout=timeout)
127
+ runs.update(_collect(futures, row_of, started, timeout, stragglers))
128
+ return [runs[name] for name, _ in members]
129
+
130
+
131
+ def _start(
132
+ members: list[tuple[str, _MemberT | WebRouterError]],
133
+ call: Callable[[_MemberT], _AnswerT],
134
+ row_of: Callable[[str, float | None, _AnswerT | WebRouterError], _RunT],
135
+ ) -> dict[str, Future[_RunT]]:
136
+ """Each buildable member's row started on a thread of its own, by name."""
137
+
138
+ def row(name: str, provider: _MemberT) -> _RunT:
139
+ own = time.perf_counter()
140
+ try:
141
+ outcome: _AnswerT | WebRouterError = call(provider)
142
+ except Exception as exc:
143
+ outcome = _failure(name, exc)
144
+ return row_of(name, own, outcome)
145
+
146
+ live = [(name, member) for name, member in members if not isinstance(member, WebRouterError)]
147
+ # Not a `with` block: leaving one joins every thread, which would hold
148
+ # the report until the slowest backend answered and make the timeout
149
+ # a fiction. A row given up on finishes on its own and is discarded;
150
+ # its provider was built before it started, so the router closes it.
151
+ pool = ThreadPoolExecutor(max_workers=max(1, len(live)))
152
+ try:
153
+ # Each in a copy of the caller's context, so a deadline set around
154
+ # the call reaches the member on its worker thread.
155
+ return {name: pool.submit(contextvars.copy_context().run, row, name, member) for name, member in live}
156
+ finally:
157
+ pool.shutdown(wait=False)
158
+
159
+
160
+ def _collect(
161
+ futures: dict[str, Future[_RunT]],
162
+ row_of: Callable[[str, float | None, Any], _RunT],
163
+ started: float,
164
+ timeout: float | None,
165
+ stragglers: Stragglers,
166
+ ) -> dict[str, _RunT]:
167
+ """Each started row as it stands once the deadline has passed: its result, or a timeout."""
168
+ runs: dict[str, _RunT] = {}
169
+ for name, future in futures.items():
170
+ if future.done():
171
+ runs[name] = future.result()
172
+ continue
173
+ # A row given up on reports how long it was waited for. Its call
174
+ # keeps running, on a provider this router will close, so close()
175
+ # waits for it -- tracked here, dropped the moment it finishes.
176
+ runs[name] = row_of(name, started, BenchmarkTimeoutError(name, timeout or 0.0))
177
+ stragglers.track(future)
178
+ return runs
179
+
180
+
181
+ async def afan_out(
182
+ members: list[tuple[str, _MemberT | WebRouterError]],
183
+ call: Callable[[_MemberT], Awaitable[_AnswerT]],
184
+ row_of: Callable[[str, float | None, _AnswerT | WebRouterError], _RunT],
185
+ timeout: float | None,
186
+ ) -> list[_RunT]:
187
+ """The async twin of :func:`fan_out`: the rows gathered on the loop under one deadline, late ones cancelled."""
188
+ deadline = None if timeout is None else time.perf_counter() + timeout
189
+
190
+ async def row(name: str, member: _MemberT | WebRouterError) -> _RunT:
191
+ if isinstance(member, WebRouterError):
192
+ return row_of(name, None, member)
193
+ own = time.perf_counter()
194
+ # One deadline for the whole comparison: a row that starts late
195
+ # gets what is left of it, not a fresh timeout of its own.
196
+ remaining = None if deadline is None else max(0.0, deadline - own)
197
+ try:
198
+ outcome: _AnswerT | WebRouterError = await asyncio.wait_for(call(member), remaining)
199
+ except asyncio.TimeoutError:
200
+ outcome = BenchmarkTimeoutError(name, timeout or 0.0)
201
+ except Exception as exc:
202
+ outcome = _failure(name, exc)
203
+ return row_of(name, own, outcome)
204
+
205
+ return list(await asyncio.gather(*(row(name, member) for name, member in members)))
web_router/_chains.py ADDED
@@ -0,0 +1,35 @@
1
+ """The default ``auto`` orders that route-eval ranked, read from ``data/chains.json``.
2
+
3
+ Search's keyed members and answer's members are ranked from the frozen benchmark
4
+ results by ``make chains`` (``benchmarks/providers/chains.py``),
5
+ which writes the file; never edit it by hand. A new backend joins by hand at the
6
+ end of its method's ``appended`` (search) or ``preferred`` (answer) list, where
7
+ the next ``make chains`` keeps it until frozen results rank it. Experimental: the
8
+ file's shape may change before 1.0.
9
+
10
+ Imports nothing from ``web_router``: :mod:`web_router.constants` and
11
+ :mod:`web_router.constants_answer` read it while they load.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from functools import lru_cache
18
+ from importlib import resources
19
+ from typing import Any
20
+
21
+ CHAINS_DATA_DIR = "data"
22
+ CHAINS_DATA_FILE = "chains.json"
23
+
24
+
25
+ @lru_cache(maxsize=1)
26
+ def chains() -> dict[str, Any]:
27
+ """The whole generated file."""
28
+ text = (resources.files("web_router") / CHAINS_DATA_DIR / CHAINS_DATA_FILE).read_text(encoding="utf-8")
29
+ data: dict[str, Any] = json.loads(text)
30
+ return data
31
+
32
+
33
+ def chain(method: str, part: str) -> tuple[str, ...]:
34
+ """One list of a method's generated order: ``preferred``, or search's ``appended``."""
35
+ return tuple(chains()[method][part])
web_router/_hosts.py ADDED
@@ -0,0 +1,105 @@
1
+ """What a URL's host is: loopback, a cloud metadata endpoint, or a private network -- read as a browser reads it.
2
+
3
+ Shared by the MCP browser tools' navigation guard and the routers'
4
+ ``url_policy``. Every check here reads the host as written: a literal address
5
+ in any spelling a browser accepts (decimal, hex, octal, short IPv4, and IPv4
6
+ mapped into IPv6) is judged as the address it is, and nothing resolves DNS.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import re
13
+ import socket
14
+
15
+ from web_router.constants_urls import (
16
+ IPV4_COMPATIBLE_PREFIX,
17
+ LEGACY_IPV4_PATTERN,
18
+ LOOPBACK_NAMES,
19
+ METADATA_HOSTS,
20
+ NAT64_PREFIX,
21
+ )
22
+
23
+ _LEGACY_IPV4 = re.compile(LEGACY_IPV4_PATTERN)
24
+ _BARE_HEX = re.compile(r"(?:^|(?<=\.))0x(?=\.|$)")
25
+
26
+
27
+ def host_address(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
28
+ """``host`` as the address it spells, an IPv4-mapped IPv6 address as its IPv4; ``None`` for a name."""
29
+ address: ipaddress.IPv4Address | ipaddress.IPv6Address
30
+ try:
31
+ address = ipaddress.ip_address(host)
32
+ except ValueError:
33
+ legacy = legacy_ipv4(host)
34
+ if legacy is None:
35
+ return None
36
+ address = legacy
37
+ if isinstance(address, ipaddress.IPv6Address):
38
+ return _embedded_ipv4(address) or address
39
+ return address
40
+
41
+
42
+ def _embedded_ipv4(address: ipaddress.IPv6Address) -> ipaddress.IPv4Address | None:
43
+ """The IPv4 address an IPv6 one carries and reaches: IPv4-mapped, NAT64 (``64:ff9b::/96``) or the deprecated
44
+ IPv4-compatible form (``::a.b.c.d``, but not ``::`` or ``::1``, which are IPv6's own).
45
+
46
+ A 6to4 address (``2002::/16``) gives its IPv4 only when that is no public host's: older Pythons call every
47
+ 6to4 address global, newer ones none, so the IPv4 decides a non-public one and the rest stay as before.
48
+ """
49
+ if address.ipv4_mapped is not None:
50
+ return address.ipv4_mapped
51
+ sixtofour = address.sixtofour
52
+ if sixtofour is not None and (not sixtofour.is_global or sixtofour.is_multicast):
53
+ return sixtofour
54
+ low = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
55
+ if address in NAT64_PREFIX:
56
+ return low
57
+ if address in IPV4_COMPATIBLE_PREFIX and int(address) > 1:
58
+ return low
59
+ return None
60
+
61
+
62
+ def numeric_host(host: str) -> bool:
63
+ """Whether ``host`` has an IPv4 address's shape: up to four dot-separated parts, each digits or ``0x`` hex.
64
+
65
+ Whether or not ``inet_aton`` reads it: ``127.0.0.09`` is no octal, but macOS's getaddrinfo reads it as decimal.
66
+ """
67
+ return _LEGACY_IPV4.fullmatch(host) is not None
68
+
69
+
70
+ def legacy_ipv4(host: str) -> ipaddress.IPv4Address | None:
71
+ """``host`` read as a browser reads an IPv4 address: ``2130706433``, ``0x7f000001``, ``127.1`` or ``0177.0.0.1``.
72
+
73
+ ``ipaddress`` accepts only dotted quads, but a browser canonicalizes all of
74
+ these to one before it connects, so each is checked as the address it is.
75
+ """
76
+ if not numeric_host(host):
77
+ return None
78
+ try:
79
+ # A browser reads a bare ``0x`` part as 0; inet_aton may refuse it or not, by platform.
80
+ return ipaddress.IPv4Address(socket.inet_aton(_BARE_HEX.sub("0x0", host)))
81
+ except (OSError, ValueError):
82
+ return None
83
+
84
+
85
+ def is_loopback(host: str) -> bool:
86
+ """``localhost``, ``0.0.0.0``, or any loopback or unspecified address."""
87
+ address = host_address(host)
88
+ return host in LOOPBACK_NAMES or (address is not None and (address.is_loopback or address.is_unspecified))
89
+
90
+
91
+ def is_metadata(host: str) -> bool:
92
+ """A cloud metadata endpoint by name, or any link-local address."""
93
+ address = host_address(host)
94
+ # All of link-local, not only the one address: the metadata service answers on 169.254/16 on several clouds.
95
+ return host in METADATA_HOSTS or (address is not None and address.is_link_local)
96
+
97
+
98
+ def is_private(host: str) -> bool:
99
+ """A literal address no public host has: private (RFC 1918, ULA), shared (CGNAT), reserved, or multicast.
100
+
101
+ Read as :func:`ipaddress.ip_address` reads ``is_global``, so loopback and
102
+ link-local are in it too. A name is never private here: nothing resolves it.
103
+ """
104
+ address = host_address(host)
105
+ return address is not None and (not address.is_global or address.is_multicast)