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
8 changes: 7 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from hermes_cli.timeouts import get_provider_request_timeout
from hermes_constants import get_hermes_home
from utils import base_url_host_matches, is_truthy_value
from hermes_cli.copilot_auth import is_copilot_api_url

# Use the same logger name as run_agent so tests patching ``run_agent.logger``
# capture our warnings. (run_agent.py also does
Expand Down Expand Up @@ -1178,7 +1179,12 @@ def init_agent(
client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base)
elif base_url_host_matches(effective_base, "api.routermint.com"):
client_kwargs["default_headers"] = _ra()._routermint_headers()
elif base_url_host_matches(effective_base, "githubcopilot.com"):
elif (
is_copilot_api_url(
effective_base,
provider=getattr(agent, "provider", "") or "",
)
):
from hermes_cli.models import copilot_default_headers

client_kwargs["default_headers"] = copilot_default_headers()
Expand Down
3 changes: 2 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from typing import Any, Dict, List, Optional, Tuple

from hermes_cli.timeouts import get_provider_request_timeout
from hermes_cli.copilot_auth import is_copilot_api_url
from agent.prompt_builder import format_steer_marker
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
from agent.trajectory import convert_scratchpad_to_think
Expand Down Expand Up @@ -2329,7 +2330,7 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
# missing Copilot headers here closes the whole class. We only ADD missing
# keys — never override headers a caller deliberately set.
try:
if base_url_host_matches(str(client_kwargs.get("base_url", "")), "githubcopilot.com"):
if is_copilot_api_url(str(client_kwargs.get("base_url", ""))):
from hermes_cli.models import copilot_default_headers
existing = dict(client_kwargs.get("default_headers") or {})
existing_lower = {k.lower() for k in existing}
Expand Down
22 changes: 11 additions & 11 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
from types import SimpleNamespace
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TYPE_CHECKING
from urllib.parse import urlparse, parse_qs, urlunparse
from hermes_cli.copilot_auth import is_copilot_api_url

# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
# openai SDK pulls a large type tree (~240 ms cold, including responses/*,
Expand Down Expand Up @@ -1208,7 +1209,7 @@ def create(self, **kwargs) -> Any:
# through this adapter instead of agent/transports/codex.py's
# build_kwargs, so they need the same guard applied independently.
_host_for_input = str(getattr(self._client, "base_url", "") or "")
_is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com")
_is_github_for_input = is_copilot_api_url(_host_for_input)
input_items = _chat_messages_to_responses_input(
replay_messages, is_github_responses=_is_github_for_input,
)
Expand Down Expand Up @@ -1324,7 +1325,7 @@ def create(self, **kwargs) -> Any:
_host_src = str(getattr(self._client, "base_url", "") or "")
_is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai")
_is_github = (
base_url_host_matches(_host_src, "githubcopilot.com")
is_copilot_api_url(_host_src)
or base_url_host_matches(_host_src, "models.github.ai")
)
if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs:
Expand Down Expand Up @@ -2357,7 +2358,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra = {}
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(base_url, "githubcopilot.com"):
elif is_copilot_api_url(base_url, provider=provider_id):
from hermes_cli.models import copilot_default_headers

extra["default_headers"] = copilot_default_headers()
Expand Down Expand Up @@ -2397,7 +2398,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra = {}
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(base_url, "githubcopilot.com"):
elif is_copilot_api_url(base_url, provider=provider_id):
from hermes_cli.models import copilot_default_headers

extra["default_headers"] = copilot_default_headers()
Expand Down Expand Up @@ -4170,7 +4171,7 @@ def _recoverable_pool_provider(
return "nous"
if base_url_host_matches(base, "api.anthropic.com"):
return "anthropic"
if base_url_host_matches(base, "githubcopilot.com"):
if is_copilot_api_url(base):
return "copilot"
if base_url_host_matches(base, "api.kimi.com"):
return "kimi-coding"
Expand Down Expand Up @@ -4493,7 +4494,7 @@ def _auth_refresh_provider_for_route(
normalized = _normalize_aux_provider(resolved_provider)
if normalized and normalized != "auto":
return normalized
if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
if is_copilot_api_url(client_base_url):
return "copilot"
if base_url_host_matches(client_base_url, "chatgpt.com"):
return "openai-codex"
Expand Down Expand Up @@ -5600,7 +5601,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
sync_base_url = str(sync_client.base_url)
if base_url_host_matches(sync_base_url, "openrouter.ai"):
async_kwargs["default_headers"] = build_or_headers()
elif base_url_host_matches(sync_base_url, "githubcopilot.com"):
elif is_copilot_api_url(sync_base_url):
from hermes_cli.copilot_auth import copilot_request_headers

async_kwargs["default_headers"] = copilot_request_headers(
Expand Down Expand Up @@ -5981,7 +5982,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
extra["default_query"] = _dq
if base_url_host_matches(custom_base, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(custom_base, "githubcopilot.com"):
elif is_copilot_api_url(custom_base):
from hermes_cli.copilot_auth import copilot_request_headers
extra["default_headers"] = copilot_request_headers(
is_agent_turn=True, is_vision=is_vision
Expand Down Expand Up @@ -6238,7 +6239,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
headers = {}
if base_url_host_matches(base_url, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
elif base_url_host_matches(base_url, "githubcopilot.com"):
elif is_copilot_api_url(base_url, provider=provider):
from hermes_cli.copilot_auth import copilot_request_headers

headers.update(copilot_request_headers(
Expand Down Expand Up @@ -6874,8 +6875,7 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di
and _read_nous_auth() is None
and (
_custom_host == "api.openai.com"
or _custom_host == "api.githubcopilot.com"
or _custom_host.endswith(".githubcopilot.com")
or is_copilot_api_url(custom_base)
)):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
Expand Down
5 changes: 3 additions & 2 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from typing import Any, Dict, Optional

from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
from hermes_cli.copilot_auth import is_copilot_api_url
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
Expand Down Expand Up @@ -1172,7 +1173,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
_ct = agent._get_transport()
is_github_responses = (
base_url_host_matches(agent.base_url, "models.github.ai")
or base_url_host_matches(agent.base_url, "githubcopilot.com")
or is_copilot_api_url(agent.base_url, provider=agent.provider)
)
is_codex_backend = (
agent.provider == "openai-codex"
Expand Down Expand Up @@ -1243,7 +1244,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
_is_or = agent._is_openrouter_url()
_is_gh = (
base_url_host_matches(agent._base_url_lower, "models.github.ai")
or base_url_host_matches(agent._base_url_lower, "githubcopilot.com")
or is_copilot_api_url(agent._base_url_lower, provider=agent.provider)
)
_is_nous = "nousresearch" in agent._base_url_lower
_is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower
Expand Down
10 changes: 6 additions & 4 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2566,10 +2566,12 @@ def _env_val(key: str) -> str:
)
active_sources.add(source_name)
pconfig = PROVIDER_REGISTRY.get(provider)
# Use enterprise base URL from token exchange if available,
# otherwise fall back to the provider's default.
effective_base_url = enterprise_base_url or (
pconfig.inference_base_url if pconfig else ""
# An explicit endpoint is authoritative; otherwise use exchange
# metadata before falling back to the public provider default.
effective_base_url = (
os.getenv("COPILOT_API_BASE_URL", "").strip().rstrip("/")
or enterprise_base_url
or (pconfig.inference_base_url if pconfig else "")
)
changed |= _upsert_entry(
entries,
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7137,7 +7137,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
if raw_token:
_, resolved = get_copilot_api_token(raw_token)
resolved = (resolved or "").strip()
if resolved:
if resolved and not env_url:
base_url = resolved
except Exception as exc:
logger.debug("Copilot base URL resolution fell back to default: %s", exc)
Expand Down
48 changes: 44 additions & 4 deletions hermes_cli/copilot_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def _try_gh_cli_token() -> Optional[str]:
subprocess environment so ``gh`` reads from its own credential store
(hosts.yml) instead of just echoing the env var back.
"""
hostname = os.getenv("COPILOT_GH_HOST", "").strip()
hostname = _normalize_copilot_host(os.getenv("COPILOT_GH_HOST", ""))

# Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN
clean_env = {k: v for k, v in os.environ.items()
Expand Down Expand Up @@ -180,9 +180,40 @@ def _try_gh_cli_token() -> Optional[str]:

# ─── OAuth Device Code Flow ────────────────────────────────────────────────

def _normalize_copilot_host(value: str) -> str:
"""Return a bare host[:port] from a hostname or URL."""
from urllib.parse import urlsplit

candidate = str(value or "").strip().rstrip("/")
if not candidate:
return ""
parsed = urlsplit(candidate if "://" in candidate else f"//{candidate}")
return parsed.netloc or parsed.path.split("/", 1)[0]


def resolve_copilot_github_host() -> str:
"""Resolve the GitHub host used for OAuth, CLI auth, and token exchange."""
return _normalize_copilot_host(os.getenv("COPILOT_GH_HOST", "")) or "github.com"


def is_copilot_api_url(base_url: str, *, provider: str = "") -> bool:
"""Return whether a URL is a public or configured Copilot inference API."""
from utils import base_url_hostname

if provider.strip().lower() in {"copilot", "github-copilot", "github"}:
return True
hostname = base_url_hostname(base_url)
if not hostname:
return False
if hostname == "api.githubcopilot.com" or hostname.endswith(".githubcopilot.com"):
return True
configured = os.getenv("COPILOT_API_BASE_URL", "").strip()
return bool(configured and hostname == base_url_hostname(configured))


def copilot_device_code_login(
*,
host: str = "github.com",
host: Optional[str] = None,
timeout_seconds: float = 300,
) -> Optional[str]:
"""Run the GitHub OAuth device code flow for Copilot.
Expand All @@ -195,7 +226,7 @@ def copilot_device_code_login(
import urllib.request
import urllib.parse

domain = host.rstrip("/")
domain = _normalize_copilot_host(host or resolve_copilot_github_host())
device_code_url = f"https://{domain}/login/device/code"
access_token_url = f"https://{domain}/login/oauth/access_token"

Expand Down Expand Up @@ -315,6 +346,15 @@ def copilot_device_code_login(
_EDITOR_VERSION = "vscode/1.104.1"
_EXCHANGE_USER_AGENT = "GitHubCopilotChat/0.26.7"


def resolve_copilot_token_exchange_url() -> str:
"""Return the public or GHE token-exchange endpoint."""
host = resolve_copilot_github_host()
if host not in {"github.com", "api.github.com"}:
return f"https://{host}/api/v3/copilot_internal/v2/token"
return _TOKEN_EXCHANGE_URL


# Transient-failure hardening for the token exchange. Gateway startup often
# races network readiness (launchd relaunch, DHCP/VPN settling); a single-shot
# exchange that fails there silently degrades to the RAW GitHub token, which the
Expand Down Expand Up @@ -528,7 +568,7 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st
)

req = urllib.request.Request(
_TOKEN_EXCHANGE_URL,
resolve_copilot_token_exchange_url(),
method="GET",
headers={
"Authorization": f"token {raw_token}",
Expand Down
31 changes: 26 additions & 5 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"

COPILOT_BASE_URL = "https://api.githubcopilot.com"
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
COPILOT_EDITOR_VERSION = "vscode/1.104.1"
COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
Expand Down Expand Up @@ -3476,21 +3475,43 @@ def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool:
# clock so wall-clock adjustments can't extend the TTL. Lock-free like the
# other module caches here — a racing thread at worst duplicates one fetch.
_github_model_catalog_cache: Optional[list[dict[str, Any]]] = None
_github_model_catalog_cache_key: Optional[str] = None
_github_model_catalog_cache_key: Optional[tuple[Optional[str], str]] = None
_github_model_catalog_cache_time: float = 0.0
_GITHUB_MODEL_CATALOG_CACHE_TTL = 300 # 5 minutes


def _resolve_copilot_catalog_base_url() -> str:
configured = os.getenv("COPILOT_API_BASE_URL", "").strip().rstrip("/")
if configured:
return configured

try:
from hermes_cli.auth import resolve_api_key_provider_credentials

credentials = resolve_api_key_provider_credentials("copilot")
resolved = str(credentials.get("base_url", "")).strip().rstrip("/")
if resolved:
return resolved
except Exception as exc:
logger.debug("Copilot catalog endpoint resolution fell back to default: %s", exc)

return COPILOT_BASE_URL


def fetch_github_model_catalog(
api_key: Optional[str] = None, timeout: float = 5.0
) -> Optional[list[dict[str, Any]]]:
"""Fetch the live GitHub Copilot model catalog for this account."""
global _github_model_catalog_cache, _github_model_catalog_cache_key
global _github_model_catalog_cache_time

base_url = _resolve_copilot_catalog_base_url()
models_url = f"{base_url}/models"
cache_key = (api_key, models_url)

if (
_github_model_catalog_cache is not None
and _github_model_catalog_cache_key == api_key
and _github_model_catalog_cache_key == cache_key
and (time.monotonic() - _github_model_catalog_cache_time) < _GITHUB_MODEL_CATALOG_CACHE_TTL
):
# Deep copy: catalog items are dicts, and a shallow copy would let
Expand All @@ -3506,7 +3527,7 @@ def fetch_github_model_catalog(
attempts.append(copilot_default_headers())

for headers in attempts:
req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers)
req = urllib.request.Request(models_url, headers=headers)
try:
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
Expand All @@ -3523,7 +3544,7 @@ def fetch_github_model_catalog(
models.append(item)
if models:
_github_model_catalog_cache = copy.deepcopy(models)
_github_model_catalog_cache_key = api_key
_github_model_catalog_cache_key = cache_key
_github_model_catalog_cache_time = time.monotonic()
return models
except Exception:
Expand Down
Loading