' +
+ '
' +
+ 'Waiting for GitHub authorization…
' +
+ '
' +
+ 'Code' +
+ '' + esc(user_code) + '' +
+ '' +
+ '
' +
+ '
Authorize on GitHub ↗' +
+ '
A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.
' +
+ '
';
+ 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');
diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js
index 4d24972..97b3fb3 100644
--- a/static/js/slashCommands.js
+++ b/static/js/slashCommands.js
@@ -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'],
diff --git a/static/style.css b/static/style.css
index 69e02e7..ea99f3e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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);
+}
diff --git a/tests/test_copilot.py b/tests/test_copilot.py
new file mode 100644
index 0000000..52d530a
--- /dev/null
+++ b/tests/test_copilot.py
@@ -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
diff --git a/tests/test_copilot_routes.py b/tests/test_copilot_routes.py
new file mode 100644
index 0000000..b75bb9f
--- /dev/null
+++ b/tests/test_copilot_routes.py
@@ -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()