mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
fix(web-fetch): stop sending .env.example placeholder keys to providers (#8206)
* fix(web-fetch): stop sending .env.example placeholder keys to providers make config and make setup copy .env.example into .env, and the template set nine search/fetch provider keys to example values such as JINA_API_KEY=your-jina-api-key. The default keyless Jina Reader sent that text as a Bearer key, so every web_fetch on a fresh install failed with 401 while make doctor reported web fetch as configured. - .env.example comments those nine lines out, like every other credential in the template already is. - provider_credentials.resolve_api_key reads a your-...-key/-token/-secret value as unset, so existing .env files that still hold the placeholders recover without editing, for every provider that resolves keys there. * chore: leave the changelog entry to merge time Per CONTRIBUTING.md (#8202), maintainers add changelog entries when they merge a pull request. * fix(web-search): route every provider key and doctor through one placeholder rule Review follow-up for #8206: - provider_credentials exposes is_template_placeholder(), the single definition of an .env.example example value. key/token/secret must be a whole hyphen-separated word, so a real value such as your-monkey or your-passkey is no longer dropped. - Serper, Serply, Sofya, SpecStream, Unbrowse, Webz, Brave, Tencent WSA and You.com resolve config and environment keys through resolve_api_key instead of private strip-and-send copies, so an old .env that still holds the template value no longer sends it. - make doctor asks the same predicate: a placeholder key is reported as still holding the .env.example value instead of "set", and a literal placeholder in config.yaml falls back to the environment key as the runtime does. - A census test keeps credential environment reads under community/ wrapped in resolve_api_key. * fix(web-search): match shipped placeholders exactly and mirror Browserless precedence in doctor Second review follow-up for #8206: - is_template_placeholder() matches exactly the your-... values that .env.example ships instead of a pattern, so a token an operator chose (a self-hosted Browserless TOKEN such as your-review-team-token) is sent again. A test keeps the set equal to the template. - make doctor mirrors Browserless: a configured token field is authoritative, blank or not, and BROWSERLESS_TOKEN is read only when the field is omitted. A blank or placeholder token now warns instead of reporting the variable, cross-checked against the client. - The keyless Jina and You.com providers stay ok but name JINA_API_KEY or YDC_API_KEY when a placeholder there is ignored, and a literal placeholder api_key in config.yaml is named when no env key backs it. * fix(doctor): match each web provider's key precedence; drop the dead Jina api_key from docs Found while re-checking #8206 against every provider's runtime: - Browserless used as web_fetch had no doctor key check; it now gets the same authoritative token handling as web_capture. - InfoQuest reads only INFOQUEST_API_KEY, so doctor no longer counts an api_key field in its tool config as configured. - GroundRoute needs a key for every call but had no doctor entry, so a missing key reported ok. - You.com names a placeholder api_key in config that it ignores. - The Jina web_fetch docs snippet carried api_key: $JINA_API_KEY, which the tool never reads. With JINA_API_KEY now commented out in .env.example, that line made config loading fail on a fresh install. - The provider_credentials docstring now scopes doctor's use of the predicate to web provider keys, and the scripts guide no longer says .env.example ships live placeholder keys.
This commit is contained in:
parent
077773a967
commit
cee54cdd49
20
.env.example
20
.env.example
@ -1,40 +1,40 @@
|
||||
# Serper API Key (Google Search) - https://serper.dev
|
||||
SERPER_API_KEY=your-serper-api-key
|
||||
# SERPER_API_KEY=your-serper-api-key
|
||||
|
||||
# Optional operator-controlled Serper-compatible API base URL, without /search or /images.
|
||||
# The configured API key is sent in X-API-KEY to this host; use a trusted provider.
|
||||
SERPER_BASE_URL=https://google.serper.dev
|
||||
|
||||
# Exa API Key - https://exa.ai
|
||||
EXA_API_KEY=your-exa-api-key
|
||||
# EXA_API_KEY=your-exa-api-key
|
||||
|
||||
# Optional operator-controlled Exa-compatible API base URL for the Exa web_search and web_fetch tools.
|
||||
# The configured API key is sent in x-api-key to this host; use a trusted provider.
|
||||
EXA_BASE_URL=https://api.exa.ai
|
||||
|
||||
# Serply API Key (Google Search, News and Scholar) - https://serply.io
|
||||
SERPLY_API_KEY=your-serply-api-key
|
||||
# SERPLY_API_KEY=your-serply-api-key
|
||||
|
||||
# TAVILY API Key
|
||||
TAVILY_API_KEY=your-tavily-api-key
|
||||
# TAVILY_API_KEY=your-tavily-api-key
|
||||
|
||||
# Optional Webz.io News Search key (select Webz in make setup)
|
||||
# WEBZ_API_KEY=your-webz-api-key
|
||||
|
||||
# Jina API Key
|
||||
JINA_API_KEY=your-jina-api-key
|
||||
# Jina API Key (optional; the Jina Reader web_fetch works without one at a lower rate limit)
|
||||
# JINA_API_KEY=your-jina-api-key
|
||||
|
||||
# InfoQuest API Key
|
||||
INFOQUEST_API_KEY=your-infoquest-api-key
|
||||
# INFOQUEST_API_KEY=your-infoquest-api-key
|
||||
|
||||
# Sofya API Key (web search and fetch) - https://sofya.co
|
||||
SOFYA_API_KEY=your-sofya-api-key
|
||||
# SOFYA_API_KEY=your-sofya-api-key
|
||||
|
||||
# SpecStream API Key (web search and fetch) - https://specstream.ai
|
||||
SPECSTREAM_API_KEY=your-specstream-api-key
|
||||
# SPECSTREAM_API_KEY=your-specstream-api-key
|
||||
|
||||
# Unbrowse API Key (web fetch) - https://unbrowse.ai/app
|
||||
UNBROWSE_API_KEY=your-unbrowse-api-key
|
||||
# UNBROWSE_API_KEY=your-unbrowse-api-key
|
||||
|
||||
# Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins).
|
||||
# Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026.
|
||||
|
||||
@ -2112,6 +2112,8 @@ models:
|
||||
- `DEER_FLOW_SKILLS_PATH` - Skills directory when `skills.path` is omitted
|
||||
- `GATEWAY_ENABLE_DOCS` - Set to `false` to disable Swagger UI (`/docs`), ReDoc (`/redoc`), and OpenAPI schema (`/openapi.json`) endpoints (default: `true`)
|
||||
|
||||
Search, fetch and capture providers treat an example value that `.env.example` ships (such as `your-jina-api-key`, matched exactly) as unset, in `config.yaml` and in the environment alike: a keyless provider such as the Jina Reader uses its free tier, and a provider that needs a key reports it missing. Any other value is sent as written, so a self-hosted token you chose keeps working. Browserless reads `BROWSERLESS_TOKEN` only when its `token` field is omitted, so a blank or placeholder `token` is not replaced by the variable. `make doctor` applies the same rules and names the variable or field that still holds a placeholder, including `JINA_API_KEY` and `YDC_API_KEY` for the keyless providers.
|
||||
|
||||
## Backend dotenv selection
|
||||
|
||||
Set `DEER_FLOW_ENV_FILE` in the backend process environment **before startup**
|
||||
|
||||
@ -17,6 +17,7 @@ import os
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_time_range import BRAVE_FRESHNESS_BY_TIME_RANGE, SearchTimeRange
|
||||
from deerflow.community.url_safety import safe_public_result_url as _safe_public_url
|
||||
from deerflow.config import get_app_config
|
||||
@ -35,14 +36,8 @@ _api_key_warned: set[str] = set()
|
||||
|
||||
def _get_api_key(tool_name: str = "web_search") -> str | None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is not None:
|
||||
api_key = (config.model_extra or {}).get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("BRAVE_SEARCH_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
configured = resolve_api_key((config.model_extra or {}).get("api_key")) if config is not None else None
|
||||
return configured or resolve_api_key(os.getenv("BRAVE_SEARCH_API_KEY"))
|
||||
|
||||
|
||||
def _coerce_max_results(
|
||||
|
||||
@ -13,6 +13,12 @@ trailing newline, and putting that raw value into a header makes the HTTP
|
||||
transport refuse it with an error that quotes the whole header, key included
|
||||
(``tests/test_provider_api_key_whitespace.py``).
|
||||
|
||||
``is_template_placeholder`` is the one definition of an ``.env.example`` example
|
||||
value. ``scripts/doctor.py`` asks it for every web provider key, so the diagnosis
|
||||
and the runtime agree on which keys count as configured. Model credentials do not
|
||||
pass through here: their clients send whatever is set, so doctor's model check
|
||||
flags likely sample values with its own, broader rule.
|
||||
|
||||
The marker and the message are module constants because the strings are part of
|
||||
the contract: provider tests and operators' log grep patterns assert on them.
|
||||
"""
|
||||
@ -24,6 +30,66 @@ from pydantic import AnyHttpUrl, SecretStr
|
||||
REDACTION_MARKER = "[REDACTED]"
|
||||
URL_USERINFO_ERROR = "base_url must not contain username or password information"
|
||||
|
||||
# Every ``your-...`` example value ``.env.example`` ships, matched exactly. A rule
|
||||
# that guesses at the shape would also drop a token an operator chose, such as a
|
||||
# self-hosted Browserless ``TOKEN`` named ``your-review-team-token``.
|
||||
# ``tests/test_provider_credentials_single_owner.py`` keeps this set equal to the
|
||||
# template's values.
|
||||
ENV_TEMPLATE_PLACEHOLDERS = frozenset(
|
||||
{
|
||||
"your-anthropic-auth-token",
|
||||
"your-claude-code-oauth-token",
|
||||
"your-deepseek-api-key",
|
||||
"your-dingtalk-client-id",
|
||||
"your-dingtalk-client-secret",
|
||||
"your-discord-bot-token",
|
||||
"your-e2b-api-key",
|
||||
"your-exa-api-key",
|
||||
"your-feishu-app-id",
|
||||
"your-feishu-app-secret",
|
||||
"your-fernet-key",
|
||||
"your-firecrawl-api-key",
|
||||
"your-gemini-api-key",
|
||||
"your-github-token",
|
||||
"your-image-generation-api-key",
|
||||
"your-infoquest-api-key",
|
||||
"your-jina-api-key",
|
||||
"your-langsmith-api-key",
|
||||
"your-langsmith-project",
|
||||
"your-minimax-api-key",
|
||||
"your-novita-api-key",
|
||||
"your-openai-api-key",
|
||||
"your-openviking-user-api-key",
|
||||
"your-opper-api-key",
|
||||
"your-provisioner-api-key",
|
||||
"your-serper-api-key",
|
||||
"your-serply-api-key",
|
||||
"your-shared-internal-token",
|
||||
"your-slack-app-token",
|
||||
"your-slack-bot-token",
|
||||
"your-sofya-api-key",
|
||||
"your-specstream-api-key",
|
||||
"your-stepfun-api-key",
|
||||
"your-tavily-api-key",
|
||||
"your-telegram-bot-token",
|
||||
"your-unbrowse-api-key",
|
||||
"your-vllm-api-key",
|
||||
"your-volcengine-api-key",
|
||||
"your-webz-api-key",
|
||||
"your-wecom-bot-id",
|
||||
"your-wecom-bot-secret",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_template_placeholder(value: str) -> bool:
|
||||
"""Whether ``value`` is an ``.env.example`` example value rather than a credential.
|
||||
|
||||
Only the exact shipped value matches, ignoring surrounding whitespace: a key
|
||||
that merely resembles one (``sk-your-jina-api-key``) is a key.
|
||||
"""
|
||||
return value.strip() in ENV_TEMPLATE_PLACEHOLDERS
|
||||
|
||||
|
||||
def resolve_api_key(raw: str | SecretStr | None) -> str | None:
|
||||
"""Return the configured or environment key with surrounding whitespace removed.
|
||||
@ -31,10 +97,14 @@ def resolve_api_key(raw: str | SecretStr | None) -> str | None:
|
||||
Blank or absent reads as ``None``: a missing key is not an error, it is an
|
||||
unconfigured provider. Unwrapping happens only here, so no provider can end
|
||||
up comparing (or printing) the repr of a ``SecretStr`` instead of its value.
|
||||
|
||||
An ``.env.example`` placeholder also reads as ``None``. ``make config`` copies
|
||||
that template verbatim, and sending ``your-jina-api-key`` as a key made the
|
||||
keyless Jina Reader fail every fetch with 401 instead of using its free tier.
|
||||
"""
|
||||
value = raw.get_secret_value() if isinstance(raw, SecretStr) else raw
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None if is_template_placeholder(value) else value.strip()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_max_results import coerce_max_results
|
||||
from deerflow.community.search_time_range import SearchTimeRange
|
||||
from deerflow.community.url_safety import safe_public_result_url as _safe_public_url
|
||||
@ -45,13 +46,7 @@ def _get_api_key(tool_name: str, *, extras: Mapping[str, object] | None = None)
|
||||
if extras is None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
extras = config.model_extra if config is not None else {}
|
||||
api_key = extras.get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("SERPER_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
return resolve_api_key(extras.get("api_key")) or resolve_api_key(os.getenv("SERPER_API_KEY"))
|
||||
|
||||
|
||||
def _count_for_request(value: object) -> int:
|
||||
|
||||
@ -17,6 +17,7 @@ import re
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_max_results import coerce_max_results
|
||||
from deerflow.community.url_safety import safe_public_result_url
|
||||
from deerflow.config import get_app_config
|
||||
@ -42,14 +43,8 @@ _api_key_warned: set[str] = set()
|
||||
|
||||
def _get_api_key(tool_name: str = "web_search") -> str | None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is not None:
|
||||
api_key = (config.model_extra or {}).get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("SERPLY_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
configured = resolve_api_key((config.model_extra or {}).get("api_key")) if config is not None else None
|
||||
return configured or resolve_api_key(os.getenv("SERPLY_API_KEY"))
|
||||
|
||||
|
||||
def _count_for_request(value: object) -> int:
|
||||
|
||||
@ -13,6 +13,7 @@ import os
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_time_range import SearchTimeRange
|
||||
from deerflow.community.url_safety import safe_public_result_url
|
||||
from deerflow.config import get_app_config
|
||||
@ -33,14 +34,8 @@ _api_key_warned: set[str] = set()
|
||||
|
||||
def _get_api_key(tool_name: str) -> str | None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is not None:
|
||||
api_key = config.model_extra.get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("SOFYA_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
configured = resolve_api_key(config.model_extra.get("api_key")) if config is not None else None
|
||||
return configured or resolve_api_key(os.getenv("SOFYA_API_KEY"))
|
||||
|
||||
|
||||
def _coerce_max_results(value: object, default: int = 5, max_allowed: int = _SOFYA_MAX_RESULTS) -> int:
|
||||
|
||||
@ -15,6 +15,7 @@ import os
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_max_results import coerce_max_results
|
||||
from deerflow.community.url_safety import safe_public_result_url
|
||||
from deerflow.config import get_app_config
|
||||
@ -34,14 +35,8 @@ _api_key_warned: set[str] = set()
|
||||
|
||||
def _get_api_key(tool_name: str) -> str | None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is not None:
|
||||
api_key = config.model_extra.get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("SPECSTREAM_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
configured = resolve_api_key(config.model_extra.get("api_key")) if config is not None else None
|
||||
return configured or resolve_api_key(os.getenv("SPECSTREAM_API_KEY"))
|
||||
|
||||
|
||||
def _missing_key_message(tool_name: str) -> str:
|
||||
|
||||
@ -9,6 +9,7 @@ from typing import Any
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.url_safety import safe_public_result_url
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
@ -32,13 +33,7 @@ def _get_tool_extras(tool_name: str) -> Mapping[str, Any]:
|
||||
|
||||
def _get_api_key(tool_name: str = "web_search", *, extras: Mapping[str, Any] | None = None) -> str | None:
|
||||
api_key = (extras if extras is not None else _get_tool_extras(tool_name)).get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
|
||||
env_key = os.getenv(_API_KEY_ENV)
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
return resolve_api_key(api_key) or resolve_api_key(os.getenv(_API_KEY_ENV))
|
||||
|
||||
|
||||
def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int:
|
||||
|
||||
@ -17,6 +17,7 @@ from urllib.parse import urlsplit, urlunsplit
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -33,14 +34,8 @@ _api_key_warned: set[str] = set()
|
||||
|
||||
def _get_api_key(tool_name: str) -> str | None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is not None:
|
||||
api_key = config.model_extra.get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("UNBROWSE_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
configured = resolve_api_key(config.model_extra.get("api_key")) if config is not None else None
|
||||
return configured or resolve_api_key(os.getenv("UNBROWSE_API_KEY"))
|
||||
|
||||
|
||||
def _resolve_render(value: object) -> str:
|
||||
|
||||
@ -10,6 +10,7 @@ from typing import Literal
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_time_range import SearchTimeRange
|
||||
from deerflow.community.url_safety import safe_public_result_url
|
||||
from deerflow.config import get_app_config
|
||||
@ -112,9 +113,7 @@ async def web_search_tool(
|
||||
|
||||
# Lazy configuration resolution can read files; keep it off the event loop.
|
||||
options = await asyncio.to_thread(_options)
|
||||
key = options.get("api_key")
|
||||
key = key.strip() if isinstance(key, str) else ""
|
||||
key = key or os.getenv("WEBZ_API_KEY", "").strip()
|
||||
key = resolve_api_key(options.get("api_key")) or resolve_api_key(os.getenv("WEBZ_API_KEY"))
|
||||
if not key:
|
||||
return json.dumps({"error": "Webz API key is missing; configure api_key or WEBZ_API_KEY"})
|
||||
count = _count(max_results if max_results is not None else options.get("max_results", 5))
|
||||
|
||||
@ -49,6 +49,7 @@ from importlib.metadata import PackageNotFoundError, version
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
|
||||
from deerflow.community.provider_credentials import resolve_api_key
|
||||
from deerflow.community.search_max_results import coerce_max_results
|
||||
from deerflow.community.url_safety import safe_public_result_url
|
||||
from deerflow.config import get_app_config
|
||||
@ -98,14 +99,8 @@ def _resolve_tool_settings(tool_name: str) -> tuple[str | None, object]:
|
||||
if config is not None:
|
||||
extra = config.model_extra or {}
|
||||
max_results = extra.get("max_results")
|
||||
candidate = extra.get("api_key")
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
api_key = candidate.strip()
|
||||
if api_key is None:
|
||||
env_key = os.getenv("YDC_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
api_key = env_key.strip()
|
||||
return api_key, max_results
|
||||
api_key = resolve_api_key(extra.get("api_key"))
|
||||
return api_key or resolve_api_key(os.getenv("YDC_API_KEY")), max_results
|
||||
|
||||
|
||||
async def _resolve_user_agent() -> str:
|
||||
|
||||
@ -99,3 +99,14 @@ def test_configure_rejects_unknown_flags_without_generating_files(config_checkou
|
||||
assert not (config_checkout / "config.yaml").exists()
|
||||
assert not (config_checkout / ".env").exists()
|
||||
assert not (config_checkout / "frontend" / ".env").exists()
|
||||
|
||||
|
||||
def test_env_template_leaves_every_placeholder_credential_commented():
|
||||
"""``make config`` and ``make setup`` copy ``.env.example`` verbatim into ``.env``.
|
||||
|
||||
A live ``JINA_API_KEY=your-jina-api-key`` line made the default keyless
|
||||
``web_fetch`` send that text as a key and fail every request with 401, so
|
||||
every ``your-...`` example value must stay behind a ``#``.
|
||||
"""
|
||||
live = [line for line in (REPO_ROOT / ".env.example").read_text(encoding="utf-8").splitlines() if not line.lstrip().startswith("#") and "=" in line and line.split("=", 1)[1].strip().lower().startswith("your-")]
|
||||
assert live == []
|
||||
|
||||
@ -609,6 +609,88 @@ class TestCheckWebSearch:
|
||||
assert result.fix is not None
|
||||
assert "make setup" in result.fix
|
||||
|
||||
def test_infoquest_ignores_a_configured_api_key_like_its_client(self, tmp_path, monkeypatch):
|
||||
"""InfoQuest reads only INFOQUEST_API_KEY, so an api_key field must not make doctor report it configured."""
|
||||
from deerflow.community.infoquest.infoquest_client import InfoQuestClient
|
||||
|
||||
monkeypatch.setenv("MY_INFOQUEST_KEY", "iq-real-key")
|
||||
monkeypatch.setenv("INFOQUEST_API_KEY", "your-infoquest-api-key")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.infoquest.tools:web_search_tool\n api_key: $MY_INFOQUEST_KEY\n")
|
||||
|
||||
result = doctor.check_web_search(cfg)
|
||||
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "infoquest configured but INFOQUEST_API_KEY still holds the .env.example placeholder"
|
||||
assert InfoQuestClient().api_key_set is False
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ["web_search", "web_fetch"])
|
||||
def test_groundroute_key_is_checked(self, tmp_path, monkeypatch, tool_name):
|
||||
"""Every GroundRoute call needs a key; doctor used to report ok without one."""
|
||||
monkeypatch.delenv("GROUNDROUTE_API_KEY", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(f"config_version: 5\ntools:\n - name: {tool_name}\n use: deerflow.community.groundroute.tools:{tool_name}_tool\n")
|
||||
check = doctor.check_web_search if tool_name == "web_search" else doctor.check_web_fetch
|
||||
|
||||
result = check(cfg)
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "groundroute configured but GROUNDROUTE_API_KEY not set"
|
||||
|
||||
monkeypatch.setenv("GROUNDROUTE_API_KEY", "gr-real-key")
|
||||
assert check(cfg).status == "ok"
|
||||
|
||||
def test_youcom_names_an_ignored_placeholder_in_config(self, tmp_path, monkeypatch):
|
||||
"""You.com drops it and uses the keyless endpoint, so the status stays ok but the field is named."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.community.youcom import tools as youcom
|
||||
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.youcom.tools:web_search_tool\n api_key: your-tavily-api-key\n")
|
||||
|
||||
result = doctor.check_web_search(cfg)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert result.detail.endswith("; api_key in config holds the .env.example placeholder and is ignored")
|
||||
monkeypatch.setattr(youcom, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra={"api_key": "your-tavily-api-key"})))
|
||||
assert youcom._resolve_tool_settings("web_search")[0] is None
|
||||
|
||||
def test_tavily_with_env_template_placeholder_warns(self, tmp_path, monkeypatch):
|
||||
"""The runtime drops an ``.env.example`` value, so doctor must not call it configured."""
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "your-tavily-api-key")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n api_key: $TAVILY_API_KEY\n")
|
||||
result = doctor.check_web_search(cfg)
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "tavily configured but TAVILY_API_KEY still holds the .env.example placeholder"
|
||||
assert "TAVILY_API_KEY" in (result.fix or "")
|
||||
|
||||
def test_literal_placeholder_in_config_falls_back_to_the_env_key(self, tmp_path, monkeypatch):
|
||||
"""``resolve_api_key`` drops the literal placeholder and the provider reads the environment instead."""
|
||||
monkeypatch.setenv("SERPER_API_KEY", "real-serper-key")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n api_key: your-serper-api-key\n")
|
||||
result = doctor.check_web_search(cfg)
|
||||
assert result.status == "ok"
|
||||
assert result.detail == "serper (SERPER_API_KEY set)"
|
||||
|
||||
def test_literal_placeholder_in_config_is_named_when_no_env_key_backs_it(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n api_key: your-tavily-api-key\n")
|
||||
result = doctor.check_web_search(cfg)
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "tavily configured but api_key in config still holds the .env.example placeholder"
|
||||
|
||||
def test_key_that_only_mentions_your_is_configured(self, tmp_path, monkeypatch):
|
||||
"""Doctor and the runtime share one rule: ``sk-your-...`` is a key both of them use."""
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "sk-your-tavily-api-key")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n")
|
||||
result = doctor.check_web_search(cfg)
|
||||
assert result.status == "ok"
|
||||
|
||||
def test_brave_with_key_ok(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
@ -787,6 +869,30 @@ class TestCheckWebFetch:
|
||||
assert result.status == "ok"
|
||||
assert "Jina AI" in result.detail
|
||||
|
||||
@pytest.mark.usefixtures("delegated_backend_dns")
|
||||
def test_browserless_fetch_explicit_token_wins_over_the_environment(self, tmp_path, monkeypatch):
|
||||
"""``web_fetch`` builds the same client as ``web_capture``, so the configured token is authoritative there too."""
|
||||
from deerflow.community.browserless.tools import _get_browserless_client
|
||||
|
||||
monkeypatch.setenv("BROWSERLESS_TOKEN", "browserless-real-token")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.browserless.tools:web_fetch_tool\n base_url: https://production-sfo.browserless.io\n token: your-github-token\n")
|
||||
|
||||
result = doctor.check_web_fetch(cfg)
|
||||
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "browserless configured but token in config still holds the .env.example placeholder"
|
||||
assert _get_browserless_client("web_fetch", cfg={"base_url": "https://production-sfo.browserless.io", "token": "your-github-token"}).token == ""
|
||||
|
||||
def test_jina_names_the_ignored_template_placeholder(self, tmp_path, monkeypatch):
|
||||
"""The runtime drops it and fetches anonymously; doctor says so instead of staying silent."""
|
||||
monkeypatch.setenv("JINA_API_KEY", "your-jina-api-key")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.jina_ai.tools:web_fetch_tool\n")
|
||||
result = doctor.check_web_fetch(cfg)
|
||||
assert result.status == "ok"
|
||||
assert result.detail == "Jina AI Reader (no key needed); JINA_API_KEY holds the .env.example placeholder and is ignored"
|
||||
|
||||
def test_firecrawl_without_key_warns(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
@ -1033,6 +1139,64 @@ class TestCheckWebCapture:
|
||||
assert result.status == "warn"
|
||||
assert "BROWSERLESS_TOKEN" in (result.fix or "")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("token", "detail"),
|
||||
[
|
||||
("your-github-token", "browserless configured but token in config still holds the .env.example placeholder"),
|
||||
('""', "browserless configured but token in config is empty, and BROWSERLESS_TOKEN is read only when token is omitted"),
|
||||
],
|
||||
ids=["placeholder", "blank"],
|
||||
)
|
||||
def test_browserless_explicit_token_wins_over_the_environment(self, tmp_path, monkeypatch, token, detail):
|
||||
"""Browserless reads BROWSERLESS_TOKEN only when ``token`` is omitted, so a valid variable cannot rescue the field."""
|
||||
from deerflow.community.browserless.tools import _get_browserless_client
|
||||
|
||||
monkeypatch.setenv("BROWSERLESS_TOKEN", "browserless-real-token")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(f"config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: {token}\n")
|
||||
|
||||
result = doctor.check_web_capture(cfg)
|
||||
|
||||
assert result.status == "warn"
|
||||
assert result.detail == detail
|
||||
assert "remove token to use BROWSERLESS_TOKEN" in (result.fix or "")
|
||||
runtime_token = _get_browserless_client("web_capture", cfg={"base_url": "https://production-sfo.browserless.io", "token": "" if token == '""' else token}).token
|
||||
assert runtime_token == "", "doctor must warn exactly when the client sends no token"
|
||||
|
||||
def test_browserless_token_reference_to_an_unset_variable_names_it_once(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BROWSERLESS_TOKEN", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: $BROWSERLESS_TOKEN\n")
|
||||
|
||||
result = doctor.check_web_capture(cfg)
|
||||
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "browserless configured but BROWSERLESS_TOKEN not set"
|
||||
|
||||
def test_browserless_self_hosted_still_warns_about_a_placeholder_token(self, tmp_path, monkeypatch):
|
||||
"""A self-hosted token is optional, but a placeholder shows the operator meant to set one."""
|
||||
monkeypatch.setenv("BROWSERLESS_TOKEN", "your-github-token")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(
|
||||
"config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: http://localhost:3032\n network_isolation_confirmed: true\n token: $BROWSERLESS_TOKEN\n"
|
||||
)
|
||||
|
||||
result = doctor.check_web_capture(cfg)
|
||||
|
||||
assert result.status == "warn"
|
||||
assert result.detail == "browserless configured but BROWSERLESS_TOKEN still holds the .env.example placeholder"
|
||||
|
||||
def test_browserless_operator_chosen_token_is_a_token(self, tmp_path, monkeypatch):
|
||||
"""Only shipped template values are placeholders; ``your-review-team-token`` is a token someone chose."""
|
||||
monkeypatch.setenv("BROWSERLESS_TOKEN", "your-review-team-token")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: $BROWSERLESS_TOKEN\n")
|
||||
|
||||
result = doctor.check_web_capture(cfg)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "BROWSERLESS_TOKEN set from config" in result.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_image_search
|
||||
|
||||
@ -246,6 +246,21 @@ async def test_crawl_includes_api_key_when_set(jina_client, monkeypatch):
|
||||
assert captured_headers["Authorization"] == "Bearer test-key-123"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_crawl_uses_the_anonymous_tier_when_env_holds_the_template_placeholder(jina_client, monkeypatch):
|
||||
"""A fresh ``make config`` leaves ``JINA_API_KEY=your-jina-api-key``; sending it makes every fetch a 401."""
|
||||
captured_headers = {}
|
||||
|
||||
async def mock_post(self, url, **kwargs):
|
||||
captured_headers.update(kwargs.get("headers", {}))
|
||||
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
monkeypatch.setenv("JINA_API_KEY", "your-jina-api-key")
|
||||
assert await jina_client.crawl("https://example.com") == "ok"
|
||||
assert "Authorization" not in captured_headers
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_crawl_warns_once_when_api_key_missing(jina_client, monkeypatch, caplog):
|
||||
"""Test that the missing API key warning is logged only once."""
|
||||
|
||||
@ -28,7 +28,10 @@ These tests pin three things the extraction must not silently give back:
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@ -104,6 +107,52 @@ def test_resolve_api_key_normalizes_without_rejecting(raw: Any, expected: str |
|
||||
assert owner.resolve_api_key(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"your-jina-api-key",
|
||||
" your-tavily-api-key\n",
|
||||
SecretStr("your-infoquest-api-key"),
|
||||
"your-github-token",
|
||||
"your-feishu-app-secret",
|
||||
],
|
||||
)
|
||||
def test_resolve_api_key_reads_an_env_template_placeholder_as_unset(raw: Any) -> None:
|
||||
"""``make config`` copies ``.env.example`` verbatim, so its ``your-...`` values are not keys."""
|
||||
assert owner.resolve_api_key(raw) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"sk-your-jina-api-key",
|
||||
"your-jina-api-key-2f9c",
|
||||
"jina_your-key",
|
||||
"yourkey",
|
||||
"your-monkey",
|
||||
"your-key",
|
||||
"YOUR-EXA-API-KEY",
|
||||
"your-review-team-token",
|
||||
"your-browserless-token",
|
||||
],
|
||||
)
|
||||
def test_resolve_api_key_keeps_values_the_template_does_not_ship(raw: str) -> None:
|
||||
"""Self-hosted Browserless and LightRAG take a token the operator chose, whatever it looks like."""
|
||||
assert owner.resolve_api_key(raw) == raw
|
||||
assert not owner.is_template_placeholder(raw)
|
||||
|
||||
|
||||
def test_placeholders_are_exactly_the_values_env_example_ships() -> None:
|
||||
"""A new template value must be added here, or an install copied from it sends the placeholder again."""
|
||||
template = (COMMUNITY_ROOT.parents[4] / ".env.example").read_text(encoding="utf-8")
|
||||
shipped = set(re.findall(r"=(your-[a-z0-9-]+)", template))
|
||||
assert shipped, "found no example values in .env.example"
|
||||
assert shipped == owner.ENV_TEMPLATE_PLACEHOLDERS
|
||||
for value in shipped:
|
||||
assert owner.is_template_placeholder(value)
|
||||
assert owner.resolve_api_key(value) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "secret", "expected"),
|
||||
[
|
||||
@ -170,6 +219,40 @@ def test_folded_providers_reduced_their_adapters_to_delegation(provider: str) ->
|
||||
assert not hasattr(faces["tools"], "_redact_api_key"), f"{provider} grew a private redaction copy again"
|
||||
|
||||
|
||||
def _tool_config(extra: dict[str, Any]):
|
||||
return SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra=dict(extra)))
|
||||
|
||||
|
||||
# Values ``.env.example`` ships: an install copied from the old template holds them.
|
||||
TEMPLATE_CONFIG_VALUE = "your-tavily-api-key"
|
||||
TEMPLATE_ENV_VALUE = "your-serper-api-key"
|
||||
|
||||
# provider module, environment variable, how the provider resolves its key
|
||||
ENV_KEY_READERS = [
|
||||
("brave", "BRAVE_SEARCH_API_KEY", lambda tools: tools._get_api_key("web_search")),
|
||||
("serper", "SERPER_API_KEY", lambda tools: tools._get_api_key("web_search", extras={"api_key": TEMPLATE_CONFIG_VALUE})),
|
||||
("serply", "SERPLY_API_KEY", lambda tools: tools._get_api_key("web_search")),
|
||||
("sofya", "SOFYA_API_KEY", lambda tools: tools._get_api_key("web_search")),
|
||||
("specstream", "SPECSTREAM_API_KEY", lambda tools: tools._get_api_key("web_search")),
|
||||
("tencent_wsa", "TENCENTCLOUD_WSA_APIKEY", lambda tools: tools._get_api_key("web_search", extras={"api_key": TEMPLATE_CONFIG_VALUE})),
|
||||
("unbrowse", "UNBROWSE_API_KEY", lambda tools: tools._get_api_key("web_fetch")),
|
||||
("youcom", "YDC_API_KEY", lambda tools: tools._resolve_tool_settings("web_search")[0]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("provider", "env_name", "read_key"), ENV_KEY_READERS, ids=[case[0] for case in ENV_KEY_READERS])
|
||||
def test_search_and_fetch_providers_drop_the_env_template_placeholder(provider: str, env_name: str, read_key, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An ``.env`` made from the old template still holds ``your-...-api-key``; it must read as unset, not be sent."""
|
||||
tools = importlib.import_module(f"deerflow.community.{provider}.tools")
|
||||
monkeypatch.setattr(tools, "get_app_config", lambda: _tool_config({"api_key": TEMPLATE_CONFIG_VALUE}))
|
||||
|
||||
monkeypatch.setenv(env_name, TEMPLATE_ENV_VALUE)
|
||||
assert read_key(tools) is None
|
||||
|
||||
monkeypatch.setenv(env_name, f" {PROVIDER_KEY}\n")
|
||||
assert read_key(tools) == PROVIDER_KEY
|
||||
|
||||
|
||||
def test_lightrag_still_accepts_an_unconfigured_key() -> None:
|
||||
"""LightRAG may run without authentication: no key is valid settings, not an error."""
|
||||
settings = _settings("lightrag")
|
||||
@ -242,7 +325,46 @@ def test_owner_holds_each_rule_exactly_once() -> None:
|
||||
assert src.count('"[REDACTED]"') == 1
|
||||
assert src.count("get_secret_value()") == 1
|
||||
assert sum(1 for node in _code_nodes(tree) if isinstance(node, ast.Attribute) and node.attr == "password") == 1
|
||||
assert {f.name for f in _functions(tree)} == {"resolve_api_key", "redact_secret", "reject_url_userinfo"}
|
||||
assert {f.name for f in _functions(tree)} == {"is_template_placeholder", "resolve_api_key", "redact_secret", "reject_url_userinfo"}
|
||||
|
||||
|
||||
_CREDENTIAL_ENV_NAME = re.compile(r"_(?:API_?KEY|TOKEN|SECRET)$")
|
||||
|
||||
|
||||
def _credential_env_reads(tree: ast.Module) -> list[tuple[ast.Call, ast.AST | None]]:
|
||||
"""``os.getenv``/``os.environ.get`` calls that read a credential variable, with their parent node."""
|
||||
constants = {target.id: node.value.value for node in tree.body if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) for target in node.targets if isinstance(target, ast.Name)}
|
||||
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
|
||||
reads = []
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and node.args and isinstance(node.func, ast.Attribute)):
|
||||
continue
|
||||
func = node.func
|
||||
is_getenv = func.attr == "getenv" and getattr(func.value, "id", None) == "os"
|
||||
is_environ_get = func.attr == "get" and isinstance(func.value, ast.Attribute) and func.value.attr == "environ"
|
||||
if not (is_getenv or is_environ_get):
|
||||
continue
|
||||
arg = node.args[0]
|
||||
name = arg.value if isinstance(arg, ast.Constant) else constants.get(getattr(arg, "id", None))
|
||||
if isinstance(name, str) and _CREDENTIAL_ENV_NAME.search(name):
|
||||
reads.append((node, parents.get(node)))
|
||||
return reads
|
||||
|
||||
|
||||
def test_search_and_fetch_providers_read_environment_keys_through_the_owner() -> None:
|
||||
"""A private strip-and-send copy keeps sending the ``.env.example`` placeholder an old ``.env`` still holds.
|
||||
|
||||
Sandbox providers are out of scope: they are not search or fetch providers,
|
||||
and their keys configure the sandbox backend rather than a request header.
|
||||
"""
|
||||
offenders = []
|
||||
for path in _provider_files():
|
||||
if path.relative_to(COMMUNITY_ROOT).parts[0].endswith("sandbox"):
|
||||
continue
|
||||
for call, parent in _credential_env_reads(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
|
||||
if not (isinstance(parent, ast.Call) and getattr(parent.func, "id", None) == "resolve_api_key" and parent.args[:1] == [call]):
|
||||
offenders.append(f"{path.relative_to(COMMUNITY_ROOT)}:{call.lineno}")
|
||||
assert offenders == [], f"wrap the environment read in resolve_api_key: {offenders}"
|
||||
|
||||
|
||||
def test_settings_models_reject_userinfo_through_the_shared_validator() -> None:
|
||||
|
||||
@ -108,6 +108,20 @@ async def test_missing_key_never_makes_request(webz):
|
||||
assert not requests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_template_placeholder_is_not_sent_as_a_key(webz, monkeypatch):
|
||||
"""An ``.env`` made from the old template still holds ``WEBZ_API_KEY=your-webz-api-key``."""
|
||||
module, options, requests, _, _ = webz
|
||||
options.update(api_key="your-webz-api-key")
|
||||
monkeypatch.setenv("WEBZ_API_KEY", "your-webz-api-key")
|
||||
assert "WEBZ_API_KEY" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))["error"]
|
||||
assert not requests
|
||||
|
||||
monkeypatch.setenv("WEBZ_API_KEY", "env-key")
|
||||
await module.web_search_tool.ainvoke({"query": "news"})
|
||||
assert requests[0].headers["authorization"] == "Bearer env-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("arguments", [{"query": " "}, {"query": "x" * 751}, {"query": "word " * 101}, {"query": "news", "published_from": "yesterday"}, {"query": "news", "published_from": "2026-09-20", "published_to": "2026-09-01"}])
|
||||
async def test_invalid_input_never_makes_request(webz, arguments):
|
||||
|
||||
@ -277,10 +277,10 @@ tools:
|
||||
- name: web_fetch
|
||||
group: web
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
api_key: $JINA_API_KEY # optional; anonymous usage has rate limits
|
||||
```
|
||||
Converts web pages to clean Markdown. Works without an API key at reduced rate
|
||||
limits.
|
||||
limits; set `JINA_API_KEY` in `.env` for higher limits. The tool reads only that
|
||||
variable, not an `api_key` field.
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab>
|
||||
```yaml
|
||||
|
||||
@ -268,9 +268,8 @@ tools:
|
||||
- name: web_fetch
|
||||
group: web
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
api_key: $JINA_API_KEY # 可选;匿名使用有速率限制
|
||||
```
|
||||
将网页转换为干净的 Markdown。无 API Key 也可使用,但有更严格的速率限制。
|
||||
将网页转换为干净的 Markdown。无 API Key 也可使用,但有更严格的速率限制;在 `.env` 中设置 `JINA_API_KEY` 可提高限额。该工具只读取这个环境变量,不读取 `api_key` 字段。
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab>
|
||||
```yaml
|
||||
|
||||
@ -52,9 +52,10 @@ Cases marked for real Compose run only when a `docker` CLI is installed.
|
||||
- **One dotenv selection per deployment.** `DEER_FLOW_COMPOSE_ENV_FILE` is
|
||||
resolved once, and Compose interpolation, service env files, secret probes and
|
||||
extras detection all use that path.
|
||||
- **Do not seed production `.env` from the examples.** `.env.example` holds
|
||||
placeholder API keys that the production Gateway would receive. Both compose
|
||||
files mark the env files optional so a fresh checkout still starts and stops.
|
||||
- **Do not seed production `.env` from the examples.** They hold development
|
||||
defaults and sample keys, not values an operator chose for production. Both
|
||||
compose files mark the env files optional so a fresh checkout still starts and
|
||||
stops.
|
||||
- **Support bundles export no secrets** and show an external runtime home as
|
||||
`{DEER_FLOW_HOME}`.
|
||||
- **Doctor does not echo model credentials.** Obvious sample key values fail the
|
||||
|
||||
@ -701,6 +701,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
"specstream": "SPECSTREAM_API_KEY",
|
||||
"tencent_wsa": "TENCENTCLOUD_WSA_APIKEY",
|
||||
"webz": "WEBZ_API_KEY",
|
||||
"groundroute": "GROUNDROUTE_API_KEY",
|
||||
},
|
||||
"web_fetch": {
|
||||
"infoquest": "INFOQUEST_API_KEY",
|
||||
@ -710,6 +711,8 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
"sofya": "SOFYA_API_KEY",
|
||||
"specstream": "SPECSTREAM_API_KEY",
|
||||
"unbrowse": "UNBROWSE_API_KEY",
|
||||
"groundroute": "GROUNDROUTE_API_KEY",
|
||||
"browserless": "BROWSERLESS_TOKEN",
|
||||
},
|
||||
"image_search": {
|
||||
"brave": "BRAVE_SEARCH_API_KEY",
|
||||
@ -721,27 +724,77 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
},
|
||||
}
|
||||
key_fields = {
|
||||
"web_fetch": {
|
||||
"browserless": "token",
|
||||
},
|
||||
"web_capture": {
|
||||
"browserless": "token",
|
||||
},
|
||||
}
|
||||
# Browserless treats a configured token field as authoritative, blank or
|
||||
# not, and reads BROWSERLESS_TOKEN only when the field is omitted
|
||||
# (community/browserless/tools.py). The other providers fall back to
|
||||
# their variable whenever the configured value gives no key.
|
||||
authoritative_key_providers = {"browserless"}
|
||||
# InfoQuest reads only INFOQUEST_API_KEY; an api_key field in its tool
|
||||
# config never reaches the client (community/infoquest/).
|
||||
env_only_key_providers = {"infoquest"}
|
||||
# Keyless providers that still send a key when one is set, and whether
|
||||
# they read an api_key field before the variable (Jina reads only the
|
||||
# variable).
|
||||
free_provider_keys = {"youcom": ("YDC_API_KEY", True), "jina_ai": ("JINA_API_KEY", False)}
|
||||
|
||||
def _configured_key_detail(tool: dict, default_var: str, key_field: str = "api_key") -> tuple[Status, str] | None:
|
||||
def _is_placeholder(value: object) -> bool:
|
||||
# Ask the runtime's owner of the rule: providers drop an .env.example
|
||||
# value, so doctor must not report it as a configured key. Without
|
||||
# the harness, the config checks already fail with the fix.
|
||||
try:
|
||||
from deerflow.community.provider_credentials import is_template_placeholder
|
||||
except ImportError:
|
||||
return False
|
||||
return isinstance(value, str) and is_template_placeholder(value)
|
||||
|
||||
def _real_key(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip()) and not _is_placeholder(value)
|
||||
|
||||
def _configured_key_detail(tool: dict, default_var: str, key_field: str = "api_key", *, authoritative: bool = False) -> tuple[Status, str] | None:
|
||||
configured_key = tool.get(key_field)
|
||||
if isinstance(configured_key, str) and configured_key.strip():
|
||||
if _real_key(configured_key):
|
||||
key = configured_key.strip()
|
||||
if key.startswith("$"):
|
||||
env_name = key[1:]
|
||||
val = os.environ.get(env_name)
|
||||
if val and val.strip():
|
||||
return ("ok", f"{env_name} set from config")
|
||||
# The referenced var is unset; fall through to the default
|
||||
# env var below, which tools use as a runtime fallback.
|
||||
else:
|
||||
if not key.startswith("$"):
|
||||
return ("warn", f"literal {key_field} set in config")
|
||||
env_name = key[1:]
|
||||
if _real_key(os.environ.get(env_name)):
|
||||
return ("ok", f"{env_name} set from config")
|
||||
if authoritative and key_field in tool:
|
||||
return None
|
||||
# A blank or placeholder configured value gives no key, and the tool
|
||||
# falls back to its default variable. An unset $VAR reference never
|
||||
# gets here at runtime: config loading fails, which
|
||||
# check_config_loadable reports.
|
||||
return ("ok", f"{default_var} set") if _real_key(os.environ.get(default_var)) else None
|
||||
|
||||
val = os.environ.get(default_var)
|
||||
return ("ok", f"{default_var} set") if val and val.strip() else None
|
||||
def _config_source(tool: dict, key_field: str) -> tuple[str, object]:
|
||||
"""The configured key's name in a message, and its value after a ``$VAR`` reference."""
|
||||
value = tool.get(key_field)
|
||||
if isinstance(value, str) and value.strip().startswith("$"):
|
||||
name = value.strip()[1:]
|
||||
return name, os.environ.get(name)
|
||||
return f"{key_field} in config", value
|
||||
|
||||
def _missing_key_reason(tool: dict, var: str, key_field: str, *, authoritative: bool) -> tuple[str, bool]:
|
||||
"""Why no key reaches the provider, and whether a placeholder is the cause."""
|
||||
name, value = _config_source(tool, key_field)
|
||||
if authoritative and key_field in tool:
|
||||
if _is_placeholder(value):
|
||||
return f"{name} still holds the .env.example placeholder", True
|
||||
if name == var:
|
||||
return f"{var} not set", False
|
||||
return f"{name} is empty, and {var} is read only when {key_field} is omitted", False
|
||||
for source, candidate in ((name, value), (var, os.environ.get(var))):
|
||||
if _is_placeholder(candidate):
|
||||
return f"{source} still holds the .env.example placeholder", True
|
||||
return f"{var} not set", False
|
||||
|
||||
def _browserless_self_hosted(tool: dict) -> bool:
|
||||
base_url = str(tool.get("base_url") or "http://localhost:3032").lower()
|
||||
@ -798,6 +851,13 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
use = tool.get("use", "")
|
||||
for provider, detail in free_providers.get(tool_name, {}).items():
|
||||
if provider in use:
|
||||
var, reads_config = free_provider_keys.get(provider, (None, False))
|
||||
sources = [_config_source(tool, "api_key")] if reads_config else []
|
||||
if var:
|
||||
sources.append((var, os.environ.get(var)))
|
||||
ignored = next((name for name, value in sources if _is_placeholder(value)), None)
|
||||
if ignored:
|
||||
detail = f"{detail}; {ignored} holds the .env.example placeholder and is ignored"
|
||||
return CheckResult(label, "ok", detail)
|
||||
|
||||
for tool in tool_entries:
|
||||
@ -805,7 +865,10 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
for provider, var in key_providers.get(tool_name, {}).items():
|
||||
if provider in use:
|
||||
key_field = key_fields.get(tool_name, {}).get(provider, "api_key")
|
||||
key_status = _configured_key_detail(tool, var, key_field=key_field)
|
||||
authoritative = provider in authoritative_key_providers
|
||||
# What the provider reads: an env-only provider never sees the field.
|
||||
read = {name: value for name, value in tool.items() if name != key_field} if provider in env_only_key_providers else tool
|
||||
key_status = _configured_key_detail(read, var, key_field=key_field, authoritative=authoritative)
|
||||
if key_status:
|
||||
status, detail = key_status
|
||||
if status == "warn":
|
||||
@ -816,13 +879,14 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
fix=f"Move the {key_field} to .env as {var}=<your-key> and reference it as ${var}",
|
||||
)
|
||||
return CheckResult(label, "ok", f"{provider} ({detail})")
|
||||
if tool_name == "web_capture" and provider == "browserless" and _browserless_self_hosted(tool):
|
||||
reason, placeholder = _missing_key_reason(read, var, key_field, authoritative=authoritative)
|
||||
if not placeholder and provider == "browserless" and _browserless_self_hosted(tool):
|
||||
return CheckResult(label, "ok", "browserless (self-hosted, token optional)")
|
||||
return CheckResult(
|
||||
label,
|
||||
"warn",
|
||||
f"{provider} configured but {var} not set",
|
||||
fix=f"Add {var}=<your-key> to .env, or run 'make setup'",
|
||||
f"{provider} configured but {reason}",
|
||||
fix=f"Add {var}=<your-key> to .env, or run 'make setup'" if not (authoritative and key_field in read) else f"Set {key_field} to ${var} with the real token in .env, or remove {key_field} to use {var}",
|
||||
)
|
||||
|
||||
for tool in tool_entries:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user