Two ABC additions to cover the surface area of the remaining four
providers (exa, parallel, tavily, firecrawl) which were untouched by the
initial spike:
1. supports_crawl() + crawl() — Tavily natively crawls a seed URL via
its /crawl endpoint. Exposing supports_crawl=True lets the crawl
tool's dispatcher route to Tavily when configured, falling back to
the auxiliary-model summarization path otherwise. Firecrawl could
add this in a follow-up (the SDK supports it; we just don't surface
it as a tool today).
2. Async-or-sync extract() — Parallel's SDK is natively async
(AsyncParallel.beta.extract); Exa and Tavily are sync; Firecrawl is
sync but called inside asyncio.to_thread() with a 60s timeout. The
ABC docstring now permits either shape: implementations declare
their own sync/async signature and the dispatcher uses
inspect.iscoroutinefunction to detect and await.
Also adds get_active_crawl_provider() to web_search_registry mirroring
the search/extract resolvers, with web.crawl_backend as the explicit
override config key.
No behavior change on its own — these are scaffolds for the four
remaining provider migrations.
Both web_search_registry._resolve() and image_gen_registry.get_active_provider()
walked their registered providers and returned the first one matching the
capability flag — without checking whether that provider was actually
usable. On a fresh install with no credentials at all, this meant
get_active_search_provider() returned `brave-free` (legacy preference
order) even though BRAVE_SEARCH_API_KEY was unset, leading the
dispatcher to surface a "BRAVE_SEARCH_API_KEY is not set" error for a
provider the user never chose. Same bug shape in image_gen for FAL.
Resolution semantics now match tools.web_tools._get_backend():
1. Explicit config name wins, ignoring is_available() — the dispatcher
surfaces a precise "X_API_KEY is not set" error rather than silently
switching backends. Matches user expectation: "I configured X, tell
me what's wrong with X."
2. Fallback (no explicit config) walks the legacy preference order
filtered by is_available() — pick the highest-priority backend the
user actually has credentials for.
is_available() is wrapped in a try/except so a buggy provider doesn't
brick resolution.
E2E verified:
- No creds + no config: get_active_search_provider() -> None
- Explicit brave-free + no key: get_active_search_provider() -> brave-free
(and .is_available() correctly reports False)
This fix was identified during the spike (#25182 finding #1) and is
fold-in to the same PR rather than a follow-up.