feat(ai): add device code login callback

This commit is contained in:
Vegard Stikbakke
2026-05-20 08:58:32 +02:00
parent 04e93af5c4
commit 1408d04137
8 changed files with 129 additions and 9 deletions
+4
View File
@@ -7,6 +7,10 @@
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources.
### Added
- Added first-class OAuth device-code callback metadata for login flows and GitHub Copilot OAuth.
### Fixed
- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)).
+7
View File
@@ -42,6 +42,13 @@ async function login(providerId: OAuthProviderId): Promise<void> {
if (info.instructions) console.log(info.instructions);
console.log();
},
onDeviceCode: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`);
console.log(`Enter code: ${info.userCode}`);
if (info.instructions && info.instructions !== `Enter code: ${info.userCode}`)
console.log(info.instructions);
console.log();
},
onPrompt: async (p) => {
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
+1
View File
@@ -32,6 +32,7 @@ export * from "./utils/json-parse.ts";
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProvider,
+14 -2
View File
@@ -4,7 +4,7 @@
import { getModels } from "../../models.ts";
import type { Api, Model } from "../../types.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
type CopilotCredentials = OAuthCredentials & {
enterpriseUrl?: string;
@@ -326,6 +326,7 @@ async function enableAllGitHubCopilotModels(
*/
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,7 +349,17 @@ 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}`);
if (options.onDeviceCode) {
options.onDeviceCode({
userCode: device.user_code,
verificationUri: device.verification_uri,
instructions: `Enter code: ${device.user_code}`,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
} else {
options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);
}
const githubAccessToken = await pollForGitHubAccessToken(
domain,
@@ -372,6 +383,7 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginGitHubCopilot({
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
onDeviceCode: callbacks.onDeviceCode,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
signal: callbacks.signal,
+9
View File
@@ -23,6 +23,14 @@ export type OAuthAuthInfo = {
instructions?: string;
};
export type OAuthDeviceCodeInfo = {
userCode: string;
verificationUri: string;
instructions?: string;
intervalSeconds?: number;
expiresInSeconds?: number;
};
export type OAuthSelectOption = {
id: string;
label: string;
@@ -35,6 +43,7 @@ export type OAuthSelectPrompt = {
export interface OAuthLoginCallbacks {
onAuth: (info: OAuthAuthInfo) => void;
onDeviceCode?: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
@@ -29,6 +29,66 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.useRealTimers();
});
it("reports device-code details through onDeviceCode when available", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 1,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
return jsonResponse({ access_token: "ghu_refresh_token" });
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires_at: 9999999999,
});
}
if (url.includes("/models/") && url.endsWith("/policy")) {
return new Response("", { status: 200 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onAuth = vi.fn();
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({
onAuth,
onDeviceCode,
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(0);
expect(onDeviceCode).toHaveBeenCalledWith({
userCode: "ABCD-EFGH",
verificationUri: "https://github.com/login/device",
instructions: "Enter code: ABCD-EFGH",
intervalSeconds: 1,
expiresInSeconds: 900,
});
expect(onAuth).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1200);
await loginPromise;
});
it("waits before the first poll and increases the safety margin after slow_down", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-03-09T00:00:00Z");
@@ -1,4 +1,4 @@
import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { exec } from "child_process";
import { theme } from "../theme/theme.ts";
@@ -101,11 +101,36 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
}
// Try to open browser
this.openUrl(url);
this.tui.requestRender();
}
/**
* Called by onDeviceCode callback - show URL and user code.
*/
showDeviceCode(info: OAuthDeviceCodeInfo): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
if (info.instructions && info.instructions !== `Enter code: ${info.userCode}`) {
this.contentContainer.addChild(new Text(theme.fg("dim", info.instructions), 1, 0));
}
this.openUrl(info.verificationUri);
this.tui.requestRender();
}
private openUrl(url: string): void {
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
exec(`${openCmd} "${url}"`);
this.tui.requestRender();
}
/**
@@ -4815,13 +4815,15 @@ export class InteractiveMode {
manualCodeReject = undefined;
}
});
} else if (providerId === "github-copilot") {
// GitHub Copilot polls after onAuth
dialog.showWaiting("Waiting for browser authentication...");
}
// For Anthropic: onPrompt is called immediately after
},
onDeviceCode: (info) => {
dialog.showDeviceCode(info);
dialog.showWaiting("Waiting for authentication...");
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
return dialog.showPrompt(prompt.message, prompt.placeholder);
},