Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/cli/ai/modern-ai-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,9 +1280,28 @@ Please provide corrected arguments for this tool. Only output the corrected JSON

switch (config.provider) {
case 'openai': {
// OpenAI provider is already response-API compatible via model options; no chainable helper here.
const openaiProvider = createOpenAI({ apiKey, compatibility: 'strict' })
baseModel = openaiProvider(config.model)
const oauthTokens = simpleConfigManager.getOpenAIOAuthTokens()

// Check if this is a Codex model and OAuth is available
const { isCodexModel } = require('../auth/openai-oauth')
if (oauthTokens && isCodexModel(config.model)) {
// Use OAuth with custom fetch for ChatGPT Pro/Plus subscription (Codex access)
const { createOAuthFetch } = require('../auth/openai-oauth')
const openaiProvider = createOpenAI({
apiKey: 'oauth', // Placeholder, actual auth via fetch
compatibility: 'strict',
fetch: createOAuthFetch(
() => Promise.resolve(simpleConfigManager.getOpenAIOAuthTokens()),
(tokens: { access: string; refresh: string; expires: number; tokenType?: string }) =>
simpleConfigManager.setOpenAIOAuthTokens(tokens)
),
})
baseModel = openaiProvider(config.model)
} else {
// Fallback to API key
const openaiProvider = createOpenAI({ apiKey, compatibility: 'strict' })
baseModel = openaiProvider(config.model)
}
break
}
case 'anthropic': {
Expand Down
181 changes: 181 additions & 0 deletions src/cli/auth/openai-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { createHash, randomBytes } from 'node:crypto'

// OpenAI OAuth configuration
const AUTH_URL = 'https://auth.openai.com/authorize'
const TOKEN_URL = 'https://auth.openai.com/oauth/token'
const REDIRECT_URI = 'https://auth.openai.com/oauth/callback'
const CODEX_API_URL = 'https://api.openai.com/v1'

// Codex models available with ChatGPT subscription
export const CODEX_MODELS = ['gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'] as const

export interface OpenAITokens {
access: string
refresh: string
expires: number
tokenType: string
}

interface PKCEChallenge {
verifier: string
challenge: string
state: string
}

/**
* Generate PKCE challenge for OAuth flow
*/
function generatePKCE(): PKCEChallenge {
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
return { verifier, challenge, state }
}

/**
* Start OAuth flow and return authorization URL
*/
export function getAuthorizationUrl(): { url: string; verifier: string; state: string } {
const pkce = generatePKCE()
const url = new URL(AUTH_URL)

url.searchParams.set('client_id', 'chatgpt-subscription')
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', REDIRECT_URI)
url.searchParams.set('scope', 'openid profile email offline_access model.request model.read')
url.searchParams.set('code_challenge', pkce.challenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', pkce.state)

return { url: url.toString(), verifier: pkce.verifier, state: pkce.state }
}


/**
* Exchange authorization code for access tokens
*/
export async function exchangeCodeForTokens(code: string, verifier: string): Promise<OpenAITokens | null> {
try {
// Code format: may include #state suffix (like Anthropic OAuth)
const splits = code.split('#')
const actualCode = splits[0]

const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: actualCode,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
client_id: 'chatgpt-subscription',
}).toString(),
})

if (!response.ok) {
const error = await response.text().catch(() => 'Unknown error')
console.error(`OAuth exchange failed: ${response.status} - ${error}`)
return null
}

const json = await response.json()
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + (json.expires_in || 3600) * 1000,
tokenType: json.token_type || 'Bearer',
}
} catch (error) {
console.error('Token exchange error:', error)
return null
}
}

/**
* Refresh access token using refresh token
*/
export async function refreshTokens(refreshToken: string): Promise<OpenAITokens | null> {
try {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'chatgpt-subscription',
}).toString(),
})

if (!response.ok) {
const error = await response.text().catch(() => 'Unknown error')
console.error(`Token refresh failed: ${response.status} - ${error}`)
return null
}

const json = await response.json()
return {
access: json.access_token,
refresh: json.refresh_token || refreshToken, // Reuse old refresh token if not provided
expires: Date.now() + (json.expires_in || 3600) * 1000,
tokenType: json.token_type || 'Bearer',
}
} catch (error) {
console.error('Token refresh error:', error)
return null
}
}

/**
* Create a custom fetch function with OAuth authentication and token refresh
*/
export function createOAuthFetch(
getTokens: () => Promise<OpenAITokens | null>,
saveTokens: (tokens: OpenAITokens) => void
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
let tokens = await getTokens()
if (!tokens) {
throw new Error('No OpenAI OAuth tokens available. Run authentication flow first.')
}

// Refresh token if expired
if (tokens.expires < Date.now()) {
const newTokens = await refreshTokens(tokens.refresh)
if (!newTokens) {
throw new Error('Token refresh failed. Please re-authenticate.')
}
tokens = newTokens
saveTokens(tokens)
}

// Prepare request headers
const headers = new Headers(init?.headers)
headers.set('Authorization', `${tokens.tokenType} ${tokens.access}`)

// Remove dummy API key headers if present
headers.delete('api-key')
headers.delete('x-api-key')

// Rewrite URL to Codex endpoint if needed
let url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
if (url.includes('/chat/completions')) {
url = url.replace(/^https?:\/\/[^/]+/, CODEX_API_URL)
}

return fetch(url, { ...init, headers })
}
}

/**
* Validate if a model is a Codex model
*/
export function isCodexModel(model: string): boolean {
return CODEX_MODELS.some((codexModel) => model.includes(codexModel))
}

/**
* Get available Codex models
*/
export function getCodexModels(): readonly string[] {
return CODEX_MODELS
}
82 changes: 82 additions & 0 deletions src/cli/core/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,18 @@ const ConfigSchema = z.object({
.optional(),
// Temporary OAuth verifier (not saved to disk)
anthropicOAuthVerifier: z.string().optional(),
// OpenAI OAuth for ChatGPT Pro/Plus subscription (Codex access)
openaiOAuth: z
.object({
access: z.string().describe('Encrypted OAuth access token'),
refresh: z.string().describe('Encrypted OAuth refresh token'),
expires: z.number().describe('Token expiration timestamp'),
tokenType: z.string().default('Bearer').describe('Token type'),
})
.optional(),
// Temporary OAuth state and verifier for OpenAI (not saved to disk)
openaiOAuthVerifier: z.string().optional(),
openaiOAuthState: z.string().optional(),
// Enhanced diff display configuration
diff: z
.object({
Expand Down Expand Up @@ -3219,6 +3231,76 @@ export class SimpleConfigManager {
this.config.anthropicOAuthVerifier = undefined
}

// OpenAI OAuth management for ChatGPT Pro/Plus subscription (Codex access)
getOpenAIOAuthTokens(): {
access: string
refresh: string
expires: number
tokenType: string
} | null {
if (!this.config.openaiOAuth) {
return null
}

try {
return {
access: KeyEncryption.decrypt(this.config.openaiOAuth.access),
refresh: KeyEncryption.decrypt(this.config.openaiOAuth.refresh),
expires: this.config.openaiOAuth.expires,
tokenType: this.config.openaiOAuth.tokenType || 'Bearer',
}
} catch (error) {
console.warn(chalk.yellow('Warning: Failed to decrypt OpenAI OAuth tokens'))
return null
}
}

setOpenAIOAuthTokens(tokens: { access: string; refresh: string; expires: number; tokenType?: string }): void {
this.config.openaiOAuth = {
access: KeyEncryption.encrypt(tokens.access),
refresh: KeyEncryption.encrypt(tokens.refresh),
expires: tokens.expires,
tokenType: tokens.tokenType || 'Bearer',
}
this.saveConfig()
}

clearOpenAIOAuth(): void {
this.config.openaiOAuth = undefined
this.saveConfig()
}

hasOpenAIOAuth(): boolean {
return !!this.config.openaiOAuth?.access
}

// Temporary verifier and state storage for OpenAI OAuth flow
setOpenAIOAuthVerifier(verifier: string): void {
this.config.openaiOAuthVerifier = verifier
// Don't save to disk - only keep in memory for security
}

getOpenAIOAuthVerifier(): string | null {
return this.config.openaiOAuthVerifier || null
}

clearOpenAIOAuthVerifier(): void {
this.config.openaiOAuthVerifier = undefined
}

setOpenAIOAuthState(state: string): void {
this.config.openaiOAuthState = state
// Don't save to disk - only keep in memory for security
}

getOpenAIOAuthState(): string | null {
return this.config.openaiOAuthState || null
}

clearOpenAIOAuthState(): void {
this.config.openaiOAuthState = undefined
}

// Session Context Awareness
setSessionContext(context: SessionContext | null): void {
this.currentSessionContext = context
Expand Down
Loading