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
This commit is contained in:
committed by
GitHub
parent
ca32b43b38
commit
1cd0aa2b8c
@@ -468,6 +468,7 @@ _API_HOSTS = frozenset([
|
||||
"api.together.xyz", "api.fireworks.ai",
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"ollama.com", "api.venice.ai",
|
||||
"api.githubcopilot.com",
|
||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||
# model name, so well-behaved local servers don't get native tool
|
||||
|
||||
253
src/copilot.py
Normal file
253
src/copilot.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# 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
|
||||
|
||||
@@ -194,6 +194,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||
headers["x-api-key"] = api_key
|
||||
headers["anthropic-version"] = "2023-06-01"
|
||||
return headers
|
||||
if provider == "copilot":
|
||||
from src.copilot import copilot_headers
|
||||
return copilot_headers(api_key)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if provider == "openrouter":
|
||||
|
||||
@@ -317,6 +317,9 @@ def _detect_provider(url: str) -> str:
|
||||
return "openrouter"
|
||||
if _host_match(url, "groq.com"):
|
||||
return "groq"
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url):
|
||||
return "copilot"
|
||||
return "openai"
|
||||
|
||||
|
||||
@@ -327,6 +330,14 @@ def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str
|
||||
if provider == "openrouter":
|
||||
h.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||
h.setdefault("X-OpenRouter-Title", "Odysseus")
|
||||
if provider == "copilot":
|
||||
# Ensure the Copilot-required headers are present even when the caller
|
||||
# didn't pass pre-built headers (e.g. model listing). build_headers()
|
||||
# already injects these for the live chat path; setdefault keeps any
|
||||
# request-specific values (x-initiator/vision) the caller set.
|
||||
from src.copilot import copilot_headers
|
||||
for k, v in copilot_headers(None).items():
|
||||
h.setdefault(k, v)
|
||||
return h
|
||||
|
||||
|
||||
@@ -340,6 +351,8 @@ def _provider_label(url: str) -> str:
|
||||
if _host_match(url, "openai.com"): return "OpenAI"
|
||||
if _host_match(url, "openrouter.ai"): return "OpenRouter"
|
||||
if _host_match(url, "groq.com"): return "Groq"
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url): return "GitHub Copilot"
|
||||
if _host_match(url, "mistral.ai"): return "Mistral"
|
||||
if _host_match(url, "deepseek.com"): return "DeepSeek"
|
||||
if _host_match(url, "googleapis.com"): return "Google"
|
||||
@@ -911,6 +924,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
)
|
||||
else:
|
||||
target_url = url
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages_copy,
|
||||
@@ -1058,6 +1074,9 @@ async def llm_call_async(
|
||||
else:
|
||||
target_url = url
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages_copy,
|
||||
@@ -1182,6 +1201,9 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
|
||||
# Short connect timeout: a reachable peer answers SYN in <100ms even on
|
||||
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
|
||||
|
||||
@@ -282,6 +282,16 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# GitHub Copilot's /models requires auth + X-GitHub-Api-Version headers that
|
||||
# aren't available here; an unauthenticated probe just 400s. All Copilot
|
||||
# picker models are major API models covered by the known-context table, so
|
||||
# rely on that instead of a doomed network call.
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(endpoint_url):
|
||||
if known:
|
||||
logger.info(f"Using known context window for {model}: {known}")
|
||||
return known or DEFAULT_CONTEXT
|
||||
|
||||
models_url = endpoint_url.replace("/chat/completions", "/models")
|
||||
try:
|
||||
r = httpx.get(models_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
Reference in New Issue
Block a user