mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: openai auth login via cli
This commit is contained in:
parent
e99c567dea
commit
f091e8e5a5
1
.nvimlog
Normal file
1
.nvimlog
Normal file
@ -0,0 +1 @@
|
||||
WRN 2026-04-29T11:21:24.001 ?.14533 server_start:199: Failed to start server: operation not permitted: /var/folders/1g/3yj40ngn5b33fs9gg0wzvms00000gn/T/nvim.apple/gwHDxl/nvim.14533.0
|
||||
@ -20,13 +20,19 @@ import {
|
||||
import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js'
|
||||
import { OAuthService } from '../../services/oauth/index.js'
|
||||
import type { OAuthTokens } from '../../services/oauth/types.js'
|
||||
import { OpenAIOAuthService } from '../../services/openaiAuth/index.js'
|
||||
import { getOpenAIOAuthTokens } from '../../services/openaiAuth/storage.js'
|
||||
import type { OpenAIOAuthTokens } from '../../services/openaiAuth/types.js'
|
||||
import {
|
||||
clearStoredClaudeAIOAuthTokens,
|
||||
clearOAuthTokenCache,
|
||||
getAnthropicApiKeyWithSource,
|
||||
getAuthTokenSource,
|
||||
getOpenAIAuthOverrideWarning,
|
||||
getOauthAccountInfo,
|
||||
getSubscriptionType,
|
||||
isUsing3PServices,
|
||||
removeApiKey,
|
||||
saveOAuthTokensIfNeeded,
|
||||
validateForceLoginOrg,
|
||||
} from '../../utils/auth.js'
|
||||
@ -109,17 +115,75 @@ export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await clearAuthRelatedCaches()
|
||||
}
|
||||
|
||||
export async function installOpenAIOAuthTokens(
|
||||
tokens: OpenAIOAuthTokens,
|
||||
): Promise<string | null> {
|
||||
await removeApiKey()
|
||||
const clearClaudeOauth = clearStoredClaudeAIOAuthTokens()
|
||||
if (!clearClaudeOauth.success) {
|
||||
throw new Error(
|
||||
clearClaudeOauth.warning ?? 'Failed to disable existing Claude auth state',
|
||||
)
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
oauthAccount: undefined,
|
||||
}))
|
||||
|
||||
if (!tokens.refreshToken) {
|
||||
throw new Error('OpenAI OAuth tokens are incomplete.')
|
||||
}
|
||||
|
||||
await clearAuthRelatedCaches()
|
||||
return getOpenAIAuthOverrideWarning()
|
||||
}
|
||||
|
||||
export async function authLogin({
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai,
|
||||
openai,
|
||||
}: {
|
||||
email?: string
|
||||
sso?: boolean
|
||||
console?: boolean
|
||||
claudeai?: boolean
|
||||
openai?: boolean
|
||||
}): Promise<void> {
|
||||
if (openai) {
|
||||
if (email || sso || useConsole || claudeai) {
|
||||
process.stderr.write(
|
||||
'Error: --openai cannot be combined with --email, --sso, --console, or --claudeai.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const openaiOAuthService = new OpenAIOAuthService()
|
||||
|
||||
try {
|
||||
const tokens = await openaiOAuthService.startOAuthFlow(async url => {
|
||||
process.stdout.write('Opening browser to sign in to OpenAI…\n')
|
||||
process.stdout.write(`If the browser did not open, visit: ${url}\n`)
|
||||
})
|
||||
|
||||
const warning = await installOpenAIOAuthTokens(tokens)
|
||||
|
||||
process.stdout.write('OpenAI login successful.\n')
|
||||
if (warning) {
|
||||
process.stdout.write(`${warning}\n`)
|
||||
}
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
process.stderr.write(`OpenAI login failed: ${errorMessage(err)}\n`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
openaiOAuthService.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if (useConsole && claudeai) {
|
||||
process.stderr.write(
|
||||
'Error: --console and --claudeai cannot be used together.\n',
|
||||
@ -232,7 +296,52 @@ export async function authLogin({
|
||||
export async function authStatus(opts: {
|
||||
json?: boolean
|
||||
text?: boolean
|
||||
openai?: boolean
|
||||
}): Promise<void> {
|
||||
if (opts.openai) {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
const loggedIn =
|
||||
!!openaiTokens &&
|
||||
openaiTokens.refreshToken.length > 0 &&
|
||||
openaiTokens.expiresAt > 0
|
||||
|
||||
if (opts.text) {
|
||||
if (!loggedIn) {
|
||||
process.stdout.write(
|
||||
'Not logged in to OpenAI. Run claude auth login --openai to authenticate.\n',
|
||||
)
|
||||
} else {
|
||||
process.stdout.write('Provider: openai\n')
|
||||
if (openaiTokens.email) {
|
||||
process.stdout.write(`Email: ${openaiTokens.email}\n`)
|
||||
}
|
||||
if (openaiTokens.accountId) {
|
||||
process.stdout.write(`Account ID: ${openaiTokens.accountId}\n`)
|
||||
}
|
||||
process.stdout.write(
|
||||
`Expires At: ${new Date(openaiTokens.expiresAt).toISOString()}\n`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(
|
||||
jsonStringify(
|
||||
{
|
||||
loggedIn,
|
||||
authMethod: loggedIn ? 'openai_oauth' : 'none',
|
||||
provider: 'openai',
|
||||
email: openaiTokens?.email ?? null,
|
||||
accountId: openaiTokens?.accountId ?? null,
|
||||
expiresAt: openaiTokens?.expiresAt ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
)
|
||||
}
|
||||
|
||||
process.exit(loggedIn ? 0 : 1)
|
||||
}
|
||||
|
||||
const { source: authTokenSource, hasToken } = getAuthTokenSource()
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
const hasApiKeyEnvVar =
|
||||
@ -240,12 +349,25 @@ export async function authStatus(opts: {
|
||||
const oauthAccount = getOauthAccountInfo()
|
||||
const subscriptionType = getSubscriptionType()
|
||||
const using3P = isUsing3PServices()
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
const usingOpenAI =
|
||||
!!openaiTokens?.refreshToken &&
|
||||
!hasToken &&
|
||||
apiKeySource === 'none' &&
|
||||
!hasApiKeyEnvVar &&
|
||||
!using3P
|
||||
const loggedIn =
|
||||
hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P
|
||||
hasToken ||
|
||||
apiKeySource !== 'none' ||
|
||||
hasApiKeyEnvVar ||
|
||||
using3P ||
|
||||
usingOpenAI
|
||||
|
||||
// Determine auth method
|
||||
let authMethod: string = 'none'
|
||||
if (using3P) {
|
||||
if (usingOpenAI) {
|
||||
authMethod = 'openai_oauth'
|
||||
} else if (using3P) {
|
||||
authMethod = 'third_party'
|
||||
} else if (authTokenSource === 'claude.ai') {
|
||||
authMethod = 'claude.ai'
|
||||
@ -311,6 +433,11 @@ export async function authStatus(opts: {
|
||||
output.orgId = oauthAccount?.organizationUuid ?? null
|
||||
output.orgName = oauthAccount?.organizationName ?? null
|
||||
output.subscriptionType = subscriptionType ?? null
|
||||
} else if (authMethod === 'openai_oauth') {
|
||||
output.provider = 'openai'
|
||||
output.email = openaiTokens?.email ?? null
|
||||
output.accountId = openaiTokens?.accountId ?? null
|
||||
output.subscriptionType = 'ChatGPT Pro/Plus'
|
||||
}
|
||||
|
||||
process.stdout.write(jsonStringify(output, null, 2) + '\n')
|
||||
@ -318,7 +445,18 @@ export async function authStatus(opts: {
|
||||
process.exit(loggedIn ? 0 : 1)
|
||||
}
|
||||
|
||||
export async function authLogout(): Promise<void> {
|
||||
export async function authLogout(opts?: { openai?: boolean }): Promise<void> {
|
||||
if (opts?.openai) {
|
||||
const openaiOAuthService = new OpenAIOAuthService()
|
||||
const success = openaiOAuthService.logout()
|
||||
if (!success) {
|
||||
process.stderr.write('Failed to log out from OpenAI.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write('Successfully logged out from your OpenAI account.\n')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await performLogout({ clearOnboarding: false })
|
||||
} catch {
|
||||
|
||||
File diff suppressed because one or more lines are too long
124
src/components/OpenAILoginFlow.tsx
Normal file
124
src/components/OpenAILoginFlow.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { installOpenAIOAuthTokens } from '../cli/handlers/auth.js'
|
||||
import { Box, Link, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import { OpenAIOAuthService } from '../services/openaiAuth/index.js'
|
||||
import { getOpenAIOAuthTokens } from '../services/openaiAuth/storage.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { Spinner } from './Spinner.js'
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
startingMessage?: string
|
||||
}
|
||||
|
||||
type OpenAILoginStatus =
|
||||
| { state: 'ready_to_start' }
|
||||
| { state: 'waiting_for_login'; url: string }
|
||||
| { state: 'success'; warning?: string | null }
|
||||
| { state: 'error'; message: string }
|
||||
|
||||
export function OpenAILoginFlow({
|
||||
onDone,
|
||||
startingMessage,
|
||||
}: Props): React.ReactNode {
|
||||
const [oauthService] = useState(() => new OpenAIOAuthService())
|
||||
const [status, setStatus] = useState<OpenAILoginStatus>({
|
||||
state: 'ready_to_start',
|
||||
})
|
||||
|
||||
useKeybinding(
|
||||
'confirm:yes',
|
||||
() => {
|
||||
logEvent('tengu_openai_oauth_success', {})
|
||||
onDone()
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
isActive: status.state === 'success',
|
||||
},
|
||||
)
|
||||
|
||||
const startOAuth = useCallback(async () => {
|
||||
try {
|
||||
logEvent('tengu_openai_oauth_flow_start', {})
|
||||
const tokens = await oauthService.startOAuthFlow(async url => {
|
||||
setStatus({ state: 'waiting_for_login', url })
|
||||
})
|
||||
const warning = await installOpenAIOAuthTokens(tokens)
|
||||
setStatus({ state: 'success', warning })
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: (error as Error).message,
|
||||
})
|
||||
logEvent('tengu_openai_oauth_error', {
|
||||
error: (error as Error)
|
||||
.message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
}, [oauthService])
|
||||
|
||||
useEffect(() => {
|
||||
if (status.state !== 'ready_to_start') {
|
||||
return
|
||||
}
|
||||
|
||||
void startOAuth()
|
||||
}, [startOAuth, status.state])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
oauthService.cleanup()
|
||||
}
|
||||
}, [oauthService])
|
||||
|
||||
const account = getOpenAIOAuthTokens()
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>
|
||||
{startingMessage ??
|
||||
'Claude Code can also run through your ChatGPT subscription via OpenAI auth.'}
|
||||
</Text>
|
||||
|
||||
{status.state === 'waiting_for_login' && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text>Opening browser to sign in to OpenAI…</Text>
|
||||
</Box>
|
||||
<Text dimColor>Use the URL below if your browser did not open:</Text>
|
||||
<Link url={status.url}>
|
||||
<Text dimColor>{status.url}</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.state === 'success' && (
|
||||
<Box flexDirection="column">
|
||||
{account?.email ? (
|
||||
<Text dimColor>
|
||||
Logged in as <Text>{account.email}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{status.warning ? (
|
||||
<Text color="warning">{status.warning}</Text>
|
||||
) : null}
|
||||
<Text color="success">
|
||||
Login successful. Press <Text bold>Enter</Text> to continue…
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.state === 'error' && (
|
||||
<Text color="error">OpenAI OAuth error: {status.message}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
157
src/components/SlashLoginFlow.tsx
Normal file
157
src/components/SlashLoginFlow.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Box, Link, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||
import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'
|
||||
import { Select } from './CustomSelect/select.js'
|
||||
import { OpenAILoginFlow } from './OpenAILoginFlow.js'
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
startingMessage?: string
|
||||
}
|
||||
|
||||
type LoginSelection = 'claudeai' | 'console' | 'openai' | 'platform' | 'idle'
|
||||
|
||||
function PlatformSetupFlow({
|
||||
onBack,
|
||||
}: {
|
||||
onBack(): void
|
||||
}): React.ReactNode {
|
||||
useKeybinding(
|
||||
'confirm:yes',
|
||||
() => {
|
||||
onBack()
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
isActive: true,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>Using 3rd-party platforms</Text>
|
||||
<Text>
|
||||
Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI.
|
||||
Set the required environment variables, then restart Claude Code.
|
||||
</Text>
|
||||
<Text>
|
||||
If you are part of an enterprise organization, contact your
|
||||
administrator for setup instructions.
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Documentation:</Text>
|
||||
<Text>
|
||||
· Amazon Bedrock:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/amazon-bedrock" />
|
||||
</Text>
|
||||
<Text>
|
||||
· Microsoft Foundry:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/microsoft-foundry" />
|
||||
</Text>
|
||||
<Text>
|
||||
· Vertex AI:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/google-vertex-ai" />
|
||||
</Text>
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
Press <Text bold>Enter</Text> to go back to login options.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlashLoginFlow({
|
||||
onDone,
|
||||
startingMessage,
|
||||
}: Props): React.ReactNode {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
const forceLoginMethod = settings.forceLoginMethod
|
||||
|
||||
const [selection, setSelection] = useState<LoginSelection>(() => {
|
||||
if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') {
|
||||
return forceLoginMethod
|
||||
}
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Claude account with subscription ·{' '}
|
||||
<Text dimColor>Pro, Max, Team, or Enterprise</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'claudeai' as const,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Anthropic Console account ·{' '}
|
||||
<Text dimColor>API usage billing</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'console' as const,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
OpenAI account · <Text dimColor>ChatGPT Pro/Plus</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'openai' as const,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
3rd-party platform ·{' '}
|
||||
<Text dimColor>Amazon Bedrock, Microsoft Foundry, or Vertex AI</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'platform' as const,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
if (selection === 'claudeai' || selection === 'console') {
|
||||
return (
|
||||
<ConsoleOAuthFlow
|
||||
onDone={onDone}
|
||||
startingMessage={startingMessage}
|
||||
forceLoginMethod={selection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (selection === 'openai') {
|
||||
return <OpenAILoginFlow onDone={onDone} startingMessage={startingMessage} />
|
||||
}
|
||||
|
||||
if (selection === 'platform') {
|
||||
return <PlatformSetupFlow onBack={() => setSelection('idle')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>
|
||||
{startingMessage ??
|
||||
'Claude Code can be used with your Claude subscription, Anthropic Console billing, or your ChatGPT subscription.'}
|
||||
</Text>
|
||||
<Text>Select login method:</Text>
|
||||
<Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => setSelection(value as LoginSelection)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
18
src/main.tsx
18
src/main.tsx
@ -4141,16 +4141,18 @@ async function run(): Promise<CommanderCommand> {
|
||||
// claude auth
|
||||
|
||||
const auth = program.command('auth').description('Manage authentication').configureHelp(createSortedHelpConfig());
|
||||
auth.command('login').description('Sign in to your Anthropic account').option('--email <email>', 'Pre-populate email address on the login page').option('--sso', 'Force SSO login flow').option('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription').option('--claudeai', 'Use Claude subscription (default)').action(async ({
|
||||
auth.command('login').description('Sign in to your Anthropic or OpenAI account').option('--email <email>', 'Pre-populate email address on the login page').option('--sso', 'Force SSO login flow').option('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription').option('--claudeai', 'Use Claude subscription (default)').option('--openai', 'Use OpenAI ChatGPT/Codex OAuth login').action(async ({
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai
|
||||
claudeai,
|
||||
openai,
|
||||
}: {
|
||||
email?: string;
|
||||
sso?: boolean;
|
||||
console?: boolean;
|
||||
claudeai?: boolean;
|
||||
openai?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
authLogin
|
||||
@ -4159,23 +4161,27 @@ async function run(): Promise<CommanderCommand> {
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai
|
||||
claudeai,
|
||||
openai,
|
||||
});
|
||||
});
|
||||
auth.command('status').description('Show authentication status').option('--json', 'Output as JSON (default)').option('--text', 'Output as human-readable text').action(async (opts: {
|
||||
auth.command('status').description('Show authentication status').option('--json', 'Output as JSON (default)').option('--text', 'Output as human-readable text').option('--openai', 'Show OpenAI OAuth status').action(async (opts: {
|
||||
json?: boolean;
|
||||
text?: boolean;
|
||||
openai?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
authStatus
|
||||
} = await import('./cli/handlers/auth.js');
|
||||
await authStatus(opts);
|
||||
});
|
||||
auth.command('logout').description('Log out from your Anthropic account').action(async () => {
|
||||
auth.command('logout').description('Log out from your Anthropic or OpenAI account').option('--openai', 'Log out from OpenAI OAuth only').action(async (opts: {
|
||||
openai?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
authLogout
|
||||
} = await import('./cli/handlers/auth.js');
|
||||
await authLogout();
|
||||
await authLogout(opts);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -303,10 +303,33 @@ describe('anthropicToOpenaiResponses', () => {
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.model).toBe('gpt-4o')
|
||||
expect(result.instructions).toBe('Be helpful')
|
||||
expect(result.store).toBe(false)
|
||||
expect(result.tools).toBeUndefined()
|
||||
expect(result.max_output_tokens).toBeUndefined()
|
||||
expect(result.input).toEqual([{ type: 'message', role: 'user', content: 'Hello' }])
|
||||
})
|
||||
|
||||
test('tools conversion uses top-level name', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
tools: [{
|
||||
name: 'get_weather',
|
||||
description: 'Get weather',
|
||||
input_schema: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.tools).toHaveLength(1)
|
||||
expect(result.tools![0]).toEqual({
|
||||
type: 'function',
|
||||
name: 'get_weather',
|
||||
description: 'Get weather',
|
||||
parameters: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
})
|
||||
})
|
||||
|
||||
test('tool_use lifted to function_call', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
|
||||
@ -10,7 +10,6 @@ import type {
|
||||
AnthropicMessage,
|
||||
OpenAIResponsesRequest,
|
||||
OpenAIResponsesInputItem,
|
||||
OpenAITool,
|
||||
OpenAIChatContentPart,
|
||||
} from './types.js'
|
||||
|
||||
@ -29,6 +28,7 @@ export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIRespon
|
||||
model: body.model,
|
||||
input,
|
||||
stream: body.stream,
|
||||
store: false,
|
||||
}
|
||||
|
||||
// system → instructions
|
||||
@ -51,13 +51,11 @@ export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIRespon
|
||||
if (body.tools && body.tools.length > 0) {
|
||||
result.tools = body.tools
|
||||
.filter((t) => t.name !== 'BatchTool')
|
||||
.map((t): OpenAITool => ({
|
||||
.map((t) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
},
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -110,11 +110,17 @@ export type OpenAIResponsesRequest = {
|
||||
model: string
|
||||
input: OpenAIResponsesInputItem[]
|
||||
instructions?: string
|
||||
store?: boolean
|
||||
max_output_tokens?: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stream?: boolean
|
||||
tools?: OpenAITool[]
|
||||
tools?: Array<{
|
||||
type: 'function'
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}>
|
||||
tool_choice?: unknown
|
||||
reasoning?: { effort?: 'low' | 'medium' | 'high' }
|
||||
}
|
||||
|
||||
@ -22,6 +22,11 @@ import {
|
||||
getSessionId,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import {
|
||||
buildOpenAICodexFetch,
|
||||
OPENAI_OAUTH_DUMMY_KEY,
|
||||
shouldUseOpenAICodexAuth,
|
||||
} from '../openaiAuth/fetch.js'
|
||||
import { isDebugToStdErr, logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
getAWSRegion,
|
||||
@ -132,11 +137,19 @@ export async function getAnthropicClient({
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
logForDebugging('[API:auth] OAuth token check complete')
|
||||
|
||||
if (!isClaudeAISubscriber()) {
|
||||
const usingOpenAICodex =
|
||||
shouldUseOpenAICodexAuth() &&
|
||||
!isClaudeAISubscriber() &&
|
||||
!process.env.ANTHROPIC_AUTH_TOKEN &&
|
||||
!(apiKey || getAnthropicApiKey())
|
||||
|
||||
if (!isClaudeAISubscriber() && !usingOpenAICodex) {
|
||||
await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
|
||||
}
|
||||
|
||||
const resolvedFetch = buildFetch(fetchOverride, source)
|
||||
const resolvedFetch = usingOpenAICodex
|
||||
? buildOpenAICodexFetch(fetchOverride, source)
|
||||
: buildFetch(fetchOverride, source)
|
||||
|
||||
const ARGS = {
|
||||
defaultHeaders,
|
||||
@ -299,7 +312,11 @@ export async function getAnthropicClient({
|
||||
|
||||
// Determine authentication method based on available tokens
|
||||
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
|
||||
apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(),
|
||||
apiKey: isClaudeAISubscriber()
|
||||
? null
|
||||
: usingOpenAICodex
|
||||
? OPENAI_OAUTH_DUMMY_KEY
|
||||
: apiKey || getAnthropicApiKey(),
|
||||
authToken: isClaudeAISubscriber()
|
||||
? getClaudeAIOAuthTokens()?.accessToken
|
||||
: undefined,
|
||||
|
||||
141
src/services/openaiAuth/client.ts
Normal file
141
src/services/openaiAuth/client.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { generateCodeChallenge } from '../oauth/crypto.js'
|
||||
import type {
|
||||
OpenAIJwtClaims,
|
||||
OpenAIOAuthTokenResponse,
|
||||
OpenAIOAuthTokens,
|
||||
} from './types.js'
|
||||
|
||||
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com'
|
||||
export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
export const OPENAI_CODEX_API_ENDPOINT =
|
||||
'https://chatgpt.com/backend-api/codex/responses'
|
||||
export const OPENAI_CODEX_OAUTH_PORT = 1455
|
||||
export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
|
||||
|
||||
const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000
|
||||
|
||||
export function buildOpenAIAuthorizeUrl(input: {
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
state: string
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: 'openid profile email offline_access',
|
||||
code_challenge: generateCodeChallenge(input.codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true',
|
||||
state: input.state,
|
||||
originator: 'opencode',
|
||||
})
|
||||
|
||||
return `${OPENAI_AUTH_ISSUER}/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function exchangeOpenAICodeForTokens(input: {
|
||||
code: string
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
}): Promise<OpenAIOAuthTokenResponse> {
|
||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
code_verifier: input.codeVerifier,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI token exchange failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenAIOAuthTokenResponse
|
||||
}
|
||||
|
||||
export async function refreshOpenAITokens(
|
||||
refreshToken: string,
|
||||
): Promise<OpenAIOAuthTokenResponse> {
|
||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI token refresh failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenAIOAuthTokenResponse
|
||||
}
|
||||
|
||||
export function parseOpenAIJwtClaims(
|
||||
token?: string,
|
||||
): OpenAIJwtClaims | undefined {
|
||||
if (!token) return undefined
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString())
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function extractOpenAIAccountId(
|
||||
claims?: OpenAIJwtClaims,
|
||||
): string | undefined {
|
||||
if (!claims) return undefined
|
||||
|
||||
return (
|
||||
claims.chatgpt_account_id ||
|
||||
claims['https://api.openai.com/auth']?.chatgpt_account_id ||
|
||||
claims.organizations?.[0]?.id
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeOpenAITokens(
|
||||
response: OpenAIOAuthTokenResponse,
|
||||
): OpenAIOAuthTokens {
|
||||
const claims =
|
||||
parseOpenAIJwtClaims(response.id_token) ??
|
||||
parseOpenAIJwtClaims(response.access_token)
|
||||
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt: Date.now() + (response.expires_in ?? 3600) * 1000,
|
||||
idToken: response.id_token,
|
||||
accountId: extractOpenAIAccountId(claims),
|
||||
email: claims?.email,
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAITokenExpired(expiresAt: number): boolean {
|
||||
return expiresAt - Date.now() <= 5 * 60 * 1000
|
||||
}
|
||||
|
||||
export function withRefreshedAccessToken(
|
||||
existing: OpenAIOAuthTokens,
|
||||
refreshed: OpenAIOAuthTokenResponse,
|
||||
): OpenAIOAuthTokens {
|
||||
const next = normalizeOpenAITokens(refreshed)
|
||||
|
||||
return {
|
||||
...next,
|
||||
accountId: next.accountId ?? existing.accountId,
|
||||
email: next.email ?? existing.email,
|
||||
idToken: next.idToken ?? existing.idToken,
|
||||
}
|
||||
}
|
||||
132
src/services/openaiAuth/fetch.ts
Normal file
132
src/services/openaiAuth/fetch.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import type { ClientOptions } from '@anthropic-ai/sdk'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { OPENAI_CODEX_API_ENDPOINT } from './client.js'
|
||||
import { ensureFreshOpenAITokens } from './index.js'
|
||||
import { resolveOpenAICodexModel } from './models.js'
|
||||
import { getOpenAIOAuthTokens } from './storage.js'
|
||||
import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js'
|
||||
import { openaiResponsesToAnthropic } from '../../server/proxy/transform/openaiResponsesToAnthropic.js'
|
||||
import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropic.js'
|
||||
import type { AnthropicRequest } from '../../server/proxy/transform/types.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
|
||||
export const OPENAI_OAUTH_DUMMY_KEY = 'openai-oauth-dummy-key'
|
||||
|
||||
export function shouldUseOpenAICodexAuth(): boolean {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
return !!openaiTokens?.refreshToken
|
||||
}
|
||||
|
||||
export function buildOpenAICodexFetch(
|
||||
fetchOverride: ClientOptions['fetch'],
|
||||
source: string | undefined,
|
||||
): ClientOptions['fetch'] {
|
||||
const inner = fetchOverride ?? globalThis.fetch
|
||||
|
||||
return async (input, init) => {
|
||||
const url = input instanceof Request ? new URL(input.url) : new URL(String(input))
|
||||
|
||||
if (!url.pathname.endsWith('/v1/messages')) {
|
||||
return inner(input, init)
|
||||
}
|
||||
|
||||
const originalBody = await readAnthropicBody(input, init)
|
||||
const mappedModel = resolveOpenAICodexModel(originalBody.model)
|
||||
const transformedBody = anthropicToOpenaiResponses({
|
||||
...originalBody,
|
||||
model: mappedModel,
|
||||
})
|
||||
|
||||
const tokens = await ensureFreshOpenAITokens()
|
||||
if (!tokens) {
|
||||
throw new Error(
|
||||
'OpenAI OAuth token is missing or expired. Run claude auth login --openai again.',
|
||||
)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('Authorization', `Bearer ${tokens.accessToken}`)
|
||||
if (tokens.accountId) {
|
||||
headers.set('ChatGPT-Account-Id', tokens.accountId)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[API REQUEST] ${url.pathname} remapped_to=OpenAI/Codex model=${mappedModel} source=${source ?? 'unknown'} request_id=${randomUUID()}`,
|
||||
)
|
||||
|
||||
const upstream = await inner(OPENAI_CODEX_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: init?.signal,
|
||||
})
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errorText = await upstream.text().catch(() => '')
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: `OpenAI upstream returned HTTP ${upstream.status}: ${errorText.slice(0, 500)}`,
|
||||
},
|
||||
},
|
||||
{ status: upstream.status },
|
||||
)
|
||||
}
|
||||
|
||||
if (transformedBody.stream) {
|
||||
if (!upstream.body) {
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'OpenAI upstream returned no body for stream',
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
openaiResponsesStreamToAnthropic(upstream.body, mappedModel),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const responseBody = await upstream.json()
|
||||
return Response.json(
|
||||
openaiResponsesToAnthropic(responseBody, mappedModel),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAnthropicBody(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<AnthropicRequest> {
|
||||
const directBody = init?.body
|
||||
|
||||
if (typeof directBody === 'string') {
|
||||
return JSON.parse(directBody) as AnthropicRequest
|
||||
}
|
||||
|
||||
if (directBody instanceof Uint8Array || directBody instanceof ArrayBuffer) {
|
||||
return JSON.parse(Buffer.from(directBody).toString('utf8')) as AnthropicRequest
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
return (await input.clone().json()) as AnthropicRequest
|
||||
}
|
||||
|
||||
throw new Error('Unable to read Anthropic request body for OpenAI/Codex transformation')
|
||||
}
|
||||
167
src/services/openaiAuth/index.ts
Normal file
167
src/services/openaiAuth/index.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
||||
import { generateCodeVerifier, generateState } from '../oauth/crypto.js'
|
||||
import {
|
||||
buildOpenAIAuthorizeUrl,
|
||||
exchangeOpenAICodeForTokens,
|
||||
isOpenAITokenExpired,
|
||||
OPENAI_CODEX_OAUTH_PORT,
|
||||
OPENAI_CODEX_REDIRECT_PATH,
|
||||
refreshOpenAITokens,
|
||||
normalizeOpenAITokens,
|
||||
withRefreshedAccessToken,
|
||||
} from './client.js'
|
||||
import {
|
||||
clearOpenAIOAuthTokenCache,
|
||||
deleteOpenAIOAuthTokens,
|
||||
getOpenAIOAuthTokensAsync,
|
||||
saveOpenAIOAuthTokens,
|
||||
} from './storage.js'
|
||||
import type { OpenAIOAuthTokens } from './types.js'
|
||||
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>cc-haha OpenAI Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display:flex; justify-content:center; align-items:center; height:100vh; margin:0; background:#131010; color:#f1ecec; }
|
||||
.container { text-align:center; padding:2rem; }
|
||||
p { color:#b7b1b1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to Claude Code Haha.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>cc-haha OpenAI Authorization Failed</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>You can close this window and return to Claude Code Haha.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export class OpenAIOAuthService {
|
||||
private codeVerifier: string
|
||||
private authCodeListener: AuthCodeListener | null = null
|
||||
private port: number | null = null
|
||||
|
||||
constructor() {
|
||||
this.codeVerifier = generateCodeVerifier()
|
||||
}
|
||||
|
||||
async startOAuthFlow(
|
||||
authURLHandler: (url: string) => Promise<void>,
|
||||
): Promise<OpenAIOAuthTokens> {
|
||||
this.authCodeListener = new AuthCodeListener(OPENAI_CODEX_REDIRECT_PATH)
|
||||
this.port = await this.authCodeListener.start(OPENAI_CODEX_OAUTH_PORT)
|
||||
|
||||
const state = generateState()
|
||||
const redirectUri = `http://localhost:${this.port}${OPENAI_CODEX_REDIRECT_PATH}`
|
||||
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
||||
redirectUri,
|
||||
codeVerifier: this.codeVerifier,
|
||||
state,
|
||||
})
|
||||
|
||||
const authorizationCode = await new Promise<string>((resolve, reject) => {
|
||||
this.authCodeListener
|
||||
?.waitForAuthorization(state, async () => {
|
||||
await authURLHandler(authorizeUrl)
|
||||
await openBrowser(authorizeUrl)
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await exchangeOpenAICodeForTokens({
|
||||
code: authorizationCode,
|
||||
redirectUri,
|
||||
codeVerifier: this.codeVerifier,
|
||||
})
|
||||
|
||||
const tokens = normalizeOpenAITokens(response)
|
||||
const storage = saveOpenAIOAuthTokens(tokens)
|
||||
if (!storage.success) {
|
||||
throw new Error(storage.warning ?? 'Failed to persist OpenAI OAuth tokens')
|
||||
}
|
||||
|
||||
this.authCodeListener?.handleSuccessRedirect([], (res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(HTML_SUCCESS)
|
||||
})
|
||||
|
||||
return tokens
|
||||
} catch (error) {
|
||||
this.authCodeListener?.handleSuccessRedirect([], (res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(HTML_ERROR)
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
async ensureFreshTokens(): Promise<OpenAIOAuthTokens | null> {
|
||||
clearOpenAIOAuthTokenCache()
|
||||
const tokens = await getOpenAIOAuthTokensAsync()
|
||||
if (!tokens) return null
|
||||
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
||||
|
||||
const refreshed = await refreshOpenAITokens(tokens.refreshToken)
|
||||
const updated = withRefreshedAccessToken(tokens, refreshed)
|
||||
const storage = saveOpenAIOAuthTokens(updated)
|
||||
if (!storage.success) {
|
||||
throw new Error(storage.warning ?? 'Failed to persist refreshed OpenAI tokens')
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
async ensureFreshAccessToken(): Promise<string | null> {
|
||||
const tokens = await this.ensureFreshTokens()
|
||||
return tokens?.accessToken ?? null
|
||||
}
|
||||
|
||||
logout(): boolean {
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return deleteOpenAIOAuthTokens()
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.authCodeListener?.close()
|
||||
this.authCodeListener = null
|
||||
this.port = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFreshOpenAITokens(): Promise<OpenAIOAuthTokens | null> {
|
||||
clearOpenAIOAuthTokenCache()
|
||||
const tokens = await getOpenAIOAuthTokensAsync()
|
||||
if (!tokens) return null
|
||||
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
||||
|
||||
try {
|
||||
const refreshed = await refreshOpenAITokens(tokens.refreshToken)
|
||||
const updated = withRefreshedAccessToken(tokens, refreshed)
|
||||
const storage = saveOpenAIOAuthTokens(updated)
|
||||
if (!storage.success) {
|
||||
throw new Error(storage.warning ?? 'Failed to persist refreshed OpenAI tokens')
|
||||
}
|
||||
return updated
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
21
src/services/openaiAuth/models.test.ts
Normal file
21
src/services/openaiAuth/models.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
OPENAI_DEFAULT_MAIN_MODEL,
|
||||
isOpenAIResponsesModel,
|
||||
resolveOpenAICodexModel,
|
||||
} from './models.js'
|
||||
|
||||
describe('openai auth model resolution', () => {
|
||||
test('does not treat opus as an OpenAI Responses model', () => {
|
||||
expect(isOpenAIResponsesModel('opus')).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts gpt and o-series models', () => {
|
||||
expect(isOpenAIResponsesModel('gpt-5.4')).toBe(true)
|
||||
expect(isOpenAIResponsesModel('o3-mini')).toBe(true)
|
||||
})
|
||||
|
||||
test('maps opus aliases to the OpenAI default model', () => {
|
||||
expect(resolveOpenAICodexModel('opus')).toBe(OPENAI_DEFAULT_MAIN_MODEL)
|
||||
})
|
||||
})
|
||||
92
src/services/openaiAuth/models.ts
Normal file
92
src/services/openaiAuth/models.ts
Normal file
@ -0,0 +1,92 @@
|
||||
export const OPENAI_DEFAULT_MAIN_MODEL = 'gpt-5.3-codex'
|
||||
export const OPENAI_DEFAULT_SONNET_MODEL = 'gpt-5.4'
|
||||
export const OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini'
|
||||
|
||||
export type OpenAIModelCatalogEntry = {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
descriptionForModel?: string
|
||||
}
|
||||
|
||||
export const OPENAI_CODEX_MODEL_CATALOG: OpenAIModelCatalogEntry[] = [
|
||||
{
|
||||
value: OPENAI_DEFAULT_MAIN_MODEL,
|
||||
label: 'GPT-5.3 Codex',
|
||||
description: 'Best for coding and agentic work',
|
||||
descriptionForModel: 'GPT-5.3 Codex - best for coding and agentic work',
|
||||
},
|
||||
{
|
||||
value: OPENAI_DEFAULT_SONNET_MODEL,
|
||||
label: 'GPT-5.4',
|
||||
description: 'Strong general-purpose model',
|
||||
descriptionForModel: 'GPT-5.4 - strong general-purpose model',
|
||||
},
|
||||
{
|
||||
value: OPENAI_DEFAULT_HAIKU_MODEL,
|
||||
label: 'GPT-5.4 Mini',
|
||||
description: 'Fastest for quick tasks',
|
||||
descriptionForModel: 'GPT-5.4 Mini - fastest for quick tasks',
|
||||
},
|
||||
]
|
||||
|
||||
export function isOpenAIResponsesModel(model: string): boolean {
|
||||
const normalized = model.trim().toLowerCase()
|
||||
return normalized.startsWith('gpt-') || /^o\d/.test(normalized)
|
||||
}
|
||||
|
||||
export function resolveOpenAICodexModel(model: string): string {
|
||||
if (process.env.OPENAI_CODEX_MODEL?.trim()) {
|
||||
return process.env.OPENAI_CODEX_MODEL.trim()
|
||||
}
|
||||
|
||||
const normalized = model.trim().toLowerCase()
|
||||
if (isOpenAIResponsesModel(normalized)) {
|
||||
return model
|
||||
}
|
||||
|
||||
if (normalized.includes('haiku')) {
|
||||
return (
|
||||
process.env.OPENAI_CODEX_HAIKU_MODEL?.trim() ||
|
||||
OPENAI_DEFAULT_HAIKU_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
if (normalized.includes('sonnet')) {
|
||||
return (
|
||||
process.env.OPENAI_CODEX_SONNET_MODEL?.trim() ||
|
||||
OPENAI_DEFAULT_SONNET_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
if (normalized.includes('opus')) {
|
||||
return (
|
||||
process.env.OPENAI_CODEX_OPUS_MODEL?.trim() || OPENAI_DEFAULT_MAIN_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
return OPENAI_DEFAULT_MAIN_MODEL
|
||||
}
|
||||
|
||||
export function getOpenAIModelDisplayName(model: string): string | null {
|
||||
switch (model.trim().toLowerCase()) {
|
||||
case 'gpt-5.3-codex':
|
||||
return 'GPT-5.3 Codex'
|
||||
case 'gpt-5.4':
|
||||
return 'GPT-5.4'
|
||||
case 'gpt-5.4-mini':
|
||||
return 'GPT-5.4 Mini'
|
||||
case 'gpt-5.2':
|
||||
return 'GPT-5.2'
|
||||
case 'gpt-5.2-codex':
|
||||
return 'GPT-5.2 Codex'
|
||||
case 'gpt-5.1-codex':
|
||||
return 'GPT-5.1 Codex'
|
||||
case 'gpt-5.1-codex-max':
|
||||
return 'GPT-5.1 Codex Max'
|
||||
case 'gpt-5.1-codex-mini':
|
||||
return 'GPT-5.1 Codex Mini'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
71
src/services/openaiAuth/storage.ts
Normal file
71
src/services/openaiAuth/storage.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import type { OpenAIOAuthTokens } from './types.js'
|
||||
|
||||
const STORAGE_KEY = 'openaiCodexOauth'
|
||||
|
||||
type SecureStorageShape = Record<string, unknown> & {
|
||||
openaiCodexOauth?: OpenAIOAuthTokens
|
||||
}
|
||||
|
||||
export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (storage.read() ?? {}) as SecureStorageShape
|
||||
data[STORAGE_KEY] = tokens
|
||||
const result = storage.update(data)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return result
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return {
|
||||
success: false,
|
||||
warning: `Failed to save OpenAI OAuth tokens: ${errorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = storage.read() as SecureStorageShape | null
|
||||
return data?.openaiCodexOauth ?? null
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export async function getOpenAIOAuthTokensAsync(): Promise<OpenAIOAuthTokens | null> {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (await storage.readAsync()) as SecureStorageShape | null
|
||||
return data?.openaiCodexOauth ?? null
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOpenAIOAuthTokenCache(): void {
|
||||
getOpenAIOAuthTokens.cache?.clear?.()
|
||||
}
|
||||
|
||||
export function deleteOpenAIOAuthTokens(): boolean {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (storage.read() ?? {}) as SecureStorageShape
|
||||
delete data[STORAGE_KEY]
|
||||
const result = storage.update(data)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return result.success
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
24
src/services/openaiAuth/types.ts
Normal file
24
src/services/openaiAuth/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export type OpenAIOAuthTokenResponse = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
export type OpenAIOAuthTokens = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export type OpenAIJwtClaims = {
|
||||
chatgpt_account_id?: string
|
||||
organizations?: Array<{ id: string }>
|
||||
email?: string
|
||||
'https://api.openai.com/auth'?: {
|
||||
chatgpt_account_id?: string
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ import {
|
||||
getMockSubscriptionType,
|
||||
shouldUseMockSubscription,
|
||||
} from '../services/mockRateLimits.js'
|
||||
import { getOpenAIOAuthTokens } from '../services/openaiAuth/storage.js'
|
||||
import {
|
||||
isOAuthTokenExpired,
|
||||
refreshOAuthToken,
|
||||
@ -1183,6 +1184,27 @@ export async function removeApiKey(): Promise<void> {
|
||||
clearLegacyApiKeyPrefetch()
|
||||
}
|
||||
|
||||
export function clearStoredClaudeAIOAuthTokens(): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
try {
|
||||
const secureStorage = getSecureStorage()
|
||||
const storageData = secureStorage.read() || {}
|
||||
|
||||
if ('claudeAiOauth' in storageData) {
|
||||
delete storageData.claudeAiOauth
|
||||
}
|
||||
|
||||
const result = secureStorage.update(storageData)
|
||||
clearOAuthTokenCache()
|
||||
return result
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return { success: false, warning: 'Failed to clear stored Claude OAuth tokens' }
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRemoveApiKeyFromMacOSKeychain(): Promise<void> {
|
||||
try {
|
||||
await maybeRemoveApiKeyFromMacOSKeychainThrows()
|
||||
@ -1856,11 +1878,67 @@ export function isConsumerSubscriber(): boolean {
|
||||
}
|
||||
|
||||
export type UserAccountInfo = {
|
||||
provider?: 'anthropic' | 'openai'
|
||||
subscription?: string
|
||||
tokenSource?: string
|
||||
apiKeySource?: ApiKeySource
|
||||
organization?: string
|
||||
email?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
export function getOpenAISubscriptionName(): string {
|
||||
return 'ChatGPT Pro/Plus'
|
||||
}
|
||||
|
||||
export function getOpenAIAuthOverrideWarning(): string | null {
|
||||
if (isUsing3PServices()) {
|
||||
return 'OpenAI login is saved, but this shell is configured for a third-party provider.'
|
||||
}
|
||||
|
||||
const { source: authTokenSource } = getAuthTokenSource()
|
||||
if (authTokenSource !== 'none') {
|
||||
return `OpenAI login is saved, but this shell is still using ${authTokenSource} for Anthropic-compatible auth.`
|
||||
}
|
||||
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
if (apiKeySource !== 'none') {
|
||||
return `OpenAI login is saved, but this shell is still using ${apiKeySource} for Anthropic-compatible auth.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isOpenAIAuthActive(): boolean {
|
||||
return getOpenAIAccountInformationIfActive() !== undefined
|
||||
}
|
||||
|
||||
function getOpenAIAccountInformationIfActive(input?: {
|
||||
authTokenSource?: ReturnType<typeof getAuthTokenSource>['source']
|
||||
apiKeySource?: ApiKeySource
|
||||
}): UserAccountInfo | undefined {
|
||||
const openaiTokens = getOpenAIOAuthTokens()
|
||||
if (!openaiTokens?.refreshToken || isUsing3PServices()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const authTokenSource = input?.authTokenSource ?? getAuthTokenSource().source
|
||||
const apiKeySource =
|
||||
input?.apiKeySource ?? getAnthropicApiKeyWithSource().source
|
||||
|
||||
if (authTokenSource !== 'none' || apiKeySource !== 'none') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'openai',
|
||||
subscription: getOpenAISubscriptionName(),
|
||||
tokenSource: 'OpenAI OAuth',
|
||||
email: openaiTokens.email,
|
||||
accountId: openaiTokens.accountId,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountInformation() {
|
||||
@ -1870,7 +1948,17 @@ export function getAccountInformation() {
|
||||
return undefined
|
||||
}
|
||||
const { source: authTokenSource } = getAuthTokenSource()
|
||||
const { key: apiKey, source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
const openAIAccountInfo = getOpenAIAccountInformationIfActive({
|
||||
authTokenSource,
|
||||
apiKeySource,
|
||||
})
|
||||
if (openAIAccountInfo) {
|
||||
return openAIAccountInfo
|
||||
}
|
||||
|
||||
const accountInfo: UserAccountInfo = {}
|
||||
accountInfo.provider = 'anthropic'
|
||||
if (
|
||||
authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN' ||
|
||||
authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR'
|
||||
@ -1881,7 +1969,6 @@ export function getAccountInformation() {
|
||||
} else {
|
||||
accountInfo.tokenSource = authTokenSource
|
||||
}
|
||||
const { key: apiKey, source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
if (apiKey) {
|
||||
accountInfo.apiKeySource = apiKeySource
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
getSubscriptionType,
|
||||
isClaudeAISubscriber,
|
||||
isMaxSubscriber,
|
||||
isOpenAIAuthActive,
|
||||
isProSubscriber,
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../auth.js'
|
||||
@ -27,6 +28,10 @@ import { getAPIProvider } from './providers.js'
|
||||
import { LIGHTNING_BOLT } from '../../constants/figures.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { type ModelAlias, isModelAlias } from './aliases.js'
|
||||
import {
|
||||
getOpenAIModelDisplayName,
|
||||
resolveOpenAICodexModel,
|
||||
} from '../../services/openaiAuth/models.js'
|
||||
import { capitalize } from '../stringUtils.js'
|
||||
|
||||
export type ModelShortName = string
|
||||
@ -103,6 +108,9 @@ export function getBestModel(): ModelName {
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultOpusModel(): ModelName {
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('opus')
|
||||
}
|
||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
}
|
||||
@ -117,6 +125,9 @@ export function getDefaultOpusModel(): ModelName {
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultSonnetModel(): ModelName {
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('sonnet')
|
||||
}
|
||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
}
|
||||
@ -129,6 +140,9 @@ export function getDefaultSonnetModel(): ModelName {
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultHaikuModel(): ModelName {
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('haiku')
|
||||
}
|
||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
}
|
||||
@ -188,6 +202,10 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
||||
)
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive()) {
|
||||
return resolveOpenAICodexModel('opus')
|
||||
}
|
||||
|
||||
// Max users get Opus as default
|
||||
if (isMaxSubscriber()) {
|
||||
return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
||||
@ -290,6 +308,12 @@ export function getCanonicalName(fullModelName: ModelName): ModelShortName {
|
||||
export function getClaudeAiUserDefaultModelDescription(
|
||||
fastMode = false,
|
||||
): string {
|
||||
if (isOpenAIAuthActive()) {
|
||||
const defaultModel = renderDefaultModelSetting(
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return `${defaultModel} · OpenAI/Codex default`
|
||||
}
|
||||
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
|
||||
if (isOpus1mMergeEnabled()) {
|
||||
return `Opus 4.7 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
|
||||
@ -351,6 +375,10 @@ export function renderModelSetting(setting: ModelName | ModelAlias): string {
|
||||
* if the model is not recognized as a public model.
|
||||
*/
|
||||
export function getPublicModelDisplayName(model: ModelName): string | null {
|
||||
const openAIModelName = getOpenAIModelDisplayName(model)
|
||||
if (openAIModelName) {
|
||||
return openAIModelName
|
||||
}
|
||||
switch (model) {
|
||||
case getModelStrings().opus46:
|
||||
return 'Opus 4.7'
|
||||
@ -572,6 +600,10 @@ export function modelDisplayString(model: ModelSetting): string {
|
||||
|
||||
// @[MODEL LAUNCH]: Add a marketing name mapping for the new model below.
|
||||
export function getMarketingNameForModel(modelId: string): string | undefined {
|
||||
const openAIModelName = getOpenAIModelDisplayName(modelId)
|
||||
if (openAIModelName) {
|
||||
return openAIModelName
|
||||
}
|
||||
if (getAPIProvider() === 'foundry') {
|
||||
// deployment ID is user-defined in Foundry, so it may have no relation to the actual model
|
||||
return undefined
|
||||
|
||||
@ -3,6 +3,7 @@ import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
||||
import {
|
||||
isClaudeAISubscriber,
|
||||
isMaxSubscriber,
|
||||
isOpenAIAuthActive,
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../auth.js'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
@ -32,6 +33,7 @@ import {
|
||||
} from './model.js'
|
||||
import { has1mContext } from '../context.js'
|
||||
import { getGlobalConfig } from '../config.js'
|
||||
import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js'
|
||||
|
||||
// @[MODEL LAUNCH]: Update all the available and default model option strings below.
|
||||
|
||||
@ -55,6 +57,18 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive()) {
|
||||
const currentModel = renderDefaultModelSetting(
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: `Use the OpenAI/Codex default model (currently ${currentModel})`,
|
||||
descriptionForModel: `OpenAI/Codex default model (currently ${currentModel})`,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribers
|
||||
if (isClaudeAISubscriber()) {
|
||||
return {
|
||||
@ -295,6 +309,18 @@ function getAzureOpenAICodexOptions(): ModelOption[] {
|
||||
]
|
||||
}
|
||||
|
||||
function getOpenAIModelOptions(): ModelOption[] {
|
||||
return [
|
||||
getDefaultOptionForUser(),
|
||||
...OPENAI_CODEX_MODEL_CATALOG.map(model => ({
|
||||
value: model.value,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
descriptionForModel: model.descriptionForModel,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
|
||||
// Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
|
||||
function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
@ -320,6 +346,10 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
]
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive()) {
|
||||
return getOpenAIModelOptions()
|
||||
}
|
||||
|
||||
if (isClaudeAISubscriber()) {
|
||||
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
|
||||
// Max and Team Premium users: Opus is default, show Sonnet as alternative
|
||||
|
||||
@ -3,6 +3,7 @@ import { MODEL_ALIASES } from './aliases.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import { isOpenAIAuthActive } from '../auth.js'
|
||||
import {
|
||||
NotFoundError,
|
||||
APIError,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
AuthenticationError,
|
||||
} from '@anthropic-ai/sdk'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
import { isOpenAIResponsesModel } from '../../services/openaiAuth/models.js'
|
||||
|
||||
// Cache valid models to avoid repeated API calls
|
||||
const validModelCache = new Map<string, boolean>()
|
||||
@ -109,6 +111,11 @@ export async function validateModel(
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (isOpenAIAuthActive() && isOpenAIResponsesModel(normalizedModel)) {
|
||||
validModelCache.set(normalizedModel, true)
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (validModelCache.has(normalizedModel)) {
|
||||
return { valid: true }
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user