From 02eed88fd8912e54a804ddebd409e2e4c08ac5ef Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 29 Sep 2026 17:16:15 +0200 Subject: [PATCH] feat(ai,coding-agent): add alternative sign in for the openai provider --- packages/ai/README.md | 7 +- packages/ai/src/api/openai-responses.ts | 30 +- packages/ai/src/auth/helpers.ts | 2 +- packages/ai/src/auth/oauth/load.ts | 6 + packages/ai/src/auth/oauth/openai-chatgpt.ts | 310 ++++++++++++++++++ packages/ai/src/auth/types.ts | 12 +- packages/ai/src/bun-oauth.ts | 2 + packages/ai/src/cli.ts | 43 +-- packages/ai/src/models.ts | 12 +- packages/ai/src/providers/openai-codex.ts | 2 +- packages/ai/src/providers/openai.ts | 13 +- packages/ai/src/utils/retry.ts | 9 + packages/ai/test/cache-retention.test.ts | 10 +- packages/ai/test/oauth-auth.test.ts | 21 +- packages/ai/test/openai-chatgpt-oauth.test.ts | 179 ++++++++++ .../openai-responses-chatgpt-sign-in.test.ts | 77 +++++ .../ai/test/openai-responses-compat.test.ts | 20 +- .../test/openai-responses-usage-limit.test.ts | 68 ++++ packages/ai/test/retry.test.ts | 13 + packages/coding-agent/src/core/bug-report.ts | 2 +- .../coding-agent/src/core/model-runtime.ts | 10 +- .../coding-agent/src/core/settings-manager.ts | 15 + .../src/experimental/micro/runtime.ts | 15 +- .../src/modes/interactive/interactive-mode.ts | 15 +- .../test/settings-manager.test.ts | 17 + 25 files changed, 846 insertions(+), 64 deletions(-) create mode 100644 packages/ai/src/auth/oauth/openai-chatgpt.ts create mode 100644 packages/ai/test/openai-chatgpt-oauth.test.ts create mode 100644 packages/ai/test/openai-responses-chatgpt-sign-in.test.ts create mode 100644 packages/ai/test/openai-responses-usage-limit.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 741bf1cc3..7ab360637 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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://.openai.azure.com` and `https://.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. diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 3af874f60..addc3a5f7 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -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; } diff --git a/packages/ai/src/auth/helpers.ts b/packages/ai/src/auth/helpers.ts index 7265cc59e..980963e79 100644 --- a/packages/ai/src/auth/helpers.ts +++ b/packages/ai/src/auth/helpers.ts @@ -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), }; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index 29461ec9d..839fe3152 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -14,6 +14,7 @@ const importOAuthModule = (specifier: string): Promise => { type OAuthFlowLoaders = { anthropic: () => OAuthAuth | Promise; openaiCodex: () => OAuthAuth | Promise; + openaiChatGPT: () => OAuthAuth | Promise; githubCopilot: () => OAuthAuth | Promise; openrouter: () => OAuthAuth | Promise; kimiCoding: () => OAuthAuth | Promise; @@ -39,6 +40,11 @@ export const loadOpenAICodexOAuth = async (): Promise => { return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth; }; +export const loadOpenAIChatGPTOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.openaiChatGPT(); + return ((await importOAuthModule("./openai-chatgpt.ts")) as { openaiChatGPTOAuth: OAuthAuth }).openaiChatGPTOAuth; +}; + export const loadGitHubCopilotOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.githubCopilot(); return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; diff --git a/packages/ai/src/auth/oauth/openai-chatgpt.ts b/packages/ai/src/auth/oauth/openai-chatgpt.ts new file mode 100644 index 000000000..0dbe5a7c8 --- /dev/null +++ b/packages/ai/src/auth/oauth/openai-chatgpt.ts @@ -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; +}; + +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 { + return new Promise((resolve, reject) => { + let resolveResult!: (result: AuthorizationResult) => void; + let rejectResult!: (error: Error) => void; + const result = new Promise((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 { + 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 { + 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 { + 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:`. */ +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 { + 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 }; + }, +}; diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index 54c4ed344..ce803b716 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -198,6 +198,16 @@ export interface ApiKeyAuth { }): Promise; } +/** 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; + login(interaction: ProviderAuthInteraction, options?: LoginOptions): Promise; /** * Exchange the refresh token. Network call; throws on failure diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index a969038ac..5e77cf1b6 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -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, diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 14403946a..04fdbaa25 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -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 { 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); diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 47243c80d..d8693c978 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -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; /** Run a provider-owned login flow and persist its returned credential. */ - login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise; + login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise; /** Remove the stored credential for a provider. */ logout(providerId: string, options?: AuthOperationOptions): Promise; @@ -752,7 +753,12 @@ class ModelsImpl implements MutableModels { }; } - async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise { + async login( + providerId: string, + type: AuthType, + interaction: AuthInteraction, + options?: LoginOptions, + ): Promise { 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 = method.login({ ...interaction, signal }); + const loginOperation: Promise = method.login({ ...interaction, signal }, options); const credential = await raceWithAbortSignal(loginOperation, signal); let mutationStarted = false; let markMutationStarted: (() => void) | undefined; diff --git a/packages/ai/src/providers/openai-codex.ts b/packages/ai/src/providers/openai-codex.ts index 75ec9e6dd..c46bd2ba7 100644 --- a/packages/ai/src/providers/openai-codex.ts +++ b/packages/ai/src/providers/openai-codex.ts @@ -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({ diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index 43f6671f2..f34b62034 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -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(), }); diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index ffe1e068d..d27526f75 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -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", ]); /** diff --git a/packages/ai/test/cache-retention.test.ts b/packages/ai/test/cache-retention.test.ts index 5e8d080ef..f943f0529 100644 --- a/packages/ai/test/cache-retention.test.ts +++ b/packages/ai/test/cache-retention.test.ts @@ -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((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((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((payload) => { diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index ceaac57f9..6033fb7fb 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -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" }); diff --git a/packages/ai/test/openai-chatgpt-oauth.test.ts b/packages/ai/test/openai-chatgpt-oauth.test.ts new file mode 100644 index 000000000..64e60da02 --- /dev/null +++ b/packages/ai/test/openai-chatgpt-oauth.test.ts @@ -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(" "), + }); + }); +}); diff --git a/packages/ai/test/openai-responses-chatgpt-sign-in.test.ts b/packages/ai/test/openai-responses-chatgpt-sign-in.test.ts new file mode 100644 index 000000000..f99f1a37f --- /dev/null +++ b/packages/ai/test/openai-responses-chatgpt-sign-in.test.ts @@ -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> { + let payload: Record | undefined; + await streamOpenAIResponses(requestModel, context, { + apiKey, + maxTokens: 1000, + temperature: 0.5, + cacheRetention: "long", + onPayload: (params) => { + payload = params as Record; + }, + 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"); + }); +}); diff --git a/packages/ai/test/openai-responses-compat.test.ts b/packages/ai/test/openai-responses-compat.test.ts index 822022546..31aa59c02 100644 --- a/packages/ai/test/openai-responses-compat.test.ts +++ b/packages/ai/test/openai-responses-compat.test.ts @@ -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; @@ -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 }; diff --git a/packages/ai/test/openai-responses-usage-limit.test.ts b/packages/ai/test/openai-responses-usage-limit.test.ts new file mode 100644 index 000000000..2484fbe83 --- /dev/null +++ b/packages/ai/test/openai-responses-usage-limit.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index 5fca45d63..faf4a3295 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -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" })), diff --git a/packages/coding-agent/src/core/bug-report.ts b/packages/coding-agent/src/core/bug-report.ts index 48f29322d..efdeb5e25 100644 --- a/packages/coding-agent/src/core/bug-report.ts +++ b/packages/coding-agent/src/core/bug-report.ts @@ -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; } diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 31f9aab6e..47663aed6 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -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 { + login( + providerId: string, + type: AuthType, + interaction: AuthInteraction, + options?: LoginOptions, + ): Promise { 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; }); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 6eef22127..aa3eae323 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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 ?? [])]; } diff --git a/packages/coding-agent/src/experimental/micro/runtime.ts b/packages/coding-agent/src/experimental/micro/runtime.ts index 0358ebffe..57cc248a9 100644 --- a/packages/coding-agent/src/experimental/micro/runtime.ts +++ b/packages/coding-agent/src/experimental/micro/runtime.ts @@ -342,11 +342,16 @@ export async function openMicro(options: OpenMicroOptions = {}): Promise 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")); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c4e316076..b7eb92054 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6163,11 +6163,16 @@ export class InteractiveMode { providerId: string, method: "api_key" | "oauth", ): Promise { - 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 { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 44c36ea48..a407fe206 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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");