Skip to content
Closed
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
2 changes: 1 addition & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 12 additions & 1 deletion agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 26 additions & 7 deletions agent/models_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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


14 changes: 12 additions & 2 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
196 changes: 181 additions & 15 deletions hermes_cli/copilot_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import json
import logging
import os
import re
import shutil
import subprocess
import time
Expand All @@ -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_")
Expand Down Expand Up @@ -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()
Expand All @@ -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]:
Expand Down Expand Up @@ -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 <raw_token>`` 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(
Expand All @@ -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",
Expand Down
Loading