feat(ai): refactor device code login for copilot
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
|
||||
import { getModels } from "../../models.ts";
|
||||
import type { Api, Model } from "../../types.ts";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
|
||||
|
||||
type CopilotCredentials = OAuthCredentials & {
|
||||
enterpriseUrl?: string;
|
||||
@@ -20,14 +21,11 @@ const COPILOT_HEADERS = {
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
} as const;
|
||||
|
||||
const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2;
|
||||
const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4;
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval: number;
|
||||
interval?: number;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
@@ -40,7 +38,6 @@ type DeviceTokenSuccessResponse = {
|
||||
type DeviceTokenErrorResponse = {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export function normalizeDomain(input: string): string | null {
|
||||
@@ -129,7 +126,7 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
|
||||
typeof deviceCode !== "string" ||
|
||||
typeof userCode !== "string" ||
|
||||
typeof verificationUri !== "string" ||
|
||||
typeof interval !== "number" ||
|
||||
(interval !== undefined && typeof interval !== "number") ||
|
||||
typeof expiresIn !== "number"
|
||||
) {
|
||||
throw new Error("Invalid device code response fields");
|
||||
@@ -144,95 +141,48 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep that can be interrupted by an AbortSignal
|
||||
*/
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Login cancelled"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForGitHubAccessToken(
|
||||
domain: string,
|
||||
deviceCode: string,
|
||||
intervalSeconds: number,
|
||||
expiresIn: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) {
|
||||
const urls = getUrls(domain);
|
||||
const deadline = Date.now() + expiresIn * 1000;
|
||||
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
|
||||
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
|
||||
let slowDownResponses = 0;
|
||||
return pollOAuthDeviceCodeFlow({
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
|
||||
const remainingMs = deadline - Date.now();
|
||||
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
|
||||
await abortableSleep(waitMs, signal);
|
||||
|
||||
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: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
|
||||
return (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") {
|
||||
continue;
|
||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
|
||||
return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token };
|
||||
}
|
||||
|
||||
if (error === "slow_down") {
|
||||
slowDownResponses += 1;
|
||||
intervalMs =
|
||||
typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000);
|
||||
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
|
||||
continue;
|
||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
||||
const { error, error_description: description } = raw as DeviceTokenErrorResponse;
|
||||
if (error === "authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
if (error === "slow_down") {
|
||||
return { status: "slow_down" };
|
||||
}
|
||||
|
||||
const descriptionSuffix = description ? `: ${description}` : "";
|
||||
return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` };
|
||||
}
|
||||
|
||||
const descriptionSuffix = description ? `: ${description}` : "";
|
||||
throw new Error(`Device flow failed: ${error}${descriptionSuffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (slowDownResponses > 0) {
|
||||
throw new Error(
|
||||
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("Device flow timed out");
|
||||
return { status: "failed", message: "Invalid device token response" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,13 +269,13 @@ async function enableAllGitHubCopilotModels(
|
||||
/**
|
||||
* Login with GitHub Copilot OAuth (device code flow)
|
||||
*
|
||||
* @param options.onAuth - Callback with URL and optional instructions (user code)
|
||||
* @param options.onDeviceCode - Callback with URL and user code
|
||||
* @param options.onPrompt - Callback to prompt user for input
|
||||
* @param options.onProgress - Optional progress callback
|
||||
* @param options.signal - Optional AbortSignal for cancellation
|
||||
*/
|
||||
export async function loginGitHubCopilot(options: {
|
||||
onAuth: (url: string, instructions?: string) => void;
|
||||
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
|
||||
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
@@ -348,15 +298,14 @@ export async function loginGitHubCopilot(options: {
|
||||
const domain = enterpriseDomain || "github.com";
|
||||
|
||||
const device = await startDeviceFlow(domain);
|
||||
options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);
|
||||
options.onDeviceCode({
|
||||
userCode: device.user_code,
|
||||
verificationUri: device.verification_uri,
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
});
|
||||
|
||||
const githubAccessToken = await pollForGitHubAccessToken(
|
||||
domain,
|
||||
device.device_code,
|
||||
device.interval,
|
||||
device.expires_in,
|
||||
options.signal,
|
||||
);
|
||||
const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal);
|
||||
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
|
||||
|
||||
// Enable all models after successful login
|
||||
@@ -370,8 +319,12 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
|
||||
name: "GitHub Copilot",
|
||||
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
if (!callbacks.onDeviceCode) {
|
||||
throw new Error("GitHub Copilot OAuth requires a device code callback");
|
||||
}
|
||||
|
||||
return loginGitHubCopilot({
|
||||
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
|
||||
onDeviceCode: callbacks.onDeviceCode,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
signal: callbacks.signal,
|
||||
|
||||
Reference in New Issue
Block a user