feat(ai,coding-agent): add alternative sign in for the openai provider

This commit is contained in:
David Brailovsky
2026-09-29 17:16:15 +02:00
committed by GitHub
parent 9d1a650352
commit 02eed88fd8
25 changed files with 846 additions and 64 deletions
+4 -3
View File
@@ -63,7 +63,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
- **OpenAI**
- **Ant Ling**
- **Azure OpenAI (Responses)**
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
- **OpenAI Codex (legacy)** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
- **Radius** (API key or OAuth, with a dynamically refreshed gateway catalog)
- **TypeSafe** (System One classifier API)
- **DeepSeek**
@@ -1729,7 +1729,8 @@ Use this when one process needs different provider settings per request, or when
Several providers support OAuth authentication instead of static API keys:
- **Anthropic** (Claude Pro/Max subscription)
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
- **OpenAI** (Sign in with ChatGPT: uses the ChatGPT subscription with the OpenAI API)
- **OpenAI Codex (legacy)** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
- **GitHub Copilot** (Copilot subscription)
- **OpenRouter** (OAuth PKCE that mints a user-controlled API key)
@@ -1809,7 +1810,7 @@ Built-in login and refresh flows are private provider implementations. Use provi
Provider notes:
**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call `cleanupSessionResources(sessionId)` when finished so the pooled connection does not keep the process alive.
**OpenAI Codex (legacy)**: Superseded by Sign in with ChatGPT on the OpenAI provider. Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call `cleanupSessionResources(sessionId)` when finished so the pooled connection does not keep the process alive.
**Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://<resource>.openai.azure.com` and `https://<resource>.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported.
+25 -5
View File
@@ -31,6 +31,20 @@ import { buildBaseOptions } from "./simple-options.ts";
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
const CHATGPT_USAGE_URL = "https://chatgpt.com/settings/usage";
/**
* OpenAI API keys start with `sk-`; a different credential sent directly to OpenAI
* is a Sign in with ChatGPT access token.
*/
function isChatGPTSignIn(model: Model<"openai-responses">, apiKey: string | undefined): boolean {
return (
model.provider === "openai" &&
model.baseUrl === "https://api.openai.com/v1" &&
apiKey !== undefined &&
!apiKey.startsWith("sk-")
);
}
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
if (!headers) return false;
@@ -205,10 +219,14 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
delete (block as { customInput?: unknown }).customInput;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(
const errorMessage = formatProviderError(
normalizeProviderError(error),
`${model.provider === "openai" ? "OpenAI" : model.provider} API error`,
);
// Sign in with ChatGPT shares the subscription's usage limit with other apps.
output.errorMessage = errorMessage.includes("subscription_sharing_usage_limit_exceeded")
? `${errorMessage}\nCheck your ChatGPT usage: ${CHATGPT_USAGE_URL}`
: errorMessage;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
@@ -307,21 +325,23 @@ function buildParams(
});
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
// Sign in with ChatGPT rejects these request fields.
const omitUnsupportedFields = isChatGPTSignIn(model, options?.apiKey);
const params: ResponseCreateParamsStreaming = {
model: model.id,
input: messages,
stream: true,
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
prompt_cache_options: getPromptCacheOptions(compat, cacheRetention),
prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention),
prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention),
store: false,
};
if (options?.maxTokens && compat.supportsMaxOutputTokens) {
if (options?.maxTokens && compat.supportsMaxOutputTokens && !omitUnsupportedFields) {
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
}
if (options?.temperature !== undefined) {
if (options?.temperature !== undefined && !omitUnsupportedFields) {
params.temperature = options?.temperature;
}
+1 -1
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),
};
+6
View File
@@ -14,6 +14,7 @@ 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>;
@@ -39,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;
@@ -0,0 +1,310 @@
/**
* 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();
let callback: CallbackServer | undefined;
try {
callback = await startCallbackServer(state);
} catch (error) {
interaction.notify({
type: "info",
message: `Could not listen on ${REDIRECT_URI}; paste the final redirect URL to continue. ${error instanceof Error ? error.message : String(error)}`,
});
}
const authorizationUrl = new URL(AUTHORIZE_URL);
authorizationUrl.search = new URLSearchParams({
client_id: DYNAMIC_CLIENT_ID,
agent_name_hint: 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 (callback ? Promise.race([callback.result, manualCode]) : 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 };
},
};
+11 -1
View File
@@ -198,6 +198,16 @@ 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;
}
/**
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
@@ -213,7 +223,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
+2
View File
@@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { metaOAuth } from "./auth/oauth/meta.ts";
import { openaiChatGPTOAuth } from "./auth/oauth/openai-chatgpt.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
@@ -13,6 +14,7 @@ export function registerBunOAuthFlows(): void {
registerBundledOAuthFlowLoaders({
anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth,
openaiChatGPT: () => openaiChatGPTOAuth,
githubCopilot: () => githubCopilotOAuth,
openrouter: () => openRouterOAuth,
kimiCoding: () => kimiCodingOAuth,
+24 -19
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
@@ -47,26 +48,30 @@ async function login(providerId: string): Promise<void> {
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const credential = await provider.auth.oauth.login({
signal: new AbortController().signal,
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
notify: (event) => {
switch (event.type) {
case "auth_url":
console.log(`\nOpen this URL in your browser:\n${event.url}`);
if (event.instructions) console.log(event.instructions);
break;
case "device_code":
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
console.log(`Enter code: ${event.userCode}`);
break;
case "info":
case "progress":
console.log(event.message);
break;
}
// This dev CLI does not persist an installation ID; apps should reuse one across logins.
const credential = await provider.auth.oauth.login(
{
signal: new AbortController().signal,
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
notify: (event) => {
switch (event.type) {
case "auth_url":
console.log(`\nOpen this URL in your browser:\n${event.url}`);
if (event.instructions) console.log(event.instructions);
break;
case "device_code":
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
console.log(`Enter code: ${event.userCode}`);
break;
case "info":
case "progress":
console.log(event.message);
break;
}
},
},
});
{ getDeviceId: randomUUID },
);
const auth = loadAuth();
auth[providerId] = credential;
saveAuth(auth);
+9 -3
View File
@@ -11,6 +11,7 @@ import type {
AuthType,
Credential,
CredentialStore,
LoginOptions,
ProviderAuth,
} from "./auth/types.ts";
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";
@@ -301,7 +302,7 @@ export interface Models {
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/** Run a provider-owned login flow and persist its returned credential. */
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise<Credential>;
/** Remove the stored credential for a provider. */
logout(providerId: string, options?: AuthOperationOptions): Promise<void>;
@@ -752,7 +753,12 @@ class ModelsImpl implements MutableModels {
};
}
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
async login(
providerId: string,
type: AuthType,
interaction: AuthInteraction,
options?: LoginOptions,
): Promise<Credential> {
const signal = operationSignal(interaction.signal);
signal.throwIfAborted();
const provider = this.providers.get(providerId);
@@ -761,7 +767,7 @@ class ModelsImpl implements MutableModels {
if (!method?.login) {
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
}
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal });
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
const credential = await raceWithAbortSignal(loginOperation, signal);
let mutationStarted = false;
let markMutationStarted: (() => void) | undefined;
+1 -1
View File
@@ -7,7 +7,7 @@ import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts";
export function openaiCodexProvider(): Provider<"openai-codex-responses"> {
return createProvider({
id: "openai-codex",
name: "OpenAI Codex",
name: "OpenAI Codex (legacy)",
baseUrl: "https://chatgpt.com/backend-api",
auth: {
oauth: lazyOAuth({
+11 -2
View File
@@ -1,5 +1,6 @@
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadOpenAIChatGPTOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts";
import { OPENAI_MODELS } from "./openai.models.ts";
@@ -8,7 +9,15 @@ export function openaiProvider(): Provider<"openai-responses"> {
id: "openai",
name: "OpenAI",
baseUrl: "https://api.openai.com/v1",
auth: { apiKey: envApiKeyAuth("OpenAI API key", ["OPENAI_API_KEY"]) },
auth: {
apiKey: envApiKeyAuth("OpenAI API key", ["OPENAI_API_KEY"]),
oauth: lazyOAuth({
name: "OpenAI (ChatGPT subscription)",
isSubscription: true,
loginLabel: "Sign in with ChatGPT",
load: loadOpenAIChatGPTOAuth,
}),
},
models: Object.values(OPENAI_MODELS),
api: openAIResponsesApi(),
});
+9
View File
@@ -21,6 +21,10 @@ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
"out of budget",
"quota exceeded",
"billing",
// Sign in with ChatGPT: the subscription's shared usage limit, which resets
// after hours rather than seconds.
"subscription_sharing_usage_limit_exceeded",
]);
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
@@ -89,6 +93,11 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
// gRPC based providers (e.g. NVIDIA NIM)
"ResourceExhausted",
// Sign in with ChatGPT: usage or user data temporarily unavailable. Usage
// failures can arrive mid-stream without an HTTP 503 in the message.
"subscription_sharing_usage_unavailable",
"subscription_sharing_user_unavailable",
]);
/**
+5 -5
View File
@@ -306,7 +306,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(proxyModel, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
@@ -333,7 +333,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "long",
sessionId: "session-compat-false",
onPayload: stopAfterPayload((payload) => {
@@ -358,7 +358,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
@@ -385,7 +385,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
@@ -416,7 +416,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "long",
sessionId: "session-2",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
+20 -1
View File
@@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
@@ -10,6 +11,7 @@ import { createModels } from "../src/models.ts";
import * as extensionOAuthCompatibility from "../src/oauth.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
import { openaiProvider } from "../src/providers/openai.ts";
const neverAbortedSignal = new AbortController().signal;
@@ -28,7 +30,14 @@ describe.sequential("OAuthAuth adapters", () => {
});
it("identifies only subscription-backed OAuth flows as subscriptions", () => {
for (const oauth of [anthropicOAuth, openaiCodexOAuth, githubCopilotOAuth, kimiCodingOAuth, xaiOAuth]) {
for (const oauth of [
anthropicOAuth,
openaiChatGPTOAuth,
openaiCodexOAuth,
githubCopilotOAuth,
kimiCodingOAuth,
xaiOAuth,
]) {
expect(oauth.isSubscription).toBe(true);
}
expect(openRouterOAuth.isSubscription).not.toBe(true);
@@ -39,6 +48,16 @@ describe.sequential("OAuthAuth adapters", () => {
expect(auth).toEqual({ apiKey: "token" });
});
it("OpenAI exposes ChatGPT OAuth alongside API-key auth", () => {
const provider = openaiProvider();
expect(provider.auth.apiKey).toBeDefined();
expect(provider.auth.oauth).toMatchObject({
name: "OpenAI (ChatGPT subscription)",
isSubscription: true,
loginLabel: "Sign in with ChatGPT",
});
});
it("openai-codex toAuth derives the api key from the access token", async () => {
const auth = await openaiCodexOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
import type { OAuthCredential, ProviderAuthInteraction } from "../src/auth/types.ts";
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
const REQUIRED_SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
const neverAbortedSignal = new AbortController().signal;
const DEVICE_ID = "e61bbe28-07ef-466d-8e5d-a344f94ab305";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function tokenResponse(scope = REQUIRED_SCOPE) {
return {
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
id_token: "id-token",
scope,
};
}
function stubTokenEndpoint(response: unknown, inspect?: (body: URLSearchParams) => void) {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(input instanceof Request ? input.url : String(input)).toBe(TOKEN_URL);
inspect?.(new URLSearchParams(String(init?.body)));
return jsonResponse(response);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function loginInteraction(options?: {
callbackClientId?: string;
onAuthorize?: (url: URL) => void;
}): ProviderAuthInteraction {
let authorizeUrl: URL | undefined;
return {
signal: neverAbortedSignal,
notify: (event) => {
if (event.type !== "auth_url") return;
authorizeUrl = new URL(event.url);
options?.onAuthorize?.(authorizeUrl);
},
prompt: async (prompt) => {
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
if (!authorizeUrl) throw new Error("Authorization URL was not emitted before the callback prompt");
const callback = new URL(authorizeUrl.searchParams.get("redirect_uri") ?? "");
callback.searchParams.set("code", "authorization-code");
callback.searchParams.set("state", authorizeUrl.searchParams.get("state") ?? "");
if (options?.callbackClientId) callback.searchParams.set("client_id", options.callbackClientId);
return callback.toString();
},
};
}
function connectedCredential(): OAuthCredential {
return {
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: 0,
clientId: "oaiapp_existing",
scopes: REQUIRED_SCOPE.split(" "),
};
}
describe("OpenAI ChatGPT OAuth", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("registers a user-owned client and stores its issued ID and granted scopes", async () => {
let authorizeUrl: URL | undefined;
let exchangeBody: URLSearchParams | undefined;
stubTokenEndpoint(tokenResponse(), (body) => {
exchangeBody = body;
});
const credential = await openaiChatGPTOAuth.login(
loginInteraction({
callbackClientId: "oaiapp_issued",
onAuthorize: (url) => {
authorizeUrl = url;
},
}),
{ getDeviceId: () => DEVICE_ID },
);
expect(authorizeUrl?.searchParams.get("client_id")).toBe("dynamic_agent_client");
expect(authorizeUrl?.searchParams.get("agent_name_hint")).toBe("Pi");
expect(authorizeUrl?.searchParams.get("ext_agent_host_id")).toBe(`urn:uuid:${DEVICE_ID}`);
expect(authorizeUrl?.searchParams.get("scope")).toBe(REQUIRED_SCOPE);
expect(authorizeUrl?.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:1455/auth/callback");
expect(authorizeUrl?.searchParams.get("resource")).toBe("https://api.openai.com/v1");
expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256");
expect(exchangeBody?.get("client_id")).toBe("oaiapp_issued");
expect(exchangeBody?.get("code")).toBe("authorization-code");
expect(exchangeBody?.get("resource")).toBe("https://api.openai.com/v1");
expect(exchangeBody?.get("code_verifier")).toBeTruthy();
expect(credential).toMatchObject({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
clientId: "oaiapp_issued",
scopes: REQUIRED_SCOPE.split(" "),
});
});
it("rejects registration without an issued client ID", async () => {
const fetchMock = stubTokenEndpoint(tokenResponse());
await expect(openaiChatGPTOAuth.login(loginInteraction(), { getDeviceId: () => DEVICE_ID })).rejects.toThrow(
"registration callback did not contain an issued client ID",
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects a token response that did not grant direct token use", async () => {
stubTokenEndpoint(tokenResponse("openid profile email offline_access resource.invoke"));
await expect(
openaiChatGPTOAuth.login(loginInteraction({ callbackClientId: "oaiapp_issued" }), {
getDeviceId: () => DEVICE_ID,
}),
).rejects.toThrow("grant did not include chatgpt.tokens.use.direct");
});
it("requires a device ID before starting authorization", async () => {
let authorizationStarted = false;
const interaction = loginInteraction({
onAuthorize: () => {
authorizationStarted = true;
},
});
await expect(openaiChatGPTOAuth.login(interaction)).rejects.toThrow("requires a device ID");
await expect(openaiChatGPTOAuth.login(interaction, { getDeviceId: () => "not-a-uuid" })).rejects.toThrow(
"requires a device ID",
);
expect(authorizationStarted).toBe(false);
});
it("requires refresh responses to rotate the refresh token", async () => {
const { refresh_token: _refreshToken, ...responseWithoutRefresh } = tokenResponse();
stubTokenEndpoint(responseWithoutRefresh);
await expect(openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal)).rejects.toThrow(
"token response has invalid refresh_token",
);
});
it("refreshes with the credential's issued client ID and stores replacement scopes", async () => {
let refreshBody: URLSearchParams | undefined;
stubTokenEndpoint({ ...tokenResponse(), access_token: "new-access", refresh_token: "new-refresh" }, (body) => {
refreshBody = body;
});
const before = Date.now();
const credential = await openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal);
// expires_in is 3600 seconds; the credential expires 3 minutes early so it is refreshed in time.
expect(credential.expires).toBeGreaterThanOrEqual(before + (3600 - 180) * 1000);
expect(credential.expires).toBeLessThanOrEqual(Date.now() + (3600 - 180) * 1000);
expect(refreshBody?.get("grant_type")).toBe("refresh_token");
expect(refreshBody?.get("client_id")).toBe("oaiapp_existing");
expect(refreshBody?.get("refresh_token")).toBe("old-refresh");
expect(refreshBody?.get("resource")).toBe("https://api.openai.com/v1");
expect(refreshBody?.has("scope")).toBe(false);
expect(credential).toMatchObject({
access: "new-access",
refresh: "new-refresh",
clientId: "oaiapp_existing",
scopes: REQUIRED_SCOPE.split(" "),
});
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const model: Model<"openai-responses"> = {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context = normalizeContext({
systemPrompt: "",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
tools: [],
});
async function capturePayload(
apiKey: string,
requestModel: Model<"openai-responses"> = model,
): Promise<Record<string, unknown>> {
let payload: Record<string, unknown> | undefined;
await streamOpenAIResponses(requestModel, context, {
apiKey,
maxTokens: 1000,
temperature: 0.5,
cacheRetention: "long",
onPayload: (params) => {
payload = params as Record<string, unknown>;
},
fetch: async () => new Response(null, { status: 500 }),
}).result();
if (!payload) throw new Error("Request payload was not captured");
return payload;
}
describe("OpenAI Responses with Sign in with ChatGPT", () => {
it("omits request fields that token sharing rejects", async () => {
const payload = await capturePayload("chatgpt-access-token");
expect(payload).not.toHaveProperty("max_output_tokens");
expect(payload).not.toHaveProperty("temperature");
expect(payload.prompt_cache_retention).toBeUndefined();
});
it("omits prompt_cache_options on models with explicit prompt cache mode", async () => {
const explicitCacheModel = { ...model, compat: { supportsExplicitPromptCacheMode: true } };
const signInPayload = await capturePayload("chatgpt-access-token", explicitCacheModel);
const apiKeyPayload = await capturePayload("sk-proj-test", explicitCacheModel);
expect(signInPayload.prompt_cache_options).toBeUndefined();
expect(apiKeyPayload.prompt_cache_options).toEqual({ ttl: "30m" });
});
it.each([
{ name: "OpenAI API keys", apiKey: "sk-proj-test", requestModel: model },
{
name: "other OpenAI-compatible endpoints",
apiKey: "gateway-key",
requestModel: { ...model, baseUrl: "https://gateway.example.com/v1" },
},
])("keeps those fields for $name", async ({ apiKey, requestModel }) => {
const payload = await capturePayload(apiKey, requestModel);
expect(payload.max_output_tokens).toBe(1000);
expect(payload.temperature).toBe(0.5);
expect(payload.prompt_cache_retention).toBe("24h");
});
});
@@ -57,7 +57,7 @@ async function captureOpenAIResponseHeaders(
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{ apiKey: "test-key", ...options },
{ apiKey: "sk-test-key", ...options },
);
for await (const event of stream) {
@@ -90,7 +90,7 @@ describe("openai-responses provider defaults", () => {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
@@ -136,7 +136,7 @@ describe("openai-responses provider defaults", () => {
],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
toolChoice: "required",
onPayload: (payload) => {
capturedPayload = payload;
@@ -187,7 +187,7 @@ describe("openai-responses provider defaults", () => {
],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
@@ -236,7 +236,7 @@ describe("openai-responses provider defaults", () => {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
@@ -272,7 +272,7 @@ describe("openai-responses provider defaults", () => {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
@@ -313,7 +313,7 @@ describe("openai-responses provider defaults", () => {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
sessionId,
onPayload: (payload) => {
capturedPayload = payload as Pick<CapturedResponsesPayload, "prompt_cache_key">;
@@ -519,7 +519,7 @@ describe("openai-responses provider defaults", () => {
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{ apiKey: "test-key", serviceTier },
{ apiKey: "sk-test-key", serviceTier },
);
const result = await stream.result();
@@ -556,7 +556,7 @@ describe("openai-responses max_output_tokens compat", () => {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
maxTokens: 1024,
onPayload: (payload) => {
capturedPayload = payload as { max_output_tokens?: number };
@@ -593,7 +593,7 @@ describe("openai-responses max_output_tokens compat", () => {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
maxTokens: 1024,
onPayload: (payload) => {
capturedPayload = payload as { max_output_tokens?: number };
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const usageLimitError = {
code: "subscription_sharing_usage_limit_exceeded",
message: "Usage limit reached.",
};
const model: Model<"openai-responses"> = {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context = normalizeContext({
systemPrompt: "",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
tools: [],
});
async function getErrorMessage(response: Response): Promise<string | undefined> {
const result = await streamOpenAIResponses(model, context, {
apiKey: "test",
fetch: async () => response,
}).result();
expect(result.stopReason).toBe("error");
return result.errorMessage;
}
describe("OpenAI Responses ChatGPT usage limit", () => {
it("links to ChatGPT usage when the request is rejected", async () => {
const response = new Response(JSON.stringify({ error: { ...usageLimitError, type: "rate_limit_error" } }), {
status: 429,
headers: { "content-type": "application/json" },
});
const errorMessage = await getErrorMessage(response);
expect(errorMessage).toContain("subscription_sharing_usage_limit_exceeded");
expect(errorMessage).toContain("Check your ChatGPT usage: https://chatgpt.com/settings/usage");
});
it("links to ChatGPT usage when the stream fails", async () => {
const event = {
type: "response.failed",
sequence_number: 0,
response: { id: "resp_failed", status: "failed", error: usageLimitError },
};
const response = new Response(`event: response.failed\ndata: ${JSON.stringify(event)}\n\n`, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
const errorMessage = await getErrorMessage(response);
expect(errorMessage).toContain("subscription_sharing_usage_limit_exceeded: Usage limit reached.");
expect(errorMessage).toContain("Check your ChatGPT usage: https://chatgpt.com/settings/usage");
});
});
+13
View File
@@ -85,6 +85,19 @@ describe("provider retry classification", () => {
).toBe(false);
});
it("keeps the ChatGPT subscription usage limit non-retryable", () => {
const errorMessage =
'OpenAI API error (429): {"code":"subscription_sharing_usage_limit_exceeded","message":"Usage limit reached."}';
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).toBe(false);
});
it.each([
"subscription_sharing_usage_unavailable: Usage cannot be checked.",
"subscription_sharing_user_unavailable: User cannot be loaded.",
])("retries temporary ChatGPT subscription errors: %s", (errorMessage) => {
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).toBe(true);
});
it("classifies assistant error messages", () => {
expect(
isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })),
+1 -1
View File
@@ -59,7 +59,7 @@ export function redactJsonValue(value: unknown): unknown {
}
function redactSettings(settings: Settings): Settings {
const { trackingId: _trackingId, ...rest } = settings;
const { trackingId: _trackingId, deviceId: _deviceId, ...rest } = settings;
return redactJsonValue(rest) as Settings;
}
@@ -29,6 +29,7 @@ import {
type ImageModel,
type ImagesContext,
type ImagesOptions,
type LoginOptions,
lazyStream,
type Message,
type Model,
@@ -813,10 +814,15 @@ export class ModelRuntime implements Models {
}
}
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
login(
providerId: string,
type: AuthType,
interaction: AuthInteraction,
options?: LoginOptions,
): Promise<Credential> {
const signal = operationSignal(interaction.signal);
return this.enqueueCredentialOperation(providerId, signal, async () => {
const credential = await this.models.login(providerId, type, { ...interaction, signal });
const credential = await this.models.login(providerId, type, { ...interaction, signal }, options);
await this.synchronizeCredentialState(providerId, "login", credential, signal);
return credential;
});
@@ -153,6 +153,7 @@ export interface Settings {
enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
enableAnalytics?: boolean; // default: false - opt-in analytics data sharing
trackingId?: string; // analytics tracking identifier, generated when analytics is enabled
deviceId?: string; // stable UUID of this installation, created when a login first needs it; global setting only
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
extensions?: string[]; // Array of local extension file paths or directories
skills?: string[]; // Array of local skill file paths or directories
@@ -1163,6 +1164,20 @@ export class SettingsManager {
this.save();
}
/**
* Stable ID of this installation, e.g. sent to OpenAI as its agent host ID.
* Created on first use. Project settings are ignored so a committed project
* settings file cannot give every clone the same ID.
*/
getOrCreateDeviceId(): string {
if (!this.globalSettings.deviceId) {
this.globalSettings.deviceId = randomUUID();
this.markModified("deviceId");
this.save();
}
return this.globalSettings.deviceId;
}
getPackages(): PackageSource[] {
return [...(this.settings.packages ?? [])];
}
@@ -342,11 +342,16 @@ export async function openMicro(options: OpenMicroOptions = {}): Promise<OpenMic
auth: { providerId, providerName: account.name, authType, notices: [] },
});
try {
await modelRuntime.login(providerId, authType, {
signal: loginController.signal,
prompt: ({ signal, ...request }: AuthPrompt) => askAuth(request, signal),
notify: addAuthNotice,
});
await modelRuntime.login(
providerId,
authType,
{
signal: loginController.signal,
prompt: ({ signal, ...request }: AuthPrompt) => askAuth(request, signal),
notify: addAuthNotice,
},
{ getDeviceId: () => settings.getOrCreateDeviceId() },
);
notice("info", `Logged in to ${account.name}.`);
} finally {
clearPendingAuth(new Error("Login finished"));
@@ -6163,11 +6163,16 @@ export class InteractiveMode {
providerId: string,
method: "api_key" | "oauth",
): Promise<void> {
await this.session.modelRuntime.login(providerId, method, {
signal: dialog.signal,
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
notify: (event) => this.notifyAuthDialog(dialog, event),
});
await this.session.modelRuntime.login(
providerId,
method,
{
signal: dialog.signal,
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
notify: (event) => this.notifyAuthDialog(dialog, event),
},
{ getDeviceId: () => this.settingsManager.getOrCreateDeviceId() },
);
}
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
@@ -111,6 +111,23 @@ describe("SettingsManager", () => {
});
});
describe("deviceId", () => {
it("creates one global device ID and reuses it in later processes", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ deviceId: "project-device" }));
const first = SettingsManager.create(projectDir, agentDir);
const deviceId = first.getOrCreateDeviceId();
await first.flush();
expect(deviceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(first.getOrCreateDeviceId()).toBe(deviceId);
expect(SettingsManager.create(projectDir, agentDir).getOrCreateDeviceId()).toBe(deviceId);
expect(JSON.parse(readFileSync(settingsPath, "utf-8"))).toEqual({ theme: "dark", deviceId });
});
});
describe("packages migration", () => {
it("should keep local-only extensions in extensions array", () => {
const settingsPath = join(agentDir, "settings.json");