mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
cc86049f10
commit
2e924c61b3
10 changed files with 413 additions and 163 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue