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 <noreply@anthropic.com>

* chore(release): add AUTHOR_MAP entry for @tjp2021 (PR #4097 salvage)

---------

Co-authored-by: Tim <tim@iteachyouai.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Teknium 2026-07-05 00:44:15 -07:00 committed by GitHub
parent 485ae54c9f
commit 6f052b7ff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 205 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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",
}

View file

@ -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,
*,

View file

@ -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)

View file

@ -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."""

View file

@ -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"