From b8eb89f5c9460e8574ba4cb4e88d3cd08f093344 Mon Sep 17 00:00:00 2001 From: Vishal Dharmadhikari Date: Thu, 9 Jul 2026 21:38:18 -0700 Subject: [PATCH] feat(gemini): improve request context for support and compatibility Include the Hermes client name and version with Gemini inference, model and tier checks, and TTS requests. Add focused coverage for the request headers and keep the Gemini-specific context scoped to Google Gemini endpoints. --- agent/gemini_native_adapter.py | 18 +++++++- hermes_cli/models.py | 2 + scripts/release.py | 1 + tests/agent/test_gemini_native_adapter.py | 50 +++++++++++++++++++++++ tests/hermes_cli/test_model_validation.py | 30 ++++++++++++++ tests/tools/test_tts_gemini.py | 13 ++++++ tools/tts_tool.py | 15 ++++++- 7 files changed, 126 insertions(+), 3 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 9d3b1eb324d5..1c25f1e6cf0a 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -32,6 +32,13 @@ from agent.gemini_schema import sanitize_gemini_tool_parameters logger = logging.getLogger(__name__) +try: + import hermes_cli as _hermes_cli + + _HERMES_VERSION = str(_hermes_cli.__version__) +except Exception: + _HERMES_VERSION = "0.0.0" + DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" # Published max output-token ceiling shared by every current Gemini text model @@ -99,7 +106,10 @@ def probe_gemini_tier( url, params={"key": key}, json=payload, - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", + }, ) except Exception as exc: logger.debug("probe_gemini_tier: network error: %s", exc) @@ -901,7 +911,11 @@ class GeminiNativeClient: "Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, - "User-Agent": "hermes-agent (gemini-native)", + # Include Hermes client context following Gemini's partner + # integration guidance. + # See https://ai.google.dev/gemini-api/docs/partner-integration + "User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)", + "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", } headers.update(self._default_headers) return headers diff --git a/hermes_cli/models.py b/hermes_cli/models.py index a6dd34d0361c..d81c409814c7 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3594,6 +3594,8 @@ def probe_api_models( tried: list[str] = [] headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} + if urllib.parse.urlparse(normalized).hostname == "generativelanguage.googleapis.com": + headers["X-Goog-Api-Client"] = f"hermes-agent/{_HERMES_VERSION}" if api_key and api_mode == "anthropic_messages": headers["x-api-key"] = api_key headers["anthropic-version"] = "2023-06-01" diff --git a/scripts/release.py b/scripts/release.py index d4ad39fb5e23..2c528e15516a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -776,6 +776,7 @@ AUTHOR_MAP = { "bzarnitz13@gmail.com": "Beandon13", "tony@tonysimons.dev": "asimons81", "jetha@google.com": "jethac", + "vishal.dharm@gmail.com": "vishal-dharm", "jani@0xhoneyjar.xyz": "deep-name", # LINE messaging plugin (synthesis PR) "32443648+leepoweii@users.noreply.github.com": "leepoweii", diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index c265572436dd..e797d6b2ae71 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -461,3 +461,53 @@ def test_explicit_max_tokens_is_respected(): req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=4096) assert req["generationConfig"]["maxOutputTokens"] == 4096 + + +# --------------------------------------------------------------------------- +# X-Goog-Api-Client header tests +# --------------------------------------------------------------------------- + + +def test_x_goog_api_client_header_is_set(): + """The X-Goog-Api-Client header should be set on inference requests.""" + from agent.gemini_native_adapter import GeminiNativeClient + + client = GeminiNativeClient(api_key="fake-key", model="gemini-2.0-flash") + headers = client._headers() + + assert "X-Goog-Api-Client" in headers, "X-Goog-Api-Client header missing" + assert "hermes-agent/" in headers["X-Goog-Api-Client"], ( + "hermes-agent not found in X-Goog-Api-Client header" + ) + + +def test_x_goog_api_client_header_format(): + """Header value should be 'hermes-agent/' matching the package version.""" + from agent.gemini_native_adapter import GeminiNativeClient, _HERMES_VERSION + + client = GeminiNativeClient(api_key="fake-key", model="gemini-2.0-flash") + headers = client._headers() + + expected = f"hermes-agent/{_HERMES_VERSION}" + assert headers["X-Goog-Api-Client"] == expected + + +def test_user_agent_contains_version(): + """User-Agent should include the hermes-agent version.""" + from agent.gemini_native_adapter import GeminiNativeClient, _HERMES_VERSION + + client = GeminiNativeClient(api_key="fake-key", model="gemini-2.0-flash") + headers = client._headers() + + assert f"hermes-agent/{_HERMES_VERSION}" in headers["User-Agent"] + + +def test_hermes_version_is_valid(): + """_HERMES_VERSION should be a non-empty string.""" + from agent.gemini_native_adapter import _HERMES_VERSION + + assert isinstance(_HERMES_VERSION, str) + assert len(_HERMES_VERSION) > 0 + assert _HERMES_VERSION != "0.0.0", ( + "Version should resolve from hermes_cli.__version__, not the fallback" + ) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 93095999d101..9e08831701e3 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -942,3 +942,33 @@ class TestProbeApiModelsUserAgent: assert ua and ua.startswith("hermes-cli/") # No Authorization was set, but UA must still be present. assert req.get_header("Authorization") is None + + def test_probe_sends_client_context_to_gemini(self): + from unittest.mock import patch + from hermes_cli.models import _HERMES_VERSION + + body = b'{"data":[]}' + with patch( + "hermes_cli.models.urllib.request.urlopen", + return_value=self._make_mock_response(body), + ) as mock_urlopen: + probe_api_models( + "gemini-key", + "https://generativelanguage.googleapis.com/v1beta/openai", + ) + + req = mock_urlopen.call_args[0][0] + assert req.get_header("X-goog-api-client") == f"hermes-agent/{_HERMES_VERSION}" + + def test_probe_omits_gemini_client_context_for_other_providers(self): + from unittest.mock import patch + + body = b'{"data":[]}' + with patch( + "hermes_cli.models.urllib.request.urlopen", + return_value=self._make_mock_response(body), + ) as mock_urlopen: + probe_api_models("provider-key", "https://api.example.com/v1") + + req = mock_urlopen.call_args[0][0] + assert req.get_header("X-goog-api-client") is None diff --git a/tests/tools/test_tts_gemini.py b/tests/tools/test_tts_gemini.py index 15e26bdbb122..da744693b588 100644 --- a/tests/tools/test_tts_gemini.py +++ b/tests/tools/test_tts_gemini.py @@ -115,6 +115,19 @@ class TestGenerateGeminiTts: # Audio payload should match the PCM we put in assert data[44:] == fake_pcm_bytes + def test_x_goog_api_client_header_is_set(self, tmp_path, monkeypatch, mock_gemini_response): + """Gemini TTS requests should include Hermes client context.""" + from tools.tts_tool import _generate_gemini_tts + + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + + with patch("requests.post", return_value=mock_gemini_response) as mock_post: + _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {}) + + headers = mock_post.call_args[1]["headers"] + assert "X-Goog-Api-Client" in headers + assert headers["X-Goog-Api-Client"].startswith("hermes-agent/") + def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response): from tools.tts_tool import ( DEFAULT_GEMINI_TTS_MODEL, diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 7d571e66b1ad..374042192414 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1850,11 +1850,24 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] }, } + try: + import hermes_cli as _hermes_cli + + _hermes_version = str(_hermes_cli.__version__) + except Exception: + _hermes_version = "0.0.0" + endpoint = f"{base_url}/models/{model}:generateContent" response = requests.post( endpoint, params={"key": api_key}, - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + # Include Hermes client context following Gemini's partner + # integration guidance: + # https://ai.google.dev/gemini-api/docs/partner-integration + "X-Goog-Api-Client": f"hermes-agent/{_hermes_version}", + }, json=payload, timeout=60, )