git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
508 lines
15 KiB
TypeScript
508 lines
15 KiB
TypeScript
/**
|
|
* GitHub Copilot OAuth flow
|
|
*/
|
|
|
|
import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts";
|
|
import { sleep } from "../../utils/sleep.ts";
|
|
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
|
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
|
|
|
const decode = (s: string) => atob(s);
|
|
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
|
|
|
|
const COPILOT_HEADERS = {
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
"Editor-Version": "vscode/1.107.0",
|
|
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
"Copilot-Integration-Id": "vscode-chat",
|
|
} as const;
|
|
const COPILOT_API_VERSION = "2026-06-01";
|
|
|
|
type DeviceCodeResponse = {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri: string;
|
|
interval?: number;
|
|
expires_in: number;
|
|
};
|
|
|
|
type DeviceTokenSuccessResponse = {
|
|
access_token: string;
|
|
token_type?: string;
|
|
scope?: string;
|
|
};
|
|
|
|
type DeviceTokenErrorResponse = {
|
|
error: string;
|
|
error_description?: string;
|
|
interval?: number;
|
|
};
|
|
|
|
function normalizeDomain(input: string): string | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
|
return url.hostname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getUrls(domain: string): {
|
|
deviceCodeUrl: string;
|
|
accessTokenUrl: string;
|
|
copilotTokenUrl: string;
|
|
} {
|
|
return {
|
|
deviceCodeUrl: `https://${domain}/login/device/code`,
|
|
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
|
|
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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}`;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
}
|
|
|
|
function parseGitHubCopilotModelCatalog(raw: unknown, allowPolicyFallback: boolean) {
|
|
const data = asRecord(raw)?.data;
|
|
if (!Array.isArray(data)) {
|
|
throw new Error("Invalid Copilot models response");
|
|
}
|
|
|
|
const accountModels = data.flatMap((rawItem) => {
|
|
const item = asRecord(rawItem);
|
|
const id = item?.id;
|
|
if (!item || typeof id !== "string") return [];
|
|
|
|
const capabilities = asRecord(item.capabilities);
|
|
const supports = asRecord(capabilities?.supports);
|
|
if (supports?.tool_calls === false) return [];
|
|
|
|
return [
|
|
{
|
|
id,
|
|
pickerEnabled: item.model_picker_enabled === true,
|
|
policyState: asRecord(item.policy)?.state,
|
|
},
|
|
];
|
|
});
|
|
const pickerModelIds = accountModels
|
|
.filter((model) => model.pickerEnabled && model.policyState !== "disabled")
|
|
.map((model) => model.id);
|
|
const usePolicyFallback = allowPolicyFallback && pickerModelIds.length === 0;
|
|
const availableModelIds =
|
|
pickerModelIds.length > 0 || !allowPolicyFallback
|
|
? pickerModelIds
|
|
: accountModels.filter((model) => model.policyState === "enabled").map((model) => model.id);
|
|
const policyModelIds = accountModels
|
|
.filter(
|
|
(model) =>
|
|
model.policyState === "unconfigured" &&
|
|
Object.hasOwn(GITHUB_COPILOT_MODELS, model.id) &&
|
|
(model.pickerEnabled || usePolicyFallback),
|
|
)
|
|
.map((model) => model.id);
|
|
return { availableModelIds, policyModelIds };
|
|
}
|
|
|
|
async function fetchWithRateLimitRetry(
|
|
url: string,
|
|
init: RequestInit,
|
|
signal: AbortSignal,
|
|
retryPolicy: { maxRetries: number; maxElapsedMs: number },
|
|
): Promise<Response> {
|
|
const retryBudgetSignal =
|
|
retryPolicy.maxRetries > 0 && retryPolicy.maxElapsedMs > 0
|
|
? AbortSignal.timeout(retryPolicy.maxElapsedMs)
|
|
: undefined;
|
|
const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal;
|
|
const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined;
|
|
for (let retry = 0; ; retry++) {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]),
|
|
});
|
|
if (response.status !== 429 || retry === retryPolicy.maxRetries) return response;
|
|
|
|
const retryAfter = response.headers.get("retry-after");
|
|
let delayMs = 500 * 2 ** retry;
|
|
if (retryAfter) {
|
|
const seconds = Number.parseFloat(retryAfter);
|
|
delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
|
|
if (!Number.isFinite(delayMs)) return response;
|
|
}
|
|
delayMs = Math.max(0, delayMs);
|
|
if (retryDeadline !== undefined && delayMs >= retryDeadline - Date.now()) return response;
|
|
await response.body?.cancel();
|
|
await sleep(delayMs, requestSignal);
|
|
}
|
|
}
|
|
|
|
async function fetchGitHubCopilotModels(
|
|
copilotToken: string,
|
|
enterpriseDomain: string | undefined,
|
|
signal: AbortSignal,
|
|
retryPolicy: { maxRetries: number; maxElapsedMs: number },
|
|
) {
|
|
const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain);
|
|
// Some Individual accounts return false for every picker flag despite explicit enabled policies.
|
|
// Limit the fallback to that endpoint so other account types keep strict picker semantics.
|
|
const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com";
|
|
const response = await fetchWithRateLimitRetry(
|
|
`${baseUrl}/models`,
|
|
{
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${copilotToken}`,
|
|
...COPILOT_HEADERS,
|
|
"X-GitHub-Api-Version": COPILOT_API_VERSION,
|
|
},
|
|
},
|
|
signal,
|
|
retryPolicy,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
|
}
|
|
return parseGitHubCopilotModelCatalog(await response.json(), allowPolicyFallback);
|
|
}
|
|
|
|
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
|
const response = await fetch(url, init);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`${response.status} ${response.statusText}: ${text}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function startDeviceFlow(domain: string, signal: AbortSignal): Promise<DeviceCodeResponse> {
|
|
const urls = getUrls(domain);
|
|
const data = await fetchJson(urls.deviceCodeUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
},
|
|
body: new URLSearchParams({
|
|
client_id: CLIENT_ID,
|
|
scope: "read:user",
|
|
}),
|
|
signal,
|
|
});
|
|
|
|
if (!data || typeof data !== "object") {
|
|
throw new Error("Invalid device code response");
|
|
}
|
|
|
|
const deviceCode = (data as Record<string, unknown>).device_code;
|
|
const userCode = (data as Record<string, unknown>).user_code;
|
|
const verificationUri = (data as Record<string, unknown>).verification_uri;
|
|
const interval = (data as Record<string, unknown>).interval;
|
|
const expiresIn = (data as Record<string, unknown>).expires_in;
|
|
|
|
if (
|
|
typeof deviceCode !== "string" ||
|
|
typeof userCode !== "string" ||
|
|
typeof verificationUri !== "string" ||
|
|
(interval !== undefined && typeof interval !== "number") ||
|
|
typeof expiresIn !== "number"
|
|
) {
|
|
throw new Error("Invalid device code response fields");
|
|
}
|
|
|
|
// The verification URI is opened in the user's browser and to prevent `open` from
|
|
// opening an executable or similar, we force it to be a URL.
|
|
let parsedUri: URL;
|
|
try {
|
|
parsedUri = new URL(verificationUri);
|
|
} catch {
|
|
throw new Error("Untrusted verification_uri in device code response");
|
|
}
|
|
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
|
|
throw new Error("Untrusted verification_uri in device code response");
|
|
}
|
|
|
|
return {
|
|
device_code: deviceCode,
|
|
user_code: userCode,
|
|
verification_uri: parsedUri.href,
|
|
interval,
|
|
expires_in: expiresIn,
|
|
};
|
|
}
|
|
|
|
async function pollForGitHubAccessToken(
|
|
domain: string,
|
|
device: DeviceCodeResponse,
|
|
signal: AbortSignal,
|
|
): Promise<string> {
|
|
const urls = getUrls(domain);
|
|
return pollOAuthDeviceCodeFlow<string>({
|
|
intervalSeconds: device.interval,
|
|
expiresInSeconds: device.expires_in,
|
|
waitBeforeFirstPoll: true,
|
|
signal,
|
|
poll: async () => {
|
|
const raw = await fetchJson(urls.accessTokenUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
},
|
|
body: new URLSearchParams({
|
|
client_id: CLIENT_ID,
|
|
device_code: device.device_code,
|
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
}),
|
|
signal,
|
|
});
|
|
|
|
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
|
|
return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token };
|
|
}
|
|
|
|
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
|
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
|
|
if (error === "authorization_pending") {
|
|
return { status: "pending" };
|
|
}
|
|
|
|
if (error === "slow_down") {
|
|
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
|
|
}
|
|
|
|
const descriptionSuffix = description ? `: ${description}` : "";
|
|
return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` };
|
|
}
|
|
|
|
return { status: "failed", message: "Invalid device token response" };
|
|
},
|
|
});
|
|
}
|
|
|
|
async function refreshGitHubCopilotAccessToken(
|
|
refreshToken: string,
|
|
enterpriseDomain: string | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<OAuthCredential> {
|
|
const domain = enterpriseDomain || "github.com";
|
|
const urls = getUrls(domain);
|
|
|
|
const raw = await fetchJson(urls.copilotTokenUrl, {
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${refreshToken}`,
|
|
...COPILOT_HEADERS,
|
|
},
|
|
signal,
|
|
});
|
|
|
|
if (!raw || typeof raw !== "object") {
|
|
throw new Error("Invalid Copilot token response");
|
|
}
|
|
|
|
const token = (raw as Record<string, unknown>).token;
|
|
const expiresAt = (raw as Record<string, unknown>).expires_at;
|
|
|
|
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
|
throw new Error("Invalid Copilot token response fields");
|
|
}
|
|
|
|
return {
|
|
type: "oauth",
|
|
refresh: refreshToken,
|
|
access: token,
|
|
expires: expiresAt * 1000 - 5 * 60 * 1000,
|
|
enterpriseUrl: enterpriseDomain,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh GitHub Copilot token
|
|
*/
|
|
async function refreshGitHubCopilotToken(
|
|
refreshToken: string,
|
|
enterpriseDomain: string | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<OAuthCredential> {
|
|
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain, signal);
|
|
const { availableModelIds } = await fetchGitHubCopilotModels(credentials.access, enterpriseDomain, signal, {
|
|
maxRetries: 0,
|
|
maxElapsedMs: 0,
|
|
});
|
|
return {
|
|
...credentials,
|
|
availableModelIds,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enable a model for the user's GitHub Copilot account.
|
|
* This is required for some models (like Claude, Grok) before they can be used.
|
|
*/
|
|
async function enableGitHubCopilotModel(
|
|
token: string,
|
|
modelId: string,
|
|
enterpriseDomain: string | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<boolean> {
|
|
const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
|
|
const url = `${baseUrl}/models/${modelId}/policy`;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetchWithRateLimitRetry(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
...COPILOT_HEADERS,
|
|
"openai-intent": "chat-policy",
|
|
"x-interaction-type": "chat-policy",
|
|
},
|
|
body: JSON.stringify({ state: "enabled" }),
|
|
},
|
|
signal,
|
|
{ maxRetries: 2, maxElapsedMs: 5000 },
|
|
);
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
return false;
|
|
}
|
|
if (response.status === 429) {
|
|
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
|
}
|
|
return response.ok;
|
|
}
|
|
|
|
/**
|
|
* Enable the requested GitHub Copilot models and return the successful IDs.
|
|
* Policy updates are best effort; exhausted rate limiting stops the batch.
|
|
*/
|
|
async function enableGitHubCopilotModels(
|
|
token: string,
|
|
modelIds: readonly string[],
|
|
enterpriseDomain: string | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<string[]> {
|
|
const enabledModelIds: string[] = [];
|
|
for (const modelId of modelIds) {
|
|
try {
|
|
if (await enableGitHubCopilotModel(token, modelId, enterpriseDomain, signal)) {
|
|
enabledModelIds.push(modelId);
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
break;
|
|
}
|
|
}
|
|
return enabledModelIds;
|
|
}
|
|
|
|
async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
|
const input = await interaction.prompt({
|
|
type: "text",
|
|
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
|
placeholder: "company.ghe.com",
|
|
});
|
|
if (interaction.signal.aborted) throw new Error("Login cancelled");
|
|
|
|
const trimmed = input.trim();
|
|
const enterpriseDomain = normalizeDomain(input);
|
|
if (trimmed && !enterpriseDomain) throw new Error("Invalid GitHub Enterprise URL/domain");
|
|
const domain = enterpriseDomain || "github.com";
|
|
|
|
const device = await startDeviceFlow(domain, interaction.signal);
|
|
interaction.notify({
|
|
type: "device_code",
|
|
userCode: device.user_code,
|
|
verificationUri: device.verification_uri,
|
|
intervalSeconds: device.interval,
|
|
expiresInSeconds: device.expires_in,
|
|
});
|
|
|
|
const githubAccessToken = await pollForGitHubAccessToken(domain, device, interaction.signal);
|
|
const credentials = await refreshGitHubCopilotAccessToken(
|
|
githubAccessToken,
|
|
enterpriseDomain ?? undefined,
|
|
interaction.signal,
|
|
);
|
|
const models = await fetchGitHubCopilotModels(
|
|
credentials.access,
|
|
enterpriseDomain ?? undefined,
|
|
interaction.signal,
|
|
{
|
|
maxRetries: 2,
|
|
maxElapsedMs: 5000,
|
|
},
|
|
);
|
|
let enabledModelIds: string[] = [];
|
|
if (models.policyModelIds.length > 0) {
|
|
interaction.notify({ type: "progress", message: "Enabling models..." });
|
|
enabledModelIds = await enableGitHubCopilotModels(
|
|
credentials.access,
|
|
models.policyModelIds,
|
|
enterpriseDomain ?? undefined,
|
|
interaction.signal,
|
|
);
|
|
}
|
|
return {
|
|
...credentials,
|
|
availableModelIds: [...new Set([...models.availableModelIds, ...enabledModelIds])],
|
|
};
|
|
}
|
|
|
|
function copilotEnterpriseDomain(credential: OAuthCredential): string | undefined {
|
|
const enterpriseUrl = credential.enterpriseUrl;
|
|
if (typeof enterpriseUrl !== "string" || !enterpriseUrl) return undefined;
|
|
return normalizeDomain(enterpriseUrl) ?? undefined;
|
|
}
|
|
|
|
export const githubCopilotOAuth: OAuthAuth = {
|
|
name: "GitHub Copilot",
|
|
isSubscription: true,
|
|
login: loginGitHubCopilot,
|
|
refresh: (credential, signal) =>
|
|
refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal),
|
|
|
|
/** Derive the credential-specific proxy endpoint for each request. */
|
|
async toAuth(credential) {
|
|
return {
|
|
apiKey: credential.access,
|
|
baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)),
|
|
};
|
|
},
|
|
};
|