Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 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 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,
Expand Down
15 changes: 14 additions & 1 deletion tools/tts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1732,11 +1732,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}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This emits a third-party attribution tag unconditionally, but AGENTS.md:118-121 requires a generic opt-in before adding such tags. It also reaches arbitrary GEMINI_BASE_URL hosts (current tests/tools/test_tts_gemini.py:248-257 verifies custom endpoints), so please do not send it by default; any future opt-in implementation must also constrain it to the intended host.

},
json=payload,
timeout=60,
)
Expand Down