Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@

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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -901,7 +911,11 @@ def _headers(self) -> Dict[str, str]:
"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
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3574,6 +3574,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"
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@
"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",
Expand Down
50 changes: 50 additions & 0 deletions tests/agent/test_gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>' 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"
)
30 changes: 30 additions & 0 deletions tests/hermes_cli/test_model_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -942,3 +942,33 @@ def test_probe_user_agent_sent_without_api_key(self):
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
29 changes: 29 additions & 0 deletions tests/tools/test_tts_gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ def test_wav_output_fast_path(self, tmp_path, monkeypatch, mock_gemini_response,
# 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 hermes_cli import __version__
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 headers["X-Goog-Api-Client"] == f"hermes-agent/{__version__}"

def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response):
from tools.tts_tool import (
DEFAULT_GEMINI_TTS_MODEL,
Expand Down Expand Up @@ -255,6 +268,22 @@ def test_custom_base_url_env(self, tmp_path, monkeypatch, mock_gemini_response):
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})

assert mock_post.call_args[0][0].startswith("https://custom-gemini.example.com/v1beta/")
assert "X-Goog-Api-Client" not in mock_post.call_args[1]["headers"]

def test_lookalike_base_url_omits_client_context(
self, tmp_path, monkeypatch, mock_gemini_response
):
from tools.tts_tool import _generate_gemini_tts

lookalike = "https://generativelanguage.googleapis.com.evil.example/v1beta"
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
monkeypatch.setenv("GEMINI_BASE_URL", lookalike)

with patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})

assert mock_post.call_args[0][0].startswith(f"{lookalike}/")
assert "X-Goog-Api-Client" not in mock_post.call_args[1]["headers"]

def test_persona_prompt_file_appends_labeled_transcript(
self, tmp_path, monkeypatch, mock_gemini_response
Expand Down
17 changes: 15 additions & 2 deletions tools/tts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
import uuid
from pathlib import Path
from typing import Callable, Dict, Any, Optional
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse

from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_constants import display_hermes_home
Expand Down Expand Up @@ -1732,11 +1732,24 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
},
}

headers = {"Content-Type": "application/json"}
if urlparse(base_url).hostname == "generativelanguage.googleapis.com":
try:
import hermes_cli as _hermes_cli

_hermes_version = str(_hermes_cli.__version__)
except Exception:
_hermes_version = "0.0.0"
# Include Hermes client context following Gemini's partner
# integration guidance:
# https://ai.google.dev/gemini-api/docs/partner-integration
headers["X-Goog-Api-Client"] = f"hermes-agent/{_hermes_version}"

endpoint = f"{base_url}/models/{model}:generateContent"
response = requests.post(
endpoint,
params={"key": api_key},
headers={"Content-Type": "application/json"},
headers=headers,
json=payload,
timeout=60,
)
Expand Down
Loading