diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 940bdfd4505b..832561e713e0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1165,7 +1165,7 @@ def _to_async_client(sync_client, model: str): base_lower = str(sync_client.base_url).lower() if "openrouter" in base_lower: async_kwargs["default_headers"] = dict(_OR_HEADERS) - elif "api.githubcopilot.com" in base_lower: + elif ".githubcopilot.com" in base_lower: from hermes_cli.models import copilot_default_headers async_kwargs["default_headers"] = copilot_default_headers() diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 2d1c02ac94a4..195aa4812660 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -209,7 +209,7 @@ def _is_custom_endpoint(base_url: str) -> bool: "generativelanguage.googleapis.com": "gemini", "inference-api.nousresearch.com": "nous", "api.deepseek.com": "deepseek", - "api.githubcopilot.com": "copilot", + ".githubcopilot.com": "copilot", "models.github.ai": "copilot", "api.fireworks.ai": "fireworks", "opencode.ai": "opencode-go", @@ -991,6 +991,17 @@ def get_model_context_length( if inferred: effective_provider = inferred + # 5a. Copilot live catalog — the /models endpoint returns the real + # per-account context windows which may differ from models.dev. + if effective_provider in ("copilot", "copilot-acp"): + try: + from hermes_cli.models import get_copilot_model_context_window + copilot_ctx = get_copilot_model_context_window(model) + if copilot_ctx: + return copilot_ctx + except Exception: + pass # fall through to models.dev + if effective_provider == "nous": ctx = _resolve_nous_context_length(model) if ctx: diff --git a/agent/models_dev.py b/agent/models_dev.py index d3620733bf83..6282bf625c3d 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -641,7 +641,9 @@ def get_model_info( """Get full model metadata from models.dev. Accepts Hermes or models.dev provider ID. Tries exact match then - case-insensitive fallback. Returns None if not found. + case-insensitive fallback. For Copilot providers, overrides the + context_window with the live value from the Copilot /models API. + Returns None if not found. """ mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) @@ -654,17 +656,34 @@ def get_model_info( if not isinstance(models, dict): return None + info: Optional[ModelInfo] = None + # Exact match raw = models.get(model_id) if isinstance(raw, dict): - return _parse_model_info(model_id, raw, mdev_id) + info = _parse_model_info(model_id, raw, mdev_id) # Case-insensitive fallback - model_lower = model_id.lower() - for mid, mdata in models.items(): - if mid.lower() == model_lower and isinstance(mdata, dict): - return _parse_model_info(mid, mdata, mdev_id) + if info is None: + model_lower = model_id.lower() + for mid, mdata in models.items(): + if mid.lower() == model_lower and isinstance(mdata, dict): + info = _parse_model_info(mid, mdata, mdev_id) + break + + if info is None: + return None - return None + # Override context_window with the live Copilot catalog value + if provider_id in ("copilot", "copilot-acp", "github-copilot"): + try: + from hermes_cli.models import get_copilot_model_context_window + live_ctx = get_copilot_model_context_window(model_id) + if live_ctx: + info.context_window = live_ctx + except Exception: + pass + + return info diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c209a8b47ec2..91cb09bc41d7 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -343,16 +343,22 @@ def has_usable_secret(value: Any, *, min_length: int = 4) -> bool: return True +# Module-level cache for the Copilot enterprise base URL derived from token exchange. +_copilot_derived_base_url: Optional[str] = None + + def _resolve_api_key_provider_secret( provider_id: str, pconfig: ProviderConfig ) -> tuple[str, str]: """Resolve an API-key provider's token and indicate where it came from.""" + global _copilot_derived_base_url if provider_id == "copilot": # Use the dedicated copilot auth module for proper token validation try: from hermes_cli.copilot_auth import resolve_copilot_token - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token() if token: + _copilot_derived_base_url = base_url return token, source except ValueError as exc: logger.warning("Copilot token validation failed: %s", exc) @@ -2372,7 +2378,11 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id == "kimi-coding": + if provider_id == "copilot" and _copilot_derived_base_url: + # Use the enterprise base URL derived from the token exchange + # (proxy-ep field in the exchanged token). + base_url = _copilot_derived_base_url + elif provider_id == "kimi-coding": base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 0db8637057d2..63ed4ab47114 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -21,6 +21,7 @@ import json import logging import os +import re import shutil import subprocess import time @@ -29,8 +30,22 @@ logger = logging.getLogger(__name__) -# OAuth device code flow constants (same client ID as opencode/Copilot CLI) -COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz" +# OAuth device code flow constants (VS Code Copilot OAuth App client ID — +# grants access to the full model catalog including internal-only models) +COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98" +COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code" +COPILOT_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + +# Copilot API constants +COPILOT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" +COPILOT_API_BASE_URL = "https://api.githubcopilot.com" +DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com" + +# Header constants — keep in sync with VS Code / Copilot CLI versions. +# Used by both token exchange and API request headers. +_EDITOR_VERSION = "vscode/1.104.1" +_EXCHANGE_USER_AGENT = "GitHubCopilotChat/0.26.7" + # Token type prefixes _CLASSIC_PAT_PREFIX = "ghp_" _SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") @@ -64,12 +79,21 @@ def validate_copilot_token(token: str) -> tuple[bool, str]: return True, "OK" -def resolve_copilot_token() -> tuple[str, str]: +def resolve_copilot_token(*, exchange: bool = True) -> tuple[str, str, Optional[str]]: """Resolve a GitHub token suitable for Copilot API use. - Returns (token, source) where source describes where the token came from. + When *exchange* is True (the default), the raw GitHub token is exchanged + for a short-lived Copilot API JWT via ``/copilot_internal/v2/token``. + This is required to access internal-access models (e.g. ``claude-opus-4.6-1m``). + If the exchange fails, the raw token is returned as a fallback. + + Returns (token, source, base_url) where source describes where the token came from, + and base_url is the derived Copilot API base URL (or None if not available). Raises ValueError if only a classic PAT is available. """ + raw_token = "" + source = "" + # 1. Check env vars in priority order for env_var in COPILOT_ENV_VARS: val = os.getenv(env_var, "").strip() @@ -80,19 +104,29 @@ def resolve_copilot_token() -> tuple[str, str]: "Token from %s is not supported: %s", env_var, msg ) continue - return val, env_var + raw_token, source = val, env_var + break # 2. Fall back to gh auth token - token = _try_gh_cli_token() - if token: - valid, msg = validate_copilot_token(token) - if not valid: - raise ValueError( - f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}" - ) - return token, "gh auth token" + if not raw_token: + token = _try_gh_cli_token() + if token: + valid, msg = validate_copilot_token(token) + if not valid: + raise ValueError( + f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}" + ) + raw_token, source = token, "gh auth token" - return "", "" + if not raw_token: + return "", "", None + + # 3. Exchange raw token for Copilot API JWT + if exchange: + jwt, base_url = resolve_copilot_api_token(raw_token) + return jwt, source, base_url + + return raw_token, source, None def _gh_cli_candidates() -> list[str]: @@ -259,6 +293,138 @@ def copilot_device_code_login( return None +# ─── Copilot Token Exchange ──────────────────────────────────────────────── + +# Module-level cache for exchanged Copilot JWT tokens. +# Maps raw_token_fingerprint -> (jwt, expires_at_epoch, base_url). +_jwt_cache: dict[str, tuple[str, float, Optional[str]]] = {} +_JWT_REFRESH_MARGIN_SECONDS = 120 # refresh 2 min before expiry + + +def _token_fp(raw_token: str) -> str: + """Short fingerprint of a raw token for cache keying (avoid storing full token).""" + import hashlib + return hashlib.sha256(raw_token.encode()).hexdigest()[:16] + + +def derive_copilot_base_url_from_token(token: str) -> Optional[str]: + """Derive the Copilot API base URL from a proxy-ep field in the token. + + The exchanged Copilot token is a semicolon-separated string like + ``tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;...``. + This function extracts the ``proxy-ep`` value and converts it to an + API base URL by replacing the leading ``proxy.`` with ``api.``. + + Returns ``https://{api_hostname}`` or ``None`` if proxy-ep is absent. + """ + m = re.search(r'(?:^|;)\s*proxy-ep=([^;\s]+)', token) + if not m: + return None + + proxy_ep = m.group(1) + + # Strip https:// prefix if present + if proxy_ep.startswith("https://"): + hostname = proxy_ep[len("https://"):] + elif proxy_ep.startswith("http://"): + hostname = proxy_ep[len("http://"):] + else: + hostname = proxy_ep + + # Strip trailing slashes + hostname = hostname.rstrip("/") + + # Replace leading "proxy." with "api." + if hostname.startswith("proxy."): + api_hostname = "api." + hostname[len("proxy."):] + else: + api_hostname = hostname + + return f"https://{api_hostname}" + + +def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float, Optional[str]]: + """Exchange a raw GitHub token for a short-lived Copilot API token. + + Calls ``GET https://api.github.com/copilot_internal/v2/token`` with + ``Authorization: Bearer `` and returns ``(token, expires_at, base_url)``. + + The returned token is a semicolon-separated string (not a JWT) that may + contain a ``proxy-ep`` field pointing to an enterprise endpoint. + + Results are cached in-process and reused until close to expiry. + + Raises ``ValueError`` on failure. + """ + fp = _token_fp(raw_token) + + # Check cache first + cached = _jwt_cache.get(fp) + if cached: + jwt, expires_at, base_url = cached + if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS: + return jwt, expires_at, base_url + + import urllib.request + + req = urllib.request.Request( + COPILOT_TOKEN_EXCHANGE_URL, + method="GET", + headers={ + "Authorization": f"Bearer {raw_token}", + "User-Agent": _EXCHANGE_USER_AGENT, + "Accept": "application/json", + "X-Github-Api-Version": "2025-04-01", + "Editor-Version": _EDITOR_VERSION, + }, + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + except Exception as exc: + logger.debug("Copilot token exchange failed: %s", exc) + raise ValueError(f"Copilot token exchange failed: {exc}") from exc + + jwt = data.get("token", "") + expires_at = data.get("expires_at", 0) + if not jwt: + raise ValueError("Copilot token exchange returned empty token") + + # Convert expires_at to float if needed + expires_at = float(expires_at) if expires_at else time.time() + 1800 + + # Derive enterprise base URL from proxy-ep in the token + base_url = derive_copilot_base_url_from_token(jwt) + + _jwt_cache[fp] = (jwt, expires_at, base_url) + logger.debug( + "Copilot token exchanged successfully, expires_at=%s, base_url=%s", + expires_at, + base_url, + ) + return jwt, expires_at, base_url + + +def resolve_copilot_api_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, Optional[str]]: + """Resolve a raw GitHub token to a Copilot API-ready token. + + Convenience wrapper around :func:`exchange_copilot_token` that returns + ``(token, base_url)``. Falls back to ``(raw_token, None)`` on exchange failure + (preserves existing behaviour for accounts that don't need exchange). + """ + if not raw_token: + return raw_token, None + try: + jwt, _, base_url = exchange_copilot_token(raw_token, timeout=timeout) + return jwt, base_url + except Exception as exc: + logger.debug( + "Copilot token exchange failed, falling back to raw token: %s", exc + ) + return raw_token, None + + # ─── Copilot API Headers ─────────────────────────────────────────────────── def copilot_request_headers( @@ -271,7 +437,7 @@ def copilot_request_headers( Replicates the header set used by opencode and the Copilot CLI. """ headers: dict[str, str] = { - "Editor-Version": "vscode/1.104.1", + "Editor-Version": _EDITOR_VERSION, "User-Agent": "HermesAgent/1.0", "Copilot-Integration-Id": "vscode-chat", "Openai-Intent": "conversation-edits", diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 273da0871972..9da69da65542 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -29,6 +29,7 @@ determine_api_mode, get_label, is_aggregator, + normalize_provider, resolve_provider_full, ) from hermes_cli.model_normalize import ( @@ -858,6 +859,30 @@ def list_authenticated_providers( # Use curated list — look up by Hermes slug, fall back to overlay key model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) + + # For Copilot, try fetching the live model catalog from the API + # so that account-specific models (e.g. claude-opus-4.6-1m) appear. + if pid == "github-copilot": + try: + from hermes_cli.auth import resolve_api_key_provider_credentials + creds = resolve_api_key_provider_credentials("copilot") + copilot_key = creds.get("api_key", "") + copilot_base = creds.get("base_url", "") or None + if copilot_key: + from hermes_cli.models import _fetch_github_models + live = _fetch_github_models(api_key=copilot_key, timeout=5.0, base_url=copilot_base) + if live: + # Merge: live catalog first, then any curated models + # not already in the live list + seen = set(live) + merged = list(live) + for m in model_ids: + if m not in seen: + merged.append(m) + seen.add(m) + model_ids = merged + except Exception as exc: + logger.debug("Copilot live catalog fetch failed: %s", exc) total = len(model_ids) top = model_ids[:max_models] diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 0d9929486446..3ea851d7c82e 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -9,10 +9,11 @@ import json import os +import time import urllib.request import urllib.error from difflib import get_close_matches -from typing import Any, Optional +from typing import Any, Dict, Optional COPILOT_BASE_URL = "https://api.githubcopilot.com" COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" @@ -1143,15 +1144,28 @@ def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | Non return {"service_tier": "priority"} -def _resolve_copilot_catalog_api_key() -> str: - """Best-effort GitHub token for fetching the Copilot model catalog.""" +def _resolve_copilot_catalog_credentials() -> tuple[str, Optional[str]]: + """Best-effort GitHub token and base URL for fetching the Copilot model catalog. + + Returns (api_key, base_url) where base_url may be an enterprise endpoint + derived from the token exchange. + """ try: from hermes_cli.auth import resolve_api_key_provider_credentials creds = resolve_api_key_provider_credentials("copilot") - return str(creds.get("api_key") or "").strip() + return ( + str(creds.get("api_key") or "").strip(), + str(creds.get("base_url") or "").strip() or None, + ) except Exception: - return "" + return "", None + + +def _resolve_copilot_catalog_api_key() -> str: + """Best-effort GitHub token for fetching the Copilot model catalog.""" + key, _ = _resolve_copilot_catalog_credentials() + return key def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]: @@ -1169,7 +1183,8 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return get_codex_model_ids() if normalized in {"copilot", "copilot-acp"}: try: - live = _fetch_github_models(_resolve_copilot_catalog_api_key()) + api_key, catalog_base_url = _resolve_copilot_catalog_credentials() + live = _fetch_github_models(api_key, base_url=catalog_base_url) if live: return live except Exception: @@ -1312,9 +1327,17 @@ def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool: def fetch_github_model_catalog( - api_key: Optional[str] = None, timeout: float = 5.0 + api_key: Optional[str] = None, timeout: float = 5.0, + base_url: Optional[str] = None, ) -> Optional[list[dict[str, Any]]]: - """Fetch the live GitHub Copilot model catalog for this account.""" + """Fetch the live GitHub Copilot model catalog for this account. + + When *base_url* is provided (e.g. an enterprise endpoint from token + exchange), the catalog is fetched from ``{base_url}/models`` instead + of the default ``api.githubcopilot.com/models``. + """ + models_url = f"{base_url.rstrip('/')}/models" if base_url else COPILOT_MODELS_URL + attempts: list[dict[str, str]] = [] if api_key: attempts.append({ @@ -1324,7 +1347,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 urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) @@ -1349,18 +1372,72 @@ def fetch_github_model_catalog( def _is_github_models_base_url(base_url: Optional[str]) -> bool: normalized = (base_url or "").strip().rstrip("/").lower() return ( - normalized.startswith(COPILOT_BASE_URL) + ".githubcopilot.com" in normalized or normalized.startswith("https://models.github.ai/inference") ) -def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]: - catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout) +def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0, base_url: Optional[str] = None) -> Optional[list[str]]: + catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout, base_url=base_url) if not catalog: return None return [item.get("id", "") for item in catalog if item.get("id")] +# --------------------------------------------------------------------------- +# Copilot catalog context-window cache +# --------------------------------------------------------------------------- + +_copilot_catalog_cache: Optional[Dict[str, Dict[str, Any]]] = None +_copilot_catalog_cache_time: float = 0.0 +_COPILOT_CATALOG_TTL = 3600 # 1 hour + + +def _get_copilot_catalog_cached() -> Dict[str, Dict[str, Any]]: + """Return {model_id: catalog_item} from the Copilot /models API (cached 1h).""" + global _copilot_catalog_cache, _copilot_catalog_cache_time + now = time.time() + if _copilot_catalog_cache is not None and (now - _copilot_catalog_cache_time) < _COPILOT_CATALOG_TTL: + return _copilot_catalog_cache + + api_key, catalog_base_url = _resolve_copilot_catalog_credentials() + catalog = fetch_github_model_catalog(api_key=api_key, base_url=catalog_base_url) if api_key else None + if catalog: + _copilot_catalog_cache = {item["id"]: item for item in catalog if item.get("id")} + _copilot_catalog_cache_time = now + return _copilot_catalog_cache + + _copilot_catalog_cache = {} + _copilot_catalog_cache_time = now + return _copilot_catalog_cache + + +def get_copilot_model_context_window(model_id: str) -> Optional[int]: + """Get the real context window for a Copilot model from the live API. + + Returns ``max_prompt_tokens`` from the Copilot /models catalog, which + represents the actual usable context (prompt) window. Falls back to + ``max_context_window_tokens`` if ``max_prompt_tokens`` is absent. + Returns None if the model is not found or the catalog is unavailable. + """ + catalog = _get_copilot_catalog_cached() + item = catalog.get(model_id) + if not item: + return None + + limits = (item.get("capabilities") or {}).get("limits") or {} + # Prefer max_prompt_tokens (actual usable context) over + # max_context_window_tokens (which includes output budget). + ctx = limits.get("max_prompt_tokens") or limits.get("max_context_window_tokens") + if isinstance(ctx, (int, float)) and ctx > 0: + return int(ctx) + # Fallback: top-level context_window key (some catalog formats) + ctx = item.get("context_window") + if isinstance(ctx, (int, float)) and ctx > 0: + return int(ctx) + return None + + _COPILOT_MODEL_ALIASES = { "openai/gpt-5": "gpt-5-mini", "openai/gpt-5-chat": "gpt-5-mini", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 3d1333c26ff1..861c169d4b7d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -166,7 +166,20 @@ def _resolve_runtime_from_pool_entry( elif provider == "nous": api_mode = "chat_completions" elif provider == "copilot": - api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) + # Exchange the raw GitHub token for a Copilot JWT and derive the + # correct API base URL (individual vs enterprise) from the exchange + # response. The pool stores the raw token; the exchange is cached + # in-process so repeated calls are cheap. + try: + from hermes_cli.copilot_auth import resolve_copilot_api_token + exchanged_token, derived_base_url = resolve_copilot_api_token(api_key) + if exchanged_token: + api_key = exchanged_token + if derived_base_url: + base_url = derived_base_url + except Exception: + pass # fall back to raw token + default base_url + api_mode = _copilot_runtime_api_mode(model_cfg, api_key) else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Honour model.base_url from config.yaml when the configured provider @@ -544,7 +557,7 @@ def _resolve_explicit_runtime( base_url = explicit_base_url if not base_url: - if provider == "kimi-coding": + if provider in ("kimi-coding", "copilot"): creds = resolve_api_key_provider_credentials(provider) base_url = creds.get("base_url", "").rstrip("/") else: @@ -781,7 +794,13 @@ def resolve_runtime_provider( cfg_base_url = "" if cfg_provider == provider: cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") - base_url = cfg_base_url or creds.get("base_url", "").rstrip("/") + # For copilot, always prefer the base_url from credentials (derived + # from token exchange) over config.yaml — the exchange returns the + # correct endpoint (individual vs enterprise) for this account. + if provider == "copilot": + base_url = creds.get("base_url", "").rstrip("/") or cfg_base_url + else: + base_url = cfg_base_url or creds.get("base_url", "").rstrip("/") api_mode = "chat_completions" if provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", "")) diff --git a/run_agent.py b/run_agent.py index b2b47676a59f..6c8582bf8b47 100644 --- a/run_agent.py +++ b/run_agent.py @@ -881,7 +881,7 @@ def __init__( "X-OpenRouter-Title": "Hermes Agent", "X-OpenRouter-Categories": "productivity,cli-agent", } - elif "api.githubcopilot.com" in effective_base.lower(): + elif ".githubcopilot.com" in effective_base.lower(): from hermes_cli.models import copilot_default_headers client_kwargs["default_headers"] = copilot_default_headers() @@ -4273,7 +4273,7 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None: normalized = (base_url or "").lower() if "openrouter" in normalized: self._client_kwargs["default_headers"] = dict(_OR_HEADERS) - elif "api.githubcopilot.com" in normalized: + elif ".githubcopilot.com" in normalized: from hermes_cli.models import copilot_default_headers self._client_kwargs["default_headers"] = copilot_default_headers() @@ -4288,6 +4288,20 @@ def _swap_credential(self, entry) -> None: runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url + # Copilot credential pool stores raw GitHub tokens (ghu_/gho_/github_pat_). + # These must be exchanged for a short-lived Copilot JWT before use, which + # also derives the correct API base URL (individual vs enterprise). + if self.provider == "copilot" and runtime_key: + try: + from hermes_cli.copilot_auth import resolve_copilot_api_token + exchanged_token, derived_base_url = resolve_copilot_api_token(runtime_key) + if exchanged_token: + runtime_key = exchanged_token + if derived_base_url: + runtime_base = derived_base_url + except Exception as exc: + logger.debug("Copilot token exchange failed during credential swap: %s", exc) + if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client, _is_oauth_token @@ -5618,7 +5632,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: is_github_responses = ( "models.github.ai" in self.base_url.lower() - or "api.githubcopilot.com" in self.base_url.lower() + or ".githubcopilot.com" in self.base_url.lower() ) is_codex_backend = ( self.provider == "openai-codex" @@ -5779,7 +5793,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: _is_openrouter = self._is_openrouter_url() _is_github_models = ( "models.github.ai" in self._base_url_lower - or "api.githubcopilot.com" in self._base_url_lower + or ".githubcopilot.com" in self._base_url_lower ) # Provider preferences (only, ignore, order, sort) are OpenRouter- @@ -5853,7 +5867,7 @@ def _supports_reasoning_extra_body(self) -> bool: return True if "ai-gateway.vercel.sh" in self._base_url_lower: return True - if "models.github.ai" in self._base_url_lower or "api.githubcopilot.com" in self._base_url_lower: + if "models.github.ai" in self._base_url_lower or ".githubcopilot.com" in self._base_url_lower: try: from hermes_cli.models import github_model_reasoning_efforts diff --git a/tests/hermes_cli/test_copilot_auth.py b/tests/hermes_cli/test_copilot_auth.py index 5c8fccf936ae..0a6e572937e1 100644 --- a/tests/hermes_cli/test_copilot_auth.py +++ b/tests/hermes_cli/test_copilot_auth.py @@ -38,14 +38,18 @@ def test_empty_token_rejected(self): class TestResolveToken: - """Token resolution with env var priority.""" + """Token resolution with env var priority. + + Tests use ``exchange=False`` to verify raw-token resolution order, + since the exchange itself is tested separately in TestTokenExchange. + """ def test_copilot_github_token_first_priority(self, monkeypatch): from hermes_cli.copilot_auth import resolve_copilot_token monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first") monkeypatch.setenv("GH_TOKEN", "gho_gh_second") monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third") - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token(exchange=False) assert token == "gho_copilot_first" assert source == "COPILOT_GITHUB_TOKEN" @@ -54,7 +58,7 @@ def test_gh_token_second_priority(self, monkeypatch): monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) monkeypatch.setenv("GH_TOKEN", "gho_gh_second") monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third") - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token(exchange=False) assert token == "gho_gh_second" assert source == "GH_TOKEN" @@ -63,7 +67,7 @@ def test_github_token_third_priority(self, monkeypatch): monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third") - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token(exchange=False) assert token == "gho_github_third" assert source == "GITHUB_TOKEN" @@ -73,7 +77,7 @@ def test_classic_pat_in_env_skipped(self, monkeypatch): monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope") monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth") - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token(exchange=False) # Should skip the ghp_ token and find the gho_ one assert token == "gho_valid_oauth" assert source == "GITHUB_TOKEN" @@ -84,7 +88,7 @@ def test_gh_cli_fallback(self, monkeypatch): monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.delenv("GITHUB_TOKEN", raising=False) with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"): - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token(exchange=False) assert token == "gho_from_cli" assert source == "gh auth token" @@ -95,7 +99,7 @@ def test_gh_cli_classic_pat_raises(self, monkeypatch): monkeypatch.delenv("GITHUB_TOKEN", raising=False) with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"): with pytest.raises(ValueError, match="classic PAT"): - resolve_copilot_token() + resolve_copilot_token(exchange=False) def test_no_token_returns_empty(self, monkeypatch): from hermes_cli.copilot_auth import resolve_copilot_token @@ -103,10 +107,261 @@ def test_no_token_returns_empty(self, monkeypatch): monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.delenv("GITHUB_TOKEN", raising=False) with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None): - token, source = resolve_copilot_token() + token, source, base_url = resolve_copilot_token(exchange=False) assert token == "" assert source == "" + def test_exchange_enabled_by_default(self, monkeypatch): + """Default resolve_copilot_token() performs token exchange.""" + from hermes_cli.copilot_auth import resolve_copilot_token + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_raw_token") + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch( + "hermes_cli.copilot_auth.resolve_copilot_api_token", + return_value=("jwt_exchanged_token", None), + ) as mock_exchange: + token, source, base_url = resolve_copilot_token() + mock_exchange.assert_called_once_with("gho_raw_token") + assert token == "jwt_exchanged_token" + assert source == "COPILOT_GITHUB_TOKEN" + + +class TestTokenExchange: + """Copilot token exchange (raw GitHub token -> Copilot API JWT).""" + + def test_exchange_calls_correct_endpoint(self): + import json as _json + from hermes_cli.copilot_auth import ( + exchange_copilot_token, + COPILOT_TOKEN_EXCHANGE_URL, + _jwt_cache, + ) + _jwt_cache.clear() + + response_body = _json.dumps({ + "token": "eyJhbGciOiJSUzI1N_test_jwt", + "expires_at": 9999999999, + }).encode() + + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: + jwt, expires_at, base_url = exchange_copilot_token("gho_test_raw") + + assert jwt == "eyJhbGciOiJSUzI1N_test_jwt" + assert expires_at == 9999999999.0 + + # Verify the request was correct + call_args = mock_urlopen.call_args + req = call_args[0][0] + assert req.full_url == COPILOT_TOKEN_EXCHANGE_URL + assert req.get_header("Authorization") == "Bearer gho_test_raw" + assert req.get_method() == "GET" + + def test_exchange_caches_jwt(self): + import json as _json + from hermes_cli.copilot_auth import exchange_copilot_token, _jwt_cache + _jwt_cache.clear() + + response_body = _json.dumps({ + "token": "jwt_cached", + "expires_at": 9999999999, + }).encode() + + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: + jwt1, _, _ = exchange_copilot_token("gho_cache_test") + jwt2, _, _ = exchange_copilot_token("gho_cache_test") + + # Should only call the API once — second call uses cache + assert mock_urlopen.call_count == 1 + assert jwt1 == jwt2 == "jwt_cached" + + def test_exchange_refreshes_expired_cache(self): + import json as _json + import time + from hermes_cli.copilot_auth import ( + exchange_copilot_token, _jwt_cache, _token_fp, + _JWT_REFRESH_MARGIN_SECONDS, + ) + _jwt_cache.clear() + + # Pre-populate cache with an expired token + fp = _token_fp("gho_expired_test") + _jwt_cache[fp] = ("old_jwt", time.time() - 10, None) + + response_body = _json.dumps({ + "token": "fresh_jwt", + "expires_at": 9999999999, + }).encode() + + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp): + jwt, _, _ = exchange_copilot_token("gho_expired_test") + + assert jwt == "fresh_jwt" + + def test_exchange_raises_on_failure(self): + from hermes_cli.copilot_auth import exchange_copilot_token, _jwt_cache + _jwt_cache.clear() + + with patch("urllib.request.urlopen", side_effect=Exception("network error")): + with pytest.raises(ValueError, match="Copilot token exchange failed"): + exchange_copilot_token("gho_fail_test") + + def test_exchange_raises_on_empty_token(self): + import json as _json + from hermes_cli.copilot_auth import exchange_copilot_token, _jwt_cache + _jwt_cache.clear() + + response_body = _json.dumps({"token": "", "expires_at": 0}).encode() + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ValueError, match="empty token"): + exchange_copilot_token("gho_empty_test") + + def test_resolve_copilot_api_token_fallback(self): + """resolve_copilot_api_token falls back to raw token on exchange failure.""" + from hermes_cli.copilot_auth import resolve_copilot_api_token, _jwt_cache + _jwt_cache.clear() + + with patch("urllib.request.urlopen", side_effect=Exception("offline")): + token, base_url = resolve_copilot_api_token("gho_fallback_raw") + + # Should return the raw token as fallback + assert token == "gho_fallback_raw" + assert base_url is None + + def test_resolve_copilot_api_token_success(self): + """resolve_copilot_api_token returns JWT on success.""" + import json as _json + from hermes_cli.copilot_auth import resolve_copilot_api_token, _jwt_cache + _jwt_cache.clear() + + response_body = _json.dumps({ + "token": "jwt_success", + "expires_at": 9999999999, + }).encode() + + mock_resp = MagicMock() + mock_resp.read.return_value = response_body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp): + token, base_url = resolve_copilot_api_token("gho_success_raw") + + assert token == "jwt_success" + + def test_resolve_copilot_api_token_empty_input(self): + """resolve_copilot_api_token returns empty string for empty input.""" + from hermes_cli.copilot_auth import resolve_copilot_api_token + token, base_url = resolve_copilot_api_token("") + assert token == "" + assert base_url is None + + +class TestDeriveBaseUrl: + """Copilot base URL derivation from token proxy-ep field.""" + + def test_extracts_proxy_ep(self): + from hermes_cli.copilot_auth import derive_copilot_base_url_from_token + token = "tid=abc;exp=123;proxy-ep=proxy.enterprise.githubcopilot.com;sku=free" + assert derive_copilot_base_url_from_token(token) == "https://api.enterprise.githubcopilot.com" + + def test_no_proxy_ep_returns_none(self): + from hermes_cli.copilot_auth import derive_copilot_base_url_from_token + token = "tid=abc;exp=123;sku=free" + assert derive_copilot_base_url_from_token(token) is None + + def test_proxy_ep_with_https_prefix(self): + from hermes_cli.copilot_auth import derive_copilot_base_url_from_token + token = "tid=abc;proxy-ep=https://proxy.individual.githubcopilot.com/" + assert derive_copilot_base_url_from_token(token) == "https://api.individual.githubcopilot.com" + + def test_proxy_ep_without_proxy_prefix(self): + from hermes_cli.copilot_auth import derive_copilot_base_url_from_token + token = "tid=abc;proxy-ep=custom.githubcopilot.com" + assert derive_copilot_base_url_from_token(token) == "https://custom.githubcopilot.com" + + def test_empty_token(self): + from hermes_cli.copilot_auth import derive_copilot_base_url_from_token + assert derive_copilot_base_url_from_token("") is None + + +class TestCopilotContextWindow: + """Copilot model catalog context window lookup.""" + + def test_returns_context_window_from_catalog(self): + from hermes_cli.models import get_copilot_model_context_window, _copilot_catalog_cache + import hermes_cli.models as models_mod + # Inject a mock catalog + models_mod._copilot_catalog_cache = { + "claude-opus-4.6-1m": { + "id": "claude-opus-4.6-1m", + "capabilities": { + "limits": { + "max_prompt_tokens": 1000000, + "max_context_window_tokens": 1048576, + } + } + } + } + models_mod._copilot_catalog_cache_time = __import__("time").time() + try: + result = get_copilot_model_context_window("claude-opus-4.6-1m") + assert result == 1000000 # prefers max_prompt_tokens + finally: + models_mod._copilot_catalog_cache = None + models_mod._copilot_catalog_cache_time = 0.0 + + def test_returns_none_for_unknown_model(self): + from hermes_cli.models import get_copilot_model_context_window + import hermes_cli.models as models_mod + models_mod._copilot_catalog_cache = {"gpt-4o": {"id": "gpt-4o", "capabilities": {}}} + models_mod._copilot_catalog_cache_time = __import__("time").time() + try: + assert get_copilot_model_context_window("nonexistent-model") is None + finally: + models_mod._copilot_catalog_cache = None + models_mod._copilot_catalog_cache_time = 0.0 + + def test_falls_back_to_context_window_tokens(self): + from hermes_cli.models import get_copilot_model_context_window + import hermes_cli.models as models_mod + models_mod._copilot_catalog_cache = { + "gpt-4o": { + "id": "gpt-4o", + "capabilities": { + "limits": { + "max_context_window_tokens": 128000, + } + } + } + } + models_mod._copilot_catalog_cache_time = __import__("time").time() + try: + assert get_copilot_model_context_window("gpt-4o") == 128000 + finally: + models_mod._copilot_catalog_cache = None + models_mod._copilot_catalog_cache_time = 0.0 + class TestRequestHeaders: """Copilot API header generation."""