# src/copilot.py """GitHub Copilot provider support. Copilot exposes an OpenAI-compatible API at ``https://api.githubcopilot.com`` (``/chat/completions`` + ``/models``). Authentication is a GitHub OAuth **device flow**: the user authorises a device code in their browser and we receive a long-lived ``access_token`` that is sent directly as ``Authorization: Bearer `` — there is no separate Copilot-token exchange and no refresh (mirrors how editors / opencode talk to Copilot). The only provider-specific wrinkle beyond the bearer token is a handful of required request headers (API version, intent, an editor-style User-Agent, and ``x-initiator`` for agent-vs-user request accounting). Those live in :func:`copilot_headers`. This module holds the constants + pure helpers; the HTTP device-flow calls live in :mod:`routes.copilot_routes` so they can be auth-gated. """ import os from typing import Dict, List, Optional from urllib.parse import urlparse import httpx # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # GitHub OAuth client id used for the device flow. Copilot's token endpoint # only accepts client ids that GitHub has allow-listed for Copilot access, so # we reuse the public VS Code client id (the de-facto standard third-party # clients use). Override via env if you register your own allow-listed app. COPILOT_CLIENT_ID = os.environ.get( "ODYSSEUS_COPILOT_CLIENT_ID", "01ab8ac9400c4e429b23" ) # Dated API version header required by the Copilot API (models + chat). COPILOT_API_VERSION = os.environ.get( "ODYSSEUS_COPILOT_API_VERSION", "2026-06-01" ) # Public Copilot API base. GitHub Enterprise uses ``copilot-api.``. COPILOT_BASE = "https://api.githubcopilot.com" # Copilot wants an editor-like User-Agent + integration id. These identify the # client to GitHub; keep them stable. COPILOT_USER_AGENT = os.environ.get( "ODYSSEUS_COPILOT_USER_AGENT", "Odysseus/1.0" ) COPILOT_INTEGRATION_ID = os.environ.get( "ODYSSEUS_COPILOT_INTEGRATION_ID", "vscode-chat" ) COPILOT_EDITOR_VERSION = os.environ.get( "ODYSSEUS_COPILOT_EDITOR_VERSION", "Odysseus/1.0" ) # OAuth scope requested during the device flow. COPILOT_SCOPE = "read:user" # Default GitHub host for the device flow (public github.com). GITHUB_HOST = "github.com" def device_code_url(host: str = GITHUB_HOST) -> str: return f"https://{host}/login/device/code" def access_token_url(host: str = GITHUB_HOST) -> str: return f"https://{host}/login/oauth/access_token" def normalize_domain(url: str) -> str: """Strip scheme/trailing slash from a GitHub Enterprise URL or domain.""" return (url or "").replace("https://", "").replace("http://", "").rstrip("/") def enterprise_base(enterprise_url: Optional[str]) -> str: """Return the Copilot API base for a deployment. Public github.com → ``https://api.githubcopilot.com``. Enterprise → ``https://copilot-api.``. """ if not enterprise_url: return COPILOT_BASE return f"https://copilot-api.{normalize_domain(enterprise_url)}" def is_copilot_base(url: Optional[str]) -> bool: """True if a base URL points at the Copilot API (public or enterprise).""" if not url: return False try: host = (urlparse(url).hostname or "").lower().rstrip(".") except Exception: return False if not host: return False # Public: api.githubcopilot.com (or any *.githubcopilot.com). if host == "githubcopilot.com" or host.endswith(".githubcopilot.com"): return True # Enterprise: copilot-api.. if host.startswith("copilot-api."): return True return False def copilot_headers( api_key: Optional[str], *, agent: bool = False, vision: bool = False, ) -> Dict[str, str]: """Build the Copilot-specific request headers. Args: api_key: the GitHub device-flow access token (sent as Bearer). agent: request originates from the agent loop (a tool-driven turn) rather than a direct user message. Sets ``x-initiator`` for Copilot's agent-vs-user request accounting. vision: the request carries an image part. """ headers: Dict[str, str] = { "X-GitHub-Api-Version": COPILOT_API_VERSION, "Openai-Intent": "conversation-edits", "User-Agent": COPILOT_USER_AGENT, "Editor-Version": COPILOT_EDITOR_VERSION, "Copilot-Integration-Id": COPILOT_INTEGRATION_ID, "x-initiator": "agent" if agent else "user", } if api_key: headers["Authorization"] = f"Bearer {api_key}" if vision: headers["Copilot-Vision-Request"] = "true" return headers # --------------------------------------------------------------------------- # Device-flow OAuth (pure HTTP; orchestration lives in routes.copilot_routes) # --------------------------------------------------------------------------- def _oauth_post_headers() -> Dict[str, str]: return { "Accept": "application/json", "Content-Type": "application/json", "User-Agent": COPILOT_USER_AGENT, } def request_device_code(host: str = GITHUB_HOST, *, timeout: float = 10.0) -> Dict: """Start the device flow. Returns GitHub's ``{device_code, user_code, verification_uri, expires_in, interval}``. """ r = httpx.post( device_code_url(host), headers=_oauth_post_headers(), json={"client_id": COPILOT_CLIENT_ID, "scope": COPILOT_SCOPE}, timeout=timeout, ) r.raise_for_status() return r.json() def poll_access_token(host: str, device_code: str, *, timeout: float = 10.0) -> Dict: """Poll once for the access token. GitHub returns HTTP 200 with an ``error`` field (``authorization_pending``/``slow_down``) while the user hasn't authorised yet, or ``{access_token, ...}`` once they have. """ r = httpx.post( access_token_url(host), headers=_oauth_post_headers(), json={ "client_id": COPILOT_CLIENT_ID, "device_code": device_code, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", }, timeout=timeout, ) r.raise_for_status() return r.json() def fetch_models(base: str, token: str, *, timeout: float = 15.0) -> List[Dict]: """Fetch Copilot's model catalogue, filtered to picker-enabled models. Returns a list of ``{id, tool_calls, vision}`` dicts. Falls back to the full list if no model advertises ``model_picker_enabled`` (defensive against API-shape drift). """ url = base.rstrip("/") + "/models" r = httpx.get(url, headers=copilot_headers(token), timeout=timeout) r.raise_for_status() data = (r.json() or {}).get("data") or [] def _parse(item: Dict) -> Optional[Dict]: mid = item.get("id") if not mid: return None supports = ((item.get("capabilities") or {}).get("supports")) or {} return { "id": mid, "tool_calls": bool(supports.get("tool_calls")), "vision": bool(supports.get("vision")), "picker": bool(item.get("model_picker_enabled")), } parsed = [p for p in (_parse(it) for it in data) if p] picker = [p for p in parsed if p["picker"]] chosen = picker or parsed for p in chosen: p.pop("picker", None) return chosen # --------------------------------------------------------------------------- # Per-request header flags # --------------------------------------------------------------------------- _IMAGE_PART_TYPES = ("image_url", "input_image", "image") def request_flags(messages) -> tuple: """Derive ``(agent, vision)`` from an OpenAI-style message list. Mirrors opencode's logic: * ``agent`` — the last message is *not* a plain user message (i.e. it's a tool result / assistant follow-up), so Copilot should treat the request as agent-initiated for request accounting. * ``vision`` — any message carries an image content part. """ msgs = messages or [] last = msgs[-1] if msgs else None agent = bool(last) and last.get("role") != "user" vision = False for m in msgs: content = m.get("content") if isinstance(m, dict) else None if isinstance(content, list) and any( isinstance(p, dict) and p.get("type") in _IMAGE_PART_TYPES for p in content ): vision = True break return agent, vision def apply_request_headers(headers: Dict[str, str], messages) -> Dict[str, str]: """Set ``x-initiator`` / ``Copilot-Vision-Request`` on a header dict based on the outgoing messages. Mutates and returns ``headers``.""" agent, vision = request_flags(messages) headers["x-initiator"] = "agent" if agent else "user" if vision: headers["Copilot-Vision-Request"] = "true" return headers