From 1cd0aa2b8c715ec7ad5643531858699ff7f71883 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Thu, 4 Jun 2026 21:13:14 +0200 Subject: [PATCH] feat(provider): add GitHub Copilot provider with device-flow auth (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- README.md | 2 +- app.py | 4 + routes/copilot_routes.py | 223 ++++++++++++++++++++++++++++++ src/agent_loop.py | 1 + src/copilot.py | 253 +++++++++++++++++++++++++++++++++++ src/endpoint_resolver.py | 3 + src/llm_core.py | 22 +++ src/model_context.py | 10 ++ static/index.html | 7 + static/js/admin.js | 72 ++++++++++ static/js/slashCommands.js | 38 +++++- static/style.css | 63 +++++++++ tests/test_copilot.py | 170 +++++++++++++++++++++++ tests/test_copilot_routes.py | 80 +++++++++++ 14 files changed, 946 insertions(+), 2 deletions(-) create mode 100644 routes/copilot_routes.py create mode 100644 src/copilot.py create mode 100644 tests/test_copilot.py create mode 100644 tests/test_copilot_routes.py diff --git a/README.md b/README.md index 4fd7f48..d720ec0 100644 --- a/README.md +++ b/README.md @@ -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.
 vLLM · llama.cpp · Ollama · OpenRouter · OpenAI + - **Chat** -- chat with any local model or API; adding them is super simple.
 vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot - **Agent** -- hand it tools and let it run the whole task itself.
 built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory - **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!
 built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving - **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.
 adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch) diff --git a/app.py b/app.py index 4160baf..4120be9 100644 --- a/app.py +++ b/app.py @@ -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)) diff --git a/routes/copilot_routes.py b/routes/copilot_routes.py new file mode 100644 index 0000000..bb2b1d2 --- /dev/null +++ b/routes/copilot_routes.py @@ -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 diff --git a/src/agent_loop.py b/src/agent_loop.py index d6d9370..7aa7e19 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -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 diff --git a/src/copilot.py b/src/copilot.py new file mode 100644 index 0000000..62d2b8c --- /dev/null +++ b/src/copilot.py @@ -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 `` — 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.``. +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 → ``https://copilot-api.``. + """ + 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.. + 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 + diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index 073f8d7..a9ab5c7 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -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": diff --git a/src/llm_core.py b/src/llm_core.py index 1baf184..092384b 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -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. diff --git a/src/model_context.py b/src/model_context.py index c985d3d..2fd0b82 100644 --- a/src/model_context.py +++ b/src/model_context.py @@ -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) diff --git a/static/index.html b/static/index.html index fadb0c6..cade5cf 100644 --- a/static/index.html +++ b/static/index.html @@ -2092,6 +2092,13 @@
+
+ +
+
diff --git a/static/js/admin.js b/static/js/admin.js index 2c2ceae..25e3faa 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[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 = + '
' + + '
' + + '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()