mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-18 10:45:31 -04:00
1cd0aa2b8c
* 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
254 lines
8.8 KiB
Python
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
|
|
|