mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Adds a new bundled web search provider plugin backed by xAI's agentic Web Search tool (server-side `web_search` on the Responses API). Slots in alongside the existing Firecrawl / Tavily / Exa / Brave / SearXNG / DDGS providers; opt in via `web.backend: xai` (or auto-selected by the registry's single-provider shortcut when it's the only available web provider, matching every other backend's behavior). Reuses the existing xAI HTTP credential plumbing (`tools/xai_http.py`) so it works with both `hermes auth login xai-oauth` (SuperGrok OAuth) and `XAI_API_KEY` — no new credential paths, no new env vars, no new setup-wizard prompts. The existing `xai_grok` post_setup hook handles credential collection. Reference: https://docs.x.ai/developers/tools/web-search Provider behavior ----------------- - Sends a structured prompt to Grok with `tools=[{"type": "web_search"}]` enabled and `include=["no_inline_citations"]`, then parses results from a `{"results": [...]}` JSON block (primary), falling back to `url_citation` annotations (secondary) and the top-level `citations` list (last-ditch). Annotation fallback falls through to citations when no rows are extractable, so future annotation types xAI may add don't silently mask real data. - HTTP 200 + `{"error": {...}}` envelopes (model-overload, refusal) are surfaced as failures rather than masked as success-with-empty- results. - HTTP 401 on the OAuth path triggers a single `force_refresh=True` retry — closes two gaps the resolver's proactive JWT-exp shortcut doesn't cover: opaque (non-JWT) access tokens and mid-window revocation. Env-var (`XAI_API_KEY`) credentials never retry; they can't be refreshed and an immediate retry would just burn quota. - `is_available()` is a cheap probe (env var OR auth.json read), never invokes the OAuth resolver — required by the ABC contract because it runs on every `hermes tools` repaint and at tool-registration time. - Class docstring documents the LLM-in-a-trench-coat trust model so callers piping untrusted input into `web_search` know returned URLs are model-generated and should be validated before fetching. Config (`config.yaml`): web: backend: xai xai: model: grok-4.3 # optional, defaults to grok-4.3 allowed_domains: # optional, max 5 — mutex with excluded_domains - arxiv.org excluded_domains: # optional, max 5 - example-spam.com timeout: 90 # optional, seconds Files ----- - plugins/web/xai/plugin.yaml (new) plugin manifest - plugins/web/xai/__init__.py (new) register(ctx) hook - plugins/web/xai/provider.py (new) XAIWebSearchProvider impl - tools/xai_http.py (+47) has_xai_credentials() cheap-probe helper + keyword-only force_refresh arg on resolve_xai_http_ credentials() (backwards compatible; all 9 other call sites unaffected) - tools/web_tools.py (+11) "xai" added to configured- backend set + branch in _is_backend_available() - tests/tools/test_web_providers_xai.py (new, 39 tests) covers identity, cheap-probe semantics, JSON / annotation / citations parse paths, request payload shape, error envelopes, OAuth force-refresh-on-401 retry, env-var-no-retry guard, 500-not- retried guard, refresh-returns- same-token guard, OAuth runtime resolution, and backend wiring. Tests ----- - 39 xai-suite passes - 79 sibling web-provider tests (brave-free, ddgs, searxng, base) pass - 119 cross-suite tests for other xai_http callers (transcription, x_search, tts) pass — verifies the new keyword-only arg is BC - scripts/check-windows-footguns.py: clean on all 5 modified files No edits to run_agent.py, cli.py, gateway/, toolsets, config schema, plugin core, or auth core.
767 lines
33 KiB
Python
767 lines
33 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_implements_web_search_provider(self):
|
|
from agent.web_search_provider import WebSearchProvider
|
|
from plugins.web.xai.provider import XAIWebSearchProvider
|
|
assert issubclass(XAIWebSearchProvider, WebSearchProvider)
|
|
|
|
def test_supports_search_only(self):
|
|
from plugins.web.xai.provider import XAIWebSearchProvider
|
|
p = XAIWebSearchProvider()
|
|
assert p.supports_search() is True
|
|
assert p.supports_extract() is False
|
|
assert p.supports_crawl() is False
|
|
|
|
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_available_via_auth_store(self, monkeypatch, tmp_path):
|
|
"""Cheap probe should detect xai-oauth tokens in ~/.hermes/auth.json
|
|
without invoking the resolver (which can trigger refresh)."""
|
|
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
auth_path = tmp_path / "auth.json"
|
|
auth_path.write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"xai-oauth": {"tokens": {"access_token": "ya29.fake-access-token"}},
|
|
},
|
|
}))
|
|
|
|
from plugins.web.xai.provider import XAIWebSearchProvider
|
|
assert XAIWebSearchProvider().is_available() is True
|
|
|
|
def test_unavailable_when_no_env_and_no_auth_store(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
# No auth.json written.
|
|
from plugins.web.xai.provider import XAIWebSearchProvider
|
|
assert XAIWebSearchProvider().is_available() is False
|
|
|
|
def test_unavailable_when_auth_store_has_empty_token(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
auth_path = tmp_path / "auth.json"
|
|
auth_path.write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {"xai-oauth": {"tokens": {"access_token": ""}}},
|
|
}))
|
|
|
|
from plugins.web.xai.provider import XAIWebSearchProvider
|
|
assert XAIWebSearchProvider().is_available() is False
|
|
|
|
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_limit_truncates_json_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("x", limit=2)
|
|
|
|
assert result["success"] is True
|
|
assert len(result["data"]["web"]) == 2
|
|
|
|
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_falls_back_to_citations_list(self):
|
|
"""If no JSON and no annotations, derive from top-level citations list."""
|
|
from plugins.web.xai import provider as xai_provider
|
|
|
|
payload = _responses_payload("free-form narration", citations=["https://a.com", "https://b.com"])
|
|
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
|
|
urls = [r["url"] for r in result["data"]["web"]]
|
|
assert urls == ["https://a.com", "https://b.com"]
|
|
|
|
def test_annotations_without_url_citations_fall_through_to_citations(self):
|
|
"""When annotations exist but none are url_citation type (e.g. future
|
|
annotation types xAI may add), the citations list MUST still be
|
|
consulted — otherwise we'd silently report success-with-no-rows
|
|
and mask real data the API provided.
|
|
"""
|
|
from plugins.web.xai import provider as xai_provider
|
|
|
|
body = "Some narration about xAI."
|
|
# Non-url_citation annotations only — the fallback shouldn't extract
|
|
# any URLs from them, and must defer to the citations list below.
|
|
annotations = [
|
|
{"type": "future_citation_type", "url": "https://ignored.example", "title": "x"},
|
|
]
|
|
payload = _responses_payload(
|
|
body,
|
|
annotations=annotations,
|
|
citations=["https://real-fallback.com"],
|
|
)
|
|
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
|
|
urls = [r["url"] for r in result["data"]["web"]]
|
|
assert urls == ["https://real-fallback.com"]
|
|
|
|
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_honors_configured_model(self):
|
|
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": []})))
|
|
|
|
with patch.object(xai_provider, "resolve_xai_http_credentials", return_value=_creds()), \
|
|
patch.object(xai_provider, "_load_xai_web_config", return_value={"model": "grok-4.3-fast"}), \
|
|
patch("httpx.post", side_effect=fake_post):
|
|
xai_provider.XAIWebSearchProvider().search("q", limit=5)
|
|
|
|
assert captured["json"]["model"] == "grok-4.3-fast"
|
|
|
|
def test_allowed_domains_passes_through_as_filters(self):
|
|
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": ["x.ai", "grokipedia.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", side_effect=fake_post):
|
|
xai_provider.XAIWebSearchProvider().search("q", limit=5)
|
|
|
|
tools = captured["json"]["tools"]
|
|
assert tools == [{
|
|
"type": "web_search",
|
|
"filters": {"allowed_domains": ["x.ai", "grokipedia.com"]},
|
|
}]
|
|
|
|
def test_excluded_domains_passes_through_as_filters(self):
|
|
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 = {"excluded_domains": ["spam.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", side_effect=fake_post):
|
|
xai_provider.XAIWebSearchProvider().search("q", limit=5)
|
|
|
|
tools = captured["json"]["tools"]
|
|
assert tools == [{
|
|
"type": "web_search",
|
|
"filters": {"excluded_domains": ["spam.com"]},
|
|
}]
|
|
|
|
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_http_error_returns_failure(self):
|
|
import httpx
|
|
from plugins.web.xai import provider as xai_provider
|
|
|
|
bad = MagicMock()
|
|
bad.status_code = 429
|
|
bad.text = "rate limited"
|
|
err = httpx.HTTPStatusError("429", request=MagicMock(), response=bad)
|
|
|
|
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", side_effect=err):
|
|
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
|
|
|
|
assert result["success"] is False
|
|
assert "429" in result["error"]
|
|
|
|
def test_request_error_returns_failure(self):
|
|
import httpx
|
|
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", side_effect=httpx.RequestError("boom")):
|
|
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
|
|
|
|
assert result["success"] is False
|
|
assert "boom" in result["error"] or "xAI" in result["error"]
|
|
|
|
def test_bad_json_response_returns_failure(self):
|
|
from plugins.web.xai import provider as xai_provider
|
|
|
|
bad = MagicMock()
|
|
bad.status_code = 200
|
|
bad.raise_for_status = MagicMock()
|
|
bad.json.side_effect = ValueError("not 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=bad):
|
|
result = xai_provider.XAIWebSearchProvider().search("q", limit=5)
|
|
|
|
assert result["success"] is False
|
|
assert "JSON" in result["error"]
|
|
|
|
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):
|
|
if force_refresh:
|
|
calls["refresh_count"] += 1
|
|
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):
|
|
if force_refresh:
|
|
calls["refreshed"] = True
|
|
# provider=="xai" signals env-var path; retry must be skipped.
|
|
return {"provider": "xai", "api_key": "sk-env-var-key", "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_401_retry_gives_up_when_refresh_returns_same_token(self):
|
|
"""If the force-refresh returns the same token (refresh-token also
|
|
dead), don't loop — surface the 401 to the caller."""
|
|
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, "refresh_count": 0}
|
|
|
|
def fake_post(url, **kwargs):
|
|
calls["posts"] += 1
|
|
raise unauthorized
|
|
|
|
def fake_resolve(*, force_refresh=False):
|
|
if force_refresh:
|
|
calls["refresh_count"] += 1
|
|
return {
|
|
"provider": "xai-oauth",
|
|
"api_key": "same-dead-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 False
|
|
assert "401" in result["error"]
|
|
# One post, one force-refresh attempt, no second post.
|
|
assert calls["posts"] == 1
|
|
assert calls["refresh_count"] == 1
|
|
|
|
def test_non_401_http_error_is_not_retried(self):
|
|
"""Only 401 is retryable — 429 / 500 / 503 must fail fast so the
|
|
agent (or upstream rate-limiter) decides what to do."""
|
|
import httpx
|
|
from plugins.web.xai import provider as xai_provider
|
|
|
|
bad = MagicMock()
|
|
bad.status_code = 500
|
|
bad.text = "internal error"
|
|
err = httpx.HTTPStatusError("500", request=MagicMock(), response=bad)
|
|
|
|
calls = {"posts": 0, "refreshed": False}
|
|
|
|
def fake_post(url, **kwargs):
|
|
calls["posts"] += 1
|
|
raise err
|
|
|
|
def fake_resolve(*, force_refresh=False):
|
|
if force_refresh:
|
|
calls["refreshed"] = True
|
|
return {"provider": "xai-oauth", "api_key": "tok", "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 "500" 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_false_when_no_creds(self, monkeypatch, tmp_path):
|
|
from tools import web_tools
|
|
|
|
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
assert web_tools._is_backend_available("xai") is False
|
|
|
|
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
|
|
runtime resolver (``hermes auth`` sign-in) rather than an env-var key.
|
|
Patches at the ``hermes_cli.runtime_provider.resolve_runtime_provider``
|
|
boundary so the full ``tools.xai_http.resolve_xai_http_credentials``
|
|
chain is exercised end-to-end.
|
|
"""
|
|
|
|
def test_search_uses_oauth_bearer_token_and_base_url(self, monkeypatch):
|
|
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)
|
|
|
|
oauth_runtime = {
|
|
"provider": "xai-oauth",
|
|
"api_mode": "codex_responses",
|
|
"base_url": "https://api.x.ai/v1",
|
|
"api_key": "ya29.fake-oauth-access-token",
|
|
"source": "hermes-auth-store",
|
|
}
|
|
|
|
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(
|
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
|
return_value=oauth_runtime,
|
|
), 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://api.x.ai/v1/responses"
|
|
assert captured["headers"].get("Authorization") == "Bearer ya29.fake-oauth-access-token"
|