diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 6aa478cccba..5fda4d3f1cc 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 + openAiCodexAuthenticatedEmail?: string debug?: boolean } @@ -664,6 +665,7 @@ export interface WebviewMessage { list?: string[] // For dismissedUpsells response organizationId?: string | null // For organization switching useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow + profileId?: string // For profile-scoped OAuth operations (openAiCodexSignIn, openAiCodexSignOut, requestOpenAiCodexRateLimits) codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts index 608f639ed44..ff042bef8f0 100644 --- a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts +++ b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts @@ -24,8 +24,8 @@ describe("OpenAiCodexHandler native tool calls", () => { }) it("yields tool_call_partial chunks when API returns function_call-only response", async () => { - vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") - vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + vi.spyOn(openAiCodexOAuthManager, "getAccessTokenForProfile").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountIdForProfile").mockResolvedValue("acct_test") // Mock OpenAI SDK streaming (preferred path). ;(handler as any).client = { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d64780c5557..1b81fd33953 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -57,6 +57,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion private abortController?: AbortController // Session ID for the Codex API (persists for the lifetime of the handler) private readonly sessionId: string + // Profile ID for profile-scoped OAuth credentials + private readonly profileId: string | undefined /** * Some Codex/Responses streams emit tool-call argument deltas without stable call id/name. * Track the last observed tool identity from output_item events so we can still @@ -89,6 +91,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.options = options // Generate a new session ID for standalone handler usage (fallback) this.sessionId = uuidv7() + // Store profile ID for profile-scoped OAuth credentials + this.profileId = options.apiConfigurationId } private normalizeUsage(usage: any, model: OpenAiCodexModel): ApiStreamUsageChunk | undefined { @@ -150,8 +154,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.pendingToolCallId = undefined this.pendingToolCallName = undefined - // Get access token from OAuth manager - let accessToken = await openAiCodexOAuthManager.getAccessToken() + // Get access token from OAuth manager (profile-scoped) + let accessToken = await openAiCodexOAuthManager.getAccessTokenForProfile(this.profileId) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -182,8 +186,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication|401/i.test(message) if (attempt === 0 && isAuthFailure) { - // Force refresh the token for retry - const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() + // Force refresh the token for retry (profile-scoped) + const refreshed = await openAiCodexOAuthManager.forceRefreshAccessTokenForProfile(this.profileId) if (!refreshed) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -340,8 +344,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Prefer OpenAI SDK streaming (same approach as openai-native) so event handling // is consistent across providers. try { - // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + // Get ChatGPT account ID for organization subscriptions (profile-scoped) + const accountId = await openAiCodexOAuthManager.getAccountIdForProfile(this.profileId) // Build Codex-specific headers. Authorization is provided by the SDK apiKey. const codexHeaders: Record = { @@ -480,8 +484,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Per the implementation guide: route to Codex backend with Bearer token const url = `${CODEX_API_BASE_URL}/responses` - // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() + // Get ChatGPT account ID for organization subscriptions (profile-scoped) + const accountId = await openAiCodexOAuthManager.getAccountIdForProfile(this.profileId) // Build headers with required Codex-specific fields const headers: Record = { @@ -1007,8 +1011,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion try { const model = this.getModel() - // Get access token - const accessToken = await openAiCodexOAuthManager.getAccessToken() + // Get access token (profile-scoped) + const accessToken = await openAiCodexOAuthManager.getAccessTokenForProfile(this.profileId) if (!accessToken) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { @@ -1042,8 +1046,8 @@ 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() + // Get ChatGPT account ID for organization subscriptions (profile-scoped) + const accountId = await openAiCodexOAuthManager.getAccountIdForProfile(this.profileId) // 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 8de7cf35e84..ca31dbcf034 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2232,14 +2232,24 @@ export class ClineProvider openRouterImageApiKey, openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, - openAiCodexIsAuthenticated: await (async () => { + ...(await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - return await openAiCodexOAuthManager.isAuthenticated() + // Get the current profile ID for profile-scoped OAuth + const profileId = listApiConfigMeta?.find(({ name }) => name === currentApiConfigName)?.id + const isAuthenticated = await openAiCodexOAuthManager.isAuthenticatedForProfile(profileId) + const email = isAuthenticated ? await openAiCodexOAuthManager.getEmailForProfile(profileId) : null + return { + openAiCodexIsAuthenticated: isAuthenticated, + openAiCodexAuthenticatedEmail: email ?? undefined, + } } catch { - return false + return { + openAiCodexIsAuthenticated: false, + openAiCodexAuthenticatedEmail: undefined, + } } - })(), + })()), 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..2a1aaceac1d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -9,6 +9,8 @@ vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), getAccountId: vi.fn(), + getAccessTokenForProfile: vi.fn(), + getAccountIdForProfile: vi.fn(), }, })) @@ -32,6 +34,8 @@ const { fetchOpenAiCodexRateLimitInfo } = await import("../../../integrations/op const mockGetModels = getModels as Mock const mockGetAccessToken = vi.mocked(openAiCodexOAuthManager.getAccessToken) const mockGetAccountId = vi.mocked(openAiCodexOAuthManager.getAccountId) +const mockGetAccessTokenForProfile = vi.mocked(openAiCodexOAuthManager.getAccessTokenForProfile) +const mockGetAccountIdForProfile = vi.mocked(openAiCodexOAuthManager.getAccountIdForProfile) const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInfo) // Mock ClineProvider @@ -599,8 +603,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { beforeEach(() => { vi.clearAllMocks() - mockGetAccessToken.mockResolvedValue(null) - mockGetAccountId.mockResolvedValue(null) + mockGetAccessTokenForProfile.mockResolvedValue(null) + mockGetAccountIdForProfile.mockResolvedValue(null) }) it("posts error when not authenticated", async () => { @@ -613,8 +617,8 @@ describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { }) it("posts values when authenticated", async () => { - mockGetAccessToken.mockResolvedValue("token") - mockGetAccountId.mockResolvedValue("acct_123") + mockGetAccessTokenForProfile.mockResolvedValue("token") + mockGetAccountIdForProfile.mockResolvedValue("acct_123") mockFetchOpenAiCodexRateLimitInfo.mockResolvedValue({ primary: { usedPercent: 10, resetsAt: 1700000000000 }, fetchedAt: 1700000000000, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 051586b119d..8331886c83a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2385,14 +2385,19 @@ export const webviewMessageHandler = async ( case "openAiCodexSignIn": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const authUrl = openAiCodexOAuthManager.startAuthorizationFlow() + // Get profile ID from message or current API configuration + const listApiConfigMeta = getGlobalState("listApiConfigMeta") + const currentApiConfigName = getGlobalState("currentApiConfigName") + const profileId = + message.profileId || listApiConfigMeta?.find(({ name }) => name === currentApiConfigName)?.id + const authUrl = openAiCodexOAuthManager.startAuthorizationFlowForProfile(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() + .waitForCallbackForProfile() .then(async () => { vscode.window.showInformationMessage("Successfully signed in to OpenAI Codex") await provider.postStateToWebview() @@ -2412,7 +2417,12 @@ export const webviewMessageHandler = async ( case "openAiCodexSignOut": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - await openAiCodexOAuthManager.clearCredentials() + // Get profile ID from message or current API configuration + const listApiConfigMeta = getGlobalState("listApiConfigMeta") + const currentApiConfigName = getGlobalState("currentApiConfigName") + const profileId = + message.profileId || listApiConfigMeta?.find(({ name }) => name === currentApiConfigName)?.id + await openAiCodexOAuthManager.clearCredentialsForProfile(profileId) vscode.window.showInformationMessage("Signed out from OpenAI Codex") await provider.postStateToWebview() } catch (error) { @@ -3244,7 +3254,12 @@ export const webviewMessageHandler = async ( case "requestOpenAiCodexRateLimits": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") - const accessToken = await openAiCodexOAuthManager.getAccessToken() + // Get profile ID from message or current API configuration + const listApiConfigMeta = getGlobalState("listApiConfigMeta") + const currentApiConfigName = getGlobalState("currentApiConfigName") + const profileId = + message.profileId || listApiConfigMeta?.find(({ name }) => name === currentApiConfigName)?.id + const accessToken = await openAiCodexOAuthManager.getAccessTokenForProfile(profileId) if (!accessToken) { provider.postMessageToWebview({ @@ -3254,7 +3269,7 @@ export const webviewMessageHandler = async ( break } - const accountId = await openAiCodexOAuthManager.getAccountId() + const accountId = await openAiCodexOAuthManager.getAccountIdForProfile(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..061815fdb26 100644 --- a/src/integrations/openai-codex/oauth.ts +++ b/src/integrations/openai-codex/oauth.ts @@ -23,8 +23,19 @@ export const OPENAI_CODEX_OAUTH_CONFIG = { callbackPort: 1455, } as const -// Token storage key -const OPENAI_CODEX_CREDENTIALS_KEY = "openai-codex-oauth-credentials" +// Token storage key prefix for profile-scoped credentials +const OPENAI_CODEX_CREDENTIALS_KEY_PREFIX = "openai-codex-oauth-credentials" + +/** + * Get the credential storage key for a specific profile + * Falls back to global key if no profile ID is provided + */ +function getCredentialsKey(profileId?: string): string { + if (profileId) { + return `${OPENAI_CODEX_CREDENTIALS_KEY_PREFIX}-${profileId}` + } + return OPENAI_CODEX_CREDENTIALS_KEY_PREFIX +} // Credentials schema const openAiCodexCredentialsSchema = z.object({ @@ -337,18 +348,27 @@ export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean { /** * OpenAiCodexOAuthManager - Handles OAuth flow and token management + * Supports profile-scoped credentials: each provider profile can have its own OAuth session */ export class OpenAiCodexOAuthManager { private context: ExtensionContext | null = null - private credentials: OpenAiCodexCredentials | null = null private logFn: ((message: string) => void) | null = null - private refreshPromise: Promise | null = null + // Profile-specific credential caches (profileId -> credentials) + private credentialsCache: Map = new Map() + // Profile-specific refresh promises (profileId -> promise) + private refreshPromises: Map> = new Map() + // Pending authorization flow with optional profile ID private pendingAuth: { codeVerifier: string state: string server?: http.Server + profileId?: string } | null = null + // Legacy: global credentials for backward compatibility + private credentials: OpenAiCodexCredentials | null = null + private refreshPromise: Promise | null = null + private log(message: string): void { if (this.logFn) { this.logFn(message) @@ -372,185 +392,239 @@ export class OpenAiCodexOAuthManager { this.logFn = logFn ?? null } + // ===================== + // PROFILE-SCOPED METHODS + // These methods allow each provider profile to have its own OAuth credentials + // ===================== + /** - * 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. + * Load credentials for a specific profile from storage */ - async forceRefreshAccessToken(): Promise { - if (!this.credentials) { - await this.loadCredentials() - } - - if (!this.credentials) { + async loadCredentialsForProfile(profileId?: string): Promise { + if (!this.context) { 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 - }) - } + const key = getCredentialsKey(profileId) + const cacheKey = profileId || "__global__" - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) - this.log(`[openai-codex-oauth] Forced token persisted (expires=${newCredentials.expires})`) - return newCredentials.access_token - } catch (error) { - this.refreshPromise = null - 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() - } - return null - } - } - - /** - * Load credentials from storage - */ - async loadCredentials(): Promise { - if (!this.context) { - return null + // Check cache first + const cached = this.credentialsCache.get(cacheKey) + if (cached) { + return cached } try { - const credentialsJson = await this.context.secrets.get(OPENAI_CODEX_CREDENTIALS_KEY) + const credentialsJson = await this.context.secrets.get(key) if (!credentialsJson) { return null } const parsed = JSON.parse(credentialsJson) - this.credentials = openAiCodexCredentialsSchema.parse(parsed) - return this.credentials + const credentials = openAiCodexCredentialsSchema.parse(parsed) + this.credentialsCache.set(cacheKey, credentials) + return credentials } catch (error) { - this.logError("[openai-codex-oauth] Failed to load credentials:", error) + this.logError( + `[openai-codex-oauth] Failed to load credentials for profile ${profileId || "global"}:`, + error, + ) return null } } /** - * Save credentials to storage + * Save credentials for a specific profile to storage */ - async saveCredentials(credentials: OpenAiCodexCredentials): Promise { + async saveCredentialsForProfile(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 key = getCredentialsKey(profileId) + const cacheKey = profileId || "__global__" + + await this.context.secrets.store(key, JSON.stringify(credentials)) + this.credentialsCache.set(cacheKey, credentials) } /** - * Clear credentials from storage + * Clear credentials for a specific profile from storage */ - async clearCredentials(): Promise { + async clearCredentialsForProfile(profileId?: string): Promise { if (!this.context) { return } - await this.context.secrets.delete(OPENAI_CODEX_CREDENTIALS_KEY) - this.credentials = null + const key = getCredentialsKey(profileId) + const cacheKey = profileId || "__global__" + + await this.context.secrets.delete(key) + this.credentialsCache.delete(cacheKey) + this.refreshPromises.delete(cacheKey) } /** - * Get a valid access token, refreshing if necessary + * Get a valid access token for a specific profile, refreshing if necessary */ - async getAccessToken(): Promise { - // Try to load credentials if not already loaded - if (!this.credentials) { - await this.loadCredentials() + async getAccessTokenForProfile(profileId?: string): Promise { + const cacheKey = profileId || "__global__" + + // Try to load credentials if not already cached + let credentials = this.credentialsCache.get(cacheKey) + if (!credentials) { + credentials = (await this.loadCredentialsForProfile(profileId)) ?? undefined } - 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) { + // De-dupe concurrent refreshes for this profile + let refreshPromise = this.refreshPromises.get(cacheKey) + if (!refreshPromise) { this.log( - `[openai-codex-oauth] Access token expired (expires=${this.credentials.expires}). Refreshing...`, + `[openai-codex-oauth] Access token expired for profile ${profileId || "global"} (expires=${credentials.expires}). Refreshing...`, ) - const prevRefreshToken = this.credentials.refresh_token - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const prevRefreshToken = credentials.refresh_token + refreshPromise = refreshAccessToken(credentials).then(async (newCreds) => { const rotated = newCreds.refresh_token !== prevRefreshToken this.log( - `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( + `[openai-codex-oauth] Refresh response received for profile ${profileId || "global"} (expires_in≈${Math.round( (newCreds.expires - Date.now()) / 1000, )}s, refresh_token_rotated=${rotated})`, ) + await this.saveCredentialsForProfile(newCreds, profileId) + this.log( + `[openai-codex-oauth] Token persisted for profile ${profileId || "global"} (expires=${newCreds.expires})`, + ) return newCreds }) + this.refreshPromises.set(cacheKey, refreshPromise) } - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) - this.log(`[openai-codex-oauth] Token persisted (expires=${newCredentials.expires})`) + const newCredentials = await refreshPromise + this.refreshPromises.delete(cacheKey) + credentials = newCredentials } catch (error) { - this.refreshPromise = null - this.logError("[openai-codex-oauth] Failed to refresh token:", error) + this.refreshPromises.delete(cacheKey) + this.logError( + `[openai-codex-oauth] Failed to refresh token for profile ${profileId || "global"}:`, + 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() + this.log( + `[openai-codex-oauth] Refresh token appears invalid for profile ${profileId || "global"}; clearing stored credentials`, + ) + await this.clearCredentialsForProfile(profileId) } return null } } - return this.credentials.access_token + return credentials.access_token } /** - * Get the user's email from credentials + * Force a refresh for a specific profile */ - async getEmail(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async forceRefreshAccessTokenForProfile(profileId?: string): Promise { + const cacheKey = profileId || "__global__" + + let credentials = this.credentialsCache.get(cacheKey) + if (!credentials) { + credentials = (await this.loadCredentialsForProfile(profileId)) ?? undefined + } + + if (!credentials) { + return null + } + + try { + // De-dupe concurrent refreshes + let refreshPromise = this.refreshPromises.get(cacheKey) + if (!refreshPromise) { + const prevRefreshToken = credentials.refresh_token + this.log( + `[openai-codex-oauth] Forcing token refresh for profile ${profileId || "global"} (expires=${credentials.expires})...`, + ) + refreshPromise = refreshAccessToken(credentials).then(async (newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Forced refresh response received for profile ${profileId || "global"} (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + await this.saveCredentialsForProfile(newCreds, profileId) + this.log( + `[openai-codex-oauth] Forced token persisted for profile ${profileId || "global"} (expires=${newCreds.expires})`, + ) + return newCreds + }) + this.refreshPromises.set(cacheKey, refreshPromise) + } + + const newCredentials = await refreshPromise + this.refreshPromises.delete(cacheKey) + return newCredentials.access_token + } catch (error) { + this.refreshPromises.delete(cacheKey) + this.logError( + `[openai-codex-oauth] Failed to force refresh token for profile ${profileId || "global"}:`, + error, + ) + if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log( + `[openai-codex-oauth] Refresh token appears invalid for profile ${profileId || "global"}; clearing stored credentials`, + ) + await this.clearCredentialsForProfile(profileId) + } + return null } - return this.credentials?.email || null } /** - * Get the ChatGPT account ID from credentials - * Used for the ChatGPT-Account-Id header required by the Codex API + * Get the user's email for a specific profile */ - async getAccountId(): Promise { - if (!this.credentials) { - await this.loadCredentials() + async getEmailForProfile(profileId?: string): Promise { + const cacheKey = profileId || "__global__" + let credentials = this.credentialsCache.get(cacheKey) + if (!credentials) { + credentials = (await this.loadCredentialsForProfile(profileId)) ?? undefined } - return this.credentials?.accountId || null + return credentials?.email || null } /** - * Check if the user is authenticated + * Get the ChatGPT account ID for a specific profile */ - async isAuthenticated(): Promise { - const token = await this.getAccessToken() + async getAccountIdForProfile(profileId?: string): Promise { + const cacheKey = profileId || "__global__" + let credentials = this.credentialsCache.get(cacheKey) + if (!credentials) { + credentials = (await this.loadCredentialsForProfile(profileId)) ?? undefined + } + return credentials?.accountId || null + } + + /** + * Check if a specific profile is authenticated + */ + async isAuthenticatedForProfile(profileId?: string): Promise { + const token = await this.getAccessTokenForProfile(profileId) return token !== null } /** - * Start the OAuth authorization flow + * Start the OAuth authorization flow for a specific profile * Returns the authorization URL to open in browser */ - startAuthorizationFlow(): string { + startAuthorizationFlowForProfile(profileId?: string): string { // Cancel any existing authorization flow before starting a new one this.cancelAuthorizationFlow() @@ -561,20 +635,22 @@ export class OpenAiCodexOAuthManager { this.pendingAuth = { codeVerifier, state, + profileId, } return buildAuthorizationUrl(codeChallenge, state) } /** - * Start a local server to receive the OAuth callback - * Returns a promise that resolves when authentication is complete + * Wait for OAuth callback and save credentials for the pending profile */ - async waitForCallback(): Promise { + async waitForCallbackForProfile(): Promise { if (!this.pendingAuth) { throw new Error("No pending authorization flow") } + const profileId = this.pendingAuth.profileId + // Close any existing server before starting a new one if (this.pendingAuth.server) { try { @@ -629,7 +705,8 @@ export class OpenAiCodexOAuthManager { // per the implementation guide (OpenAI rejects it) const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier) - await this.saveCredentials(credentials) + // Save to the profile-specific storage + await this.saveCredentialsForProfile(credentials, profileId) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(` @@ -638,22 +715,22 @@ export class OpenAiCodexOAuthManager { Authentication Successful @@ -718,6 +795,107 @@ export class OpenAiCodexOAuthManager { }) } + /** + * Get credentials for a specific profile (for display purposes) + */ + getCredentialsForProfile(profileId?: string): OpenAiCodexCredentials | null { + const cacheKey = profileId || "__global__" + return this.credentialsCache.get(cacheKey) || null + } + + // ===================== + // LEGACY METHODS (for backward compatibility) + // These methods use global credentials when no profile is specified + // ===================== + + /** + * 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. + * @deprecated Use forceRefreshAccessTokenForProfile for profile-scoped credentials + */ + async forceRefreshAccessToken(): Promise { + return this.forceRefreshAccessTokenForProfile() + } + + /** + * Load credentials from storage + * @deprecated Use loadCredentialsForProfile for profile-scoped credentials + */ + async loadCredentials(): Promise { + const creds = await this.loadCredentialsForProfile() + this.credentials = creds + return creds + } + + /** + * Save credentials to storage + * @deprecated Use saveCredentialsForProfile for profile-scoped credentials + */ + async saveCredentials(credentials: OpenAiCodexCredentials): Promise { + await this.saveCredentialsForProfile(credentials) + this.credentials = credentials + } + + /** + * Clear credentials from storage + * @deprecated Use clearCredentialsForProfile for profile-scoped credentials + */ + async clearCredentials(): Promise { + await this.clearCredentialsForProfile() + this.credentials = null + } + + /** + * Get a valid access token, refreshing if necessary + * @deprecated Use getAccessTokenForProfile for profile-scoped credentials + */ + async getAccessToken(): Promise { + return this.getAccessTokenForProfile() + } + + /** + * Get the user's email from credentials + * @deprecated Use getEmailForProfile for profile-scoped credentials + */ + async getEmail(): Promise { + return this.getEmailForProfile() + } + + /** + * Get the ChatGPT account ID from credentials + * Used for the ChatGPT-Account-Id header required by the Codex API + * @deprecated Use getAccountIdForProfile for profile-scoped credentials + */ + async getAccountId(): Promise { + return this.getAccountIdForProfile() + } + + /** + * Check if the user is authenticated + * @deprecated Use isAuthenticatedForProfile for profile-scoped credentials + */ + async isAuthenticated(): Promise { + return this.isAuthenticatedForProfile() + } + + /** + * Start the OAuth authorization flow + * Returns the authorization URL to open in browser + * @deprecated Use startAuthorizationFlowForProfile for profile-scoped credentials + */ + startAuthorizationFlow(): string { + return this.startAuthorizationFlowForProfile() + } + + /** + * Start a local server to receive the OAuth callback + * Returns a promise that resolves when authentication is complete + * @deprecated Use waitForCallbackForProfile for profile-scoped credentials + */ + async waitForCallback(): Promise { + return this.waitForCallbackForProfile() + } + /** * Cancel any pending authorization flow */ @@ -730,6 +908,7 @@ export class OpenAiCodexOAuthManager { /** * Get the current credentials (for display purposes) + * @deprecated Use getCredentialsForProfile for profile-scoped credentials */ getCredentials(): OpenAiCodexCredentials | null { return this.credentials diff --git a/src/shared/api.ts b/src/shared/api.ts index b2ba1e35420..da7a6bfb907 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -23,6 +23,12 @@ export type ApiHandlerOptions = Omit & { * When undefined, Ollama will use the model's default num_ctx from the Modelfile. */ ollamaNumCtx?: number + /** + * Optional API configuration ID (profile ID). + * Used by providers that support profile-scoped authentication (e.g., OpenAI Codex OAuth). + * This allows each profile to have its own credentials/session. + */ + apiConfigurationId?: string } // RouterName diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 939d2734d4b..8e701cd87b2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -145,7 +145,17 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated, openAiCodexIsAuthenticated } = useExtensionState() + const { + organizationAllowList, + cloudIsAuthenticated, + openAiCodexIsAuthenticated, + openAiCodexAuthenticatedEmail, + listApiConfigMeta, + currentApiConfigName, + } = useExtensionState() + + // Get the current profile ID for profile-scoped OAuth operations + const currentProfileId = listApiConfigMeta?.find(({ name }) => name === currentApiConfigName)?.id const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -563,6 +573,8 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} simplifySettings={fromWelcomeView} openAiCodexIsAuthenticated={openAiCodexIsAuthenticated} + openAiCodexAuthenticatedEmail={openAiCodexAuthenticatedEmail} + profileId={currentProfileId} /> )} diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index 755b272702a..16a0f34f2ba 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 + openAiCodexAuthenticatedEmail?: string + profileId?: string } export const OpenAICodex: React.FC = ({ @@ -21,6 +23,8 @@ export const OpenAICodex: React.FC = ({ setApiConfigurationField, simplifySettings, openAiCodexIsAuthenticated = false, + openAiCodexAuthenticatedEmail, + profileId, }) => { const { t } = useAppTranslation() @@ -29,20 +33,30 @@ export const OpenAICodex: React.FC = ({ {/* Authentication Section */}
{openAiCodexIsAuthenticated ? ( -
- +
+ {openAiCodexAuthenticatedEmail && ( +

+ {t("settings:providers.openAiCodex.signedInAs", { + defaultValue: "Signed in as {{email}}", + email: openAiCodexAuthenticatedEmail, + })} +

+ )} +
+ +
) : (
{/* 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, + profileId, +}) => { const { t } = useAppTranslation() const [rateLimits, setRateLimits] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -98,8 +102,8 @@ export const OpenAICodexRateLimitDashboard: React.FC { const handleMessage = (event: MessageEvent) => {