Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 17:54:42 -07:00
commit 147e451cc8
8 changed files with 89 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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<void>
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)
}

View file

@ -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}
/>
)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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: