diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 6aa478cccba..5d86d31ba33 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -403,6 +403,7 @@ export type ExtensionState = Pick< taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean openAiCodexIsAuthenticated?: boolean + openAiCodexAccountEmail?: string | null debug?: boolean } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d64780c5557..e5e3f82957f 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -26,6 +26,7 @@ import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth" import { t } from "../../i18n" +import { ContextProxy } from "../../core/config/ContextProxy" export type OpenAiCodexModel = ReturnType @@ -64,6 +65,20 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion */ private pendingToolCallId: string | undefined private pendingToolCallName: string | undefined + private resolveProfileId(): string | undefined { + try { + const contextProxy = ContextProxy.instance + const currentApiConfigName = contextProxy.getValue("currentApiConfigName") + const listApiConfigMeta = contextProxy.getValue("listApiConfigMeta") + if (!Array.isArray(listApiConfigMeta)) { + return undefined + } + const match = listApiConfigMeta.find((profile) => profile?.name === currentApiConfigName) + return typeof match?.id === "string" ? match.id : undefined + } catch { + return undefined + } + } // Event types handled by the shared event processor private readonly coreHandledEventTypes = new Set([ @@ -151,7 +166,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.pendingToolCallName = undefined // Get access token from OAuth manager - let accessToken = await openAiCodexOAuthManager.getAccessToken() + const profileId = this.resolveProfileId() + let accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -183,7 +199,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (attempt === 0 && isAuthFailure) { // Force refresh the token for retry - const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() + const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken(profileId) if (!refreshed) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -341,7 +357,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // is consistent across providers. try { // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(this.resolveProfileId()) // Build Codex-specific headers. Authorization is provided by the SDK apiKey. const codexHeaders: Record = { @@ -481,7 +497,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const url = `${CODEX_API_BASE_URL}/responses` // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(this.resolveProfileId()) // Build headers with required Codex-specific fields const headers: Record = { @@ -1008,7 +1024,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const model = this.getModel() // Get access token - const accessToken = await openAiCodexOAuthManager.getAccessToken() + const accessToken = await openAiCodexOAuthManager.getAccessToken(this.resolveProfileId()) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -1043,7 +1059,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const url = `${CODEX_API_BASE_URL}/responses` // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(this.resolveProfileId()) // Build headers with required Codex-specific fields const headers: Record = { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bc3f6bd6ef1..67ed1522f5f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2105,6 +2105,21 @@ export class ClineProvider const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) + const openAiCodexProfileId = Array.isArray(listApiConfigMeta) + ? listApiConfigMeta.find((profile) => profile.name === currentApiConfigName)?.id + : undefined + let openAiCodexIsAuthenticated = false + let openAiCodexAccountEmail: string | null = null + + try { + const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") + openAiCodexIsAuthenticated = await openAiCodexOAuthManager.isAuthenticated(openAiCodexProfileId) + openAiCodexAccountEmail = await openAiCodexOAuthManager.getEmail(openAiCodexProfileId) + } catch { + openAiCodexIsAuthenticated = false + openAiCodexAccountEmail = null + } + return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -2232,14 +2247,8 @@ export class ClineProvider openRouterImageApiKey, openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, - openAiCodexIsAuthenticated: await (async () => { - try { - const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - return await openAiCodexOAuthManager.isAuthenticated() - } catch { - return false - } - })(), + openAiCodexIsAuthenticated, + openAiCodexAccountEmail, debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), } } diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index faa8e926825..d0ae3f69f00 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -599,6 +599,10 @@ describe("webviewMessageHandler - requestRouterModels", () => { describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { beforeEach(() => { vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ + currentApiConfigName: "test-profile", + listApiConfigMeta: [{ id: "test-profile-id", name: "test-profile" }], + }) mockGetAccessToken.mockResolvedValue(null) mockGetAccountId.mockResolvedValue(null) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 051586b119d..af15b32aa48 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2385,14 +2385,16 @@ export const webviewMessageHandler = async ( case "openAiCodexSignIn": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const authUrl = openAiCodexOAuthManager.startAuthorizationFlow() + const { currentApiConfigName, listApiConfigMeta } = await provider.getState() + const profileId = listApiConfigMeta?.find((profile) => profile.name === currentApiConfigName)?.id + const authUrl = openAiCodexOAuthManager.startAuthorizationFlow(profileId) // Open the authorization URL in the browser await vscode.env.openExternal(vscode.Uri.parse(authUrl)) // Wait for the callback in a separate promise (non-blocking) openAiCodexOAuthManager - .waitForCallback() + .waitForCallback(profileId) .then(async () => { vscode.window.showInformationMessage("Successfully signed in to OpenAI Codex") await provider.postStateToWebview() @@ -2412,7 +2414,9 @@ export const webviewMessageHandler = async ( case "openAiCodexSignOut": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - await openAiCodexOAuthManager.clearCredentials() + const { currentApiConfigName, listApiConfigMeta } = await provider.getState() + const profileId = listApiConfigMeta?.find((profile) => profile.name === currentApiConfigName)?.id + await openAiCodexOAuthManager.clearCredentials(profileId) vscode.window.showInformationMessage("Signed out from OpenAI Codex") await provider.postStateToWebview() } catch (error) { @@ -3244,7 +3248,9 @@ export const webviewMessageHandler = async ( case "requestOpenAiCodexRateLimits": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const accessToken = await openAiCodexOAuthManager.getAccessToken() + const { currentApiConfigName, listApiConfigMeta } = await provider.getState() + const profileId = listApiConfigMeta?.find((profile) => profile.name === currentApiConfigName)?.id + const accessToken = await openAiCodexOAuthManager.getAccessToken(profileId) if (!accessToken) { provider.postMessageToWebview({ @@ -3254,7 +3260,7 @@ export const webviewMessageHandler = async ( break } - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountId(profileId) const { fetchOpenAiCodexRateLimitInfo } = await import("../../integrations/openai-codex/rate-limits") const rateLimits = await fetchOpenAiCodexRateLimitInfo(accessToken, { accountId }) diff --git a/src/integrations/openai-codex/oauth.ts b/src/integrations/openai-codex/oauth.ts index 0cae6a41640..cf93444a8a5 100644 --- a/src/integrations/openai-codex/oauth.ts +++ b/src/integrations/openai-codex/oauth.ts @@ -3,6 +3,7 @@ import * as http from "http" import { URL } from "url" import type { ExtensionContext } from "vscode" import { z } from "zod" +import { ContextProxy } from "../../core/config/ContextProxy" /** * OpenAI Codex OAuth Configuration @@ -24,7 +25,9 @@ export const OPENAI_CODEX_OAUTH_CONFIG = { } as const // Token storage key -const OPENAI_CODEX_CREDENTIALS_KEY = "openai-codex-oauth-credentials" +const OPENAI_CODEX_CREDENTIALS_KEY_PREFIX = "openai-codex-oauth-credentials" +const OPENAI_CODEX_LEGACY_CREDENTIALS_KEY = OPENAI_CODEX_CREDENTIALS_KEY_PREFIX +const DEFAULT_PROFILE_ID = "default" // Credentials schema const openAiCodexCredentialsSchema = z.object({ @@ -93,6 +96,14 @@ function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { ) } +function extractEmailFromIdToken(idToken?: string): string | undefined { + if (!idToken) return undefined + const claims = parseJwtClaims(idToken) + if (!claims || typeof claims.email !== "string") return undefined + const email = claims.email.trim() + return email.length > 0 ? email : undefined +} + /** * Extract ChatGPT account ID from token response * Tries id_token first, then access_token @@ -262,13 +273,14 @@ export async function exchangeCodeForTokens(code: string, codeVerifier: string): id_token: tokenResponse.id_token, access_token: tokenResponse.access_token, }) + const email = extractEmailFromIdToken(tokenResponse.id_token) ?? tokenResponse.email return { type: "openai-codex", access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires: expiresAt, - email: tokenResponse.email, + email, accountId, } } @@ -314,13 +326,14 @@ export async function refreshAccessToken(credentials: OpenAiCodexCredentials): P id_token: tokenResponse.id_token, access_token: tokenResponse.access_token, }) + const refreshedEmail = extractEmailFromIdToken(tokenResponse.id_token) ?? tokenResponse.email return { type: "openai-codex", access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token ?? credentials.refresh_token, expires: expiresAt, - email: tokenResponse.email ?? credentials.email, + email: refreshedEmail ?? credentials.email, // Prefer newly extracted accountId, fall back to existing accountId: newAccountId ?? credentials.accountId, } @@ -340,12 +353,14 @@ export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean { */ export class OpenAiCodexOAuthManager { private context: ExtensionContext | null = null - private credentials: OpenAiCodexCredentials | null = null + private credentialsByProfile = new Map() private logFn: ((message: string) => void) | null = null - private refreshPromise: Promise | null = null + private refreshPromises = new Map>() + private lastResolvedProfileId: string | null = null private pendingAuth: { codeVerifier: string state: string + profileId: string server?: http.Server } | null = null @@ -364,6 +379,52 @@ export class OpenAiCodexOAuthManager { console.error(full) } + private resolveProfileId(profileId?: string): string { + const normalized = profileId?.trim() + if (normalized) { + this.lastResolvedProfileId = normalized + return normalized + } + + const fromContext = this.getProfileIdFromContext() + if (fromContext) { + this.lastResolvedProfileId = fromContext + return fromContext + } + + if (this.lastResolvedProfileId) { + return this.lastResolvedProfileId + } + + this.lastResolvedProfileId = DEFAULT_PROFILE_ID + return DEFAULT_PROFILE_ID + } + + private getProfileIdFromContext(): string | undefined { + try { + const contextProxy = ContextProxy.instance + const currentApiConfigName = contextProxy.getValue("currentApiConfigName") + const listApiConfigMeta = contextProxy.getValue("listApiConfigMeta") + + if (Array.isArray(listApiConfigMeta)) { + const match = listApiConfigMeta.find((profile) => profile?.name === currentApiConfigName) + if (match?.id && typeof match.id === "string") { + return match.id + } + } + } catch { + return undefined + } + + return undefined + } + + private getCredentialsKey(profileId: string): string { + const normalized = profileId.trim() + const safeProfileId = normalized.length > 0 ? normalized : DEFAULT_PROFILE_ID + return `${OPENAI_CODEX_CREDENTIALS_KEY_PREFIX}-${safeProfileId}` + } + /** * Initialize the OAuth manager with VS Code extension context */ @@ -376,42 +437,53 @@ export class OpenAiCodexOAuthManager { * Force a refresh using the stored refresh token even if the access token is not expired. * Useful when the server invalidates an access token early. */ - async forceRefreshAccessToken(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async forceRefreshAccessToken(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - if (!this.credentials) { + if (!credentials) { return null } try { // De-dupe concurrent refreshes - if (!this.refreshPromise) { - const prevRefreshToken = this.credentials.refresh_token - this.log(`[openai-codex-oauth] Forcing token refresh (expires=${this.credentials.expires})...`) - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { - const rotated = newCreds.refresh_token !== prevRefreshToken - this.log( - `[openai-codex-oauth] Forced refresh response received (expires_in≈${Math.round( - (newCreds.expires - Date.now()) / 1000, - )}s, refresh_token_rotated=${rotated})`, - ) - return newCreds - }) + if (!this.refreshPromises.has(resolvedProfileId)) { + const prevRefreshToken = credentials.refresh_token + this.log(`[openai-codex-oauth] Forcing token refresh (expires=${credentials.expires})...`) + this.refreshPromises.set( + resolvedProfileId, + refreshAccessToken(credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Forced refresh response received (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }), + ) + } + + const refreshPromise = this.refreshPromises.get(resolvedProfileId) + if (!refreshPromise) { + return null } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) + const newCredentials = await refreshPromise + this.refreshPromises.delete(resolvedProfileId) + await this.saveCredentials(newCredentials, resolvedProfileId) this.log(`[openai-codex-oauth] Forced token persisted (expires=${newCredentials.expires})`) return newCredentials.access_token } catch (error) { - this.refreshPromise = null + this.refreshPromises.delete(resolvedProfileId) this.logError("[openai-codex-oauth] Failed to force refresh token:", error) if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") - await this.clearCredentials() + await this.clearCredentials(resolvedProfileId) } return null } @@ -420,20 +492,49 @@ export class OpenAiCodexOAuthManager { /** * Load credentials from storage */ - async loadCredentials(): Promise { + async loadCredentials(profileId?: string): Promise { if (!this.context) { return null } + const resolvedProfileId = this.resolveProfileId(profileId) + const cached = this.credentialsByProfile.get(resolvedProfileId) + if (cached) { + return cached + } + try { - const credentialsJson = await this.context.secrets.get(OPENAI_CODEX_CREDENTIALS_KEY) + const credentialsKey = this.getCredentialsKey(resolvedProfileId) + let credentialsJson = await this.context.secrets.get(credentialsKey) + + if (!credentialsJson && resolvedProfileId === DEFAULT_PROFILE_ID) { + const legacyJson = await this.context.secrets.get(OPENAI_CODEX_LEGACY_CREDENTIALS_KEY) + if (legacyJson) { + credentialsJson = legacyJson + } + } + if (!credentialsJson) { return null } const parsed = JSON.parse(credentialsJson) - this.credentials = openAiCodexCredentialsSchema.parse(parsed) - return this.credentials + const credentials = openAiCodexCredentialsSchema.parse(parsed) + this.credentialsByProfile.set(resolvedProfileId, credentials) + + if (resolvedProfileId === DEFAULT_PROFILE_ID && credentialsJson) { + try { + await this.context.secrets.store( + this.getCredentialsKey(resolvedProfileId), + JSON.stringify(credentials), + ) + await this.context.secrets.delete(OPENAI_CODEX_LEGACY_CREDENTIALS_KEY) + } catch (migrationError) { + this.logError("[openai-codex-oauth] Failed to migrate legacy credentials:", migrationError) + } + } + + return credentials } catch (error) { this.logError("[openai-codex-oauth] Failed to load credentials:", error) return null @@ -443,106 +544,129 @@ export class OpenAiCodexOAuthManager { /** * Save credentials to storage */ - async saveCredentials(credentials: OpenAiCodexCredentials): Promise { + async saveCredentials(credentials: OpenAiCodexCredentials, profileId?: string): Promise { if (!this.context) { throw new Error("OAuth manager not initialized") } - await this.context.secrets.store(OPENAI_CODEX_CREDENTIALS_KEY, JSON.stringify(credentials)) - this.credentials = credentials + const resolvedProfileId = this.resolveProfileId(profileId) + + await this.context.secrets.store(this.getCredentialsKey(resolvedProfileId), JSON.stringify(credentials)) + this.credentialsByProfile.set(resolvedProfileId, credentials) } /** * Clear credentials from storage */ - async clearCredentials(): Promise { + async clearCredentials(profileId?: string): Promise { if (!this.context) { return } - await this.context.secrets.delete(OPENAI_CODEX_CREDENTIALS_KEY) - this.credentials = null + const resolvedProfileId = this.resolveProfileId(profileId) + + await this.context.secrets.delete(this.getCredentialsKey(resolvedProfileId)) + this.credentialsByProfile.delete(resolvedProfileId) + if (resolvedProfileId === DEFAULT_PROFILE_ID) { + await this.context.secrets.delete(OPENAI_CODEX_LEGACY_CREDENTIALS_KEY) + } } /** * Get a valid access token, refreshing if necessary */ - async getAccessToken(): Promise { + async getAccessToken(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + // Try to load credentials if not already loaded - if (!this.credentials) { - await this.loadCredentials() + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - if (!this.credentials) { + if (!credentials) { return null } // Check if token is expired and refresh if needed - if (isTokenExpired(this.credentials)) { + if (isTokenExpired(credentials)) { try { // De-dupe concurrent refreshes - if (!this.refreshPromise) { + if (!this.refreshPromises.has(resolvedProfileId)) { this.log( - `[openai-codex-oauth] Access token expired (expires=${this.credentials.expires}). Refreshing...`, + `[openai-codex-oauth] Access token expired (expires=${credentials.expires}). Refreshing...`, ) - const prevRefreshToken = this.credentials.refresh_token - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { - const rotated = newCreds.refresh_token !== prevRefreshToken - this.log( - `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( - (newCreds.expires - Date.now()) / 1000, - )}s, refresh_token_rotated=${rotated})`, - ) - return newCreds - }) + const prevRefreshToken = credentials.refresh_token + this.refreshPromises.set( + resolvedProfileId, + refreshAccessToken(credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }), + ) + } + + const refreshPromise = this.refreshPromises.get(resolvedProfileId) + if (!refreshPromise) { + return null } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) + const newCredentials = await refreshPromise + this.refreshPromises.delete(resolvedProfileId) + await this.saveCredentials(newCredentials, resolvedProfileId) + credentials = newCredentials this.log(`[openai-codex-oauth] Token persisted (expires=${newCredentials.expires})`) } catch (error) { - this.refreshPromise = null + this.refreshPromises.delete(resolvedProfileId) this.logError("[openai-codex-oauth] Failed to refresh token:", error) // Only clear secrets when the refresh token is clearly invalid/revoked. if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") - await this.clearCredentials() + await this.clearCredentials(resolvedProfileId) } return null } } - return this.credentials.access_token + return credentials.access_token } /** * Get the user's email from credentials */ - async getEmail(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async getEmail(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - return this.credentials?.email || null + return credentials?.email || null } /** * Get the ChatGPT account ID from credentials * Used for the ChatGPT-Account-Id header required by the Codex API */ - async getAccountId(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async getAccountId(profileId?: string): Promise { + const resolvedProfileId = this.resolveProfileId(profileId) + let credentials: OpenAiCodexCredentials | null = this.credentialsByProfile.get(resolvedProfileId) ?? null + if (!credentials) { + credentials = await this.loadCredentials(resolvedProfileId) } - return this.credentials?.accountId || null + return credentials?.accountId || null } /** * Check if the user is authenticated */ - async isAuthenticated(): Promise { - const token = await this.getAccessToken() + async isAuthenticated(profileId?: string): Promise { + const token = await this.getAccessToken(profileId) return token !== null } @@ -550,17 +674,19 @@ export class OpenAiCodexOAuthManager { * Start the OAuth authorization flow * Returns the authorization URL to open in browser */ - startAuthorizationFlow(): string { + startAuthorizationFlow(profileId?: string): string { // Cancel any existing authorization flow before starting a new one this.cancelAuthorizationFlow() const codeVerifier = generateCodeVerifier() const codeChallenge = generateCodeChallenge(codeVerifier) const state = generateState() + const resolvedProfileId = this.resolveProfileId(profileId) this.pendingAuth = { codeVerifier, state, + profileId: resolvedProfileId, } return buildAuthorizationUrl(codeChallenge, state) @@ -570,11 +696,20 @@ export class OpenAiCodexOAuthManager { * Start a local server to receive the OAuth callback * Returns a promise that resolves when authentication is complete */ - async waitForCallback(): Promise { + async waitForCallback(profileId?: string): Promise { if (!this.pendingAuth) { throw new Error("No pending authorization flow") } + if (profileId) { + const resolvedProfileId = this.resolveProfileId(profileId) + if (resolvedProfileId !== this.pendingAuth.profileId) { + this.log( + `[openai-codex-oauth] Profile mismatch during callback (pending=${this.pendingAuth.profileId}, requested=${resolvedProfileId})`, + ) + } + } + // Close any existing server before starting a new one if (this.pendingAuth.server) { try { @@ -585,6 +720,7 @@ export class OpenAiCodexOAuthManager { this.pendingAuth.server = undefined } + const pendingProfileId = this.pendingAuth.profileId return new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { try { @@ -629,7 +765,7 @@ export class OpenAiCodexOAuthManager { // per the implementation guide (OpenAI rejects it) const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier) - await this.saveCredentials(credentials) + await this.saveCredentials(credentials, pendingProfileId) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(` @@ -731,8 +867,9 @@ export class OpenAiCodexOAuthManager { /** * Get the current credentials (for display purposes) */ - getCredentials(): OpenAiCodexCredentials | null { - return this.credentials + getCredentials(profileId?: string): OpenAiCodexCredentials | null { + const resolvedProfileId = this.resolveProfileId(profileId) + return this.credentialsByProfile.get(resolvedProfileId) ?? null } } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 939d2734d4b..a07d3441a22 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -145,7 +145,13 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated, openAiCodexIsAuthenticated } = useExtensionState() + const { + organizationAllowList, + cloudIsAuthenticated, + openAiCodexIsAuthenticated, + openAiCodexAccountEmail, + currentApiConfigName, + } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -563,6 +569,8 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} simplifySettings={fromWelcomeView} openAiCodexIsAuthenticated={openAiCodexIsAuthenticated} + openAiCodexAccountEmail={openAiCodexAccountEmail} + currentApiConfigName={currentApiConfigName} /> )} diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index 755b272702a..954527fa9f8 100644 --- a/webview-ui/src/components/settings/providers/OpenAICodex.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -14,6 +14,8 @@ interface OpenAICodexProps { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void simplifySettings?: boolean openAiCodexIsAuthenticated?: boolean + openAiCodexAccountEmail?: string | null + currentApiConfigName?: string } export const OpenAICodex: React.FC = ({ @@ -21,6 +23,8 @@ export const OpenAICodex: React.FC = ({ setApiConfigurationField, simplifySettings, openAiCodexIsAuthenticated = false, + openAiCodexAccountEmail, + currentApiConfigName, }) => { const { t } = useAppTranslation() @@ -29,15 +33,39 @@ export const OpenAICodex: React.FC = ({ {/* Authentication Section */}
{openAiCodexIsAuthenticated ? ( -
- +
+
+
+
+ {t("settings:providers.openAiCodex.connectedLabel", { + defaultValue: "Connected", + })} +
+ {openAiCodexAccountEmail ? ( +
+ {openAiCodexAccountEmail} +
+ ) : null} +
+
+ + +
+
) : (
{/* Rate Limit Dashboard - only shown when authenticated */} - + {/* Model Picker */} ) => string @@ -84,7 +85,10 @@ const UsageProgressBar: React.FC<{ usedPercent: number; label?: string }> = ({ u ) } -export const OpenAICodexRateLimitDashboard: React.FC = ({ isAuthenticated }) => { +export const OpenAICodexRateLimitDashboard: React.FC = ({ + isAuthenticated, + currentApiConfigName, +}) => { const { t } = useAppTranslation() const [rateLimits, setRateLimits] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -120,10 +124,13 @@ export const OpenAICodexRateLimitDashboard: React.FC { + setRateLimits(null) + setError(null) + setIsLoading(false) if (isAuthenticated) { fetchRateLimits() } - }, [isAuthenticated, fetchRateLimits]) + }, [isAuthenticated, currentApiConfigName, fetchRateLimits]) if (!isAuthenticated) return null