| |
| """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 <token>`` — 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 |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| COPILOT_CLIENT_ID = os.environ.get( |
| "ODYSSEUS_COPILOT_CLIENT_ID", "01ab8ac9400c4e429b23" |
| ) |
|
|
| |
| COPILOT_API_VERSION = os.environ.get( |
| "ODYSSEUS_COPILOT_API_VERSION", "2026-06-01" |
| ) |
|
|
| |
| COPILOT_BASE = "https://api.githubcopilot.com" |
|
|
| |
| |
| 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" |
| ) |
|
|
| |
| COPILOT_SCOPE = "read:user" |
|
|
| |
| 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 <domain> → ``https://copilot-api.<domain>``. |
| """ |
| 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 |
| |
| if host == "githubcopilot.com" or host.endswith(".githubcopilot.com"): |
| return True |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| _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 |
|
|
|
|