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