feat: support codex.

This commit is contained in:
libr 2025-11-07 20:14:48 +08:00
parent a619000340
commit 3abc948b77
10 changed files with 597 additions and 8 deletions

View File

@ -334,6 +334,18 @@ export enum IpcChannel {
Anthropic_HasCredentials = 'anthropic:has-credentials',
Anthropic_ClearCredentials = 'anthropic:clear-credentials',
// OpenAI OAuth
OpenAI_StartOAuthFlow = 'openai:start-oauth-flow',
OpenAI_CompleteOAuthWithRedirectUrl = 'openai:complete-oauth-with-redirect-url',
OpenAI_CancelOAuthFlow = 'openai:cancel-oauth-flow',
OpenAI_GetAccessToken = 'openai:get-access-token',
OpenAI_GetApiKey = 'openai:get-api-key',
OpenAI_GetIdToken = 'openai:get-id-token',
OpenAI_GetAccountId = 'openai:get-account-id',
OpenAI_GetSessionId = 'openai:get-session-id',
OpenAI_HasCredentials = 'openai:has-credentials',
OpenAI_ClearCredentials = 'openai:clear-credentials',
// CodeTools
CodeTools_Run = 'code-tools:run',
CodeTools_GetAvailableTerminals = 'code-tools:get-available-terminals',

View File

@ -6,6 +6,7 @@ import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryai'
import anthropicService from '@main/services/AnthropicService'
import openAIService from '@main/services/OpenAIService'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
@ -885,6 +886,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Anthropic_HasCredentials, () => anthropicService.hasCredentials())
ipcMain.handle(IpcChannel.Anthropic_ClearCredentials, () => anthropicService.clearCredentials())
// OpenAI OAuth
ipcMain.handle(IpcChannel.OpenAI_StartOAuthFlow, () => openAIService.startOAuthFlow())
ipcMain.handle(IpcChannel.OpenAI_CompleteOAuthWithRedirectUrl, (_, url: string) =>
openAIService.completeOAuthWithRedirectUrl(url)
)
ipcMain.handle(IpcChannel.OpenAI_CancelOAuthFlow, () => openAIService.cancelOAuthFlow())
ipcMain.handle(IpcChannel.OpenAI_GetAccessToken, () => openAIService.getValidAccessToken())
ipcMain.handle(IpcChannel.OpenAI_GetApiKey, () => openAIService.getApiKey())
ipcMain.handle(IpcChannel.OpenAI_GetIdToken, () => openAIService.getIdToken())
ipcMain.handle(IpcChannel.OpenAI_GetAccountId, () => openAIService.getAccountId())
ipcMain.handle(IpcChannel.OpenAI_GetSessionId, () => openAIService.getSessionId())
ipcMain.handle(IpcChannel.OpenAI_HasCredentials, () => openAIService.hasCredentials())
ipcMain.handle(IpcChannel.OpenAI_ClearCredentials, () => openAIService.clearCredentials())
// CodeTools
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
ipcMain.handle(IpcChannel.CodeTools_GetAvailableTerminals, () => codeToolsService.getAvailableTerminalsForPlatform())

View File

@ -0,0 +1,266 @@
import path from 'node:path'
import { loggerService } from '@logger'
import { getConfigDir } from '@main/utils/file'
import * as crypto from 'crypto'
import { net, shell } from 'electron'
import { promises } from 'fs'
import { dirname } from 'path'
const logger = loggerService.withContext('OpenAIOAuth')
// Client configuration
const DEFAULT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const CREDS_PATH = path.join(getConfigDir(), 'oauth', 'openai.json')
const REDIRECT_URI = 'http://localhost:1455/auth/callback'
const ISSUER = 'https://auth.openai.com'
interface Credentials {
access_token: string
refresh_token: string
expires_at: number
id_token?: string
}
interface PKCEState {
verifier: string
challenge: string
state: string
}
class OpenAIService {
private current: PKCEState | null = null
private generatePKCEState(): PKCEState {
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
const state = crypto.randomBytes(16).toString('base64url')
return { verifier, challenge, state }
}
private buildAuthorizeUrl(pkce: PKCEState, clientId: string): string {
const url = new URL(`${ISSUER}/oauth/authorize`)
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', REDIRECT_URI)
url.searchParams.set('scope', 'openid profile email offline_access')
url.searchParams.set('code_challenge', pkce.challenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', pkce.state)
// Only required OAuth params; remove non-essential extras
logger.debug(`Built OpenAI authorize URL: ${url.toString()}`)
return url.toString()
}
private async exchangeCodeForTokens(
code: string,
verifier: string,
clientId: string
): Promise<Credentials> {
const response = await net.fetch(`${ISSUER}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: clientId,
code_verifier: verifier
}).toString()
})
if (!response.ok) {
throw new Error(`OpenAI token exchange failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000,
id_token: data.id_token
}
}
private async refreshAccessToken(refreshToken: string, clientId: string): Promise<Credentials> {
const response = await net.fetch(`${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: clientId
}).toString()
})
if (!response.ok) {
throw new Error(`OpenAI token refresh failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return {
access_token: data.access_token,
refresh_token: data.refresh_token ?? refreshToken,
expires_at: Date.now() + data.expires_in * 1000,
id_token: data.id_token
}
}
private async saveCredentials(creds: Credentials) {
await promises.mkdir(dirname(CREDS_PATH), { recursive: true })
await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2))
await promises.chmod(CREDS_PATH, 0o600)
}
private async loadCredentials(): Promise<Credentials | null> {
try {
const txt = await promises.readFile(CREDS_PATH, 'utf-8')
return JSON.parse(txt)
} catch {
return null
}
}
public async getValidAccessToken(): Promise<string | null> {
const clientId = DEFAULT_CLIENT_ID
if (!clientId || clientId.startsWith('0000')) {
logger.warn('OPENAI_OAUTH_CLIENT_ID is not set. OAuth may fail until configured.')
}
const creds = await this.loadCredentials()
if (!creds) return null
if (creds.expires_at > Date.now() + 60000) {
return creds.access_token
}
try {
const refreshed = await this.refreshAccessToken(creds.refresh_token, clientId)
// Preserve previous id_token if refresh did not include one
const merged: Credentials = { ...refreshed, id_token: refreshed.id_token ?? creds.id_token }
await this.saveCredentials(merged)
return merged.access_token
} catch (e) {
logger.error('OpenAI access token refresh failed', e as Error)
return null
}
}
public async getApiKey(): Promise<string | null> {
// For OAuth-based access, the access token serves as bearer token
return this.getValidAccessToken()
}
public async startOAuthFlow(): Promise<string> {
const clientId = DEFAULT_CLIENT_ID
if (!clientId || clientId.startsWith('0000')) {
logger.warn('OPENAI_OAUTH_CLIENT_ID is not set. Please configure it for production use.')
}
// If already have valid access, short-circuit
const existing = await this.getValidAccessToken()
if (existing) return 'already_authenticated'
this.current = this.generatePKCEState()
const authUrl = this.buildAuthorizeUrl(this.current, clientId)
await shell.openExternal(authUrl)
return authUrl
}
public async completeOAuthWithRedirectUrl(redirectUrl: string): Promise<string> {
if (!this.current) {
throw new Error('OAuth flow not started. Please call startOAuthFlow first.')
}
const clientId = DEFAULT_CLIENT_ID
const url = new URL(redirectUrl)
const code = url.searchParams.get('code') || ''
const state = url.searchParams.get('state') || ''
if (!code) {
throw new Error('Authorization code not found in redirect URL')
}
if (!state || state !== this.current.state) {
throw new Error('State mismatch detected')
}
try {
const base = await this.exchangeCodeForTokens(code, this.current.verifier, clientId)
await this.saveCredentials(base)
this.current = null
return base.access_token
} catch (e) {
this.current = null
logger.error('OpenAI OAuth code exchange failed', e as Error)
throw e
}
}
public cancelOAuthFlow(): void {
if (this.current) {
logger.info('Cancelling OpenAI OAuth flow')
this.current = null
}
}
public async clearCredentials(): Promise<void> {
try {
await promises.unlink(CREDS_PATH)
logger.info('OpenAI credentials cleared')
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
}
}
public async hasCredentials(): Promise<boolean> {
const creds = await this.loadCredentials()
return creds !== null
}
public async getIdToken(): Promise<string | null> {
const creds = await this.loadCredentials()
return creds?.id_token ?? null
}
public async getAccountId(): Promise<string | null> {
const idToken = await this.getIdToken()
if (!idToken) return null
try {
const payload = this.decodeJwtPayload(idToken)
if (!payload) return null
// Try common fields for account/user identifiers
const candidates = [
payload.account_id,
payload.chatgpt_user_id,
payload.aid,
payload.sub
]
const id = candidates.find((v) => typeof v === 'string' && v.length > 0)
return id ?? null
} catch (e) {
logger.warn('Failed to parse OpenAI ID token for account id', e as Error)
return null
}
}
public async getSessionId(): Promise<string | null> {
// Derive a stable session id from ID token claims when possible
const idToken = await this.getIdToken()
if (!idToken) return null
try {
const payload = this.decodeJwtPayload(idToken)
// Prefer standard-ish fields if present
const rawCandidate =
(payload && (payload.sid || payload.session_id || payload.jti || payload.sub)) || idToken
const hash = crypto.createHash('sha256').update(String(rawCandidate)).digest('hex').slice(0, 32)
return `sess_${hash}`
} catch (e) {
logger.warn('Failed to derive OpenAI session id', e as Error)
return null
}
}
private decodeJwtPayload(token: string): any | null {
const parts = token.split('.')
if (parts.length < 2) return null
const payload = parts[1]
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padLen = (4 - (normalized.length % 4)) % 4
const padded = normalized + '='.repeat(padLen)
const json = Buffer.from(padded, 'base64').toString('utf8')
return JSON.parse(json)
}
}
export default new OpenAIService()

View File

@ -481,6 +481,19 @@ const api = {
hasCredentials: () => ipcRenderer.invoke(IpcChannel.Anthropic_HasCredentials),
clearCredentials: () => ipcRenderer.invoke(IpcChannel.Anthropic_ClearCredentials)
},
openai_oauth: {
startOAuthFlow: () => ipcRenderer.invoke(IpcChannel.OpenAI_StartOAuthFlow),
completeOAuthWithRedirectUrl: (url: string) =>
ipcRenderer.invoke(IpcChannel.OpenAI_CompleteOAuthWithRedirectUrl, url),
cancelOAuthFlow: () => ipcRenderer.invoke(IpcChannel.OpenAI_CancelOAuthFlow),
getAccessToken: () => ipcRenderer.invoke(IpcChannel.OpenAI_GetAccessToken),
getApiKey: () => ipcRenderer.invoke(IpcChannel.OpenAI_GetApiKey),
getIdToken: () => ipcRenderer.invoke(IpcChannel.OpenAI_GetIdToken),
getAccountId: () => ipcRenderer.invoke(IpcChannel.OpenAI_GetAccountId),
getSessionId: () => ipcRenderer.invoke(IpcChannel.OpenAI_GetSessionId),
hasCredentials: () => ipcRenderer.invoke(IpcChannel.OpenAI_HasCredentials),
clearCredentials: () => ipcRenderer.invoke(IpcChannel.OpenAI_ClearCredentials)
},
codeTools: {
run: (
cliTool: string,

File diff suppressed because one or more lines are too long

View File

@ -4391,7 +4391,25 @@
"official_website": "Official Website"
},
"openai": {
"alert": "OpenAI Provider no longer support the old calling methods. If using a third-party API, please create a new service provider."
"alert": "OpenAI provider no longer supports the old calling methods. If you are using a third-party API, please create a new service provider.",
"auth_method": "Authentication Method",
"oauth": "Web OAuth",
"description": "OAuth Authentication",
"description_detail": "Click 'Start Authorization' to open the OpenAI login page in your browser. After logging in, copy the entire URL that redirects to the local address (http://127.0.0.1:1455/auth/callback?...) and paste it here to complete authentication.",
"start_auth": "Start Authorization",
"authenticating": "Authenticating",
"enter_redirect_url": "Redirect URL",
"submit_url": "Submit URL",
"url_placeholder": "Paste the full URL starting with http://127.0.0.1:1455/auth/callback",
"cancel": "Cancel",
"auth_success": "OpenAI OAuth authentication successful",
"auth_failed": "OpenAI OAuth authentication failed",
"url_error": "URL parsing or authentication failed, please try again",
"authenticated": "Authenticated",
"logout": "Logout",
"logout_success": "Logged out",
"logout_failed": "Logout failed",
"apikey":"API key"
},
"remove_duplicate_keys": "Remove Duplicate Keys",
"remove_invalid_keys": "Remove Invalid Keys",

View File

@ -4391,7 +4391,25 @@
"official_website": "官方网站"
},
"openai": {
"alert": "OpenAI 服务商不再支持旧的调用方式,如果使用第三方 API 请新建服务商"
"alert": "OpenAI 服务商不再支持旧的调用方式,如果使用第三方 API 请新建服务商",
"auth_method": "认证方式",
"oauth": "网页 OAuth",
"description": "使用 OpenAI OAuth 登录",
"description_detail": "点击开始授权将在浏览器中打开 OpenAI 登录页。登录后复制跳转到本地地址的整条 URLhttp://127.0.0.1:1455/auth/callback?...),粘贴回此处完成认证。",
"start_auth": "开始授权",
"authenticating": "正在认证",
"enter_redirect_url": "重定向 URL",
"submit_url": "提交 URL",
"url_placeholder": "粘贴以 http://127.0.0.1:1455/auth/callback 开头的完整 URL",
"cancel": "取消",
"auth_success": "OpenAI OAuth 认证成功",
"auth_failed": "OpenAI OAuth 认证失败",
"url_error": "URL 解析或认证失败,请重试",
"authenticated": "已认证",
"logout": "登出",
"logout_success": "已登出",
"logout_failed": "登出失败",
"apikey":"API key"
},
"remove_duplicate_keys": "移除重复密钥",
"remove_invalid_keys": "删除无效密钥",

View File

@ -4391,7 +4391,25 @@
"official_website": "官方網站"
},
"openai": {
"alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API請建立新的服務供應商"
"alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API請建立新的服務供應商",
"auth_method": "認證方式",
"oauth": "網頁 OAuth",
"description": "使用 OpenAI OAuth 登入",
"description_detail": "點擊開始授權後會在瀏覽器開啟 OpenAI 登入頁。登入後請複製跳轉至本機位址的完整 URLhttp://127.0.0.1:1455/auth/callback?...),貼回此處完成認證。",
"start_auth": "開始授權",
"authenticating": "正在認證",
"enter_redirect_url": "重新導向 URL",
"submit_url": "提交 URL",
"url_placeholder": "貼上以 http://127.0.0.1:1455/auth/callback 開頭的完整 URL",
"cancel": "取消",
"auth_success": "OpenAI OAuth 認證成功",
"auth_failed": "OpenAI OAuth 認證失敗",
"url_error": "URL 解析或認證失敗,請重試",
"authenticated": "已認證",
"logout": "登出",
"logout_success": "已登出",
"logout_failed": "登出失敗",
"apikey":"API key"
},
"remove_duplicate_keys": "移除重複金鑰",
"remove_invalid_keys": "刪除無效金鑰",

View File

@ -0,0 +1,159 @@
import { ExclamationCircleOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { Alert, Button, Input, Modal } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('OpenAISettings')
enum AuthStatus {
NOT_STARTED,
AUTHENTICATING,
AUTHENTICATED
}
const OpenAISettings = () => {
const { t } = useTranslation()
const [authStatus, setAuthStatus] = useState<AuthStatus>(AuthStatus.NOT_STARTED)
const [loading, setLoading] = useState<boolean>(false)
const [urlModalVisible, setUrlModalVisible] = useState<boolean>(false)
const [redirectUrl, setRedirectUrl] = useState<string>('')
useEffect(() => {
const check = async () => {
try {
const hasCredentials = await window.api.openai_oauth.hasCredentials()
if (hasCredentials) setAuthStatus(AuthStatus.AUTHENTICATED)
} catch (e) {
logger.error('Failed to check OpenAI OAuth state', e as Error)
}
}
check()
}, [])
const handleRedirectOAuth = async () => {
try {
setLoading(true)
await window.api.openai_oauth.startOAuthFlow()
setAuthStatus(AuthStatus.AUTHENTICATING)
setUrlModalVisible(true)
} catch (e) {
logger.error('OpenAI OAuth start failed', e as Error)
window.toast.error(t('settings.provider.openai.auth_failed'))
} finally {
setLoading(false)
}
}
const handleSubmitUrl = async () => {
logger.info('Submitting OpenAI redirect URL')
try {
setLoading(true)
await window.api.openai_oauth.completeOAuthWithRedirectUrl(redirectUrl)
setAuthStatus(AuthStatus.AUTHENTICATED)
setUrlModalVisible(false)
window.toast.success(t('settings.provider.openai.auth_success'))
} catch (e) {
logger.error('OpenAI redirect URL submit failed', e as Error)
window.toast.error(t('settings.provider.openai.url_error'))
} finally {
setLoading(false)
}
}
const handleCancelAuth = () => {
window.api.openai_oauth.cancelOAuthFlow()
setAuthStatus(AuthStatus.NOT_STARTED)
setUrlModalVisible(false)
setRedirectUrl('')
}
const handleLogout = async () => {
try {
await window.api.openai_oauth.clearCredentials()
setAuthStatus(AuthStatus.NOT_STARTED)
window.toast.success(t('settings.provider.openai.logout_success'))
} catch (e) {
logger.error('OpenAI logout failed', e as Error)
window.toast.error(t('settings.provider.openai.logout_failed'))
}
}
const renderContent = () => {
switch (authStatus) {
case AuthStatus.AUTHENTICATED:
return (
<StartContainer>
<Alert
type="success"
message={t('settings.provider.openai.authenticated')}
action={
<Button type="primary" onClick={handleLogout}>
{t('settings.provider.openai.logout')}
</Button>
}
showIcon
icon={<ExclamationCircleOutlined />}
/>
</StartContainer>
)
case AuthStatus.AUTHENTICATING:
return (
<StartContainer>
<Alert
type="info"
message={t('settings.provider.openai.authenticating')}
showIcon
icon={<ExclamationCircleOutlined />}
/>
<Modal
title={t('settings.provider.openai.enter_redirect_url')}
open={urlModalVisible}
onOk={handleSubmitUrl}
onCancel={handleCancelAuth}
okButtonProps={{ loading }}
okText={t('settings.provider.openai.submit_url')}
cancelText={t('settings.provider.openai.cancel')}
centered>
<Input
value={redirectUrl}
onChange={(e) => setRedirectUrl(e.target.value)}
placeholder={t('settings.provider.openai.url_placeholder')}
/>
</Modal>
</StartContainer>
)
default:
return (
<StartContainer>
<Alert
type="info"
message={t('settings.provider.openai.description')}
description={t('settings.provider.openai.description_detail')}
action={
<Button type="primary" loading={loading} onClick={handleRedirectOAuth}>
{t('settings.provider.openai.start_auth')}
</Button>
}
showIcon
icon={<ExclamationCircleOutlined />}
/>
</StartContainer>
)
}
}
return <Container>{renderContent()}</Container>
}
const Container = styled.div`
padding-top: 10px;
`
const StartContainer = styled.div`
margin-bottom: 10px;
`
export default OpenAISettings

View File

@ -1,4 +1,3 @@
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { LoadingIcon } from '@renderer/components/Icons'
import { HStack } from '@renderer/components/Layout'
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
@ -62,6 +61,7 @@ import DMXAPISettings from './DMXAPISettings'
import GithubCopilotSettings from './GithubCopilotSettings'
import GPUStackSettings from './GPUStackSettings'
import LMStudioSettings from './LMStudioSettings'
import OpenAISettings from './OpenAISettings'
import OVMSSettings from './OVMSSettings'
import ProviderOAuth from './ProviderOAuth'
import SelectProviderModelPopup from './SelectProviderModelPopup'
@ -372,7 +372,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
: t('settings.provider.api_host_tooltip')
const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth'
const isOpenAIOAuth = () => provider.id === 'openai' && provider.authType === 'oauth'
return (
<SettingContainer theme={theme} style={{ background: 'var(--color-background)' }}>
<SettingTitle>
@ -407,7 +407,20 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
</SettingTitle>
<Divider style={{ width: '100%', margin: '10px 0' }} />
{isProviderSupportAuth(provider) && <ProviderOAuth providerId={provider.id} />}
{provider.id === 'openai' && <OpenAIAlert />}
{provider.id === 'openai' && (
<>
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.openai.auth_method')}</SettingSubtitle>
<Select
style={{ width: '40%', marginTop: 5, marginBottom: 10 }}
value={provider.authType || 'apiKey'}
onChange={(value) => updateProvider({ authType: value })}
options={[
{ value: 'apiKey', label: t('settings.provider.openai.apikey') },
{ value: 'oauth', label: t('settings.provider.openai.oauth') }
]}
/>
{provider.authType === 'oauth' && <OpenAISettings />}
</>)}
{provider.id === 'ovms' && <OVMSSettings />}
{isDmxapi && <DMXAPISettings providerId={provider.id} />}
{provider.id === 'anthropic' && (
@ -425,7 +438,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{provider.authType === 'oauth' && <AnthropicSettings />}
</>
)}
{!hideApiInput && !isAnthropicOAuth() && (
{!hideApiInput && !isAnthropicOAuth() && !isOpenAIOAuth() && (
<>
{!hideApiKeyInput && (
<>