diff --git a/src/constants/oauth.ts b/src/constants/oauth.ts index a56e3fa..6f53d5b 100644 --- a/src/constants/oauth.ts +++ b/src/constants/oauth.ts @@ -35,6 +35,10 @@ export const CLAUDE_AI_PROFILE_SCOPE = 'user:profile' as const const CONSOLE_SCOPE = 'org:create_api_key' as const export const OAUTH_BETA_HEADER = 'oauth-2025-04-20' as const +// OpenAI OAuth scopes +export const OPENAI_API_SCOPE = 'api' as const +export const OPENAI_CODEX_SCOPE = 'codex' as const + // Console OAuth scopes - for API key creation via Console export const CONSOLE_OAUTH_SCOPES = [ CONSOLE_SCOPE, @@ -50,6 +54,12 @@ export const CLAUDE_AI_OAUTH_SCOPES = [ 'user:file_upload', ] as const +// OpenAI OAuth scopes - for OpenAI Codex users +export const OPENAI_OAUTH_SCOPES = [ + OPENAI_API_SCOPE, + OPENAI_CODEX_SCOPE, +] as const + // All OAuth scopes - union of all scopes used in Claude CLI // When logging in, request all scopes in order to handle both Console -> Claude.ai redirect // Ensure that `OAuthConsentPage` in apps repo is kept in sync with this list. @@ -68,11 +78,16 @@ type OauthConfig = { * /settings/connectors, and other claude.ai web pages. */ CLAUDE_AI_ORIGIN: string + // OpenAI OAuth URLs + OPENAI_AUTHORIZE_URL: string + OPENAI_TOKEN_URL: string + OPENAI_CLIENT_ID: string TOKEN_URL: string API_KEY_URL: string ROLES_URL: string CONSOLE_SUCCESS_URL: string CLAUDEAI_SUCCESS_URL: string + OPENAI_SUCCESS_URL: string MANUAL_REDIRECT_URL: string CLIENT_ID: string OAUTH_FILE_SUFFIX: string @@ -88,6 +103,10 @@ const PROD_OAUTH_CONFIG = { // visits for attribution. 307s to claude.ai/oauth/authorize in two hops. CLAUDE_AI_AUTHORIZE_URL: 'https://claude.com/cai/oauth/authorize', CLAUDE_AI_ORIGIN: 'https://claude.ai', + // OpenAI OAuth URLs + OPENAI_AUTHORIZE_URL: 'https://openai.com/oauth/authorize', + OPENAI_TOKEN_URL: 'https://openai.com/oauth/token', + OPENAI_CLIENT_ID: 'claude-code-client', TOKEN_URL: 'https://platform.claude.com/v1/oauth/token', API_KEY_URL: 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key', ROLES_URL: 'https://api.anthropic.com/api/oauth/claude_cli/roles', @@ -95,6 +114,8 @@ const PROD_OAUTH_CONFIG = { 'https://platform.claude.com/buy_credits?returnUrl=/oauth/code/success%3Fapp%3Dclaude-code', CLAUDEAI_SUCCESS_URL: 'https://platform.claude.com/oauth/code/success?app=claude-code', + OPENAI_SUCCESS_URL: + 'https://openai.com/oauth/success?app=claude-code', MANUAL_REDIRECT_URL: 'https://platform.claude.com/oauth/code/callback', CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', // No suffix for production config @@ -124,6 +145,10 @@ const STAGING_OAUTH_CONFIG = CLAUDE_AI_AUTHORIZE_URL: 'https://claude-ai.staging.ant.dev/oauth/authorize', CLAUDE_AI_ORIGIN: 'https://claude-ai.staging.ant.dev', + // OpenAI OAuth URLs (staging) + OPENAI_AUTHORIZE_URL: 'https://openai.com/oauth/authorize', + OPENAI_TOKEN_URL: 'https://openai.com/oauth/token', + OPENAI_CLIENT_ID: 'claude-code-staging-client', TOKEN_URL: 'https://platform.staging.ant.dev/v1/oauth/token', API_KEY_URL: 'https://api-staging.anthropic.com/api/oauth/claude_cli/create_api_key', @@ -133,6 +158,8 @@ const STAGING_OAUTH_CONFIG = 'https://platform.staging.ant.dev/buy_credits?returnUrl=/oauth/code/success%3Fapp%3Dclaude-code', CLAUDEAI_SUCCESS_URL: 'https://platform.staging.ant.dev/oauth/code/success?app=claude-code', + OPENAI_SUCCESS_URL: + 'https://openai.com/oauth/success?app=claude-code-staging', MANUAL_REDIRECT_URL: 'https://platform.staging.ant.dev/oauth/code/callback', CLIENT_ID: '22422756-60c9-4084-8eb7-27705fd5cf9a', @@ -160,11 +187,16 @@ function getLocalOauthConfig(): OauthConfig { CONSOLE_AUTHORIZE_URL: `${consoleBase}/oauth/authorize`, CLAUDE_AI_AUTHORIZE_URL: `${apps}/oauth/authorize`, CLAUDE_AI_ORIGIN: apps, + // OpenAI OAuth URLs (local dev) + OPENAI_AUTHORIZE_URL: 'https://openai.com/oauth/authorize', + OPENAI_TOKEN_URL: 'https://openai.com/oauth/token', + OPENAI_CLIENT_ID: 'claude-code-local-client', TOKEN_URL: `${api}/v1/oauth/token`, API_KEY_URL: `${api}/api/oauth/claude_cli/create_api_key`, ROLES_URL: `${api}/api/oauth/claude_cli/roles`, CONSOLE_SUCCESS_URL: `${consoleBase}/buy_credits?returnUrl=/oauth/code/success%3Fapp%3Dclaude-code`, CLAUDEAI_SUCCESS_URL: `${consoleBase}/oauth/code/success?app=claude-code`, + OPENAI_SUCCESS_URL: 'https://openai.com/oauth/success?app=claude-code-local', MANUAL_REDIRECT_URL: `${consoleBase}/oauth/code/callback`, CLIENT_ID: '22422756-60c9-4084-8eb7-27705fd5cf9a', OAUTH_FILE_SUFFIX: '-local-oauth', diff --git a/src/services/oauth/client.ts b/src/services/oauth/client.ts index 76bfdce..84fb133 100644 --- a/src/services/oauth/client.ts +++ b/src/services/oauth/client.ts @@ -43,6 +43,34 @@ export function parseScopes(scopeString?: string): string[] { return scopeString?.split(' ').filter(Boolean) ?? [] } +export function buildOpenAIAuthUrl({ + codeChallenge, + state, + port, + isManual, +}: { + codeChallenge: string + state: string + port: number + isManual: boolean +}): string { + const authUrl = new URL(getOauthConfig().OPENAI_AUTHORIZE_URL) + authUrl.searchParams.append('client_id', getOauthConfig().OPENAI_CLIENT_ID) + authUrl.searchParams.append('response_type', 'code') + authUrl.searchParams.append( + 'redirect_uri', + isManual + ? getOauthConfig().MANUAL_REDIRECT_URL + : `http://localhost:${port}/callback`, + ) + authUrl.searchParams.append('scope', 'openai') + authUrl.searchParams.append('code_challenge', codeChallenge) + authUrl.searchParams.append('code_challenge_method', 'S256') + authUrl.searchParams.append('state', state) + + return authUrl.toString() +} + export function buildAuthUrl({ codeChallenge, state, diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 7cf5a29..d406eae 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1626,6 +1626,17 @@ export function isClaudeAISubscriber(): boolean { return shouldUseClaudeAIAuth(getClaudeAIOAuthTokens()?.scopes) } +export function isCodexSubscriber(): boolean { + // Check if we're using OpenAI API + if (getAPIProvider() !== 'openai') { + return false + } + + // For now, return true if OpenAI provider is configured + // This can be extended to check for specific token scopes when available + return true +} + /** * Check if the user is authenticated via OpenAI Codex OAuth. * Returns true when a valid Codex access token is present in the config. diff --git a/src/utils/codex-fetch-adapter.ts b/src/utils/codex-fetch-adapter.ts new file mode 100644 index 0000000..e6b0e2f --- /dev/null +++ b/src/utils/codex-fetch-adapter.ts @@ -0,0 +1,157 @@ +/** + * OpenAI Codex API adapter for Claude Code + * Provides compatibility layer between Claude's API expectations and OpenAI's Codex API + */ + +import type { Message } from '../types/message.js' +import { logError } from './log.js' + +/** + * OpenAI message format for API requests + */ +interface OpenAIMessage { + role: 'system' | 'user' | 'assistant' + content: string | Array<{ + type: 'text' | 'image_url' + text?: string + image_url?: { + url: string + } + }> +} + +/** + * OpenAI API response format + */ +interface OpenAIResponse { + id: string + object: string + created: number + model: string + choices: Array<{ + index: number + message: { + role: string + content: string + } + finish_reason: string + }> + usage: { + prompt_tokens: number + completion_tokens: number + total_tokens: number + } +} + +/** + * Convert Claude Code message format to OpenAI format + */ +function convertToOpenAIMessage(message: Message): OpenAIMessage { + if (typeof message.content === 'string') { + return { + role: message.role === 'human' ? 'user' : message.role as 'system' | 'assistant', + content: message.content, + } + } + + // Handle multi-modal content + const content: Array = [] + + for (const item of message.content) { + if (item.type === 'text') { + content.push({ + type: 'text', + text: item.text, + }) + } else if (item.type === 'image') { + // Convert Anthropic base64 image schema to OpenAI format + content.push({ + type: 'image_url', + image_url: { + url: item.source.type === 'base64' + ? `data:${item.source.media_type};base64,${item.source.data}` + : item.source.data + } + }) + } + } + + return { + role: message.role === 'human' ? 'user' : message.role as 'system' | 'assistant', + content, + } +} + +/** + * Make a request to OpenAI Codex API + */ +export async function fetchCodexResponse( + messages: Message[], + model: string, + options: { + apiKey?: string + baseUrl?: string + stream?: boolean + } = {} +): Promise { + const { apiKey, baseUrl = 'https://api.openai.com/v1', stream = false } = options + + if (!apiKey) { + throw new Error('OpenAI API key is required for Codex requests') + } + + const openAIMessages = messages.map(convertToOpenAIMessage) + + const requestBody = { + model, + messages: openAIMessages, + stream, + temperature: 0.7, + max_tokens: 4096, + } + + try { + const response = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() as OpenAIResponse + return data + } catch (error) { + logError(error) + throw error + } +} + +/** + * Convert OpenAI response to Claude Code format + */ +export function convertFromOpenAIResponse(response: OpenAIResponse): { + content: string + usage: { + input_tokens: number + output_tokens: number + } +} { + const choice = response.choices[0] + if (!choice) { + throw new Error('No choices in OpenAI response') + } + + return { + content: choice.message.content, + usage: { + input_tokens: response.usage.prompt_tokens, + output_tokens: response.usage.completion_tokens, + }, + } +} \ No newline at end of file diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 89f243d..14e3ed9 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -11,6 +11,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { bedrock: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', vertex: 'claude-3-7-sonnet@20250219', foundry: 'claude-3-7-sonnet', + openai: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -18,6 +19,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { bedrock: 'anthropic.claude-3-5-sonnet-20241022-v2:0', vertex: 'claude-3-5-sonnet-v2@20241022', foundry: 'claude-3-5-sonnet', + openai: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -25,6 +27,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { bedrock: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', vertex: 'claude-3-5-haiku@20241022', foundry: 'claude-3-5-haiku', + openai: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -32,6 +35,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { bedrock: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', vertex: 'claude-haiku-4-5@20251001', foundry: 'claude-haiku-4-5', + openai: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -39,6 +43,7 @@ export const CLAUDE_SONNET_4_CONFIG = { bedrock: 'us.anthropic.claude-sonnet-4-20250514-v1:0', vertex: 'claude-sonnet-4@20250514', foundry: 'claude-sonnet-4', + openai: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -46,6 +51,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { bedrock: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', vertex: 'claude-sonnet-4-5@20250929', foundry: 'claude-sonnet-4-5', + openai: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -53,6 +59,7 @@ export const CLAUDE_OPUS_4_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-20250514-v1:0', vertex: 'claude-opus-4@20250514', foundry: 'claude-opus-4', + openai: 'claude-opus-4-20250514', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -60,6 +67,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-1-20250805-v1:0', vertex: 'claude-opus-4-1@20250805', foundry: 'claude-opus-4-1', + openai: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -67,6 +75,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-5-20251101-v1:0', vertex: 'claude-opus-4-5@20251101', foundry: 'claude-opus-4-5', + openai: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -74,6 +83,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-6-v1', vertex: 'claude-opus-4-6', foundry: 'claude-opus-4-6', + openai: 'claude-opus-4-6', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -81,6 +91,32 @@ export const CLAUDE_SONNET_4_6_CONFIG = { bedrock: 'us.anthropic.claude-sonnet-4-6', vertex: 'claude-sonnet-4-6', foundry: 'claude-sonnet-4-6', + openai: 'claude-sonnet-4-6', +} as const satisfies ModelConfig + +// OpenAI Codex models +export const GPT_5_4_CONFIG = { + firstParty: 'gpt-5.4', + bedrock: 'gpt-5.4', + vertex: 'gpt-5.4', + foundry: 'gpt-5.4', + openai: 'gpt-5.4', +} as const satisfies ModelConfig + +export const GPT_5_3_CODEX_CONFIG = { + firstParty: 'gpt-5.3-codex', + bedrock: 'gpt-5.3-codex', + vertex: 'gpt-5.3-codex', + foundry: 'gpt-5.3-codex', + openai: 'gpt-5.3-codex', +} as const satisfies ModelConfig + +export const GPT_5_4_MINI_CONFIG = { + firstParty: 'gpt-5.4-mini', + bedrock: 'gpt-5.4-mini', + vertex: 'gpt-5.4-mini', + foundry: 'gpt-5.4-mini', + openai: 'gpt-5.4-mini', } as const satisfies ModelConfig // @[MODEL LAUNCH]: Register the new config here. @@ -96,6 +132,10 @@ export const ALL_MODEL_CONFIGS = { opus41: CLAUDE_OPUS_4_1_CONFIG, opus45: CLAUDE_OPUS_4_5_CONFIG, opus46: CLAUDE_OPUS_4_6_CONFIG, + // OpenAI Codex models + gpt54: GPT_5_4_CONFIG, + gpt53codex: GPT_5_3_CODEX_CONFIG, + gpt54mini: GPT_5_4_MINI_CONFIG, } as const satisfies Record export type ModelKey = keyof typeof ALL_MODEL_CONFIGS diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 01ce4cc..74bb90f 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -9,6 +9,7 @@ import { getMainLoopModelOverride } from '../../bootstrap/state.js' import { getSubscriptionType, isClaudeAISubscriber, + isCodexSubscriber, isMaxSubscriber, isProSubscriber, isTeamPremiumSubscriber, @@ -190,6 +191,11 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { ) } + // Codex users get GPT-5.3 Codex as default + if (isCodexSubscriber()) { + return getModelStrings().gpt53codex + } + // Max users get Opus as default if (isMaxSubscriber()) { return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '') @@ -267,6 +273,16 @@ export function firstPartyNameToCanonical(name: ModelName): ModelShortName { if (name.includes('claude-3-haiku')) { return 'claude-3-haiku' } + // OpenAI GPT models + if (name.includes('gpt-5.4-mini')) { + return 'gpt-5.4-mini' + } + if (name.includes('gpt-5.4')) { + return 'gpt-5.4' + } + if (name.includes('gpt-5.3-codex')) { + return 'gpt-5.3-codex' + } const match = name.match(/(claude-(\d+-\d+-)?\w+)/) if (match && match[1]) { return match[1] @@ -293,7 +309,7 @@ export function getClaudeAiUserDefaultModelDescription( fastMode = false, ): string { if (isCodexSubscriber()) { - return 'Codex 5.2 · Best for everyday coding tasks' + return 'GPT-5.3 Codex · Optimized for code generation and understanding' } if (isMaxSubscriber() || isTeamPremiumSubscriber()) { if (isOpus1mMergeEnabled()) { @@ -397,6 +413,12 @@ export function getPublicModelDisplayName(model: ModelName): string | null { return 'Haiku 4.5' case getModelStrings().haiku35: return 'Haiku 3.5' + case getModelStrings().gpt54: + return 'GPT-5.4' + case getModelStrings().gpt53codex: + return 'GPT-5.3 Codex' + case getModelStrings().gpt54mini: + return 'GPT-5.4 Mini' default: return null } @@ -631,6 +653,16 @@ export function getMarketingNameForModel(modelId: string): string | undefined { if (canonical.includes('claude-3-5-haiku')) { return 'Claude 3.5 Haiku' } + // OpenAI Codex models + if (canonical.includes('gpt-5.4-mini')) { + return 'GPT-5.4 Mini' + } + if (canonical.includes('gpt-5.4')) { + return 'GPT-5.4' + } + if (canonical.includes('gpt-5.3-codex')) { + return 'GPT-5.3 Codex' + } return undefined } diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index f8ef296..5ebb68a 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -2,6 +2,7 @@ import { getInitialMainLoopModel } from '../../bootstrap/state.js' import { isClaudeAISubscriber, + isCodexSubscriber, isMaxSubscriber, isTeamPremiumSubscriber, } from '../auth.js' @@ -208,6 +209,34 @@ function getHaikuOption(): ModelOption { : getHaiku35Option() } +// OpenAI Codex model options +function getGpt54Option(): ModelOption { + return { + value: 'gpt-5.4', + label: 'GPT-5.4', + description: 'GPT-5.4 · Advanced reasoning and code generation', + descriptionForModel: 'GPT-5.4 - advanced reasoning and code generation capabilities', + } +} + +function getGpt53CodexOption(): ModelOption { + return { + value: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + description: 'GPT-5.3 Codex · Optimized for code generation and understanding', + descriptionForModel: 'GPT-5.3 Codex - specialized for code generation and understanding', + } +} + +function getGpt54MiniOption(): ModelOption { + return { + value: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + description: 'GPT-5.4 Mini · Fast and efficient for simple tasks', + descriptionForModel: 'GPT-5.4 Mini - fast and efficient for simple coding tasks', + } +} + function getMaxOpusOption(fastMode = false): ModelOption { return { value: 'opus', @@ -287,6 +316,16 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { ] } + // Codex subscribers get OpenAI model options + if (isCodexSubscriber()) { + return [ + getDefaultOptionForUser(), + getGpt54Option(), + getGpt53CodexOption(), + getGpt54MiniOption(), + ] + } + if (isClaudeAISubscriber()) { if (isMaxSubscriber() || isTeamPremiumSubscriber()) { // Max and Team Premium users: Opus is default, show Sonnet as alternative diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index aba9b7d..f385e4c 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -1,7 +1,7 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' import { isEnvTruthy } from '../envUtils.js' -export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' +export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'openai' export function getAPIProvider(): APIProvider { return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) @@ -10,7 +10,9 @@ export function getAPIProvider(): APIProvider { ? 'vertex' : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ? 'foundry' - : 'firstParty' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + ? 'openai' + : 'firstParty' } export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {