hermes-agent/tests/tools/test_web_providers_xai.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

584 lines
24 KiB
Python

"""Tests for the xAI Web Search provider (plugins/web/xai/).
Covers:
- XAIWebSearchProvider.is_available() — cheap probe (env var + auth.json)
- search() — JSON happy path, annotation fallback, citations fallback, empty results
- search() error paths — HTTP error, request error, missing creds, mutually-exclusive domain filters,
200-OK error envelope
- Request payload shape — model, tools list, allowed_domains/excluded_domains filters
- OAuth credential resolution end-to-end through tools.xai_http
- _is_backend_available("xai") integration with tools.web_tools
- _get_backend() accepts "xai" as a configured backend
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
def _creds(api_key: str = "xai-test-key", base_url: str = "https://api.x.ai/v1") -> dict:
return {"provider": "xai", "api_key": api_key, "base_url": base_url}
def _mock_resp(json_data, status_code: int = 200):
m = MagicMock()
m.status_code = status_code
m.json.return_value = json_data
m.raise_for_status = MagicMock()
return m
def _responses_payload(text: str, annotations=None, citations=None) -> dict:
"""Build a minimal Responses-API reply with one message + output_text block."""
chunk: dict = {"type": "output_text", "text": text}
if annotations is not None:
chunk["annotations"] = annotations
payload: dict = {
"output": [
{
"type": "message",
"content": [chunk],
}
],
}
if citations is not None:
payload["citations"] = citations
return payload
# ---------------------------------------------------------------------------
# Provider identity / availability
# ---------------------------------------------------------------------------
class TestXAIProviderIdentity:
def test_provider_name(self):
from plugins.web.xai.provider import XAIWebSearchProvider
assert XAIWebSearchProvider().name == "xai"
def test_display_name(self):
from plugins.web.xai.provider import XAIWebSearchProvider
assert "Grok" in XAIWebSearchProvider().display_name
class TestXAIProviderIsAvailable:
"""``is_available()`` MUST be cheap — no network, no token refresh, no
auth-store lock. It runs on every ``hermes tools`` repaint and at
tool-registration time, so any I/O regression here would surface as
visible CLI latency.
"""
def test_available_via_env_var(self, monkeypatch):
monkeypatch.setenv("XAI_API_KEY", "sk-xai-test")
from plugins.web.xai.provider import XAIWebSearchProvider
assert XAIWebSearchProvider().is_available() is True
def test_unavailable_when_auth_store_corrupted(self, monkeypatch, tmp_path):
"""A malformed auth.json must not crash availability scans."""
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "auth.json").write_text("not json at all }{")
from plugins.web.xai.provider import XAIWebSearchProvider
assert XAIWebSearchProvider().is_available() is False
def test_is_available_does_not_call_resolver(self, monkeypatch):
"""Regression guard: ``is_available()`` must NEVER touch the resolver,
because the OAuth resolver can trigger a network refresh."""
monkeypatch.setenv("XAI_API_KEY", "sk-xai-test")
from plugins.web.xai import provider as xai_provider
with patch.object(
xai_provider, "resolve_xai_http_credentials",
side_effect=AssertionError("is_available must not call the resolver"),
):
assert xai_provider.XAIWebSearchProvider().is_available() is True
# ---------------------------------------------------------------------------
# search() happy + parse paths
# ---------------------------------------------------------------------------
class TestXAIProviderSearchJSONPath:
_GROK_JSON = json.dumps({
"results": [
{"title": "xAI", "url": "https://x.ai", "description": "The company."},
{"title": "Grok docs", "url": "https://docs.x.ai", "description": "API reference."},
{"title": "Grokipedia", "url": "https://grokipedia.com", "description": "Wiki."},
]
})
def test_happy_path_normalizes_results(self):
from plugins.web.xai import provider as xai_provider
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", return_value=_mock_resp(_responses_payload(self._GROK_JSON))):
result = xai_provider.XAIWebSearchProvider().search("what is xai", limit=5)
assert result["success"] is True
web = result["data"]["web"]
assert len(web) == 3
assert web[0] == {
"title": "xAI",
"url": "https://x.ai",
"description": "The company.",
"position": 1,
}
assert web[2]["position"] == 3
def test_parses_json_with_leading_prose(self):
"""Reasoning models sometimes narrate before the JSON block; we tolerate it."""
from plugins.web.xai import provider as xai_provider
text = "Here are the results:\n" + self._GROK_JSON
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", return_value=_mock_resp(_responses_payload(text))):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is True
assert len(result["data"]["web"]) == 3
def test_drops_rows_without_url(self):
from plugins.web.xai import provider as xai_provider
bad_json = json.dumps({
"results": [
{"title": "no url", "description": "skip me"},
{"title": "good", "url": "https://ok.com", "description": "keep"},
]
})
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", return_value=_mock_resp(_responses_payload(bad_json))):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is True
web = result["data"]["web"]
assert len(web) == 1
assert web[0]["url"] == "https://ok.com"
assert web[0]["position"] == 1
class TestXAIProviderSearchFallbacks:
def test_falls_back_to_annotations_when_json_missing(self):
"""If Grok ignores the JSON instruction, derive results from url_citation annotations."""
from plugins.web.xai import provider as xai_provider
body = "xAI is an AI company founded in 2023. They make Grok."
annotations = [
{
"type": "url_citation",
"url": "https://x.ai/about",
"title": "1",
"start_index": 4,
"end_index": 9,
},
{
"type": "url_citation",
"url": "https://docs.x.ai",
"title": "2",
"start_index": 47,
"end_index": 52,
},
]
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", return_value=_mock_resp(_responses_payload(body, annotations=annotations))):
result = xai_provider.XAIWebSearchProvider().search("xai", limit=5)
assert result["success"] is True
urls = [r["url"] for r in result["data"]["web"]]
assert urls == ["https://x.ai/about", "https://docs.x.ai"]
assert result["data"]["web"][0]["position"] == 1
assert result["data"]["web"][1]["position"] == 2
def test_empty_response_returns_empty_success(self):
from plugins.web.xai import provider as xai_provider
payload = _responses_payload("", citations=[])
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", return_value=_mock_resp(payload)):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is True
assert result["data"]["web"] == []
# ---------------------------------------------------------------------------
# Request payload shape
# ---------------------------------------------------------------------------
class TestXAIProviderRequestShape:
def test_posts_to_responses_endpoint_with_bearer_token(self):
from plugins.web.xai import provider as xai_provider
captured: dict = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
captured["json"] = kwargs.get("json", {})
return _mock_resp(_responses_payload(json.dumps({"results": []})))
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds("secret-key")), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", side_effect=fake_post):
xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert captured["url"] == "https://api.x.ai/v1/responses"
assert captured["headers"].get("Authorization") == "Bearer secret-key"
body = captured["json"]
# Assert against the module constant rather than the literal value,
# so renaming DEFAULT_MODEL (when xAI deprecates grok-4.3) doesn't
# turn this into a change-detector failure.
assert body["model"] == xai_provider.DEFAULT_MODEL
assert body["tools"] == [{"type": "web_search"}]
assert body["input"][0]["role"] == "user"
# No-inline-citations is opt-in via `include` per xAI Responses docs.
assert "no_inline_citations" in body.get("include", [])
def test_allowed_domains_capped_at_five(self):
"""xAI caps domain filters at 5; we trim silently to avoid 400s."""
from plugins.web.xai import provider as xai_provider
captured: dict = {}
def fake_post(url, **kwargs):
captured["json"] = kwargs.get("json", {})
return _mock_resp(_responses_payload(json.dumps({"results": []})))
cfg = {"allowed_domains": [f"d{i}.com" for i in range(10)]}
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value=cfg), \
patch("httpx.post", side_effect=fake_post):
xai_provider.XAIWebSearchProvider().search("q", limit=5)
domains = captured["json"]["tools"][0]["filters"]["allowed_domains"]
assert len(domains) == 5
# ---------------------------------------------------------------------------
# Error paths
# ---------------------------------------------------------------------------
class TestXAIProviderSearchErrors:
def test_missing_creds_returns_failure(self):
from plugins.web.xai import provider as xai_provider
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds("")):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is False
assert "xAI" in result["error"]
def test_mutually_exclusive_domain_filters_rejected_locally(self):
from plugins.web.xai import provider as xai_provider
cfg = {"allowed_domains": ["a.com"], "excluded_domains": ["b.com"]}
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value=cfg), \
patch("httpx.post") as posted:
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is False
assert "cannot both be set" in result["error"]
posted.assert_not_called()
def test_401_on_oauth_path_triggers_force_refresh_and_retry(self):
"""OAuth credentials → 401 must force-refresh and retry once.
Closes the two-gap scenario the resolver's JWT-exp shortcut doesn't
cover: opaque (non-JWT) tokens and mid-window revocation. We expect
``httpx.post`` to be called twice with two different Bearer tokens.
"""
import httpx
from plugins.web.xai import provider as xai_provider
bad = MagicMock()
bad.status_code = 401
bad.text = "Unauthorized"
unauthorized = httpx.HTTPStatusError("401", request=MagicMock(), response=bad)
calls = {"posts": [], "refresh_count": 0}
def fake_post(url, **kwargs):
calls["posts"].append(kwargs.get("headers", {}).get("Authorization"))
if len(calls["posts"]) == 1:
raise unauthorized
return _mock_resp(_responses_payload(json.dumps({"results": []})))
def fake_resolve(*, force_refresh=False, api_key_hint=None):
if force_refresh:
calls["refresh_count"] += 1
assert api_key_hint == "stale-token"
return {
"provider": "xai-oauth",
"api_key": "fresh-after-refresh",
"base_url": "https://api.x.ai/v1",
}
return {
"provider": "xai-oauth",
"api_key": "stale-token",
"base_url": "https://api.x.ai/v1",
}
with patch.object(xai_provider, "resolve_xai_http_credentials", side_effect=fake_resolve), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", side_effect=fake_post):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is True
assert calls["refresh_count"] == 1
assert calls["posts"] == ["Bearer stale-token", "Bearer fresh-after-refresh"]
def test_401_on_env_var_path_does_not_retry(self):
"""Env-var (XAI_API_KEY) creds can't be refreshed — must not retry."""
import httpx
from plugins.web.xai import provider as xai_provider
bad = MagicMock()
bad.status_code = 401
bad.text = "Unauthorized"
unauthorized = httpx.HTTPStatusError("401", request=MagicMock(), response=bad)
calls = {"posts": 0, "refreshed": False}
def fake_post(url, **kwargs):
calls["posts"] += 1
raise unauthorized
def fake_resolve(*, force_refresh=False, api_key_hint=None):
if force_refresh:
calls["refreshed"] = True
# provider=="xai" signals env-var path; retry must be skipped.
return {"provider": "xai", "api_key": "«redacted:sk-…»", "base_url": "https://api.x.ai/v1"}
with patch.object(xai_provider, "resolve_xai_http_credentials", side_effect=fake_resolve), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", side_effect=fake_post):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is False
assert "401" in result["error"]
assert calls["posts"] == 1
assert calls["refreshed"] is False
def test_http_200_with_error_envelope_surfaces_failure(self):
"""xAI sometimes returns 200 with ``{"error": {...}}`` (model
overloaded, refusal, etc.). Must be surfaced as a failure rather
than silently masked as success-with-empty-results.
"""
from plugins.web.xai import provider as xai_provider
payload = {"error": {"message": "model overloaded", "type": "server_error"}}
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", return_value=_mock_resp(payload)):
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
assert result["success"] is False
assert "model overloaded" in result["error"]
# ---------------------------------------------------------------------------
# Integration with tools/web_tools.py backend wiring
# ---------------------------------------------------------------------------
class TestXAIBackendWiring:
def test_is_backend_available_true_via_env_var(self, monkeypatch):
from tools import web_tools
monkeypatch.setenv("XAI_API_KEY", "sk-xai-test")
assert web_tools._is_backend_available("xai") is True
def test_is_backend_available_does_not_call_resolver(self, monkeypatch):
"""Regression guard — `_is_backend_available` runs on every web_search
dispatch and every `hermes tools` repaint. It must not invoke the
OAuth resolver (which can trigger a network refresh)."""
from tools import web_tools
monkeypatch.setenv("XAI_API_KEY", "sk-xai-test")
with patch(
"tools.xai_http.resolve_xai_http_credentials",
side_effect=AssertionError("must not call resolver"),
):
assert web_tools._is_backend_available("xai") is True
def test_configured_backend_xai_accepted(self, monkeypatch):
from tools import web_tools
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "xai"})
assert web_tools._get_backend() == "xai"
def test_xai_not_in_legacy_backend_candidate_chain(self, monkeypatch):
"""The hardcoded ``backend_candidates`` tuple in ``_get_backend()``
does not include xAI — by design, since the no-config legacy
chain is for users who set env vars but never ran ``hermes tools``,
and we don't want a stray ``XAI_API_KEY`` (perhaps set for chat
inference) to silently re-route web_search through Grok.
Note: this does NOT prevent the registry's single-provider
shortcut (``agent.web_search_registry._resolve``) from selecting
xAI when it's the only available web provider. That path is the
normal "pick the one provider the user actually configured"
behavior shared by every other backend.
"""
from tools import web_tools
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
for key in (
"FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY",
"TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL", "BRAVE_SEARCH_API_KEY",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
assert web_tools._get_backend() != "xai"
# ---------------------------------------------------------------------------
# OAuth credential resolution (end-to-end through tools.xai_http)
# ---------------------------------------------------------------------------
class TestXAIProviderOAuthPath:
"""Verifies the provider works when credentials come from the OAuth
credential pool (``hermes auth`` sign-in) rather than an env-var key.
The full ``tools.xai_http.resolve_xai_http_credentials`` chain is exercised
against a temporary auth store.
"""
def test_search_uses_oauth_bearer_token_and_base_url(self, monkeypatch, tmp_path):
from plugins.web.xai import provider as xai_provider
# Force the env-var fallback to fail so resolution must go via OAuth.
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_XAI_BASE_URL", "https://proxy.x.ai/v1/")
(tmp_path / "auth.json").write_text(json.dumps({
"version": 1,
"active_provider": "xai-oauth",
"providers": {
"xai-oauth": {
"tokens": {
"access_token": "ya29.fake-oauth-access-token",
"refresh_token": "fake-oauth-refresh-token",
},
},
},
}))
captured: dict = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
return _mock_resp(_responses_payload(json.dumps({"results": []})))
with patch.object(xai_provider, "_load_xai_web_config", return_value={}), \
patch("httpx.post", side_effect=fake_post):
result = xai_provider.XAIWebSearchProvider().search("q", limit=3)
assert result["success"] is True
assert captured["url"] == "https://proxy.x.ai/v1/responses"
assert captured["headers"].get("Authorization") == "Bearer ya29.fake-oauth-access-token"
def test_pool_only_direct_refresh_updates_main_runtime(self, monkeypatch, tmp_path):
"""A direct 401 refresh must rotate the exact manual pool row.
xAI refresh tokens are one-time credentials. Persisting the refreshed
pair only to ``providers.xai-oauth`` leaves the manual row stale and
breaks the next main-runtime load.
"""
from hermes_cli.runtime_provider import resolve_runtime_provider
from tools.xai_http import resolve_xai_http_credentials
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.delenv("XAI_OAUTH_ACCESS_TOKEN", raising=False)
(tmp_path / "config.yaml").write_text(
"credential_pool_strategies:\n xai-oauth: round_robin\n"
)
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps({
"version": 1,
"active_provider": "xai-oauth",
"providers": {},
"credential_pool": {
"xai-oauth": [{
"id": "manual-xai",
"label": "pool-only",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "rejected-access",
"refresh_token": "one-time-refresh",
"base_url": "https://api.x.ai/v1",
}, {
"id": "backup-xai",
"label": "backup",
"auth_type": "oauth",
"priority": 1,
"source": "manual:device_code",
"access_token": "backup-access",
"refresh_token": "backup-refresh",
"base_url": "https://api.x.ai/v1",
}],
},
}))
refresh_calls = []
def fake_refresh(access_token, refresh_token, **_kwargs):
refresh_calls.append((access_token, refresh_token))
return {
"access_token": "fresh-access",
"refresh_token": "rotated-refresh",
"last_refresh": "2026-07-12T12:00:00Z",
}
monkeypatch.setattr(
"hermes_cli.auth.refresh_xai_oauth_pure",
fake_refresh,
)
initial = resolve_xai_http_credentials()
assert initial["api_key"] == "rejected-access"
refreshed = resolve_xai_http_credentials(
force_refresh=True,
api_key_hint=initial["api_key"],
)
assert refreshed["api_key"] == "fresh-access"
assert refresh_calls == [("rejected-access", "one-time-refresh")]
stored = json.loads(auth_path.read_text())
entries = {
item["id"]: item
for item in stored["credential_pool"]["xai-oauth"]
}
entry = entries["manual-xai"]
assert entry["access_token"] == "fresh-access"
assert entry["refresh_token"] == "rotated-refresh"
assert entries["backup-xai"]["access_token"] == "backup-access"
assert entries["backup-xai"]["refresh_token"] == "backup-refresh"
assert "xai-oauth" not in stored.get("providers", {})
runtime = resolve_runtime_provider(requested="xai-oauth")
assert runtime["api_key"] == "backup-access"
assert refresh_calls == [("rejected-access", "one-time-refresh")]