From 6f052b7ff1d694c7fe0679b5f4202ee199ca121a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:44:15 -0700 Subject: [PATCH] fix(copilot): set x-initiator per turn so user prompts bill as premium requests (salvage #4097) (#58544) * fix(cli): set correct x-initiator header per Copilot turn copilot_default_headers() always hardcoded x-initiator: agent, but GitHub Copilot billing requires "user" for user-initiated prompts and "agent" for tool/follow-up calls. This caused premium requests to never be consumed correctly, risking billing issues or account bans. Adds is_agent_turn param to copilot_default_headers() and injects extra_headers={"x-initiator": "user"} on the first API call of each user turn when targeting Copilot URLs. The flag flips to False after injection so subsequent calls (tool use, streaming fallback) default back to "agent". Fixes #3040 Co-Authored-By: Claude Opus 4.6 * chore(release): add AUTHOR_MAP entry for @tjp2021 (PR #4097 salvage) --------- Co-authored-by: Tim Co-authored-by: Claude Opus 4.6 --- agent/agent_init.py | 2 + agent/conversation_loop.py | 8 ++ agent/turn_context.py | 3 + hermes_cli/models.py | 6 +- run_agent.py | 11 ++ scripts/release.py | 2 + tests/hermes_cli/test_copilot_auth.py | 28 +++++ tests/test_copilot_initiator.py | 148 ++++++++++++++++++++++++++ 8 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 tests/test_copilot_initiator.py 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"