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:
Kenny Van de Maele
2026-06-04 21:13:14 +02:00
committed by GitHub
parent ca32b43b38
commit 1cd0aa2b8c
14 changed files with 946 additions and 2 deletions

View File

@@ -11,7 +11,7 @@
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
## Features
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI</sub>
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
- **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!<br> <sub>built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving</sub>
- **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.<br> <sub>adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)</sub>

4
app.py
View File

@@ -587,6 +587,10 @@ app.include_router(setup_embedding_routes())
from routes.model_routes import setup_model_routes
app.include_router(setup_model_routes(model_discovery))
# GitHub Copilot device-flow login
from routes.copilot_routes import setup_copilot_routes
app.include_router(setup_copilot_routes())
# TTS
from routes.tts_routes import setup_tts_routes
app.include_router(setup_tts_routes(tts_service))

223
routes/copilot_routes.py Normal file
View File

@@ -0,0 +1,223 @@
# routes/copilot_routes.py
"""GitHub Copilot device-flow login.
Drives the GitHub OAuth *device flow* and, on success, creates (or refreshes)
an owner-scoped ``ModelEndpoint`` pointing at the Copilot API with the
device-flow access token stored as its (encrypted) ``api_key``. After that the
endpoint behaves like any other OpenAI-compatible provider — the Copilot-
specific request headers are injected centrally by ``build_headers`` /
``_provider_headers`` (see :mod:`src.copilot`).
Flow:
1. ``POST /api/copilot/device/start`` → returns a ``poll_id`` plus the
``user_code`` + ``verification_uri`` to show the user. The secret
``device_code`` is kept server-side, never sent to the browser.
2. The browser polls ``POST /api/copilot/device/poll`` with ``poll_id``.
While pending it returns ``{status: "pending"}``; once the user authorises
it provisions the endpoint and returns ``{status: "authorized", ...}``.
All routes are admin-gated (endpoint/provider management is an admin action).
"""
import json
import time
import uuid
import logging
import threading
from typing import Dict, Optional
import httpx
from fastapi import APIRouter, Request, Form, HTTPException
from core.database import SessionLocal, ModelEndpoint
from core.middleware import require_admin
from src.auth_helpers import get_current_user
from src import copilot
logger = logging.getLogger(__name__)
# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a
# bearer-like secret, so it lives here (server memory) rather than in the
# browser. Entries expire with the GitHub device code.
#
# NOTE: this is per-process state. The device flow assumes a single worker
# (Odysseus' default): with multiple uvicorn workers, the poll request can land
# on a worker that never saw the start, returning "Unknown or expired login
# session". Move this to a shared store (DB/Redis) if running multi-worker.
_PENDING: Dict[str, Dict] = {}
_PENDING_LOCK = threading.Lock()
def _prune_expired() -> None:
now = time.time()
with _PENDING_LOCK:
for k in [k for k, v in _PENDING.items() if v.get("expires_at", 0) < now]:
_PENDING.pop(k, None)
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
"""Create or update the owner's Copilot endpoint with a fresh token."""
try:
models = copilot.fetch_models(base, token)
except Exception as e:
logger.warning(f"Copilot model fetch failed during provisioning: {e}")
models = []
model_ids = [m["id"] for m in models]
# Copilot picker models support OpenAI-style tool calling; mark the endpoint
# tool-capable so the agent loop sends native tool schemas.
# Tool-capable if any picker model advertises tool_calls. When the model
# fetch failed (empty list) default to True, since Copilot picker models
# support OpenAI-style tool calling.
supports_tools = bool(not models or any(m.get("tool_calls") for m in models))
db = SessionLocal()
try:
ep = (
db.query(ModelEndpoint)
.filter(ModelEndpoint.base_url == base)
.filter((ModelEndpoint.owner.is_(None)) | (ModelEndpoint.owner == owner))
.order_by(ModelEndpoint.owner.desc())
.first()
)
if ep is None:
ep = ModelEndpoint(
id=str(uuid.uuid4())[:8],
name="GitHub Copilot",
base_url=base,
model_type="llm",
owner=owner,
)
db.add(ep)
ep.api_key = token
ep.is_enabled = True
ep.supports_tools = supports_tools
if model_ids:
ep.cached_models = json.dumps(model_ids)
db.commit()
result = {
"id": ep.id,
"name": ep.name,
"base_url": ep.base_url,
"models": model_ids,
}
finally:
db.close()
# Best-effort: refresh the model cache so the new endpoint shows up.
try:
from routes.model_routes import _invalidate_models_cache
_invalidate_models_cache()
except Exception:
pass
return result
def setup_copilot_routes() -> APIRouter:
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
@router.post("/device/start")
def device_start(request: Request, enterprise_url: str = Form("")):
require_admin(request)
_prune_expired()
host = copilot.GITHUB_HOST
ent = (enterprise_url or "").strip()
if ent:
host = copilot.normalize_domain(ent)
try:
data = copilot.request_device_code(host)
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else "unknown"
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
except Exception as e:
raise HTTPException(502, f"GitHub device-code request failed: {e}")
device_code = data.get("device_code")
if not device_code:
raise HTTPException(502, "GitHub did not return a device code")
interval = int(data.get("interval") or 5)
expires_in = int(data.get("expires_in") or 900)
poll_id = uuid.uuid4().hex
with _PENDING_LOCK:
_PENDING[poll_id] = {
"device_code": device_code,
"host": host,
"enterprise_url": ent,
"interval": interval,
"owner": get_current_user(request) or None,
"expires_at": time.time() + expires_in,
"next_poll_at": 0.0,
}
# verification_uri_complete embeds the user code, so the browser tab we
# open lands the user straight on GitHub's "Authorize" screen with the
# code pre-filled — one click, no manual code entry.
return {
"poll_id": poll_id,
"user_code": data.get("user_code"),
"verification_uri": data.get("verification_uri"),
"verification_uri_complete": data.get("verification_uri_complete"),
"interval": interval,
"expires_in": expires_in,
}
@router.post("/device/poll")
def device_poll(request: Request, poll_id: str = Form(...)):
require_admin(request)
_prune_expired()
with _PENDING_LOCK:
pending = _PENDING.get(poll_id)
if not pending:
raise HTTPException(404, "Unknown or expired login session")
# Enforce GitHub's polling interval server-side so a chatty client
# can't trip slow_down.
now = time.time()
if now < pending.get("next_poll_at", 0):
return {"status": "pending"}
try:
data = copilot.poll_access_token(pending["host"], pending["device_code"])
except Exception as e:
return {"status": "pending", "detail": f"poll error: {e}"}
token = data.get("access_token")
if token:
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
try:
result = _provision_endpoint(token, base, pending["owner"])
except Exception as e:
logger.exception("Copilot endpoint provisioning failed")
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "authorized", "endpoint": result}
err = data.get("error")
if err == "authorization_pending":
with _PENDING_LOCK:
if poll_id in _PENDING:
_PENDING[poll_id]["next_poll_at"] = now + pending["interval"]
return {"status": "pending"}
if err == "slow_down":
new_interval = int(data.get("interval") or (pending["interval"] + 5))
with _PENDING_LOCK:
if poll_id in _PENDING:
_PENDING[poll_id]["interval"] = new_interval
_PENDING[poll_id]["next_poll_at"] = now + new_interval
return {"status": "pending"}
if err in ("expired_token", "access_denied"):
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "failed", "error": err}
# Unknown error — surface but keep the session for another try.
return {"status": "pending", "detail": err or "unknown"}
@router.post("/device/cancel")
def device_cancel(request: Request, poll_id: str = Form(...)):
require_admin(request)
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "cancelled"}
return router

View File

@@ -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
View 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

View File

@@ -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":

View File

@@ -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.

View File

@@ -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)

View File

@@ -2092,6 +2092,13 @@
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
</div>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
<div class="adm-copilot-connect">
<button class="admin-btn-sm" id="adm-copilotConnectBtn" type="button" title="Sign in to GitHub Copilot via device flow">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-2px;margin-right:5px;opacity:0.8"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.5v-1.7c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.4 11.4 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.6.8.5 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
Connect GitHub Copilot
</button>
<div id="adm-copilotStatus" class="adm-ep-inline-msg"></div>
</div>
</div>
</div>
</div>

View File

@@ -912,6 +912,78 @@ function initEndpointForm() {
btn.disabled = false; btn.textContent = 'Add';
});
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
// code + verification link, and polls until they authorise (or it expires).
const copilotBtn = el('adm-copilotConnectBtn');
if (copilotBtn) {
let copilotPolling = false;
copilotBtn.addEventListener('click', async () => {
if (copilotPolling) return;
const status = el('adm-copilotStatus');
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
status.textContent = ''; status.className = 'adm-ep-inline-msg';
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
copilotPolling = true;
let start;
try {
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
start = await res.json();
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
// Prefer the "complete" URL — it embeds the code so the user only has to
// click "Authorize" (no manual code entry).
const authUrl = verification_uri_complete || verification_uri || '';
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
copilotBtn.textContent = 'Waiting…';
// Cohesive waiting panel: spinner + status line, the device code as a
// copyable chip, and a primary "Authorize on GitHub" action.
status.className = '';
status.innerHTML =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>Waiting for GitHub authorization…</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
'</div>';
const copyBtn = status.querySelector('.adm-copilot-copy');
if (copyBtn) copyBtn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
});
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
const deadline = Date.now() + (expires_in || 900) * 1000;
const stepMs = Math.max((interval || 5), 2) * 1000;
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
const poll = async () => {
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
try {
const fd = new FormData(); fd.append('poll_id', poll_id);
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
const d = await r.json();
if (d.status === 'authorized') {
const n = ((d.endpoint && d.endpoint.models) || []).length;
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
await loadEndpoints();
await _selectAddedModelInChat(d.endpoint || {});
return;
}
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
} catch (e) { /* transient — keep polling */ }
setTimeout(poll, stepMs);
};
setTimeout(poll, stepMs);
});
}
// Local "Add" button — sibling form for self-hosted base URLs.
const localAddBtn = el('adm-epLocalAddBtn');
const localTestBtn = el('adm-epLocalTestBtn');

View File

@@ -4735,11 +4735,47 @@ function _clearSetupCommandInput() {
}
}
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
async function _setupCopilot() {
_clearSetupGuideMessages();
await _setupReply('Starting GitHub Copilot sign-in…');
let start;
try {
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
start = await r.json();
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
} catch (e) { await _setupReply('Request failed.'); return; }
const authUrl = start.verification_uri_complete || start.verification_uri || '';
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
const deadline = Date.now() + (start.expires_in || 900) * 1000;
const stepMs = Math.max((start.interval || 5), 2) * 1000;
const poll = async () => {
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
try {
const fd = new FormData(); fd.append('poll_id', start.poll_id);
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
const d = await r.json();
if (d.status === 'authorized') {
const n = ((d.endpoint && d.endpoint.models) || []).length;
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
if (modelsModule) modelsModule.refreshModels(true);
return;
}
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
} catch (e) { /* transient — keep polling */ }
setTimeout(poll, stepMs);
};
setTimeout(poll, stepMs);
}
async function _cmdSetup(args, ctx) {
_hideWelcomeScreen();
_clearSetupCommandInput();
const topic = (args[0] || '').trim().toLowerCase();
const topicArgs = args.slice(1);
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
const provider = _setupProviderFromInput(topic);
if (provider) {
_clearSetupGuideMessages();
@@ -5464,7 +5500,7 @@ const COMMANDS = {
category: 'Getting started',
help: 'Add local or API model endpoints',
handler: _cmdSetup,
usage: '/setup local URL · /setup groq KEY · /setup endpoint'
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint'
},
demo: {
alias: ['tour'],

View File

@@ -35782,3 +35782,66 @@ body.theme-frosted .modal {
is already 16px and never zoomed leave it so we don't shrink it. */
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
}
/* GitHub Copilot device-flow connect block (model endpoints → API) */
.adm-copilot-connect {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.adm-copilot-connect #adm-copilotStatus { flex-basis: 100%; margin-top: 0; }
.adm-copilot-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.adm-copilot-wait {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: color-mix(in srgb, var(--fg) 70%, transparent);
}
.adm-copilot-coderow {
display: flex;
align-items: center;
gap: 8px;
}
.adm-copilot-code-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
.adm-copilot-code {
font-family: var(--mono, ui-monospace, monospace);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.12em;
padding: 4px 10px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg);
user-select: all;
}
.adm-copilot-copy { margin-left: auto; }
.adm-copilot-auth {
text-align: center;
text-decoration: none;
padding: 7px 12px;
font-size: 12px;
}
.adm-copilot-hint {
font-size: 11px;
line-height: 1.4;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}

170
tests/test_copilot.py Normal file
View File

@@ -0,0 +1,170 @@
"""Tests for the GitHub Copilot provider integration (src/copilot.py + wiring)."""
import types
import pytest
from src import copilot
# ── Provider detection ─────────────────────────────────────────────────────
@pytest.mark.parametrize("url,expected", [
("https://api.githubcopilot.com", True),
("https://api.githubcopilot.com/chat/completions", True),
("https://copilot-api.acme.ghe.com", True),
("https://sub.githubcopilot.com", True),
("https://api.openai.com/v1", False),
("https://githubcopilot.com.evil.test", False), # lookalike host
("", False),
(None, False),
])
def test_is_copilot_base(url, expected):
assert copilot.is_copilot_base(url) is expected
def test_detect_provider_copilot():
from src.llm_core import _detect_provider
assert _detect_provider("https://api.githubcopilot.com") == "copilot"
assert _detect_provider("https://copilot-api.acme.ghe.com") == "copilot"
# lookalike must not be classified as copilot
assert _detect_provider("https://githubcopilot.com.evil.test") == "openai"
def test_enterprise_base():
assert copilot.enterprise_base(None) == "https://api.githubcopilot.com"
assert copilot.enterprise_base("https://acme.ghe.com/") == "https://copilot-api.acme.ghe.com"
assert copilot.enterprise_base("acme.ghe.com") == "https://copilot-api.acme.ghe.com"
# ── Headers ────────────────────────────────────────────────────────────────
def test_copilot_headers_core():
h = copilot.copilot_headers("TOK")
assert h["Authorization"] == "Bearer TOK"
assert h["X-GitHub-Api-Version"] == copilot.COPILOT_API_VERSION
assert h["Openai-Intent"] == "conversation-edits"
assert h["Copilot-Integration-Id"]
assert h["x-initiator"] == "user"
assert "Copilot-Vision-Request" not in h
def test_copilot_headers_agent_vision():
h = copilot.copilot_headers("TOK", agent=True, vision=True)
assert h["x-initiator"] == "agent"
assert h["Copilot-Vision-Request"] == "true"
def test_copilot_headers_no_token():
h = copilot.copilot_headers(None)
assert "Authorization" not in h
assert h["X-GitHub-Api-Version"] == copilot.COPILOT_API_VERSION
def test_build_headers_dispatches_to_copilot():
from src.endpoint_resolver import build_headers
h = build_headers("TOK", "https://api.githubcopilot.com")
assert h["Authorization"] == "Bearer TOK"
assert h["X-GitHub-Api-Version"] == copilot.COPILOT_API_VERSION
# OpenAI base must stay plain bearer (no copilot headers)
ho = build_headers("TOK", "https://api.openai.com/v1")
assert "X-GitHub-Api-Version" not in ho
# ── Per-request flags ──────────────────────────────────────────────────────
def test_request_flags_user():
assert copilot.request_flags([{"role": "user", "content": "hi"}]) == (False, False)
def test_request_flags_agent_when_tool_last():
msgs = [{"role": "user", "content": "hi"}, {"role": "tool", "content": "x"}]
assert copilot.request_flags(msgs) == (True, False)
def test_request_flags_vision():
msgs = [{"role": "user", "content": [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:..."}},
]}]
agent, vision = copilot.request_flags(msgs)
assert vision is True
def test_apply_request_headers_mutates():
h = {"X-GitHub-Api-Version": "v"}
copilot.apply_request_headers(h, [{"role": "tool", "content": "x"}])
assert h["x-initiator"] == "agent"
# ── Model discovery ────────────────────────────────────────────────────────
def _fake_response(payload):
r = types.SimpleNamespace()
r.json = lambda: payload
r.raise_for_status = lambda: None
return r
def test_fetch_models_filters_picker(monkeypatch):
payload = {"data": [
{"id": "gpt-4o", "model_picker_enabled": True,
"capabilities": {"supports": {"tool_calls": True, "vision": True}}},
{"id": "internal-embed", "model_picker_enabled": False,
"capabilities": {"supports": {"tool_calls": False}}},
{"id": "claude-3.5", "model_picker_enabled": True,
"capabilities": {"supports": {"tool_calls": True}}},
]}
monkeypatch.setattr(copilot.httpx, "get", lambda *a, **k: _fake_response(payload))
models = copilot.fetch_models("https://api.githubcopilot.com", "TOK")
ids = {m["id"] for m in models}
assert ids == {"gpt-4o", "claude-3.5"}
gpt = next(m for m in models if m["id"] == "gpt-4o")
assert gpt["tool_calls"] is True and gpt["vision"] is True
def test_fetch_models_fallback_when_no_picker(monkeypatch):
payload = {"data": [
{"id": "m1", "capabilities": {"supports": {}}},
{"id": "m2", "capabilities": {"supports": {}}},
]}
monkeypatch.setattr(copilot.httpx, "get", lambda *a, **k: _fake_response(payload))
models = copilot.fetch_models("https://api.githubcopilot.com", "TOK")
assert {m["id"] for m in models} == {"m1", "m2"}
# ── Device flow ────────────────────────────────────────────────────────────
def test_request_device_code(monkeypatch):
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["url"] = url
captured["json"] = json
return _fake_response({"device_code": "DC", "user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"interval": 5, "expires_in": 900})
monkeypatch.setattr(copilot.httpx, "post", fake_post)
data = copilot.request_device_code()
assert data["device_code"] == "DC"
assert captured["url"] == "https://github.com/login/device/code"
assert captured["json"]["client_id"] == copilot.COPILOT_CLIENT_ID
assert captured["json"]["scope"] == "read:user"
def test_poll_access_token(monkeypatch):
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["json"] = json
return _fake_response({"access_token": "GHTOKEN"})
monkeypatch.setattr(copilot.httpx, "post", fake_post)
data = copilot.poll_access_token("github.com", "DC")
assert data["access_token"] == "GHTOKEN"
assert captured["json"]["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code"
assert captured["json"]["device_code"] == "DC"
def test_agent_loop_host_allowlisted():
from src.agent_loop import _API_HOSTS
assert "api.githubcopilot.com" in _API_HOSTS

View File

@@ -0,0 +1,80 @@
"""DB-backed tests for Copilot endpoint provisioning (routes/copilot_routes.py)."""
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, ModelEndpoint
import routes.copilot_routes as cr
def _mem_db(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestSessionLocal = sessionmaker(bind=engine)
monkeypatch.setattr(cr, "SessionLocal", TestSessionLocal)
return TestSessionLocal
def test_provision_creates_owner_scoped_endpoint(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(
cr.copilot, "fetch_models",
lambda base, token: [
{"id": "gpt-4o", "tool_calls": True, "vision": True},
{"id": "claude-3.5", "tool_calls": True, "vision": False},
],
)
res = cr._provision_endpoint("GHTOK", "https://api.githubcopilot.com", "alice")
assert res["base_url"] == "https://api.githubcopilot.com"
assert res["models"] == ["gpt-4o", "claude-3.5"]
db = TestSessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == res["id"]).first()
assert ep is not None
assert ep.owner == "alice"
assert ep.is_enabled is True
assert ep.supports_tools is True
assert ep.api_key == "GHTOK" # round-trips through EncryptedText
assert json.loads(ep.cached_models) == ["gpt-4o", "claude-3.5"]
finally:
db.close()
def test_provision_refreshes_existing_token(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(cr.copilot, "fetch_models", lambda base, token: [{"id": "gpt-4o", "tool_calls": True}])
first = cr._provision_endpoint("OLD", "https://api.githubcopilot.com", "bob")
second = cr._provision_endpoint("NEW", "https://api.githubcopilot.com", "bob")
# Same row reused (no duplicate), token refreshed.
assert first["id"] == second["id"]
db = TestSessionLocal()
try:
rows = db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()
assert len(rows) == 1
assert rows[0].api_key == "NEW"
finally:
db.close()
def test_provision_handles_model_fetch_failure(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
def boom(base, token):
raise RuntimeError("network down")
monkeypatch.setattr(cr.copilot, "fetch_models", boom)
# Should still create the endpoint (login succeeded) with an empty model list.
res = cr._provision_endpoint("GHTOK", "https://api.githubcopilot.com", "carol")
assert res["models"] == []
db = TestSessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == res["id"]).first()
assert ep is not None and ep.api_key == "GHTOK"
finally:
db.close()