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.
- web_router/__init__.py +293 -0
- web_router/_benchmark.py +205 -0
- web_router/_chains.py +35 -0
- web_router/_hosts.py +105 -0
- web_router/_urls.py +77 -0
- web_router/_version.py +1 -0
- web_router/client.py +896 -0
- web_router/client_answer.py +121 -0
- web_router/client_benchmark.py +325 -0
- web_router/client_budget.py +45 -0
- web_router/client_plan.py +192 -0
- web_router/client_requests.py +162 -0
- web_router/client_scope.py +134 -0
- web_router/client_template.py +125 -0
- web_router/constants.py +4332 -0
- web_router/constants_agentcore.py +80 -0
- web_router/constants_answer.py +119 -0
- web_router/constants_brave.py +86 -0
- web_router/constants_browserbase.py +160 -0
- web_router/constants_budget.py +48 -0
- web_router/constants_candidates.py +36 -0
- web_router/constants_deepseek.py +82 -0
- web_router/constants_exa.py +143 -0
- web_router/constants_invoke.py +32 -0
- web_router/constants_keenable.py +51 -0
- web_router/constants_kernel.py +72 -0
- web_router/constants_languages.py +52 -0
- web_router/constants_linkup.py +57 -0
- web_router/constants_mcp.py +174 -0
- web_router/constants_mcp_hints.py +37 -0
- web_router/constants_merge.py +70 -0
- web_router/constants_parallel.py +173 -0
- web_router/constants_params.py +57 -0
- web_router/constants_perplexity.py +105 -0
- web_router/constants_prices.py +52 -0
- web_router/constants_routing.py +23 -0
- web_router/constants_search.py +88 -0
- web_router/constants_tavily.py +78 -0
- web_router/constants_urls.py +65 -0
- web_router/constants_webiq.py +49 -0
- web_router/constants_you.py +136 -0
- web_router/data/chains.json +183 -0
- web_router/data/efforts.json +108 -0
- web_router/data/prices.json +1668 -0
- web_router/evals/__init__.py +29 -0
- web_router/evals/datasets.py +357 -0
- web_router/evals/judge.py +340 -0
- web_router/evals/prompts/LICENSE-simple-evals +21 -0
- web_router/evals/prompts/simpleqa_grader.txt +78 -0
- web_router/evals/run.py +224 -0
- web_router/evals/types.py +116 -0
- web_router/exceptions.py +379 -0
- web_router/exceptions_budget.py +28 -0
- web_router/integrations/__init__.py +6 -0
- web_router/integrations/_langchain_base.py +76 -0
- web_router/integrations/_options.py +67 -0
- web_router/integrations/_render.py +81 -0
- web_router/integrations/_specs.py +82 -0
- web_router/integrations/_tools.py +241 -0
- web_router/integrations/anthropic.py +78 -0
- web_router/integrations/langchain.py +227 -0
- web_router/integrations/openai.py +128 -0
- web_router/mcp/__init__.py +33 -0
- web_router/mcp/__main__.py +15 -0
- web_router/mcp/browsers.py +400 -0
- web_router/mcp/budget.py +187 -0
- web_router/mcp/chains.py +44 -0
- web_router/mcp/env.py +263 -0
- web_router/mcp/env_credentials.py +86 -0
- web_router/mcp/messages.py +30 -0
- web_router/mcp/methods.py +178 -0
- web_router/mcp/navigation.py +293 -0
- web_router/mcp/routing.py +152 -0
- web_router/mcp/routing_config.py +130 -0
- web_router/mcp/schema.py +102 -0
- web_router/mcp/server.py +239 -0
- web_router/mcp/shaping.py +507 -0
- web_router/mcp/shaping_templates.py +167 -0
- web_router/mcp/tools.py +634 -0
- web_router/mcp/urls.py +76 -0
- web_router/prices.py +196 -0
- web_router/prices_latency.py +27 -0
- web_router/providers/__init__.py +49 -0
- web_router/providers/_auto_extract.py +74 -0
- web_router/providers/_chain.py +341 -0
- web_router/providers/_chain_deadline.py +78 -0
- web_router/providers/_clamp.py +45 -0
- web_router/providers/_clock.py +40 -0
- web_router/providers/_deadline.py +371 -0
- web_router/providers/_error_pages.py +27 -0
- web_router/providers/_http.py +604 -0
- web_router/providers/_language.py +40 -0
- web_router/providers/_mcp.py +118 -0
- web_router/providers/_page.py +801 -0
- web_router/providers/_png.py +30 -0
- web_router/providers/_race.py +145 -0
- web_router/providers/_search_text.py +79 -0
- web_router/providers/_utils.py +259 -0
- web_router/providers/agentcore.py +316 -0
- web_router/providers/agentcore_sigv4.py +75 -0
- web_router/providers/anchor.py +123 -0
- web_router/providers/answer.py +68 -0
- web_router/providers/auto.py +817 -0
- web_router/providers/auto_answer.py +221 -0
- web_router/providers/auto_browser.py +233 -0
- web_router/providers/base.py +1883 -0
- web_router/providers/brave.py +133 -0
- web_router/providers/brave_answer.py +121 -0
- web_router/providers/brightdata.py +106 -0
- web_router/providers/browser_use.py +176 -0
- web_router/providers/browserbase.py +275 -0
- web_router/providers/browserbase_extract.py +117 -0
- web_router/providers/deepseek.py +170 -0
- web_router/providers/exa.py +168 -0
- web_router/providers/exa_answer.py +69 -0
- web_router/providers/exa_mcp.py +86 -0
- web_router/providers/firecrawl.py +110 -0
- web_router/providers/hyperbrowser.py +116 -0
- web_router/providers/jina.py +99 -0
- web_router/providers/keenable.py +120 -0
- web_router/providers/keenable_extract.py +124 -0
- web_router/providers/kernel.py +97 -0
- web_router/providers/linkup.py +329 -0
- web_router/providers/local.py +600 -0
- web_router/providers/max.py +235 -0
- web_router/providers/nimble.py +530 -0
- web_router/providers/nimble_template.py +259 -0
- web_router/providers/oxylabs.py +102 -0
- web_router/providers/parallel.py +257 -0
- web_router/providers/parallel_mcp.py +59 -0
- web_router/providers/parallel_search.py +162 -0
- web_router/providers/perplexity.py +219 -0
- web_router/providers/perplexity_answer.py +160 -0
- web_router/providers/skyvern.py +114 -0
- web_router/providers/steel.py +128 -0
- web_router/providers/tavily.py +152 -0
- web_router/providers/tavily_extract.py +175 -0
- web_router/providers/webiq.py +182 -0
- web_router/providers/you.py +133 -0
- web_router/providers/you_answer.py +88 -0
- web_router/providers/you_mcp.py +85 -0
- web_router/providers/you_research.py +86 -0
- web_router/py.typed +0 -0
- web_router/registry.py +453 -0
- web_router/registry_answer.py +121 -0
- web_router/routing/__init__.py +34 -0
- web_router/routing/_forms.py +36 -0
- web_router/routing/_runner.py +154 -0
- web_router/routing/attempts.py +125 -0
- web_router/routing/budget.py +225 -0
- web_router/routing/candidates.py +148 -0
- web_router/routing/candidates_priced.py +87 -0
- web_router/routing/contract.py +173 -0
- web_router/routing/decide.py +94 -0
- web_router/routing/effort_call.py +77 -0
- web_router/routing/effort_table.py +65 -0
- web_router/routing/eligible.py +109 -0
- web_router/routing/invoke.py +199 -0
- web_router/routing/ledger.py +214 -0
- web_router/routing/merge.py +174 -0
- web_router/routing/merge_extract.py +69 -0
- web_router/routing/merge_params.py +177 -0
- web_router/routing/merge_shape.py +61 -0
- web_router/routing/merge_types.py +99 -0
- web_router/routing/merge_urls.py +34 -0
- web_router/routing/params.py +179 -0
- web_router/routing/pool.py +193 -0
- web_router/routing/pool_bind.py +43 -0
- web_router/routing/pool_status.py +28 -0
- web_router/routing/pricing.py +151 -0
- web_router/routing/record.py +41 -0
- web_router/routing/routed.py +188 -0
- web_router/routing/rules.py +21 -0
- web_router/routing/submitted.py +59 -0
- web_router/routing/trail.py +75 -0
- web_router/routing/url_policy.py +165 -0
- web_router/types.py +935 -0
- web_router/types_answer.py +72 -0
- web_router/types_benchmark.py +158 -0
- web_router/types_decision.py +108 -0
- web_router/types_options.py +86 -0
- web_router/types_page.py +69 -0
- webrouter-0.11.0.dist-info/METADATA +2533 -0
- webrouter-0.11.0.dist-info/RECORD +188 -0
- webrouter-0.11.0.dist-info/WHEEL +4 -0
- webrouter-0.11.0.dist-info/entry_points.txt +2 -0
- webrouter-0.11.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|
web_router/_benchmark.py
ADDED
|
@@ -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)
|