Add GitHub Copilot support (#191)

- OAuth login for GitHub Copilot via /login command
- Support for github.com and GitHub Enterprise
- Models sourced from models.dev (Claude, GPT, Gemini, Grok, etc.)
- Dynamic base URL from token's proxy-ep field
- Use vscode-chat integration ID for API compatibility
- Documentation for model enablement at github.com/settings/copilot/features

Co-authored-by: cau1k <cau1k@users.noreply.github.com>
This commit is contained in:
Mario Zechner 2025-12-15 19:05:17 +01:00
parent ce4ba70d33
commit b66157c649
7 changed files with 664 additions and 726 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- **GitHub Copilot support**: Use GitHub Copilot models via OAuth login (`/login` -> "GitHub Copilot"). Supports both github.com and GitHub Enterprise. Models are sourced from models.dev and include Claude, GPT, Gemini, Grok, and more. Some models require enablement at https://github.com/settings/copilot/features before use. ([#191](https://github.com/badlogic/pi-mono/pull/191) by [@cau1k](https://github.com/cau1k))
### Fixed
- Model selector fuzzy search now matches against provider name (not just model ID) and supports space-separated tokens where all tokens must match

View file

@ -125,6 +125,23 @@ pi
/login # Select "Anthropic (Claude Pro/Max)", authorize in browser
```
**GitHub Copilot:**
```bash
pi
/login # Select "GitHub Copilot", authorize in browser
```
During login, you'll be prompted for an enterprise domain. Press Enter to use github.com, or enter your GitHub Enterprise Server domain (e.g., `github.mycompany.com`).
Some models require explicit enablement before use. If you get "The requested model is not supported" error, enable the model at:
**https://github.com/settings/copilot/features**
For enterprise users, check with your organization's Copilot administrator for model availability and policies.
Note: Enabling some models (e.g., Grok from xAI) may involve sharing usage data with the provider. Review the terms before enabling.
Tokens stored in `~/.pi/agent/oauth.json` (mode 0600). Use `/logout` to clear.
### Quick Start

View file

@ -242,18 +242,15 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
const combined = [...builtInModels, ...customModels];
// Update github-copilot base URL based on OAuth token or enterprise domain
const copilotCreds = loadOAuthCredentials("github-copilot");
if (copilotCreds?.enterpriseUrl) {
const domain = normalizeDomain(copilotCreds.enterpriseUrl);
if (domain) {
const baseUrl = getGitHubCopilotBaseUrl(domain);
return {
models: combined.map((m) =>
m.provider === "github-copilot" && m.baseUrl === "https://api.githubcopilot.com" ? { ...m, baseUrl } : m,
),
error: null,
};
}
if (copilotCreds) {
const domain = copilotCreds.enterpriseUrl ? normalizeDomain(copilotCreds.enterpriseUrl) : undefined;
const baseUrl = getGitHubCopilotBaseUrl(copilotCreds.access, domain ?? undefined);
return {
models: combined.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)),
error: null,
};
}
return { models: combined, error: null };
@ -288,23 +285,31 @@ export async function getApiKeyForModel(model: Model<Api>): Promise<string | und
}
if (model.provider === "github-copilot") {
// 1. Check OAuth storage (from device flow login)
const oauthToken = await getOAuthToken("github-copilot");
if (oauthToken) {
return oauthToken;
}
// 2. Use GitHub token directly (works with copilot scope on github.com)
const githubToken = process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (!githubToken) {
return undefined;
}
// 3. For enterprise, exchange token for short-lived Copilot token
const enterpriseDomain = process.env.COPILOT_ENTERPRISE_URL
? normalizeDomain(process.env.COPILOT_ENTERPRISE_URL)
: undefined;
const creds = await refreshGitHubCopilotToken(githubToken, enterpriseDomain || undefined);
saveOAuthCredentials("github-copilot", creds);
return creds.access;
if (enterpriseDomain) {
const creds = await refreshGitHubCopilotToken(githubToken, enterpriseDomain);
saveOAuthCredentials("github-copilot", creds);
return creds.access;
}
// 4. For github.com, use token directly
return githubToken;
}
// For built-in providers, use getApiKey from @mariozechner/pi-ai

View file

@ -4,11 +4,9 @@ const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const COPILOT_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.105.1",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "copilot-developer-cli",
"Openai-Intent": "conversation-edits",
"X-Initiator": "agent",
"Copilot-Integration-Id": "vscode-chat",
} as const;
type DeviceCodeResponse = {
@ -54,9 +52,29 @@ function getUrls(domain: string): {
};
}
export function getGitHubCopilotBaseUrl(enterpriseDomain?: string): string {
if (!enterpriseDomain) return "https://api.githubcopilot.com";
return `https://copilot-api.${enterpriseDomain}`;
/**
* Parse the proxy-ep from a Copilot token and convert to API base URL.
* Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...
* Returns API URL like https://api.individual.githubcopilot.com
*/
export function getBaseUrlFromToken(token: string): string | null {
const match = token.match(/proxy-ep=([^;]+)/);
if (!match) return null;
const proxyHost = match[1];
// Convert proxy.xxx to api.xxx
const apiHost = proxyHost.replace(/^proxy\./, "api.");
return `https://${apiHost}`;
}
export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string {
// If we have a token, extract the base URL from proxy-ep
if (token) {
const urlFromToken = getBaseUrlFromToken(token);
if (urlFromToken) return urlFromToken;
}
// Fallback for enterprise or if token parsing fails
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
return "https://api.individual.githubcopilot.com";
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {