diff --git a/agent/agent_init.py b/agent/agent_init.py index 22f617b7f1c6..a99a0de89916 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1156,6 +1156,10 @@ def init_agent( elif base_url_host_matches(effective_base, "chatgpt.com"): from agent.auxiliary_client import _codex_cloudflare_headers client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key) + elif base_url_host_matches(effective_base, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + client_kwargs["default_headers"] = hermes_xai_default_headers() elif "default_headers" not in client_kwargs: # Fall back to profile.default_headers for providers that # declare custom headers (e.g. Kimi User-Agent on non-kimi.com diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9ef03534ad3d..da85f03659e8 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2957,7 +2957,13 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str return None, None api_key, base_url = resolved logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model) - real_client = _create_openai_client(api_key=api_key, base_url=base_url) + from tools.xai_http import hermes_xai_default_headers + + real_client = _create_openai_client( + api_key=api_key, + base_url=base_url, + default_headers=hermes_xai_default_headers(), + ) return CodexAuxiliaryClient(real_client, model), model @@ -5085,6 +5091,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) + elif base_url_host_matches(sync_base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + async_kwargs["default_headers"] = hermes_xai_default_headers() else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -5708,6 +5718,10 @@ def resolve_provider_client( )) elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): headers.update(build_nvidia_nim_headers(base_url)) + elif base_url_host_matches(base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + headers.update(hermes_xai_default_headers()) else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx index 26a0a76681c5..b6deaa742d94 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx @@ -1,4 +1,9 @@ -import { type AppendMessage, AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react' +import { + type AppendMessage, + AssistantRuntimeProvider, + ExportedMessageRepository, + type ThreadMessage +} from '@assistant-ui/react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -105,6 +110,7 @@ function Harness({ onEdit }: { onEdit: (message: AppendMessage) => Promise describe('edit send arrow — macOS click gesture (blur races cancel)', () => { it('sends without an uncaught "Composer is not available" when the arrow-click blurs the editor', async () => { const uncaught: unknown[] = [] + const onUncaught = (err: unknown) => { uncaught.push(err) } diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index c482cefdc1cc..68e69d2aff7c 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -16,6 +16,11 @@ const HasTooltipProvider = React.createContext(false) function TooltipProvider({ delayDuration = 0, + // Radix's "skip" grace: after one tip opens, every trigger touched within + // this window opens INSTANTLY, delay bypassed. Its 300ms default meant a + // cursor sweeping the chrome still flashed a trail of tips despite the + // hover delay. Zero it so each tip independently honors `delayDuration`. + skipDelayDuration = 0, // Tips are labels, not interactive surfaces. Hoverable content + Radix's // pointer-grace bridge is what leaves tips stuck open — especially over // Electron `-webkit-app-region: drag` chrome where pointermove never fires @@ -28,6 +33,7 @@ function TooltipProvider({ data-slot="tooltip-provider" delayDuration={delayDuration} disableHoverableContent={disableHoverableContent} + skipDelayDuration={skipDelayDuration} {...props} /> ) diff --git a/plugins/model-providers/xai/__init__.py b/plugins/model-providers/xai/__init__.py index 8d73ae0199e8..89b77d1e1a3a 100644 --- a/plugins/model-providers/xai/__init__.py +++ b/plugins/model-providers/xai/__init__.py @@ -1,5 +1,6 @@ """xAI (Grok) provider profile.""" +from hermes_cli import __version__ as _HERMES_VERSION from providers import register_provider from providers.base import ProviderProfile @@ -10,6 +11,7 @@ xai = ProviderProfile( env_vars=("XAI_API_KEY",), base_url="https://api.x.ai/v1", auth_type="api_key", + default_headers={"User-Agent": f"Hermes-Agent/{_HERMES_VERSION}"}, ) register_provider(xai) diff --git a/run_agent.py b/run_agent.py index 72cd732f205d..d624656facd7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5086,6 +5086,11 @@ class AIAgent: self._client_kwargs["default_headers"] = _codex_cloudflare_headers( self._client_kwargs.get("api_key", "") ) + elif base_url_host_matches(base_url, "x.ai"): + # Cover both provider=xai and provider=xai-oauth (api.x.ai). + from tools.xai_http import hermes_xai_default_headers + + self._client_kwargs["default_headers"] = hermes_xai_default_headers() else: # No URL-specific headers — check profile.default_headers before clearing. _ph_headers = None diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index dec932e34e0f..109f8bd4e9c3 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -159,6 +159,46 @@ def test_gmi_base_url_picks_up_profile_user_agent(mock_openai): assert headers["User-Agent"].startswith("HermesAgent/") +@patch("run_agent.OpenAI") +def test_xai_base_url_applies_hermes_user_agent(mock_openai): + """Direct xAI chat must send Hermes-Agent/* instead of OpenAI/Python.""" + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://api.x.ai/v1", + model="grok-4", + provider="xai", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._apply_client_headers_for_base_url("https://api.x.ai/v1") + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"].startswith("Hermes-Agent/") + + +@patch("run_agent.OpenAI") +def test_xai_oauth_base_url_applies_hermes_user_agent(mock_openai): + """xai-oauth uses the same api.x.ai host and must get the Hermes UA.""" + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="oauth-token", + base_url="https://api.x.ai/v1", + model="grok-4", + provider="xai-oauth", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._apply_client_headers_for_base_url("https://api.x.ai/v1") + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"].startswith("Hermes-Agent/") + + @patch("run_agent.OpenAI") def test_unknown_base_url_clears_default_headers(mock_openai): mock_openai.return_value = MagicMock() diff --git a/tools/xai_http.py b/tools/xai_http.py index 6d5287e4d868..81f2e264ca0c 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -97,6 +97,16 @@ def hermes_xai_user_agent() -> str: return f"Hermes-Agent/{__version__}" +def hermes_xai_default_headers() -> Dict[str, str]: + """Default headers for OpenAI-SDK and raw HTTP clients talking to xAI. + + Replaces the OpenAI Python SDK's identifying ``User-Agent: OpenAI/Python …`` + so chat/completions and Responses traffic is attributed as Hermes Agent, + matching the direct HTTP integrations (search, TTS, STT, image, video). + """ + return {"User-Agent": hermes_xai_user_agent()} + + def _load_config_section(section_name: str) -> Dict[str, Any]: """Return a top-level Hermes config section as a dict, or empty.""" try: