diff --git a/agent/agent_init.py b/agent/agent_init.py index 7539d59d35d..b3b499a9934 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1822,6 +1822,8 @@ def init_agent( working_dir=os.getenv("TERMINAL_CWD") or None, ) agent._user_turn_count = 0 + # Copilot x-initiator flag: first API call of a user turn sends "user" (#3040). + agent._is_user_initiated_turn = False # Cumulative token usage for the session agent.session_prompt_tokens = 0 diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 41a13f5d628..dff11c489b5 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1158,6 +1158,14 @@ def run_conversation( _sanitize_structure_non_ascii(api_kwargs) if agent.api_mode == "codex_responses": api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False) + # Copilot x-initiator: the first API call of a user turn is + # marked "user" so Copilot bills a premium request; tool-loop + # follow-ups keep the default "agent" header (#3040). + if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url(): + _xh = dict(api_kwargs.get("extra_headers") or {}) + _xh["x-initiator"] = "user" + api_kwargs["extra_headers"] = _xh + agent._is_user_initiated_turn = False try: from hermes_cli.middleware import apply_llm_request_middleware diff --git a/agent/turn_context.py b/agent/turn_context.py index 88980b4ad27..f7951666d7e 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -277,6 +277,9 @@ def build_turn_context( # Track user turns for memory flush and periodic nudge logic. agent._user_turn_count += 1 + # Copilot x-initiator: the first API call of this user turn is + # user-initiated; tool-loop follow-ups revert to "agent" (#3040). + agent._is_user_initiated_turn = True # Reset the streaming context scrubber at the top of each turn. scrubber = getattr(agent, "_stream_context_scrubber", None) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b9b5fc69967..f8ddf1ab33c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2783,7 +2783,7 @@ def _payload_items(payload: Any) -> list[dict[str, Any]]: return [] -def copilot_default_headers() -> dict[str, str]: +def copilot_default_headers(*, is_agent_turn: bool = True) -> dict[str, str]: """Standard headers for Copilot API requests. Includes Openai-Intent and x-initiator headers that opencode and the @@ -2791,13 +2791,13 @@ def copilot_default_headers() -> dict[str, str]: """ try: from hermes_cli.copilot_auth import copilot_request_headers - return copilot_request_headers(is_agent_turn=True) + return copilot_request_headers(is_agent_turn=is_agent_turn) except ImportError: return { "Editor-Version": COPILOT_EDITOR_VERSION, "User-Agent": "HermesAgent/1.0", "Openai-Intent": "conversation-edits", - "x-initiator": "agent", + "x-initiator": "agent" if is_agent_turn else "user", } diff --git a/run_agent.py b/run_agent.py index cdd82f459c8..3cd458cd505 100644 --- a/run_agent.py +++ b/run_agent.py @@ -735,6 +735,10 @@ class AIAgent: # Turn counter (added after reset_session_state was first written — #2635) self._user_turn_count = 0 + # Copilot x-initiator: True for the first API call of a user turn, + # False for tool-loop follow-ups (#3040). + self._is_user_initiated_turn = False + # Context engine reset/transition (works for built-in compressor and plugins) self._transition_context_engine_session( old_session_id=old_session_id, @@ -1327,6 +1331,13 @@ class AIAgent: """Return True when the base URL targets OpenRouter.""" return base_url_host_matches(self._base_url_lower, "openrouter.ai") + def _is_copilot_url(self) -> bool: + """Return True when the base URL targets GitHub Copilot or GitHub Models.""" + return ( + "api.githubcopilot.com" in self._base_url_lower + or "models.github.ai" in self._base_url_lower + ) + def _anthropic_prompt_cache_policy( self, *, diff --git a/scripts/release.py b/scripts/release.py index baf873fe87d..058d9fb29e5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "tim@iteachyouai.com": "tjp2021", # PR #4097 salvage (copilot: per-turn x-initiator header so user prompts bill as premium requests; #3040) + "3723267+kevinrajaram@users.noreply.github.com": "kevinrajaram", # PR #3850 salvage (gateway: add POSIX system dirs to PATH so launchctl/systemctl resolve under UV's minimal-PATH bundled Python; #3849) "jonathan@mintrx.com": "JAlmanzarMint", # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890 "al3060388206@gmail.com": "ooiuuii", # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings). Also PR #58433 salvage (codex: accept recorded final_text when app-server omits turn/completed) and PR #58472 salvage (gateway: cap proxy SSE line buffer at 16MiB). "Jigoooo@users.noreply.github.com": "Jigoooo", # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry) diff --git a/tests/hermes_cli/test_copilot_auth.py b/tests/hermes_cli/test_copilot_auth.py index 3d0b0bdeb72..b658584f302 100644 --- a/tests/hermes_cli/test_copilot_auth.py +++ b/tests/hermes_cli/test_copilot_auth.py @@ -152,6 +152,34 @@ class TestCopilotDefaultHeaders: headers = copilot_default_headers() assert "x-initiator" in headers + def test_default_is_agent_turn(self): + """Calling with no args preserves backward-compatible default (agent).""" + from hermes_cli.models import copilot_default_headers + headers = copilot_default_headers() + assert headers["x-initiator"] == "agent" + + def test_user_turn_sets_user_initiator(self): + """Passing is_agent_turn=False sets x-initiator to 'user'.""" + from hermes_cli.models import copilot_default_headers + headers = copilot_default_headers(is_agent_turn=False) + assert headers["x-initiator"] == "user" + + def test_agent_turn_explicit(self): + """Explicitly passing is_agent_turn=True sets x-initiator to 'agent'.""" + from hermes_cli.models import copilot_default_headers + headers = copilot_default_headers(is_agent_turn=True) + assert headers["x-initiator"] == "agent" + + def test_param_passthrough_both_values(self): + """is_agent_turn param correctly maps to x-initiator for both True and False.""" + from hermes_cli.models import copilot_default_headers + for is_agent, expected in [(True, "agent"), (False, "user")]: + headers = copilot_default_headers(is_agent_turn=is_agent) + assert headers["x-initiator"] == expected, ( + f"is_agent_turn={is_agent} should produce x-initiator={expected!r}, " + f"got {headers['x-initiator']!r}" + ) + class TestApiModeSelection: """API mode selection matching opencode's shouldUseCopilotResponsesApi.""" diff --git a/tests/test_copilot_initiator.py b/tests/test_copilot_initiator.py new file mode 100644 index 00000000000..aa2915d0225 --- /dev/null +++ b/tests/test_copilot_initiator.py @@ -0,0 +1,148 @@ +"""Tests for per-turn Copilot x-initiator header injection (issue #3040). + +Copilot bills "premium requests" only when a request is marked as +user-initiated via the ``x-initiator: user`` header. Hermes previously sent +``x-initiator: agent`` on every request (client-level default headers), so +user prompts never consumed premium requests and were throttled as agent +traffic. The fix marks the FIRST API call of each user turn as "user" and +lets tool-loop follow-ups keep the "agent" default. + +Salvaged from PR #4097 (@tjp2021); adapted to the post-refactor layout +(conversation_loop.py owns the injection site, the codex transport now +accepts extra_headers). +""" + +import pytest + +from run_agent import AIAgent + + +def _tool_defs(*names): + return [ + {"type": "function", "function": {"name": n, "description": n, "parameters": {}}} + for n in names + ] + + +class _FakeOpenAI: + def __init__(self, **kw): + self.api_key = kw.get("api_key", "test") + self.base_url = kw.get("base_url", "http://test") + + def close(self): + pass + + +def _make_agent(monkeypatch, base_url, api_mode="chat_completions"): + """Create an AIAgent pointing at the given base_url.""" + monkeypatch.setattr("run_agent.get_tool_definitions", lambda **kw: _tool_defs("web_search")) + monkeypatch.setattr("run_agent.check_toolset_requirements", lambda: {}) + monkeypatch.setattr("run_agent.OpenAI", _FakeOpenAI) + return AIAgent( + api_key="test-key", + base_url=base_url, + provider="copilot" if "githubcopilot" in base_url else "openrouter", + api_mode=api_mode, + max_iterations=4, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +def _inject(agent, api_kwargs): + """Mirror the injection block in agent/conversation_loop.py.""" + if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url(): + _xh = dict(api_kwargs.get("extra_headers") or {}) + _xh["x-initiator"] = "user" + api_kwargs["extra_headers"] = _xh + agent._is_user_initiated_turn = False + return api_kwargs + + +class TestIsCopilotUrl: + """_is_copilot_url() detects GitHub Copilot endpoints.""" + + def test_standard_copilot_url(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com") + assert agent._is_copilot_url() is True + + def test_copilot_url_with_path(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com/v1") + assert agent._is_copilot_url() is True + + def test_github_models_url(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://models.github.ai/inference") + assert agent._is_copilot_url() is True + + def test_openrouter_url(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://openrouter.ai/api/v1") + assert agent._is_copilot_url() is False + + def test_case_insensitive(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://API.GITHUBCOPILOT.COM") + assert agent._is_copilot_url() is True + + +class TestUserInitiatedTurnFlag: + """_is_user_initiated_turn lifecycle.""" + + def test_default_is_false(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com") + assert agent._is_user_initiated_turn is False + + def test_reset_session_clears_flag(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com") + agent._is_user_initiated_turn = True + agent.reset_session_state() + assert agent._is_user_initiated_turn is False + + +class TestFlagFlipOnInjection: + """Flag flips immediately on injection so tool-loop calls use 'agent'.""" + + def test_first_call_injects_user_initiator(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com") + agent._is_user_initiated_turn = True + kwargs = _inject(agent, {}) + assert kwargs["extra_headers"] == {"x-initiator": "user"} + assert agent._is_user_initiated_turn is False + + def test_second_call_has_no_injection(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com") + agent._is_user_initiated_turn = True + kwargs1 = _inject(agent, {}) + kwargs2 = _inject(agent, {}) + assert "extra_headers" in kwargs1 + assert "extra_headers" not in kwargs2 + + def test_existing_extra_headers_preserved(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://api.githubcopilot.com") + agent._is_user_initiated_turn = True + kwargs = _inject(agent, {"extra_headers": {"x-custom": "1"}}) + assert kwargs["extra_headers"]["x-custom"] == "1" + assert kwargs["extra_headers"]["x-initiator"] == "user" + + def test_non_copilot_flag_not_flipped(self, monkeypatch): + agent = _make_agent(monkeypatch, "https://openrouter.ai/api/v1") + agent._is_user_initiated_turn = True + kwargs = _inject(agent, {}) + assert "extra_headers" not in kwargs + # Flag unchanged — non-Copilot path doesn't touch it + assert agent._is_user_initiated_turn is True + + +class TestHeaderValues: + """copilot_default_headers(is_agent_turn=...) sets x-initiator correctly.""" + + def test_default_is_agent(self): + from hermes_cli.models import copilot_default_headers + assert copilot_default_headers()["x-initiator"] == "agent" + + def test_user_turn(self): + from hermes_cli.models import copilot_default_headers + assert copilot_default_headers(is_agent_turn=False)["x-initiator"] == "user" + + def test_agent_turn_explicit(self): + from hermes_cli.models import copilot_default_headers + assert copilot_default_headers(is_agent_turn=True)["x-initiator"] == "agent"