Files
odysseus/src/copilot.py
T
Kenny Van de Maele 1cd0aa2b8c feat(provider): add GitHub Copilot provider with device-flow auth (#1480)
* feat(provider): add GitHub Copilot provider with device-flow auth

Adds GitHub Copilot as a model provider, so Copilot models (gpt-4o/4.1/5,
Claude, Gemini, …) work through the normal chat + agent loop, incl. native
tool calling and vision.

Auth is one-click via the GitHub OAuth device flow; the access token is stored
as the endpoint's (encrypted) api_key and sent directly as `Authorization:
Bearer` (no Copilot-token exchange, no refresh — matching how editors talk to
the Copilot API). Copilot is a normal ModelEndpoint detected by host; the only
provider-specific behaviour is a small set of required request headers,
injected centrally.

Sign-in is available from Settings → model endpoints ("Connect GitHub
Copilot") and from chat via `/setup copilot`.

- src/copilot.py (new), routes/copilot_routes.py (new): constants, header
  builders, device-flow start/poll, model discovery, owner-scoped endpoint
  provisioning.
- src/llm_core.py, src/endpoint_resolver.py: detect `copilot`, inject headers,
  per-request x-initiator/vision.
- src/agent_loop.py: allowlist api.githubcopilot.com for native tool schemas.
- src/model_context.py: known context windows for Copilot (no unauthenticated
  /models probe).
- static/, README, tests/test_copilot*.py.

* Tidy copilot_routes: clarify supports_tools, note _PENDING is per-process
2026-06-04 21:13:14 +02:00

254 lines
8.8 KiB
Python

# 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 <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
# ---------------------------------------------------------------------------
# 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.<domain>``.
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 <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
# Public: api.githubcopilot.com (or any *.githubcopilot.com).
if host == "githubcopilot.com" or host.endswith(".githubcopilot.com"):
return True
# Enterprise: copilot-api.<domain>.
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