Hyeonsang Cho 6c94ecc6cb
fix(community): strip provider API keys before they reach a header (#7981)
* fix(community): strip provider API keys before they reach a header

GroundRoute (from GROUNDROUTE_API_KEY), InfoQuest, Tavily, Exa, Jina,
Firecrawl, fastCRW and Crawl4AI put the configured or environment key into
an HTTP header as read. A key read from a file keeps its trailing newline
(a Kubernetes Secret created with --from-file and mounted through envFrom),
and the transport refuses it with an error that quotes the whole header:
h11's "Illegal header value b'Bearer <key>\n'" or requests' "... in header
value: '<key>\n'". The providers returned that message as the tool result,
so the key reached the model and the checkpointed conversation, and
GroundRoute, InfoQuest, Jina and Crawl4AI also wrote it to the error log.

Resolve each key through provider_credentials.resolve_api_key, as the other
bundled providers already strip theirs. Tavily, Exa and Firecrawl now read
their environment fallback themselves instead of leaving it to the SDK,
which would read the raw value again. A blank configured key now falls back
to the environment, as it already does for GroundRoute and the stripping
providers.

* docs(changelog): reference #7981 for the provider API key fix

* fix(community): keep fastCRW from sending the Firecrawl key

fastCRW builds the Firecrawl SDK client, and the SDK reads
FIRECRAWL_API_KEY whenever api_key is None. Once a blank configured key
resolved to None, a fastCRW config with api_key: "" and no CRW_API_KEY sent
the Firecrawl key to the fastCRW endpoint. With no api_key configured at
all, main already did the same.

Pass "" when fastCRW resolves no key of its own; the SDK falls back only on
None and sends no Authorization header for an empty key. A loopback test
pins the empty, blank and absent cases with FIRECRAWL_API_KEY set.

* fix(community): keep a blank environment key away from the SDKs

Tavily, Exa and Firecrawl resolved their environment fallback themselves,
but passed None when it was blank, and each SDK then re-read the same
variable unstripped. Tavily and Firecrawl now pass "", which the SDKs
treat as a missing key; Exa sends "" as is, so it fails before any
request instead.

The fastCRW comment records that only the Firecrawl SDK's v2 client
honours "", and the fastCRW regression test now covers web_fetch as well
as web_search, so a move to the v1 client would fail it.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 08:44:56 +08:00

175 lines
6.8 KiB
Python

import json
import logging
import os
from urllib.parse import urlparse
from exa_py import Exa
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.url_safety import safe_public_result_url, validate_delegated_backend_url
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
DEFAULT_MAX_RESULTS = 5
DEFAULT_CONTENTS_MAX_CHARACTERS = 1000
def _coerce_positive_int(value: object, default: int, option: str) -> int:
"""Normalize a config value before handing it to the Exa SDK.
``$VAR`` references in config.yaml resolve to strings, and exa-py rejects a
string ``num_results`` outright, so every search would fail. Only an integer
or an integer-form string is accepted: ``int()`` would silently truncate a
float such as an unquoted ``3.5`` and turn ``true`` into 1. Anything else
(blank, non-numeric, fractional, boolean, zero or negative) warns and keeps
the default instead.
"""
if isinstance(value, int) and not isinstance(value, bool):
count = value
elif isinstance(value, str) and value.strip().isdecimal():
count = int(value.strip())
else:
count = 0
if count <= 0:
logger.warning("Invalid Exa %s=%r; using default %s", option, value, default)
return default
return count
def _coerce_bool(value: object, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return default
def _resolve_base_url(extra: dict) -> str | None:
"""Return the configured Exa-compatible base URL, or ``None`` for the SDK default.
A non-blank string ``base_url`` in the tool entry takes precedence over
``EXA_BASE_URL``. Overrides must be absolute HTTP(S) URLs with a host, a
valid port, and no query or fragment; an invalid one raises without echoing
the configured value and never falls back to another host.
"""
base_url = extra.get("base_url")
if not isinstance(base_url, str) or not base_url.strip():
base_url = os.getenv("EXA_BASE_URL") or ""
base_url = base_url.strip()
if not base_url:
return None
try:
parsed = urlparse(base_url)
valid = parsed.scheme in {"http", "https"} and bool(parsed.netloc and parsed.hostname) and "?" not in base_url and "#" not in base_url
# Accessing port also rejects invalid and out-of-range ports.
parsed.port
except ValueError:
valid = False
if not valid:
raise ValueError("Invalid Exa base_url/EXA_BASE_URL: use an absolute HTTP(S) URL with a host, a valid port, and no query or fragment")
return base_url.rstrip("/")
def _get_exa_client(tool_name: str = "web_search") -> Exa:
config = get_app_config().get_tool_config(tool_name)
extra = config.model_extra if config is not None else {}
return _build_exa_client(extra, _resolve_base_url(extra))
def _build_exa_client(extra: dict, base_url: str | None) -> Exa:
api_key = resolve_api_key(extra.get("api_key")) or resolve_api_key(os.getenv("EXA_API_KEY"))
# The SDK re-reads EXA_API_KEY unstripped when api_key is None and sends "" as
# is, so a missing or blank key fails here, before any request.
if api_key is None:
raise ValueError("Exa API key must be set in the tool config or EXA_API_KEY")
kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
return Exa(**kwargs)
@tool("web_search", parse_docstring=True)
def web_search_tool(query: str) -> str:
"""Search the web.
Args:
query: The query to search for.
"""
try:
config = get_app_config().get_tool_config("web_search")
max_results = DEFAULT_MAX_RESULTS
search_type = "auto"
contents_max_characters = DEFAULT_CONTENTS_MAX_CHARACTERS
if config is not None:
max_results = _coerce_positive_int(config.model_extra.get("max_results", max_results), DEFAULT_MAX_RESULTS, "max_results")
search_type = config.model_extra.get("search_type", search_type)
contents_max_characters = _coerce_positive_int(
config.model_extra.get("contents_max_characters", contents_max_characters),
DEFAULT_CONTENTS_MAX_CHARACTERS,
"contents_max_characters",
)
client = _get_exa_client()
res = client.search(
query,
type=search_type,
num_results=max_results,
contents={"highlights": {"max_characters": contents_max_characters}},
)
normalized_results = [
{
"title": result.title or "",
"url": safe_public_result_url(result.url),
"snippet": "\n".join(result.highlights) if result.highlights else "",
}
for result in res.results
]
json_results = json.dumps(normalized_results, indent=2, ensure_ascii=False)
return json_results
except Exception as e:
return f"Error: {str(e)}"
@tool("web_fetch", parse_docstring=True)
def web_fetch_tool(url: str) -> str:
"""Fetch the contents of a web page at a given URL.
Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools.
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
Do NOT add www. to URLs that do NOT have them.
URLs must include the schema: https://example.com is a valid URL while example.com is an invalid URL.
Args:
url: The URL to fetch the contents of.
"""
try:
config = get_app_config().get_tool_config("web_fetch")
extra = config.model_extra if config is not None else {}
base_url = _resolve_base_url(extra)
if base_url:
# A custom backend fetches the URL in its own network, like other delegated fetch services.
backend_error = validate_delegated_backend_url(
base_url,
network_isolation_confirmed=_coerce_bool(extra.get("network_isolation_confirmed"), False),
)
if backend_error:
return backend_error
client = _build_exa_client(extra, base_url)
res = client.get_contents([url], text={"max_characters": 4096})
if res.results:
result = res.results[0]
title = result.title or "Untitled"
text = result.text or ""
return f"# {title}\n\n{text[:4096]}"
else:
return "Error: No results found"
except Exception as e:
return f"Error: {str(e)}"