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:
committed by
GitHub
parent
ca32b43b38
commit
1cd0aa2b8c
@@ -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.
|
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
|
## 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>
|
- **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>
|
- **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>
|
- **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
4
app.py
@@ -587,6 +587,10 @@ app.include_router(setup_embedding_routes())
|
|||||||
from routes.model_routes import setup_model_routes
|
from routes.model_routes import setup_model_routes
|
||||||
app.include_router(setup_model_routes(model_discovery))
|
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
|
# TTS
|
||||||
from routes.tts_routes import setup_tts_routes
|
from routes.tts_routes import setup_tts_routes
|
||||||
app.include_router(setup_tts_routes(tts_service))
|
app.include_router(setup_tts_routes(tts_service))
|
||||||
|
|||||||
223
routes/copilot_routes.py
Normal file
223
routes/copilot_routes.py
Normal 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
|
||||||
@@ -468,6 +468,7 @@ _API_HOSTS = frozenset([
|
|||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
"ollama.com", "api.venice.ai",
|
"ollama.com", "api.venice.ai",
|
||||||
|
"api.githubcopilot.com",
|
||||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||||
# model name, so well-behaved local servers don't get native tool
|
# model name, so well-behaved local servers don't get native tool
|
||||||
|
|||||||
253
src/copilot.py
Normal file
253
src/copilot.py
Normal 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
|
||||||
|
|
||||||
@@ -194,6 +194,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
|||||||
headers["x-api-key"] = api_key
|
headers["x-api-key"] = api_key
|
||||||
headers["anthropic-version"] = "2023-06-01"
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
return headers
|
return headers
|
||||||
|
if provider == "copilot":
|
||||||
|
from src.copilot import copilot_headers
|
||||||
|
return copilot_headers(api_key)
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ def _detect_provider(url: str) -> str:
|
|||||||
return "openrouter"
|
return "openrouter"
|
||||||
if _host_match(url, "groq.com"):
|
if _host_match(url, "groq.com"):
|
||||||
return "groq"
|
return "groq"
|
||||||
|
from src.copilot import is_copilot_base
|
||||||
|
if is_copilot_base(url):
|
||||||
|
return "copilot"
|
||||||
return "openai"
|
return "openai"
|
||||||
|
|
||||||
|
|
||||||
@@ -327,6 +330,14 @@ def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str
|
|||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
h.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
h.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||||
h.setdefault("X-OpenRouter-Title", "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
|
return h
|
||||||
|
|
||||||
|
|
||||||
@@ -340,6 +351,8 @@ def _provider_label(url: str) -> str:
|
|||||||
if _host_match(url, "openai.com"): return "OpenAI"
|
if _host_match(url, "openai.com"): return "OpenAI"
|
||||||
if _host_match(url, "openrouter.ai"): return "OpenRouter"
|
if _host_match(url, "openrouter.ai"): return "OpenRouter"
|
||||||
if _host_match(url, "groq.com"): return "Groq"
|
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, "mistral.ai"): return "Mistral"
|
||||||
if _host_match(url, "deepseek.com"): return "DeepSeek"
|
if _host_match(url, "deepseek.com"): return "DeepSeek"
|
||||||
if _host_match(url, "googleapis.com"): return "Google"
|
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:
|
else:
|
||||||
target_url = url
|
target_url = url
|
||||||
|
if provider == "copilot":
|
||||||
|
from src.copilot import apply_request_headers
|
||||||
|
apply_request_headers(h, messages_copy)
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages_copy,
|
"messages": messages_copy,
|
||||||
@@ -1058,6 +1074,9 @@ async def llm_call_async(
|
|||||||
else:
|
else:
|
||||||
target_url = url
|
target_url = url
|
||||||
h = _provider_headers(provider, headers)
|
h = _provider_headers(provider, headers)
|
||||||
|
if provider == "copilot":
|
||||||
|
from src.copilot import apply_request_headers
|
||||||
|
apply_request_headers(h, messages_copy)
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages_copy,
|
"messages": messages_copy,
|
||||||
@@ -1182,6 +1201,9 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
h = _provider_headers(provider, headers)
|
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
|
# Short connect timeout: a reachable peer answers SYN in <100ms even on
|
||||||
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
|
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
|
||||||
|
|||||||
@@ -282,6 +282,16 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
models_url = endpoint_url.replace("/chat/completions", "/models")
|
||||||
try:
|
try:
|
||||||
r = httpx.get(models_url, timeout=REQUEST_TIMEOUT)
|
r = httpx.get(models_url, timeout=REQUEST_TIMEOUT)
|
||||||
|
|||||||
@@ -2092,6 +2092,13 @@
|
|||||||
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -912,6 +912,78 @@ function initEndpointForm() {
|
|||||||
btn.disabled = false; btn.textContent = 'Add';
|
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 =
|
||||||
|
'<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.
|
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||||
const localAddBtn = el('adm-epLocalAddBtn');
|
const localAddBtn = el('adm-epLocalAddBtn');
|
||||||
const localTestBtn = el('adm-epLocalTestBtn');
|
const localTestBtn = el('adm-epLocalTestBtn');
|
||||||
|
|||||||
@@ -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) {
|
async function _cmdSetup(args, ctx) {
|
||||||
_hideWelcomeScreen();
|
_hideWelcomeScreen();
|
||||||
_clearSetupCommandInput();
|
_clearSetupCommandInput();
|
||||||
const topic = (args[0] || '').trim().toLowerCase();
|
const topic = (args[0] || '').trim().toLowerCase();
|
||||||
const topicArgs = args.slice(1);
|
const topicArgs = args.slice(1);
|
||||||
|
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
|
||||||
const provider = _setupProviderFromInput(topic);
|
const provider = _setupProviderFromInput(topic);
|
||||||
if (provider) {
|
if (provider) {
|
||||||
_clearSetupGuideMessages();
|
_clearSetupGuideMessages();
|
||||||
@@ -5464,7 +5500,7 @@ const COMMANDS = {
|
|||||||
category: 'Getting started',
|
category: 'Getting started',
|
||||||
help: 'Add local or API model endpoints',
|
help: 'Add local or API model endpoints',
|
||||||
handler: _cmdSetup,
|
handler: _cmdSetup,
|
||||||
usage: '/setup local URL · /setup groq KEY · /setup endpoint'
|
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint'
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
alias: ['tour'],
|
alias: ['tour'],
|
||||||
|
|||||||
@@ -35782,3 +35782,66 @@ body.theme-frosted .modal {
|
|||||||
is already ≥16px and never zoomed — leave it so we don't shrink it. */
|
is already ≥16px and never zoomed — leave it so we don't shrink it. */
|
||||||
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
|
.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
170
tests/test_copilot.py
Normal 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
|
||||||
80
tests/test_copilot_routes.py
Normal file
80
tests/test_copilot_routes.py
Normal 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()
|
||||||
Reference in New Issue
Block a user