Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
38 changes: 37 additions & 1 deletion hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,40 @@ def _provider_supports_explicit_api_mode(provider: Optional[str], configured_pro
return normalized_configured == normalized_provider


def _looks_like_raw_github_token(token: str) -> bool:
"""True when ``token`` is a raw GitHub OAuth/PAT token (needs exchange)."""
t = (token or "").strip()
return t.startswith(("ghu_", "gho_", "ghp_", "ghs_", "github_pat_"))


def _exchange_copilot_api_key(api_key: str, base_url: str) -> tuple[str, str]:
"""Return ``(api_key, base_url)`` with any raw GitHub token exchanged.

Copilot chat requests must use the short-lived Copilot API token
(``tid=...``) obtained from the token-exchange endpoint. A raw ``ghu_``
token makes GitHub ignore ``Copilot-Integration-Id: vscode-chat`` and pin
the request to integrator ``copilot-language-server``, whose model
allow-list is tiny, so Claude/Gemini/most GPT models fail with HTTP 400
``model_not_available_for_integrator``. Non-raw tokens and exchange
failures pass through unchanged, so behaviour is never worse than before.
"""
if not _looks_like_raw_github_token(api_key):
return api_key, base_url
try:
from hermes_cli.copilot_auth import get_copilot_api_token

exchanged, exchanged_base_url = get_copilot_api_token(api_key)
if exchanged:
api_key = exchanged
if exchanged_base_url:
base_url = exchanged_base_url.rstrip("/")
except Exception as exc: # pragma: no cover - defensive
logger.debug(
"Copilot token exchange during runtime resolution failed: %s", exc
)
return api_key, base_url


def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
Expand Down Expand Up @@ -443,7 +477,8 @@ def _resolve_runtime_from_pool_entry(
api_mode = "chat_completions"
base_url = _nous_inference_base_url_override() or base_url
elif provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
api_key, base_url = _exchange_copilot_api_key(api_key, base_url)
api_mode = _copilot_runtime_api_mode(model_cfg, api_key)
base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url
elif provider == "azure-foundry":
# Azure Foundry: read api_mode and base_url from config
Expand Down Expand Up @@ -1480,6 +1515,7 @@ def _resolve_explicit_runtime(

api_mode = "chat_completions"
if provider == "copilot":
api_key, base_url = _exchange_copilot_api_key(api_key, base_url)
api_mode = _copilot_runtime_api_mode(model_cfg, api_key)
elif provider == "xai":
api_mode = "codex_responses"
Expand Down
76 changes: 76 additions & 0 deletions tests/hermes_cli/test_copilot_runtime_token_exchange.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Regression tests for Copilot raw-token exchange during runtime resolution.

A raw GitHub OAuth token (``ghu_...``) sent as the Bearer to
``api.githubcopilot.com`` makes GitHub ignore ``Copilot-Integration-Id:
vscode-chat`` and pin the request to integrator ``copilot-language-server``,
whose model allow-list is tiny (only a handful of GPT models). Claude, Gemini
and most GPT models then fail with HTTP 400
``model_not_available_for_integrator``. The credential pool can end up holding
the raw token (the env seeder overwrites the exchanged singleton entry under
the same source key), so runtime resolution must defensively exchange a raw
token for a short-lived Copilot API token (``tid=...``) before use.

These tests pin that behaviour without any network access.
"""
from unittest.mock import patch

from hermes_cli import runtime_provider as rp


def test_looks_like_raw_github_token_recognizes_prefixes():
for raw in (
"ghu_abc123",
"gho_abc123",
"ghp_abc123",
"ghs_abc123",
"github_pat_abc123",
" ghu_leading_ws ",
):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please add a regression test through _resolve_runtime_from_pool_entry() with a raw ghu_... entry. These helper-only tests do not prove the changed pooled resolver returns the exchanged key and enterprise base URL.

assert rp._looks_like_raw_github_token(raw) is True

# Already-exchanged Copilot API tokens and empty values are not "raw".
for other in ("tid=abc;exp=123", "", "sk-proj-abc", "some-random-key"):
assert rp._looks_like_raw_github_token(other) is False


def test_exchange_copilot_api_key_exchanges_raw_token_and_adopts_base_url():
with patch(
"hermes_cli.copilot_auth.get_copilot_api_token",
return_value=("tid=exchanged;exp=999", "https://api.business.githubcopilot.com/"),
) as mock_exchange:
api_key, base_url = rp._exchange_copilot_api_key(
"ghu_rawtoken", "https://api.githubcopilot.com"
)

mock_exchange.assert_called_once_with("ghu_rawtoken")
assert api_key == "tid=exchanged;exp=999"
# Account-specific endpoint advertised by the exchange is adopted (trailing
# slash stripped) so Business/Enterprise tenants hit the right host.
assert base_url == "https://api.business.githubcopilot.com"


def test_exchange_copilot_api_key_passes_through_already_exchanged_token():
with patch("hermes_cli.copilot_auth.get_copilot_api_token") as mock_exchange:
api_key, base_url = rp._exchange_copilot_api_key(
"tid=already;exp=1", "https://api.githubcopilot.com"
)

# No exchange attempted for a non-raw token; values pass through unchanged.
mock_exchange.assert_not_called()
assert api_key == "tid=already;exp=1"
assert base_url == "https://api.githubcopilot.com"


def test_exchange_copilot_api_key_keeps_base_url_when_exchange_returns_none():
# get_copilot_api_token returns (raw_token, None) on exchange failure; the
# original base_url must be preserved rather than clobbered with None.
with patch(
"hermes_cli.copilot_auth.get_copilot_api_token",
return_value=("ghu_rawtoken", None),
):
api_key, base_url = rp._exchange_copilot_api_key(
"ghu_rawtoken", "https://api.githubcopilot.com"
)

assert api_key == "ghu_rawtoken"
assert base_url == "https://api.githubcopilot.com"