feat: implement profile-scoped OAuth for OpenAI Codex

Implements profile-scoped OAuth credentials for the OpenAI Codex provider,
enabling users to use different OpenAI accounts for different provider profiles
without manual logout/clearing sessions.

Changes:
- oauth.ts: Add profile-scoped credential storage using profile-specific keys
  - Add Map-based in-memory caching per profile
  - Add de-duplication of concurrent token refresh requests per profile
  - Maintain backward compatibility with legacy global methods
- api.ts: Add apiConfigurationId to ApiHandlerOptions
- openai-codex.ts: Update to use profile-scoped OAuth methods
- webviewMessageHandler.ts: Update sign-in/sign-out/rate-limits handlers
- vscode-extension-host.ts: Add profileId to WebviewMessage and email to ExtensionState
- OpenAICodex.tsx: Add props for email display and profileId
- OpenAICodexRateLimitDashboard.tsx: Add profileId prop
- ClineProvider.ts: Update getStateToPostToWebview to use profile-scoped auth
- ApiOptions.tsx: Pass new props to OpenAICodex component
- webviewMessageHandler.spec.ts: Update tests for profile-scoped methods

Closes #11094
This commit is contained in:
Roo Code 2026-01-30 02:16:59 +00:00
parent cc86049f10
commit 2e924c61b3
10 changed files with 413 additions and 163 deletions

View file

@ -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

View file

@ -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<string, string> = {
@ -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<string, string> = {
@ -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<string, string> = {

View file

@ -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<boolean>("debug", false),
}
}

View file

@ -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<typeof getModels>
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,

View file

@ -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 })

View file

@ -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<OpenAiCodexCredentials> | null = null
// Profile-specific credential caches (profileId -> credentials)
private credentialsCache: Map<string, OpenAiCodexCredentials> = new Map()
// Profile-specific refresh promises (profileId -> promise)
private refreshPromises: Map<string, Promise<OpenAiCodexCredentials>> = 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<OpenAiCodexCredentials> | null = null
private log(message: string): void {
if (this.logFn) {
this.logFn(message)
@ -372,185 +392,239 @@ export class OpenAiCodexOAuthManager {
this.logFn = logFn ?? null
}
/**
* 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<string | null> {
if (!this.credentials) {
await this.loadCredentials()
}
if (!this.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
})
}
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
}
}
// =====================
// PROFILE-SCOPED METHODS
// These methods allow each provider profile to have its own OAuth credentials
// =====================
/**
* Load credentials from storage
* Load credentials for a specific profile from storage
*/
async loadCredentials(): Promise<OpenAiCodexCredentials | null> {
async loadCredentialsForProfile(profileId?: string): Promise<OpenAiCodexCredentials | null> {
if (!this.context) {
return null
}
const key = getCredentialsKey(profileId)
const cacheKey = profileId || "__global__"
// 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<void> {
async saveCredentialsForProfile(credentials: OpenAiCodexCredentials, profileId?: string): Promise<void> {
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<void> {
async clearCredentialsForProfile(profileId?: string): Promise<void> {
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<string | null> {
// Try to load credentials if not already loaded
if (!this.credentials) {
await this.loadCredentials()
async getAccessTokenForProfile(profileId?: string): Promise<string | null> {
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((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})`,
)
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)
await this.saveCredentialsForProfile(newCredentials, profileId)
this.log(
`[openai-codex-oauth] Token persisted for profile ${profileId || "global"} (expires=${newCredentials.expires})`,
)
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<string | null> {
if (!this.credentials) {
await this.loadCredentials()
async forceRefreshAccessTokenForProfile(profileId?: string): Promise<string | null> {
const cacheKey = profileId || "__global__"
let credentials = this.credentialsCache.get(cacheKey)
if (!credentials) {
credentials = (await this.loadCredentialsForProfile(profileId)) ?? undefined
}
return this.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<string | null> {
if (!this.credentials) {
await this.loadCredentials()
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((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})`,
)
return newCreds
})
this.refreshPromises.set(cacheKey, refreshPromise)
}
const newCredentials = await refreshPromise
this.refreshPromises.delete(cacheKey)
await this.saveCredentialsForProfile(newCredentials, profileId)
this.log(
`[openai-codex-oauth] Forced token persisted for profile ${profileId || "global"} (expires=${newCredentials.expires})`,
)
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?.accountId || null
}
/**
* Check if the user is authenticated
* Get the user's email for a specific profile
*/
async isAuthenticated(): Promise<boolean> {
const token = await this.getAccessToken()
async getEmailForProfile(profileId?: string): Promise<string | null> {
const cacheKey = profileId || "__global__"
let credentials = this.credentialsCache.get(cacheKey)
if (!credentials) {
credentials = (await this.loadCredentialsForProfile(profileId)) ?? undefined
}
return credentials?.email || null
}
/**
* Get the ChatGPT account ID for a specific profile
*/
async getAccountIdForProfile(profileId?: string): Promise<string | null> {
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<boolean> {
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<OpenAiCodexCredentials> {
async waitForCallbackForProfile(): Promise<OpenAiCodexCredentials> {
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(`<!DOCTYPE html>
@ -638,22 +715,22 @@ export class OpenAiCodexOAuthManager {
<meta charset="utf-8">
<title>Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #10a37f 0%, #0d8f6f 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { font-size: 2rem; margin-bottom: 1rem; }
p { opacity: 0.9; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #10a37f 0%, #0d8f6f 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { font-size: 2rem; margin-bottom: 1rem; }
p { opacity: 0.9; }
</style>
</head>
<body>
@ -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<string | null> {
return this.forceRefreshAccessTokenForProfile()
}
/**
* Load credentials from storage
* @deprecated Use loadCredentialsForProfile for profile-scoped credentials
*/
async loadCredentials(): Promise<OpenAiCodexCredentials | null> {
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<void> {
await this.saveCredentialsForProfile(credentials)
this.credentials = credentials
}
/**
* Clear credentials from storage
* @deprecated Use clearCredentialsForProfile for profile-scoped credentials
*/
async clearCredentials(): Promise<void> {
await this.clearCredentialsForProfile()
this.credentials = null
}
/**
* Get a valid access token, refreshing if necessary
* @deprecated Use getAccessTokenForProfile for profile-scoped credentials
*/
async getAccessToken(): Promise<string | null> {
return this.getAccessTokenForProfile()
}
/**
* Get the user's email from credentials
* @deprecated Use getEmailForProfile for profile-scoped credentials
*/
async getEmail(): Promise<string | null> {
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<string | null> {
return this.getAccountIdForProfile()
}
/**
* Check if the user is authenticated
* @deprecated Use isAuthenticatedForProfile for profile-scoped credentials
*/
async isAuthenticated(): Promise<boolean> {
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<OpenAiCodexCredentials> {
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

View file

@ -23,6 +23,12 @@ export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider"> & {
* 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

View file

@ -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}
/>
)}

View file

@ -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<OpenAICodexProps> = ({
@ -21,6 +23,8 @@ export const OpenAICodex: React.FC<OpenAICodexProps> = ({
setApiConfigurationField,
simplifySettings,
openAiCodexIsAuthenticated = false,
openAiCodexAuthenticatedEmail,
profileId,
}) => {
const { t } = useAppTranslation()
@ -29,20 +33,30 @@ export const OpenAICodex: React.FC<OpenAICodexProps> = ({
{/* Authentication Section */}
<div className="flex flex-col gap-2">
{openAiCodexIsAuthenticated ? (
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => vscode.postMessage({ type: "openAiCodexSignOut" })}>
{t("settings:providers.openAiCodex.signOutButton", {
defaultValue: "Sign Out",
})}
</Button>
<div className="flex flex-col gap-2">
{openAiCodexAuthenticatedEmail && (
<p className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.openAiCodex.signedInAs", {
defaultValue: "Signed in as {{email}}",
email: openAiCodexAuthenticatedEmail,
})}
</p>
)}
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => vscode.postMessage({ type: "openAiCodexSignOut", profileId })}>
{t("settings:providers.openAiCodex.signOutButton", {
defaultValue: "Sign Out",
})}
</Button>
</div>
</div>
) : (
<Button
variant="primary"
onClick={() => vscode.postMessage({ type: "openAiCodexSignIn" })}
onClick={() => vscode.postMessage({ type: "openAiCodexSignIn", profileId })}
className="w-fit">
{t("settings:providers.openAiCodex.signInButton", {
defaultValue: "Sign in to OpenAI Codex",
@ -52,7 +66,7 @@ export const OpenAICodex: React.FC<OpenAICodexProps> = ({
</div>
{/* Rate Limit Dashboard - only shown when authenticated */}
<OpenAICodexRateLimitDashboard isAuthenticated={openAiCodexIsAuthenticated} />
<OpenAICodexRateLimitDashboard isAuthenticated={openAiCodexIsAuthenticated} profileId={profileId} />
{/* Model Picker */}
<ModelPicker

View file

@ -6,6 +6,7 @@ import { vscode } from "@src/utils/vscode"
interface OpenAICodexRateLimitDashboardProps {
isAuthenticated: boolean
profileId?: string
}
type Translate = (key: string, options?: Record<string, any>) => string
@ -84,7 +85,10 @@ const UsageProgressBar: React.FC<{ usedPercent: number; label?: string }> = ({ u
)
}
export const OpenAICodexRateLimitDashboard: React.FC<OpenAICodexRateLimitDashboardProps> = ({ isAuthenticated }) => {
export const OpenAICodexRateLimitDashboard: React.FC<OpenAICodexRateLimitDashboardProps> = ({
isAuthenticated,
profileId,
}) => {
const { t } = useAppTranslation()
const [rateLimits, setRateLimits] = useState<OpenAiCodexRateLimitInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@ -98,8 +102,8 @@ export const OpenAICodexRateLimitDashboard: React.FC<OpenAICodexRateLimitDashboa
}
setIsLoading(true)
setError(null)
vscode.postMessage({ type: "requestOpenAiCodexRateLimits" })
}, [isAuthenticated])
vscode.postMessage({ type: "requestOpenAiCodexRateLimits", profileId })
}, [isAuthenticated, profileId])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {