chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -52,7 +52,7 @@ export function lazyOAuth(input: {
name: input.name,
isSubscription: input.isSubscription,
loginLabel: input.loginLabel,
login: async (interaction) => (await loaded()).login(interaction),
login: async (interaction, options) => (await loaded()).login(interaction, options),
refresh: async (credential, signal) => (await loaded()).refresh(credential, signal),
toAuth: async (credential) => (await loaded()).toAuth(credential),
};

View File

@@ -5,49 +5,26 @@
* It is only intended for CLI use, not browser environments.
*/
import type { Server } from "node:http";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { generatePKCE } from "./pkce.ts";
type CallbackServerInfo = {
server: Server;
redirectUri: string;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string; state: string } | null>;
};
type NodeApis = {
createServer: typeof import("node:http").createServer;
};
let nodeApis: NodeApis | null = null;
let nodeApisPromise: Promise<NodeApis> | null = null;
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
// Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port,
// so login falls back to a free port when this one cannot be bound (#10571).
const CALLBACK_PORT = 53692;
const CALLBACK_PATH = "/callback";
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
const COPY_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
const ANTHROPIC_BROWSER_LOGIN_METHOD = "browser";
const ANTHROPIC_COPY_CODE_LOGIN_METHOD = "copy_code";
const SCOPES =
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
async function getNodeApis(): Promise<NodeApis> {
if (nodeApis) return nodeApis;
if (!nodeApisPromise) {
if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) {
throw new Error("Anthropic OAuth is only available in Node.js environments");
}
nodeApisPromise = import("node:http").then((httpModule) => ({
createServer: httpModule.createServer,
}));
}
nodeApis = await nodeApisPromise;
return nodeApis;
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
@@ -96,77 +73,6 @@ function formatErrorDetails(error: unknown): string {
return String(error);
}
async function startCallbackServer(expectedState: string): Promise<CallbackServerInfo> {
const { createServer } = await getNodeApis();
return new Promise((resolve, reject) => {
let settleWait: ((value: { code: string; state: string } | null) => void) | undefined;
const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {
let settled = false;
settleWait = (value) => {
if (settled) return;
settled = true;
resolveWait(value);
};
});
const server = createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Callback route not found."));
return;
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`));
return;
}
if (!code || !state) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Missing code or state parameter."));
return;
}
if (state !== expectedState) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("State mismatch."));
return;
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window."));
settleWait?.({ code, state });
} catch {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Internal error");
}
});
server.on("error", (err) => {
reject(err);
});
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
server,
redirectUri: REDIRECT_URI,
cancelWait: () => {
settleWait?.(null);
},
waitForCode: () => waitForCodePromise,
});
});
});
}
async function postJson(url: string, body: Record<string, string | number>, signal: AbortSignal): Promise<string> {
const response = await fetch(url, {
method: "POST",
@@ -233,22 +139,29 @@ async function exchangeAuthorizationCode(
async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier);
const manualAbort = new AbortController();
const onAbort = () => server.cancelWait();
interaction.signal.addEventListener("abort", onAbort, { once: true });
if (interaction.signal.aborted) onAbort();
let code: string | undefined;
let state: string | undefined;
let manualInput: string | undefined;
let manualError: Error | undefined;
const startCallbackServer = (port: number) =>
startOAuthCallbackServer({
providerName: "Anthropic",
host: CALLBACK_HOST,
port,
path: CALLBACK_PATH,
redirectHost: "localhost",
state: verifier,
complete: async (code) => code,
signal: interaction.signal,
});
// Without a callback server, login continues with the pasted redirect URL.
const callback = await startCallbackServer(CALLBACK_PORT)
.catch(() => startCallbackServer(0))
.catch(() => undefined);
const redirectUri = callback?.redirectUri ?? REDIRECT_URI;
try {
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
redirect_uri: redirectUri,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
@@ -261,56 +174,66 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAu
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
});
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
})
.then((input) => {
manualInput = input;
server.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
server.cancelWait();
});
const result = await server.waitForCode();
if (manualError) throw manualError;
if (result?.code) {
code = result.code;
state = result.state;
} else if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
const result = await waitForCallbackOrManualInput(interaction, callback, {
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: redirectUri,
});
let code: string | undefined;
let state = verifier;
if (result.type === "callback") {
code = result.value;
} else {
const parsed = parseAuthorizationInput(result.input);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
code = parsed.code;
state = parsed.state ?? verifier;
}
if (!code) {
await manualPromise;
if (manualError) throw manualError;
if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
code = parsed.code;
state = parsed.state ?? verifier;
}
}
if (!code) throw new Error("Missing authorization code");
if (!state) throw new Error("Missing OAuth state");
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal);
return await exchangeAuthorizationCode(code, state, verifier, redirectUri, interaction.signal);
} finally {
interaction.signal.removeEventListener("abort", onAbort);
manualAbort.abort();
server.server.close();
callback?.close();
}
}
async function loginAnthropicCopyCode(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: COPY_CODE_REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
interaction.notify({
type: "auth_url",
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
instructions: "Complete login in your browser, then copy the code Anthropic shows and paste it here.",
});
const input = await interaction.prompt({
type: "manual_code",
message: "Paste the code Anthropic shows after you sign in:",
placeholder: "code#state",
signal: interaction.signal,
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
if (!parsed.code) throw new Error("Missing authorization code");
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return await exchangeAuthorizationCode(
parsed.code,
parsed.state ?? verifier,
verifier,
COPY_CODE_REDIRECT_URI,
interaction.signal,
);
}
/**
* Refresh Anthropic OAuth token
*/
@@ -355,7 +278,27 @@ async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal):
export const anthropicOAuth: OAuthAuth = {
name: "Anthropic (Claude Pro/Max)",
isSubscription: true,
login: loginAnthropic,
async login(interaction) {
const method = await interaction.prompt({
type: "select",
message: "Select Anthropic login method:",
options: [
{ id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
{ id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" },
],
});
if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) {
return loginAnthropicCopyCode(interaction);
}
if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown Anthropic login method: ${method}`);
}
return loginAnthropic(interaction);
},
refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),
async toAuth(credential) {

View File

@@ -0,0 +1,183 @@
/**
* Loopback OAuth redirect handler shared by the browser sign-in flows.
*
* NOTE: This module uses node:http. It is only reachable through the lazily
* loaded OAuth flow modules, never from browser-facing entry points.
*/
import { createServer, type ServerResponse } from "node:http";
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
import type { ProviderAuthInteraction } from "../types.ts";
export interface OAuthCallbackServerOptions<T> {
/** Provider name used on the browser page, for example `OpenAI`. */
providerName: string;
/** Address to listen on. */
host: string;
/** Port to listen on; `0` picks a free port. */
port: number;
path: string;
/** Host in `redirectUri` when it differs from `host`, for example `localhost`. */
redirectHost?: string;
/** Expected `state` parameter. Omit when the provider does not send one. */
state?: string;
/**
* Finishes the sign-in with the received code before the browser page is sent, so the page can
* show exchange failures. Pass `async (code) => code` to exchange the code later.
*/
complete: (code: string) => Promise<T>;
signal?: AbortSignal;
timeoutMs?: number;
}
export interface OAuthCallbackServer<T> {
redirectUri: string;
/**
* Resolves with the result of `complete`, or `undefined` after `cancel()`. Rejects when the provider
* redirects with an error, `complete` fails, the signal aborts, or the timeout elapses.
*/
wait(): Promise<T | undefined>;
/** Stop waiting for the browser unless a callback is already being completed. */
cancel(): void;
close(): void;
}
function sendPage(response: ServerResponse, status: number, html: string): void {
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
response.end(html);
}
export async function startOAuthCallbackServer<T>(
options: OAuthCallbackServerOptions<T>,
): Promise<OAuthCallbackServer<T>> {
const { providerName, signal } = options;
if (signal?.aborted) throw new Error("Login cancelled");
let resolveWait: (value: T | undefined) => void = () => {};
let rejectWait: (error: Error) => void = () => {};
const waitPromise = new Promise<T | undefined>((resolve, reject) => {
resolveWait = resolve;
rejectWait = reject;
});
// A cancelled or closed wait may never be observed.
waitPromise.catch(() => undefined);
let claimed = false;
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const onAbort = () => finish({ error: new Error("Login cancelled") });
const finish = (result: { value: T | undefined } | { error: Error }): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
if ("error" in result) rejectWait(result.error);
else resolveWait(result.value);
};
const server = createServer((request, response) => {
void (async () => {
const url = new URL(request.url ?? "/", "http://localhost");
if (request.method !== "GET" || url.pathname !== options.path) {
sendPage(response, 404, oauthErrorHtml("Callback route not found."));
return;
}
if (options.state !== undefined && url.searchParams.get("state") !== options.state) {
sendPage(response, 400, oauthErrorHtml("State mismatch."));
return;
}
if (claimed || settled) {
sendPage(response, 409, oauthErrorHtml("This sign-in has already been handled."));
return;
}
const error = url.searchParams.get("error");
if (error) {
const description = url.searchParams.get("error_description") ?? error;
sendPage(response, 400, oauthErrorHtml(`${providerName} authorization failed.`, description));
finish({ error: new Error(`${providerName} authorization failed: ${description}`) });
return;
}
const code = url.searchParams.get("code");
if (!code) {
sendPage(response, 400, oauthErrorHtml("Missing authorization code."));
return;
}
claimed = true;
try {
const value = await options.complete(code);
sendPage(response, 200, oauthSuccessHtml(`Signed in to ${providerName}. You may now close this page.`));
finish({ value });
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
sendPage(response, 502, oauthErrorHtml(`${providerName} sign-in failed.`, failure.message));
finish({ error: failure });
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port, options.host, () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("OAuth callback server did not bind to TCP");
}
server.on("error", (error) => finish({ error }));
signal?.addEventListener("abort", onAbort, { once: true });
if (options.timeoutMs !== undefined) {
timer = setTimeout(() => finish({ error: new Error(`${providerName} sign-in timed out`) }), options.timeoutMs);
}
const redirectHost = options.redirectHost ?? options.host;
return {
redirectUri: `http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${options.path}`,
wait: () => waitPromise,
cancel: () => {
if (!claimed) finish({ value: undefined });
},
close: () => {
finish({ error: new Error("OAuth callback server closed") });
server.close();
},
};
}
/**
* Wait for the browser callback, or for the user to paste the code or redirect URL when the browser
* cannot reach the loopback server (for example over SSH). Without a callback server only the manual
* prompt is used.
*/
export async function waitForCallbackOrManualInput<T>(
interaction: ProviderAuthInteraction,
callback: OAuthCallbackServer<T> | undefined,
prompt: { message: string; placeholder: string },
): Promise<{ type: "callback"; value: T } | { type: "manual"; input: string }> {
const manualAbort = new AbortController();
let manualError: Error | undefined;
const manual = interaction
.prompt({ type: "manual_code", ...prompt, signal: manualAbort.signal })
.then((input) => {
callback?.cancel();
return input;
})
.catch((error: unknown) => {
manualError = error instanceof Error ? error : new Error(String(error));
callback?.cancel();
return undefined;
});
try {
const value = await callback?.wait();
if (manualError) throw manualError;
if (value !== undefined) return { type: "callback", value };
const input = await manual;
if (manualError) throw manualError;
return { type: "manual", input: input ?? "" };
} finally {
manualAbort.abort();
}
}

View File

@@ -14,9 +14,11 @@ const importOAuthModule = (specifier: string): Promise<unknown> => {
type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
openaiChatGPT: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
meta: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => OAuthAuth | Promise<OAuthAuth>;
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
};
@@ -38,6 +40,11 @@ export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> => {
return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth;
};
export const loadOpenAIChatGPTOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openaiChatGPT();
return ((await importOAuthModule("./openai-chatgpt.ts")) as { openaiChatGPTOAuth: OAuthAuth }).openaiChatGPTOAuth;
};
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.githubCopilot();
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
@@ -53,6 +60,11 @@ export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
};
export const loadMetaOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.meta();
return ((await importOAuthModule("./meta.ts")) as { metaOAuth: OAuthAuth }).metaOAuth;
};
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.xai();
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;

View File

@@ -0,0 +1,208 @@
/**
* Meta Model API OAuth flow
*
* RFC 8628 device authorization grant against https://auth.meta.com (JSON
* responses). Meta splits identity from API access: the resulting identity
* token is not accepted for inference, so it is exchanged for a Model API
* key via the Muse Code key-mint endpoint (minted keys live about a day).
* The identity token is stored as `refresh` and the minted key as `access`,
* so the standard OAuth scheduler re-mints the key when it expires with no
* bespoke renewal machinery. The identity token itself is not renewable
* (auth.meta.com answers grant_type=refresh_token with 404 and issues no
* refresh_token), so a 401/403 from mint means the session is dead and the
* user must sign in again.
*/
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
// Muse Code CLI client id.
const CLIENT_ID = "1031625952748946";
const AUTH_HOST = "https://auth.meta.com";
const DEVICE_AUTHORIZATION_URL = `${AUTH_HOST}/oidc/device/authorization/`;
const DEVICE_TOKEN_URL = `${AUTH_HOST}/oidc/device/token/`;
const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key";
const API_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 30 * 1000;
type DeviceAuthorization = {
deviceCode: string;
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
};
function requestSignal(signal: AbortSignal): AbortSignal {
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
}
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
try {
const json = await response.json();
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
} catch {
return null;
}
}
function errorDetail(json: Record<string, unknown> | null): string {
for (const key of ["error_description", "detail", "message", "error"]) {
const value = json?.[key];
if (typeof value === "string" && value.trim()) return `: ${value.trim()}`;
}
return "";
}
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
function positiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
async function startDeviceAuthorization(signal: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(DEVICE_AUTHORIZATION_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
signal: requestSignal(signal),
});
const json = await readJson(response);
if (!response.ok) {
throw new Error(`Meta device authorization failed with status ${response.status}${errorDetail(json)}`);
}
const deviceCode = json?.device_code;
const userCode = json?.user_code;
const verificationUri = trustedHttpUrl(json?.verification_uri_complete) ?? trustedHttpUrl(json?.verification_uri);
if (typeof deviceCode !== "string" || !deviceCode || typeof userCode !== "string" || !userCode || !verificationUri) {
throw new Error(`Invalid Meta device authorization response: ${JSON.stringify(json)}`);
}
return {
deviceCode,
userCode,
verificationUri,
intervalSeconds: positiveNumber(json?.interval),
expiresInSeconds: positiveNumber(json?.expires_in),
};
}
async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSignal): Promise<string> {
return pollOAuthDeviceCodeFlow<string>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await fetch(DEVICE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device.deviceCode,
client_id: CLIENT_ID,
}).toString(),
signal: requestSignal(signal),
});
const json = await readJson(response);
if (response.ok && typeof json?.access_token === "string" && json.access_token) {
return { status: "complete", value: json.access_token };
}
switch (json?.error) {
case "authorization_pending":
return { status: "pending" };
case "slow_down":
return { status: "slow_down", intervalSeconds: positiveNumber(json?.interval) };
case "access_denied":
return { status: "failed", message: "Meta login was denied." };
case "expired_token":
return { status: "failed", message: "Meta device authorization expired. Please restart login." };
default:
return {
status: "failed",
message: `Meta device token request failed with status ${response.status}${errorDetail(json)}`,
};
}
},
});
}
/** Exchange an identity token for a Model API key. Keys are valid for about a day. */
async function mintApiKey(identityToken: string, signal: AbortSignal): Promise<OAuthCredential> {
const response = await fetch(API_KEY_MINT_URL, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${identityToken}`,
"Content-Type": "application/json",
"x-api-version": "1.0.0",
},
body: "{}",
signal: requestSignal(signal),
});
const json = await readJson(response);
if (response.status === 401 || response.status === 403) {
// Identity token is not renewable (see file header); only a fresh device flow helps.
throw new Error(
`Meta session expired (status ${response.status}). Run \`/login meta\` to sign in again.${errorDetail(json)}`,
);
}
if (!response.ok) {
throw new Error(`Meta API key mint failed with status ${response.status}${errorDetail(json)}`);
}
const apiKey = json?.api_key;
if (typeof apiKey !== "string" || !apiKey) {
const actionUrl = trustedHttpUrl(json?.action_url);
throw new Error(`Meta did not issue an API key.${actionUrl ? ` Complete setup at ${actionUrl}` : ""}`);
}
return { type: "oauth", refresh: identityToken, access: apiKey, expires: Date.now() + API_KEY_LIFETIME_MS };
}
async function loginMeta(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
try {
const device = await startDeviceAuthorization(interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: device.verificationUri,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
});
const identityToken = await pollForIdentityToken(device, interaction.signal);
interaction.notify({ type: "progress", message: "Enabling Meta Model API access..." });
return await mintApiKey(identityToken, interaction.signal);
} catch (e) {
// An in-flight fetch rejects with a DOMException on abort; the login UI matches on this message.
if (interaction.signal.aborted) throw new Error("Login cancelled");
throw e;
}
}
export const metaOAuth: OAuthAuth = {
name: "Meta (Muse subscription)",
isSubscription: true,
loginLabel: "Sign in with Meta",
login: loginMeta,
refresh: (credential, signal) => mintApiKey(credential.refresh, signal),
async toAuth(credential) {
return { apiKey: credential.access };
},
};

View File

@@ -1,109 +0,0 @@
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>`;
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderPage(options: { title: string; heading: string; message: string; details?: string }): string {
const title = escapeHtml(options.title);
const heading = escapeHtml(options.heading);
const message = escapeHtml(options.message);
const details = options.details ? escapeHtml(options.details) : undefined;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root {
--text: #fafafa;
--text-dim: #a1a1aa;
--page-bg: #09090b;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--page-bg);
color: var(--text);
font-family: var(--font-sans);
text-align: center;
}
main {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
width: 72px;
height: 72px;
display: block;
margin-bottom: 24px;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
line-height: 1.15;
font-weight: 650;
color: var(--text);
}
p {
margin: 0;
line-height: 1.7;
color: var(--text-dim);
font-size: 15px;
}
.details {
margin-top: 16px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-dim);
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<main>
<div class="logo">${LOGO_SVG}</div>
<h1>${heading}</h1>
<p>${message}</p>
${details ? `<div class="details">${details}</div>` : ""}
</main>
</body>
</html>`;
}
export function oauthSuccessHtml(message: string): string {
return renderPage({
title: "Authentication successful",
heading: "Authentication successful",
message,
});
}
export function oauthErrorHtml(message: string, details?: string): string {
return renderPage({
title: "Authentication failed",
heading: "Authentication failed",
message,
details,
});
}

View File

@@ -0,0 +1,309 @@
/**
* OpenAI Responses API token sharing through Sign in with ChatGPT.
*
* This public-client flow uses no client secret and sends the resulting user
* access token directly to api.openai.com.
*/
import { randomBytes } from "node:crypto";
import { createServer, type Server, type ServerResponse } from "node:http";
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { generatePKCE } from "./pkce.ts";
// every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
const DYNAMIC_CLIENT_ID = "dynamic_agent_client";
const AGENT_NAME_HINT = "Pi";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize";
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
const RESOURCE = "https://api.openai.com/v1";
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
const CALLBACK_PORT = 1455;
const CALLBACK_PATH = "/auth/callback";
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
const DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct";
const SCOPE = `openid profile email offline_access resource.invoke ${DIRECT_TOKEN_SCOPE}`;
// Refresh this long before the real expiry so a request never starts with a token about to expire.
const EXPIRY_MARGIN_MS = 3 * 60 * 1000;
type AuthorizationResult = {
code: string;
clientId: string;
};
type CallbackServer = {
server: Server;
result: Promise<AuthorizationResult>;
};
type TokenResponse = {
access_token?: unknown;
refresh_token?: unknown;
expires_in?: unknown;
id_token?: unknown;
scope?: unknown;
};
function randomValue(): string {
return randomBytes(32).toString("base64url");
}
function authorizationResultFromCallback(url: URL, expectedState: string): AuthorizationResult {
const code = url.searchParams.get("code");
if (!code) throw new Error("Missing authorization code");
const state = url.searchParams.get("state");
if (!state) throw new Error("Missing OAuth state");
if (state !== expectedState) throw new Error("OAuth state mismatch");
const clientId = url.searchParams.get("client_id")?.trim();
if (!clientId) throw new Error("OpenAI OAuth registration callback did not contain an issued client ID");
return { code, clientId };
}
function authorizationResultFromManualInput(input: string, expectedState: string): AuthorizationResult {
let url: URL;
try {
url = new URL(input.trim());
} catch {
throw new Error("Paste the full callback URL from the browser");
}
const expected = new URL(REDIRECT_URI);
if (url.origin !== expected.origin || url.pathname !== expected.pathname) {
throw new Error(`The pasted callback URL must start with ${REDIRECT_URI}`);
}
const error = url.searchParams.get("error");
if (error) throw new Error(`ChatGPT authorization failed: ${error}`);
return authorizationResultFromCallback(url, expectedState);
}
function sendHtml(response: ServerResponse, status: number, body: string): void {
response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
response.end(body);
}
function startCallbackServer(expectedState: string): Promise<CallbackServer> {
return new Promise((resolve, reject) => {
let resolveResult!: (result: AuthorizationResult) => void;
let rejectResult!: (error: Error) => void;
const result = new Promise<AuthorizationResult>((resolveAuthorization, rejectAuthorization) => {
resolveResult = resolveAuthorization;
rejectResult = rejectAuthorization;
});
const server = createServer((request, response) => {
try {
const url = new URL(request.url || "", REDIRECT_URI);
if (url.pathname !== CALLBACK_PATH) {
sendHtml(response, 404, oauthErrorHtml("Callback route not found."));
return;
}
const error = url.searchParams.get("error");
if (error) {
sendHtml(response, 400, oauthErrorHtml("ChatGPT was not connected.", `Error: ${error}`));
rejectResult(new Error(`ChatGPT authorization failed: ${error}`));
return;
}
let authorizationResult: AuthorizationResult;
try {
authorizationResult = authorizationResultFromCallback(url, expectedState);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid callback";
sendHtml(response, 400, oauthErrorHtml(message));
return;
}
sendHtml(response, 200, oauthSuccessHtml("ChatGPT authentication completed. You can close this window."));
resolveResult(authorizationResult);
} catch {
sendHtml(response, 500, oauthErrorHtml("Internal error while processing the callback."));
}
});
server.once("error", reject);
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
server.removeListener("error", reject);
server.on("error", rejectResult);
resolve({ server, result });
});
});
}
async function requestToken(body: URLSearchParams, signal: AbortSignal): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body,
signal,
});
if (!response.ok) {
const responseBody = await response.text().catch(() => "");
throw new Error(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`);
}
const data: unknown = await response.json();
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error("OpenAI OAuth token response must be an object");
}
return data as TokenResponse;
}
function requireTokenString(value: unknown, field: "access_token" | "refresh_token" | "scope"): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`OpenAI OAuth token response has invalid ${field}`);
}
return value;
}
function credentialFromTokenResponse(token: TokenResponse, clientId: string): OAuthCredential {
const access = requireTokenString(token.access_token, "access_token");
const refresh = requireTokenString(token.refresh_token, "refresh_token");
const scope = requireTokenString(token.scope, "scope");
if (typeof token.expires_in !== "number" || !Number.isFinite(token.expires_in) || token.expires_in <= 0) {
throw new Error("OpenAI OAuth token response has invalid expires_in");
}
const scopes = scope.trim().split(/\s+/).filter(Boolean);
if (!scopes.includes(DIRECT_TOKEN_SCOPE)) {
throw new Error(`OpenAI OAuth grant did not include ${DIRECT_TOKEN_SCOPE}`);
}
return {
type: "oauth",
access,
refresh,
expires: Date.now() + token.expires_in * 1000 - EXPIRY_MARGIN_MS,
clientId,
scopes,
};
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
clientId: string,
signal: AbortSignal,
): Promise<OAuthCredential> {
const token = await requestToken(
new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
resource: RESOURCE,
}),
signal,
);
// Pi does not use the ID token to identify the user or read profile data.
// Keep the presence check as part of the token-response contract.
if (typeof token.id_token !== "string" || token.id_token.trim().length === 0) {
throw new Error("OpenAI OAuth token response did not contain an ID token");
}
return credentialFromTokenResponse(token, clientId);
}
async function refreshAccessToken(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential> {
const clientId = credential.clientId;
if (typeof clientId !== "string" || clientId.trim().length === 0) {
throw new Error("Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT");
}
const token = await requestToken(
new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: credential.refresh,
resource: RESOURCE,
}),
signal,
);
return credentialFromTokenResponse(token, clientId);
}
/** OpenAI identifies each installation ("agent host") by a stable URI such as `urn:uuid:<uuid>`. */
function agentHostId(deviceId: string | undefined): string {
if (!deviceId || !UUID_PATTERN.test(deviceId)) {
throw new Error("Sign in with ChatGPT requires a device ID (UUID) for this installation");
}
return `urn:uuid:${deviceId.toLowerCase()}`;
}
async function loginOpenAIChatGPT(
interaction: ProviderAuthInteraction,
options?: LoginOptions,
): Promise<OAuthCredential> {
const hostId = agentHostId(options?.getDeviceId?.());
const { verifier, challenge } = await generatePKCE();
const state = randomValue();
const nonce = randomValue();
// Without this server, the browser's callback would reach whatever else holds the port (another
// pending login or the Codex CLI), which rejects it as a state mismatch. Fail with a clear error instead.
const callback = await startCallbackServer(state).catch((error: unknown) => {
if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
throw new Error(
`Port ${CALLBACK_PORT} is in use, probably by an unfinished login in another pi session or by the Codex CLI. Cancel that login and try again.`,
);
});
const authorizationUrl = new URL(AUTHORIZE_URL);
authorizationUrl.search = new URLSearchParams({
client_id: DYNAMIC_CLIENT_ID,
agent_name_hint: options?.agentName ?? AGENT_NAME_HINT,
ext_agent_host_id: hostId,
response_type: "code",
redirect_uri: REDIRECT_URI,
resource: RESOURCE,
scope: SCOPE,
state,
code_challenge: challenge,
code_challenge_method: "S256",
nonce,
}).toString();
interaction.notify({
type: "auth_url",
url: authorizationUrl.toString(),
instructions:
"Complete sign-in in your browser. If the callback does not complete, paste the final redirect URL here.",
});
const manualAbort = new AbortController();
const manualCode = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the final redirect URL here:",
placeholder: REDIRECT_URI,
signal: AbortSignal.any([manualAbort.signal, interaction.signal]),
})
.then((input) => authorizationResultFromManualInput(input, state));
try {
const result = await Promise.race([callback.result, manualCode]);
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal);
} catch (error) {
if (interaction.signal.aborted) throw new Error("Login cancelled");
throw error;
} finally {
manualAbort.abort();
callback.server.close();
// close() only stops accepting new connections. Browsers open spare connections ahead of
// time, and one that has not sent a request yet stays open and attached to this server.
// A later login in the same process starts a new server with a new state, but the browser
// may send that login's callback over the spare connection. This server would then handle
// it and reject it with "OAuth state mismatch", and the new login would never see it.
callback.server.closeAllConnections();
}
}
export const openaiChatGPTOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT subscription)",
isSubscription: true,
loginLabel: "Sign in with ChatGPT",
login: loginOpenAIChatGPT,
refresh: refreshAccessToken,
async toAuth(credential) {
return { apiKey: credential.access };
},
};

View File

@@ -1,26 +1,22 @@
/**
* OpenAI Codex (ChatGPT OAuth) flow
*
* NOTE: This module uses Node.js crypto and http for the OAuth callback.
* NOTE: This module uses Node.js crypto and http (via callback-server.ts) for the OAuth callback.
* It is only intended for CLI use, not browser environments.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds
let _randomBytes: typeof import("node:crypto").randomBytes | null = null;
let _http: typeof import("node:http") | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
import("node:crypto").then((m) => {
_randomBytes = m.randomBytes;
});
import("node:http").then((m) => {
_http = m;
});
}
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -311,88 +307,6 @@ async function createAuthorizationFlow(
return { verifier, state, url: url.toString() };
}
type OAuthServerInfo = {
close: () => void;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string } | null>;
};
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
if (!_http) {
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
}
let settleWait: ((value: { code: string } | null) => void) | undefined;
const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => {
let settled = false;
settleWait = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
});
const server = _http.createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== "/auth/callback") {
res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("State mismatch."));
return;
}
const code = url.searchParams.get("code");
if (!code) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Missing authorization code."));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window."));
settleWait?.({ code });
} catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Internal error while processing OAuth callback."));
}
});
return new Promise((resolve) => {
server
.listen(1455, getCallbackHost(), () => {
resolve({
close: () => server.close(),
cancelWait: () => {
settleWait?.(null);
},
waitForCode: () => waitForCodePromise,
});
})
.on("error", (_err: NodeJS.ErrnoException) => {
settleWait?.(null);
resolve({
close: () => {
try {
server.close();
} catch {
// ignore
}
},
cancelWait: () => {},
waitForCode: async () => null,
});
});
});
}
function getAccountId(accessToken: string): string | null {
const payload = decodeJwt(accessToken);
const auth = payload?.[JWT_CLAIM_PATH];
@@ -442,16 +356,21 @@ async function loginOpenAICodexDeviceCode(interaction: ProviderAuthInteraction):
);
}
async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
const manualAbort = new AbortController();
const onAbort = () => server.cancelWait();
interaction.signal.addEventListener("abort", onAbort, { once: true });
if (interaction.signal.aborted) onAbort();
let code: string | undefined;
let manualCode: string | undefined;
let manualError: Error | undefined;
async function loginOpenAICodex(
interaction: ProviderAuthInteraction,
options?: LoginOptions,
): Promise<OAuthCredential> {
const { verifier, state, url } = await createAuthorizationFlow(options?.agentName);
// Port 1455 is shared with the Codex CLI; when it is taken, fall back to the pasted redirect URL.
const callback = await startOAuthCallbackServer({
providerName: "OpenAI",
host: getCallbackHost(),
port: 1455,
path: "/auth/callback",
state,
complete: async (code) => code,
signal: interaction.signal,
}).catch(() => undefined);
interaction.notify({
type: "auth_url",
@@ -460,48 +379,23 @@ async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise<O
});
try {
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
})
.then((input) => {
manualCode = input;
server.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
server.cancelWait();
});
const result = await server.waitForCode();
if (manualError) throw manualError;
if (result?.code) {
code = result.code;
} else if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
const result = await waitForCallbackOrManualInput(interaction, callback, {
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
});
let code: string | undefined;
if (result.type === "callback") {
code = result.value;
} else {
const parsed = parseAuthorizationInput(result.input);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
if (!code) {
await manualPromise;
if (manualError) throw manualError;
if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
}
if (!code) throw new Error("Missing authorization code");
return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal);
return await exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal);
} finally {
interaction.signal.removeEventListener("abort", onAbort);
manualAbort.abort();
server.close();
callback?.close();
}
}
@@ -516,7 +410,7 @@ export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)",
isSubscription: true,
async login(interaction) {
async login(interaction, options) {
const method = await interaction.prompt({
type: "select",
message: "Select OpenAI Codex login method:",
@@ -533,7 +427,7 @@ export const openaiCodexOAuth: OAuthAuth = {
throw new Error(`Unknown OpenAI Codex login method: ${method}`);
}
return loginOpenAICodex(interaction);
return loginOpenAICodex(interaction, options);
},
refresh: (credential, signal) => refreshOpenAICodexToken(credential.refresh, signal),

View File

@@ -7,14 +7,13 @@
* manual prompt so remote/headless sessions can paste the redirect URL when
* the browser cannot reach the loopback server.
*
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
* NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
*/
import { createServer, type Server, type ServerResponse } from "node:http";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { generatePKCE } from "./pkce.ts";
const AUTHORIZE_URL = "https://openrouter.ai/auth";
@@ -28,27 +27,6 @@ function getCallbackHost(): string {
type JsonObject = Record<string, unknown>;
type OpenRouterCallbackServer = {
callbackUrl: string;
/** Stop listening and release timers without settling `waitForCredential`. */
close: () => void;
/** Hand the login over to manual code entry unless a callback already claimed the exchange. */
cancelWait: () => void;
/**
* Resolves with the credential once a browser callback completes the key
* exchange, or with null once `cancelWait` hands the login over to manual
* code entry. Rejects on timeout, cancellation, or a failed exchange.
*/
waitForCredential: () => Promise<OAuthCredential | null>;
};
function sendHtml(response: ServerResponse, status: number, html: string): void {
response.statusCode = status;
response.setHeader("content-type", "text/html; charset=utf-8");
response.setHeader("cache-control", "no-store");
response.end(html);
}
function parseAuthorizationInput(input: string): string | undefined {
const value = input.trim();
if (!value) return undefined;
@@ -132,132 +110,30 @@ async function exchangeAuthorizationCode(
};
}
async function startCallbackServer(
callbackPath: string,
verifier: string,
signal: AbortSignal,
): Promise<OpenRouterCallbackServer> {
if (signal.aborted) throw new Error("Login cancelled");
const callbackHost = getCallbackHost();
let resolveCredential: (credential: OAuthCredential | null) => void = () => {};
let rejectCredential: (error: Error) => void = () => {};
const credential = new Promise<OAuthCredential | null>((resolve, reject) => {
resolveCredential = resolve;
rejectCredential = reject;
});
let server: Server;
let claimed = false;
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const close = (): void => {
if (timeout) clearTimeout(timeout);
if (onAbort) signal.removeEventListener("abort", onAbort);
server.close();
};
const finish = (result: { credential: OAuthCredential | null } | { error: Error }): void => {
if (settled) return;
settled = true;
close();
if ("credential" in result) resolveCredential(result.credential);
else rejectCredential(result.error);
};
server = createServer((request, response) => {
void (async () => {
const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`);
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found."));
return;
}
if (claimed || settled) {
sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used."));
return;
}
const oauthError = requestUrl.searchParams.get("error");
if (oauthError) {
const description = requestUrl.searchParams.get("error_description") ?? oauthError;
sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description));
finish({ error: new Error(`OpenRouter authorization failed: ${description}`) });
return;
}
const code = requestUrl.searchParams.get("code");
if (!code) {
sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code."));
return;
}
claimed = true;
try {
const result = await exchangeAuthorizationCode(code, verifier, signal);
sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page."));
finish({ credential: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown token exchange error";
sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message));
finish({ error: error instanceof Error ? error : new Error(message) });
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, callbackHost, () => {
server.removeListener("error", reject);
resolve();
});
});
server.on("error", (error) => finish({ error }));
onAbort = () => finish({ error: new Error("Login cancelled") });
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
close();
throw new Error("Login cancelled");
}
timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
const address = server.address();
if (!address || typeof address === "string") {
close();
throw new Error("Could not determine the OpenRouter OAuth callback port");
}
return {
callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
close,
// A claimed callback is already exchanging its code; let that exchange settle the login.
cancelWait: () => {
if (!claimed) finish({ credential: null });
},
waitForCredential: () => credential,
};
}
async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
const manualAbort = new AbortController();
let manualInput: string | undefined;
let manualError: Error | undefined;
// OpenRouter sends no `state`; the random path keeps stray requests from completing the sign-in.
const callback = await startOAuthCallbackServer({
providerName: "OpenRouter",
host: getCallbackHost(),
port: 0,
path: `/oauth/callback/${crypto.randomUUID()}`,
complete: (code) => exchangeAuthorizationCode(code, verifier, interaction.signal),
signal: interaction.signal,
timeoutMs: LOGIN_TIMEOUT_MS,
});
try {
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.search = new URLSearchParams({
callback_url: callback.callbackUrl,
callback_url: callback.redirectUri,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
interaction.notify({
type: "progress",
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
message: `Listening for OpenRouter OAuth callback on ${callback.redirectUri}`,
});
interaction.notify({
type: "auth_url",
@@ -266,34 +142,16 @@ async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise<OA
"Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.",
});
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
placeholder: callback.callbackUrl,
signal: manualAbort.signal,
})
.then((input) => {
manualInput = input;
callback.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
callback.cancelWait();
});
const credential = await callback.waitForCredential();
if (manualError) throw manualError;
if (credential) return credential;
await manualPromise;
if (manualError) throw manualError;
const code = manualInput ? parseAuthorizationInput(manualInput) : undefined;
const result = await waitForCallbackOrManualInput(interaction, callback, {
message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
placeholder: callback.redirectUri,
});
if (result.type === "callback") return result.value;
const code = parseAuthorizationInput(result.input);
if (!code) throw new Error("Missing authorization code");
interaction.notify({ type: "progress", message: "Exchanging authorization code for an API key..." });
return await exchangeAuthorizationCode(code, verifier, interaction.signal);
} finally {
manualAbort.abort();
callback.close();
}
}

View File

@@ -5,22 +5,14 @@
* gateway; only the interactive browser authorization endpoint is discovered.
* Model catalog loading is owned by the Radius provider.
*
* NOTE: This module uses node:http for the OAuth callback server.
* NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds
let _http: typeof import("node:http") | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
import("node:http").then((m) => {
_http = m;
});
}
import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const CALLBACK_HOST = "127.0.0.1";
@@ -139,84 +131,6 @@ async function requestOAuthToken(
};
}
type OAuthCallbackServer = {
waitForCode(): Promise<string | null>;
close(): void;
};
function startOAuthCallbackServer(expectedState: string, signal: AbortSignal): Promise<OAuthCallbackServer> {
if (!_http) {
throw new Error("Radius OAuth is only available in Node.js environments");
}
let settle: (code: string | null) => void = () => {};
let settled = false;
const wait = new Promise<string | null>((resolve) => {
settle = resolve;
});
const finish = (code: string | null) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
settle(code);
};
const onAbort = () => finish(null);
signal.addEventListener("abort", onAbort, { once: true });
const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => {
response.statusCode = status;
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(html);
};
const server = _http.createServer((request, response) => {
const url = new URL(request.url ?? "/", REDIRECT_URI);
if (url.pathname !== CALLBACK_PATH) {
sendPage(response, 404, oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== expectedState) {
sendPage(response, 400, oauthErrorHtml("OAuth state mismatch."));
return;
}
const error = url.searchParams.get("error");
if (error) {
sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error));
finish(null);
return;
}
const code = url.searchParams.get("code");
if (!code) {
sendPage(response, 400, oauthErrorHtml("Missing authorization code."));
return;
}
sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page."));
finish(code);
});
return new Promise((resolve) => {
server
.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
waitForCode: () => wait,
close: () => {
finish(null);
server.close();
},
});
})
.once("error", () => {
finish(null);
resolve({ waitForCode: async () => null, close: () => {} });
});
});
}
async function loginWithBrowser(
gateway: string,
authorizationEndpoint: string,
@@ -236,7 +150,26 @@ async function loginWithBrowser(
state,
}).toString();
const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
const callback = await startOAuthCallbackServer({
providerName: "Radius",
host: CALLBACK_HOST,
port: CALLBACK_PORT,
path: CALLBACK_PATH,
state,
complete: (code) =>
requestOAuthToken(
gateway,
new URLSearchParams({
grant_type: "authorization_code",
client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI,
code,
code_verifier: verifier,
}),
interaction.signal,
),
signal: interaction.signal,
});
interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
interaction.notify({
type: "auth_url",
@@ -245,26 +178,11 @@ async function loginWithBrowser(
});
try {
const code = await callbackServer.waitForCode();
if (!code) {
if (interaction.signal.aborted) {
throw new Error("Login cancelled");
}
throw new Error("OAuth callback did not complete.");
}
return await requestOAuthToken(
gateway,
new URLSearchParams({
grant_type: "authorization_code",
client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI,
code,
code_verifier: verifier,
}),
interaction.signal,
);
const credential = await callback.wait();
if (!credential) throw new Error("OAuth callback did not complete.");
return credential;
} finally {
callbackServer.close();
callback.close();
}
}

View File

@@ -1,6 +1,9 @@
import type { ProviderEnv } from "../types.ts";
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
import { formatThrownValue } from "../utils/diagnostics.ts";
import { ModelsError } from "../utils/models-error.ts";
export { ModelsError, type ModelsErrorCode } from "../utils/models-error.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
@@ -13,8 +16,6 @@ import type {
ProviderAuth,
} from "./types.ts";
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export interface AuthResolutionOverrides {
apiKey?: string;
env?: ProviderEnv;
@@ -23,26 +24,8 @@ export interface AuthResolutionOverrides {
signal?: AbortSignal;
}
export class ModelsError extends Error {
readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(withCauseDetail(message, options?.cause), options);
this.name = "ModelsError";
this.code = code;
}
}
/** Callers surface `error.message` only, so keep the underlying reason in it. */
function withCauseDetail(message: string, cause: unknown): string {
if (cause === undefined || cause === null) return message;
const detail = formatThrownValue(cause).trim();
if (!detail || message.includes(detail)) return message;
return `${message}: ${detail}`;
}
/**
* Auth resolution shared by the `Models` and `ImagesModels` collections.
* Auth resolution shared by all operations in a `Models` collection.
* A stored credential owns the provider: ambient/env is consulted only when
* nothing is stored. No silent env fallback after a failed refresh or for a
* credential type without a matching handler.
@@ -119,10 +102,61 @@ function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext
const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000;
const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000;
/**
* Refresh a stored OAuth credential under the credential-store lock and persist
* the result before the lock is released. `needsRefresh` is re-checked under
* the lock, so concurrent callers and processes refresh only once.
*
* `signal` cancels only the wait for the lock. Once a refresh starts, the
* provider may already have rotated the refresh token, so the refresh and its
* persistence ignore `signal` and are bounded only by a timeout. Otherwise a
* cancelled caller could discard the only valid refresh token. Callers that
* must return promptly on cancellation race this promise with their signal.
*
* Resolves with the stored OAuth credential after the operation, or undefined
* when the provider no longer has an OAuth credential.
*/
export async function refreshStoredOAuthCredential(
credentials: CredentialStore,
providerId: string,
oauth: OAuthAuth,
needsRefresh: (credential: OAuthCredential) => boolean,
signal: AbortSignal,
): Promise<OAuthCredential | undefined> {
let post: Credential | undefined;
const lockWait = new AbortController();
const cancelLockWait = () => lockWait.abort(signal.reason);
signal.addEventListener("abort", cancelLockWait, { once: true });
if (signal.aborted) cancelLockWait();
try {
post = await credentials.modify(
providerId,
async (current) => {
signal.removeEventListener("abort", cancelLockWait);
signal.throwIfAborted();
if (current?.type !== "oauth") return undefined; // logged out meanwhile
if (!needsRefresh(current)) return undefined; // another process/request refreshed
try {
return await oauth.refresh(current, AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS));
} catch (error) {
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
}
},
{ signal: lockWait.signal },
);
} catch (error) {
if (error instanceof ModelsError) throw error;
signal.throwIfAborted();
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
} finally {
signal.removeEventListener("abort", cancelLockWait);
}
return post?.type === "oauth" ? post : undefined;
}
/**
* OAuth resolution with double-checked locking: tokens with less than five
* minutes remaining lock, re-check expiry under the lock, refresh once
* globally, and persist the rotated credential before release.
* minutes remaining are refreshed through `refreshStoredOAuthCredential`.
*/
async function resolveStoredOAuth(
credentials: CredentialStore,
@@ -138,30 +172,8 @@ async function resolveStoredOAuth(
if (expiresSoon(credential)) {
// Optimistic check said expired; the authoritative check runs under the lock.
let post: Credential | undefined;
try {
post = await credentials.modify(
providerId,
async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
if (!expiresSoon(current)) return undefined; // another process/request refreshed
try {
const refreshSignal = AbortSignal.any([
signal,
AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS),
]);
return await oauth.refresh(current, refreshSignal);
} catch (error) {
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
}
},
{ signal },
);
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
}
if (post?.type !== "oauth") return undefined; // logged out meanwhile
const post = await refreshStoredOAuthCredential(credentials, providerId, oauth, expiresSoon, signal);
if (!post) return undefined; // logged out meanwhile
credential = post;
// The normal five-minute window triggers a refresh but does not impose a
// provider contract. Explicit callers (such as bearer-token export) do

View File

@@ -198,6 +198,21 @@ export interface ApiKeyAuth {
}): Promise<AuthResult | undefined>;
}
/** App-supplied context for `Models.login`. */
export interface LoginOptions {
/**
* Returns the stable ID of this app installation, e.g. sent to OpenAI as its
* agent host ID. Called only by login flows that need it, so apps can create
* the ID on first use and must return the same ID on every later call.
*/
getDeviceId?: () => string;
/**
* Name this app introduces itself with during login, e.g. OpenAI's agent name hint and
* Codex originator. Defaults to pi's own name.
*/
agentName?: string;
}
/**
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
@@ -213,7 +228,7 @@ export interface OAuthAuth {
/** Selector label for the OAuth login option, e.g. "Sign in with SuperGrok or X Premium". */
loginLabel?: string;
login(interaction: ProviderAuthInteraction): Promise<OAuthCredential>;
login(interaction: ProviderAuthInteraction, options?: LoginOptions): Promise<OAuthCredential>;
/**
* Exchange the refresh token. Network call; throws on failure