diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d4560aa0d..1db0e8a9f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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)). diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 38ee7e346..ead8e4377 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -42,6 +42,13 @@ async function login(providerId: OAuthProviderId): Promise { 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})` : ""}:`); }, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index fd06fe81b..ed7aeaa87 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -32,6 +32,7 @@ export * from "./utils/json-parse.ts"; export type { OAuthAuthInfo, OAuthCredentials, + OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthPrompt, OAuthProvider, diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 6182ab910..095b09511 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -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; 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 { return loginGitHubCopilot({ onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), + onDeviceCode: callbacks.onDeviceCode, onPrompt: callbacks.onPrompt, onProgress: callbacks.onProgress, signal: callbacks.signal, diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index a1426d815..540df973a 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -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; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 0367892cb..07b001f74 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -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 => { + 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"); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 80560f142..3ad97c25e 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -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(); } /** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c93beb93a..7616d7e0c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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); },