mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(ai): add device code login callback
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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})` : ""}:`);
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from "./utils/json-parse.ts";
|
||||
export type {
|
||||
OAuthAuthInfo,
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProvider,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user