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

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