From 32170dd1a255cbb5aff7672f56bf8f69443e7b5c Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 17 Aug 2026 09:15:12 +0800 Subject: [PATCH] fix(x_search): prefer an explicit API key over subscription OAuth When a paid XAI_API_KEY is configured alongside xAI SuperGrok OAuth, _resolve_xai_bearer() took the OAuth path unconditionally. The OAuth credential authorizes /v1/responses but answers in a degraded Grok explanatory mode with no citations, while the API key returns real posts - so every x_search query silently degraded (#88040). Prefer the explicit API key when set (same shape as the TTS fix for #87045 in #87081), keeping OAuth as the fallback when no API key is configured. The shared resolver and every other xAI call site are unchanged. --- tests/tools/test_x_search_tool.py | 81 +++++++++++++++++++++++++++++++ tools/x_search_tool.py | 16 +++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_x_search_tool.py b/tests/tools/test_x_search_tool.py index 47bfd7cd2f..37dbdcd531 100644 --- a/tests/tools/test_x_search_tool.py +++ b/tests/tools/test_x_search_tool.py @@ -312,3 +312,84 @@ def test_x_search_not_degraded_when_no_filters_active(monkeypatch): assert result["degraded"] is False assert result["degraded_reason"] is None + +def _xcred(prefix: str) -> str: + """Synthesize a distinct fake credential value (never a bare literal).""" + return prefix + "-key-" + "x1" + + +def test_x_search_prefers_explicit_api_key_over_oauth(monkeypatch): + """#88040: with a paid XAI_API_KEY configured alongside subscription + OAuth, x_search must use the API key — the OAuth path authorizes but + answers /v1/responses in a degraded Grok explanatory mode with no + citations. Same prefer-API-key shape as the TTS fix (#87045/#87081).""" + from tools.registry import invalidate_check_fn_cache + from tools.x_search_tool import x_search_tool + + paid_key = _xcred("paid") + oauth_token = _xcred("oauth") + + monkeypatch.setattr( + "hermes_cli.config.get_env_value", + lambda name, default=None: { + "XAI_API_KEY": paid_key, + "XAI_BASE_URL": None, + }.get(name, default), + ) + + def _fake_resolve(): + return { + "provider": "xai-oauth", + "api_key": oauth_token, + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve + ) + invalidate_check_fn_cache() + + captured = {} + + def _fake_post(url, headers=None, json=None, timeout=None): + captured["headers"] = headers + return _FakeResponse({"output_text": "Real posts with citations."}) + + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads(x_search_tool(query="from:elon latest")) + + assert result["success"] is True + assert result["credential_source"] == "xai" + assert captured["headers"]["Authorization"] == "Bearer " + paid_key + + +def test_x_search_bearer_helper_falls_back_to_oauth_without_api_key(monkeypatch): + """No explicit XAI_API_KEY: the OAuth-first resolver path is unchanged.""" + from tools.x_search_tool import _resolve_xai_bearer + + oauth_token = _xcred("oauth") + + monkeypatch.setattr( + "hermes_cli.config.get_env_value", + lambda name, default=None: default, + ) + + def _fake_resolve(): + return { + "provider": "xai-oauth", + "api_key": oauth_token, + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve + ) + + api_key, base_url, source = _resolve_xai_bearer() + assert (api_key, base_url, source) == ( + oauth_token, + "https://api.x.ai/v1", + "xai-oauth", + ) + diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py index 8a89ead21c..579e435593 100644 --- a/tools/x_search_tool.py +++ b/tools/x_search_tool.py @@ -52,7 +52,6 @@ import requests from tools.registry import registry, tool_error from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials - logger = logging.getLogger(__name__) DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" @@ -128,7 +127,22 @@ def _resolve_xai_bearer() -> Tuple[str, str, str]: gate makes that case unreachable in normal operation, but the runtime check exists so a credential that expires between registration and invocation produces a clean tool error instead of a 401. + + x_search is API-index access: when a subscription OAuth credential is + configured alongside a paid ``XAI_API_KEY``, the OAuth path authorizes + but answers ``/v1/responses`` in a degraded Grok explanatory mode with + no citations, while the API key returns real posts (#88040). Prefer the + explicit API key — same shape as the TTS fix for #87045 (#87081) — + keeping OAuth as the fallback when no API key is configured. """ + from hermes_cli.config import get_env_value + + explicit_key = str(get_env_value("XAI_API_KEY") or "").strip() + if explicit_key: + base_url = str( + get_env_value("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL + ).strip().rstrip("/") + return explicit_key, base_url, "xai" creds = resolve_xai_http_credentials() api_key = str(creds.get("api_key") or "").strip() if not api_key: