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