From 81928f03ab5841362e526df011e3eb74159aea8b Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 8 May 2026 12:42:56 +0530 Subject: [PATCH] refactor(gmi): move User-Agent to profile.default_headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous revision of this PR added six GMI-specific branches (`elif base_url_host_matches(..., 'api.gmi-serving.com')`) across run_agent.py and agent/auxiliary_client.py, plus a _HERMES_UA_HEADERS constant in auxiliary_client.py. ProviderProfile already has a `default_headers: dict[str, str]` field commented as 'Client-level quirks (set once at client construction)'. Other plugins (ai-gateway, kimi-coding) already use it. Two of the four auxiliary_client sites we previously patched already had a generic `else: profile.default_headers` fallback that picked it up (so did both run_agent sites). This revision: * Sets `default_headers={'User-Agent': 'HermesAgent/'}` on the GMI profile in plugins/model-providers/gmi/__init__.py. * Reverts all six GMI-specific branches in run_agent.py and auxiliary_client.py. * Adds the generic profile-fallback `else` block to the two auxiliary_client sites (`_to_async_client`, `resolve_provider_client`) that didn't have it yet. This benefits every provider whose profile declares default_headers, not just GMI — e.g. Vercel AI Gateway's HTTP-Referer/X-Title now flow through the async client path too. * Replaces the GMI-specific URL-branch tests with a profile-level assertion and keeps the run_agent integration test (with `provider='gmi'` so the fallback picks up the profile). Net diff vs main: +82/-0 across 5 files, touching only the GMI plugin, two generic fallback blocks in auxiliary_client.py, AUTHOR_MAP, and tests. No core files change. Based on #20907 by @isaachuangGMICLOUD. --- agent/auxiliary_client.py | 36 +++++++++++++++++++ plugins/model-providers/gmi/__init__.py | 5 +++ tests/hermes_cli/test_gmi_provider.py | 16 +++++++++ .../test_provider_attribution_headers.py | 25 +++++++++++++ 4 files changed, 82 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bd4e6be457..00f461e77e 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2141,6 +2141,20 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): ) elif base_url_host_matches(sync_base_url, "api.kimi.com"): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + else: + # Fall back to profile.default_headers for providers that declare + # client-level headers on their ProviderProfile (e.g. attribution + # User-Agent strings). Provider is inferred from the hostname. + try: + from agent.model_metadata import _infer_provider_from_url + from providers import get_provider_profile as _gpf_async + _inferred = _infer_provider_from_url(sync_base_url) + if _inferred: + _ph_async = _gpf_async(_inferred) + if _ph_async and _ph_async.default_headers: + async_kwargs["default_headers"] = dict(_ph_async.default_headers) + except Exception: + pass return AsyncOpenAI(**async_kwargs), model @@ -2368,6 +2382,16 @@ def resolve_provider_client( extra["default_headers"] = copilot_request_headers( is_agent_turn=True, is_vision=is_vision ) + else: + # Fall back to profile.default_headers for providers that + # declare client-level attribution headers on their profile. + try: + from providers import get_provider_profile as _gpf_custom + _ph_custom = _gpf_custom(provider) + if _ph_custom and _ph_custom.default_headers: + extra["default_headers"] = dict(_ph_custom.default_headers) + except Exception: + pass client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra) client = _wrap_if_needed(client, final_model, custom_base, custom_key) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode @@ -2556,6 +2580,18 @@ def resolve_provider_client( headers.update(copilot_request_headers( is_agent_turn=True, is_vision=is_vision )) + else: + # Fall back to profile.default_headers for providers that declare + # client-level attribution headers on their profile (e.g. GMI + # User-Agent for traffic identification, Vercel AI Gateway + # Referer/Title for analytics). + try: + from providers import get_provider_profile as _gpf_main + _ph_main = _gpf_main(provider) + if _ph_main and _ph_main.default_headers: + headers.update(_ph_main.default_headers) + except Exception: + pass client = OpenAI(api_key=api_key, base_url=base_url, **({"default_headers": headers} if headers else {})) diff --git a/plugins/model-providers/gmi/__init__.py b/plugins/model-providers/gmi/__init__.py index a7cc32e552..fb02207080 100644 --- a/plugins/model-providers/gmi/__init__.py +++ b/plugins/model-providers/gmi/__init__.py @@ -1,5 +1,6 @@ """GMI Cloud provider profile.""" +from hermes_cli import __version__ as _HERMES_VERSION from providers import register_provider from providers.base import ProviderProfile @@ -12,6 +13,10 @@ gmi = ProviderProfile( env_vars=("GMI_API_KEY", "GMI_BASE_URL"), base_url="https://api.gmi-serving.com/v1", auth_type="api_key", + # Attribution so GMI can identify traffic from Hermes Agent. + # The generic profile.default_headers fallback in run_agent.py and + # agent/auxiliary_client.py picks this up at client construction time. + default_headers={"User-Agent": f"HermesAgent/{_HERMES_VERSION}"}, default_aux_model="google/gemini-3.1-flash-lite-preview", fallback_models=( "zai-org/GLM-5.1-FP8", diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py index 0b9363e675..06863b6682 100644 --- a/tests/hermes_cli/test_gmi_provider.py +++ b/tests/hermes_cli/test_gmi_provider.py @@ -284,6 +284,22 @@ class TestGmiAuxiliary: assert model == "google/gemini-3.1-flash-lite-preview" assert mock_openai.call_args.kwargs["api_key"] == "gmi-test-key" assert mock_openai.call_args.kwargs["base_url"] == "https://api.gmi-serving.com/v1" + # GMI profile declares default_headers with a HermesAgent User-Agent + # for traffic attribution. The generic profile-fallback branch in + # resolve_provider_client should carry it through to the OpenAI client. + headers = mock_openai.call_args.kwargs.get("default_headers", {}) + assert headers.get("User-Agent", "").startswith("HermesAgent/") + + def test_gmi_profile_declares_hermes_user_agent(self): + """The GMI plugin sets a HermesAgent/ User-Agent on its profile.""" + from providers import get_provider_profile + + profile = get_provider_profile("gmi") + assert profile is not None + ua = profile.default_headers.get("User-Agent", "") + assert ua.startswith("HermesAgent/"), ( + f"expected GMI profile User-Agent to start with 'HermesAgent/', got {ua!r}" + ) def test_resolve_provider_client_accepts_gmi_alias(self, monkeypatch): monkeypatch.setenv("GMI_API_KEY", "gmi-test-key") diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index 673a906cfb..2a1d9088c4 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -65,6 +65,31 @@ def test_routermint_base_url_applies_user_agent_header(mock_openai): assert headers["User-Agent"].startswith("HermesAgent/") +@patch("run_agent.OpenAI") +def test_gmi_base_url_picks_up_profile_user_agent(mock_openai): + """GMI declares User-Agent on its ProviderProfile.default_headers. + + The ``_apply_client_headers_for_base_url`` else-branch looks up the + provider profile and applies its default_headers, so no GMI-specific + branch is needed in run_agent. + """ + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://api.gmi-serving.com/v1", + model="test/model", + provider="gmi", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._apply_client_headers_for_base_url("https://api.gmi-serving.com/v1") + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"].startswith("HermesAgent/") + + @patch("run_agent.OpenAI") def test_unknown_base_url_clears_default_headers(mock_openai): mock_openai.return_value = MagicMock()