diff --git a/.gitignore b/.gitignore index e644c04..7072e69 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ usage-cache.json *-auth-token.json api-potluck-keys.json api-potluck-data.json +# Codex credentials +configs/codex/ # Orchids credentials configs/orchids/*_orchids_creds/ configs/orchids/*.json diff --git a/package-lock.json b/package-lock.json index 8e81846..114fe5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "AIClient2API", + "name": "AIClient-2-API", "lockfileVersion": 3, "requires": true, "packages": { @@ -101,6 +101,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2959,6 +2960,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", diff --git a/src/auth/codex-oauth.js b/src/auth/codex-oauth.js new file mode 100644 index 0000000..9e8af37 --- /dev/null +++ b/src/auth/codex-oauth.js @@ -0,0 +1,589 @@ +import crypto from 'crypto'; +import http from 'http'; +import open from 'open'; +import axios from 'axios'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Codex OAuth 配置 + */ +const CODEX_CONFIG = { + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + authUrl: 'https://auth.openai.com/oauth/authorize', + tokenUrl: 'https://auth.openai.com/oauth/token', + redirectUri: 'http://localhost:1455/auth/callback', + port: 1455, + scopes: 'openid email profile offline_access' +}; + +/** + * Codex OAuth 认证类 + * 实现 OAuth2 + PKCE 流程 + */ +export class CodexAuth { + constructor(config) { + this.config = config; + this.httpClient = axios.create({ + timeout: 30000 + }); + this.server = null; // 存储服务器实例 + } + + /** + * 生成 PKCE 代码 + * @returns {{verifier: string, challenge: string}} + */ + generatePKCECodes() { + // 生成 code verifier (96 随机字节 → 128 base64url 字符) + const verifier = crypto.randomBytes(96) + .toString('base64url'); + + // 生成 code challenge (SHA256 of verifier) + const challenge = crypto.createHash('sha256') + .update(verifier) + .digest('base64url'); + + return { verifier, challenge }; + } + + /** + * 生成授权 URL(不启动完整流程) + * @returns {{authUrl: string, state: string, pkce: Object, server: Object}} + */ + async generateAuthUrl() { + const pkce = this.generatePKCECodes(); + const state = crypto.randomBytes(16).toString('hex'); + + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Generating auth URL...`); + + // 启动本地回调服务器 + const server = await this.startCallbackServer(); + this.server = server; + + // 构建授权 URL + const authUrl = new URL(CODEX_CONFIG.authUrl); + authUrl.searchParams.set('client_id', CODEX_CONFIG.clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', CODEX_CONFIG.redirectUri); + authUrl.searchParams.set('scope', CODEX_CONFIG.scopes); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', pkce.challenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('prompt', 'login'); + authUrl.searchParams.set('id_token_add_organizations', 'true'); + authUrl.searchParams.set('codex_cli_simplified_flow', 'true'); + + return { + authUrl: authUrl.toString(), + state, + pkce, + server + }; + } + + /** + * 完成 OAuth 流程(在收到回调后调用) + * @param {string} code - 授权码 + * @param {string} state - 状态参数 + * @param {string} expectedState - 期望的状态参数 + * @param {Object} pkce - PKCE 代码 + * @returns {Promise} tokens 和凭据路径 + */ + async completeOAuthFlow(code, state, expectedState, pkce) { + // 验证 state + if (state !== expectedState) { + throw new Error('State mismatch - possible CSRF attack'); + } + + // 用 code 换取 tokens + const tokens = await this.exchangeCodeForTokens(code, pkce.verifier); + + // 解析 JWT 提取账户信息 + const claims = this.parseJWT(tokens.id_token); + + // 保存凭据(遵循 CLIProxyAPI 格式) + const credentials = { + id_token: tokens.id_token, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub, + last_refresh: new Date().toISOString(), + email: claims.email, + type: 'codex', + expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString() + }; + + // 获取凭据保存路径 + const email = credentials.email || this.config.CODEX_EMAIL || 'default'; + let credPath; + if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { + credPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; + } else { + // 保存到 configs/codex 目录(与其他供应商一致) + const projectDir = process.cwd(); + const targetDir = path.join(projectDir, 'configs', 'codex'); + await fs.mkdir(targetDir, { recursive: true }); + const timestamp = Date.now(); + const filename = `${timestamp}_codex-${email}.json`; + credPath = path.join(targetDir, filename); + } + + const saveResult = await this.saveCredentials(credentials); + credPath = saveResult.credsPath; + const relativePath = saveResult.relativePath; + + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Authentication successful!`); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Email: ${credentials.email}`); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Account ID: ${credentials.account_id}`); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Credentials saved to: ${relativePath}`); + + // 关闭服务器 + if (this.server) { + this.server.close(); + this.server = null; + } + + return { + ...credentials, + credPath, + relativePath + }; + } + + /** + * 启动 OAuth 流程 + * @returns {Promise} 返回 tokens + */ + async startOAuthFlow() { + const pkce = this.generatePKCECodes(); + const state = crypto.randomBytes(16).toString('hex'); + + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Starting OAuth flow...`); + + // 启动本地回调服务器 + const server = await this.startCallbackServer(); + + // 构建授权 URL + const authUrl = new URL(CODEX_CONFIG.authUrl); + authUrl.searchParams.set('client_id', CODEX_CONFIG.clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', CODEX_CONFIG.redirectUri); + authUrl.searchParams.set('scope', CODEX_CONFIG.scopes); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', pkce.challenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('prompt', 'login'); + authUrl.searchParams.set('id_token_add_organizations', 'true'); + authUrl.searchParams.set('codex_cli_simplified_flow', 'true'); + + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Opening browser for authentication...`); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} If browser doesn't open, visit: ${authUrl.toString()}`); + + try { + await open(authUrl.toString()); + } catch (error) { + console.warn(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Failed to open browser automatically:`, error.message); + } + + // 等待回调 + const result = await this.waitForCallback(server, state); + + // 用 code 换取 tokens + const tokens = await this.exchangeCodeForTokens(result.code, pkce.verifier); + + // 解析 JWT 提取账户信息 + const claims = this.parseJWT(tokens.id_token); + + // 保存凭据(遵循 CLIProxyAPI 格式) + const credentials = { + id_token: tokens.id_token, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub, + last_refresh: new Date().toISOString(), + email: claims.email, + type: 'codex', + expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString() + }; + + await this.saveCredentials(credentials); + + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Authentication successful!`); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Email: ${credentials.email}`); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Account ID: ${credentials.account_id}`); + + return credentials; + } + + /** + * 启动回调服务器 + * @returns {Promise} + */ + async startCallbackServer() { + return new Promise((resolve, reject) => { + const server = http.createServer(); + + server.on('request', (req, res) => { + if (req.url.startsWith('/auth/callback')) { + const url = new URL(req.url, `http://localhost:${CODEX_CONFIG.port}`); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + Authentication Failed + + + +

❌ Authentication Failed

+

${errorDescription || error}

+

You can close this window and try again.

+ + + `); + server.emit('auth-error', new Error(errorDescription || error)); + } else if (code && state) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + Authentication Successful + + + + +

✅ Authentication Successful!

+

You can now close this window and return to the application.

+

This window will close automatically in 10 seconds.

+ + + `); + server.emit('auth-success', { code, state }); + } + } else if (req.url === '/success') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('

Success!

'); + } + }); + + server.listen(CODEX_CONFIG.port, () => { + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Callback server listening on port ${CODEX_CONFIG.port}`); + resolve(server); + }); + + server.on('error', (error) => { + if (error.code === 'EADDRINUSE') { + reject(new Error(`Port ${CODEX_CONFIG.port} is already in use. Please close other applications using this port.`)); + } else { + reject(error); + } + }); + }); + } + + /** + * 等待 OAuth 回调 + * @param {http.Server} server + * @param {string} expectedState + * @returns {Promise<{code: string, state: string}>} + */ + async waitForCallback(server, expectedState) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + server.close(); + reject(new Error('Authentication timeout (10 minutes)')); + }, 10 * 60 * 1000); // 10 分钟 + + server.once('auth-success', (result) => { + clearTimeout(timeout); + server.close(); + + if (result.state !== expectedState) { + reject(new Error('State mismatch - possible CSRF attack')); + } else { + resolve(result); + } + }); + + server.once('auth-error', (error) => { + clearTimeout(timeout); + server.close(); + reject(error); + }); + }); + } + + /** + * 用授权码换取 tokens + * @param {string} code + * @param {string} codeVerifier + * @returns {Promise} + */ + async exchangeCodeForTokens(code, codeVerifier) { + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Exchanging authorization code for tokens...`); + + try { + const response = await this.httpClient.post( + CODEX_CONFIG.tokenUrl, + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CODEX_CONFIG.clientId, + code: code, + redirect_uri: CODEX_CONFIG.redirectUri, + code_verifier: codeVerifier + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + } + ); + + return response.data; + } catch (error) { + console.error(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Token exchange failed:`, error.response?.data || error.message); + throw new Error(`Failed to exchange code for tokens: ${error.response?.data?.error_description || error.message}`); + } + } + + /** + * 刷新 tokens + * @param {string} refreshToken + * @returns {Promise} + */ + async refreshTokens(refreshToken) { + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Refreshing access token...`); + + try { + const response = await this.httpClient.post( + CODEX_CONFIG.tokenUrl, + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: CODEX_CONFIG.clientId, + refresh_token: refreshToken + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + } + ); + + const tokens = response.data; + const claims = this.parseJWT(tokens.id_token); + + return { + id_token: tokens.id_token, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || refreshToken, + account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub, + last_refresh: new Date().toISOString(), + email: claims.email, + type: 'codex', + expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString() + }; + } catch (error) { + console.error(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Token refresh failed:`, error.response?.data || error.message); + throw new Error(`Failed to refresh tokens: ${error.response?.data?.error_description || error.message}`); + } + } + + /** + * 解析 JWT token + * @param {string} token + * @returns {Object} + */ + parseJWT(token) { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT token format'); + } + + // 解码 payload (base64url) + const payload = Buffer.from(parts[1], 'base64url').toString('utf8'); + return JSON.parse(payload); + } catch (error) { + console.error(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Failed to parse JWT:`, error.message); + throw new Error(`Failed to parse JWT token: ${error.message}`); + } + } + + /** + * 保存凭据到文件 + * @param {Object} creds + * @returns {Promise} + */ + async saveCredentials(creds) { + const email = creds.email || this.config.CODEX_EMAIL || 'default'; + + // 优先使用配置中指定的路径,否则保存到 configs/codex 目录 + let credsPath; + if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { + credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; + } else { + // 保存到 configs/codex 目录(与其他供应商一致) + const projectDir = process.cwd(); + const targetDir = path.join(projectDir, 'configs', 'codex'); + await fs.mkdir(targetDir, { recursive: true }); + const timestamp = Date.now(); + const filename = `${timestamp}_codex-${email}.json`; + credsPath = path.join(targetDir, filename); + } + + try { + const credsDir = path.dirname(credsPath); + await fs.mkdir(credsDir, { recursive: true }); + await fs.writeFile(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); + + const relativePath = path.relative(process.cwd(), credsPath); + console.log(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Credentials saved to ${relativePath}`); + + // 返回保存路径供后续使用 + return { credsPath, relativePath }; + } catch (error) { + console.error(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Failed to save credentials:`, error.message); + throw new Error(`Failed to save credentials: ${error.message}`); + } + } + + /** + * 加载凭据 + * @param {string} email + * @returns {Promise} + */ + async loadCredentials(email) { + // 优先使用配置中指定的路径,否则从 configs/codex 目录加载 + let credsPath; + if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { + credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; + } else { + // 从 configs/codex 目录加载(与其他供应商一致) + const projectDir = process.cwd(); + const targetDir = path.join(projectDir, 'configs', 'codex'); + + // 扫描目录找到匹配的凭据文件 + try { + const files = await fs.readdir(targetDir); + const emailPattern = email || 'default'; + const matchingFile = files + .filter(f => f.includes(`codex-${emailPattern}`) && f.endsWith('.json')) + .sort() + .pop(); // 获取最新的文件 + + if (matchingFile) { + credsPath = path.join(targetDir, matchingFile); + } else { + return null; + } + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } + } + + try { + const data = await fs.readFile(credsPath, 'utf8'); + return JSON.parse(data); + } catch (error) { + if (error.code === 'ENOENT') { + return null; // 文件不存在 + } + throw error; + } + } + + /** + * 检查凭据文件是否存在 + * @param {string} email + * @returns {Promise} + */ + async credentialsExist(email) { + // 优先使用配置中指定的路径,否则从 configs/codex 目录检查 + let credsPath; + if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { + credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; + } else { + const projectDir = process.cwd(); + const targetDir = path.join(projectDir, 'configs', 'codex'); + + try { + const files = await fs.readdir(targetDir); + const emailPattern = email || 'default'; + const hasMatch = files.some(f => + f.includes(`codex-${emailPattern}`) && f.endsWith('.json') + ); + return hasMatch; + } catch (error) { + return false; + } + } + + try { + await fs.access(credsPath); + return true; + } catch { + return false; + } + } +} + +/** + * 带重试的 token 刷新 + * @param {string} refreshToken + * @param {Object} config + * @param {number} maxRetries + * @returns {Promise} + */ +export async function refreshTokensWithRetry(refreshToken, config = {}, maxRetries = 3) { + const auth = new CodexAuth(config); + let lastError; + + for (let i = 0; i < maxRetries; i++) { + try { + return await auth.refreshTokens(refreshToken); + } catch (error) { + lastError = error; + console.warn(`${CODEX_CONFIG.logPrefix || '[Codex Auth]'} Retry ${i + 1}/${maxRetries} failed:`, error.message); + + if (i < maxRetries - 1) { + // 指数退避 + const delay = Math.min(1000 * Math.pow(2, i), 10000); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; +} diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js index c2e578a..757e8f2 100644 --- a/src/auth/oauth-handlers.js +++ b/src/auth/oauth-handlers.js @@ -31,6 +31,13 @@ const OAUTH_PROVIDERS = { credentialsFile: 'oauth_creds.json', scope: ['https://www.googleapis.com/auth/cloud-platform'], logPrefix: '[Antigravity Auth]' + }, + 'openai-codex-oauth': { + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + port: 1455, + credentialsDir: 'configs/codex', + credentialsFile: '{timestamp}_codex-{email}.json', + logPrefix: '[Codex Auth]' } }; @@ -2284,3 +2291,148 @@ export async function handleOrchidsOAuth(currentConfig, options = {}) { }; } +/** + * 处理 Codex OAuth 认证 + * @param {Object} currentConfig - 当前配置 + * @param {Object} options - 选项 + * @returns {Promise} 返回认证结果 + */ +export async function handleCodexOAuth(currentConfig, options = {}) { + const { CodexAuth } = await import('./codex-oauth.js'); + const auth = new CodexAuth(currentConfig); + + try { + console.log('[Codex Auth] Generating OAuth URL...'); + + // 生成授权 URL 和启动回调服务器 + const { authUrl, state, pkce, server } = await auth.generateAuthUrl(); + + console.log('[Codex Auth] OAuth URL generated successfully'); + + // 存储 OAuth 会话信息,供后续回调使用 + if (!global.codexOAuthSessions) { + global.codexOAuthSessions = new Map(); + } + + const sessionId = state; // 使用 state 作为 session ID + global.codexOAuthSessions.set(sessionId, { + auth, + state, + pkce, + server, + createdAt: Date.now() + }); + + // 10 分钟后自动清理会话 + setTimeout(() => { + if (global.codexOAuthSessions.has(sessionId)) { + const session = global.codexOAuthSessions.get(sessionId); + if (session.server) { + session.server.close(); + } + global.codexOAuthSessions.delete(sessionId); + console.log('[Codex Auth] Session expired and cleaned up'); + } + }, 10 * 60 * 1000); + + return { + success: true, + authUrl: authUrl, + authInfo: { + provider: 'openai-codex-oauth', + method: 'oauth2-pkce', + sessionId: sessionId, + redirectUri: 'http://localhost:1455/auth/callback', + instructions: [ + '1. 点击下方按钮在浏览器中打开授权链接', + '2. 使用您的 OpenAI 账户登录', + '3. 授权应用访问您的 Codex API', + '4. 授权成功后会自动保存凭据', + '5. 如果浏览器未自动跳转,请手动复制回调 URL' + ] + } + }; + } catch (error) { + console.error('[Codex Auth] Failed to generate OAuth URL:', error.message); + + return { + success: false, + error: error.message, + authInfo: { + provider: 'openai-codex-oauth', + method: 'oauth2-pkce', + instructions: [ + '1. 确保端口 1455 未被占用', + '2. 确保可以访问 auth.openai.com', + '3. 确保浏览器可以正常打开', + '4. 如果问题持续,请检查网络连接' + ] + } + }; + } +} + +/** + * 处理 Codex OAuth 回调 + * @param {string} code - 授权码 + * @param {string} state - 状态参数 + * @returns {Promise} 返回认证结果 + */ +export async function handleCodexOAuthCallback(code, state) { + try { + if (!global.codexOAuthSessions || !global.codexOAuthSessions.has(state)) { + throw new Error('Invalid or expired OAuth session'); + } + + const session = global.codexOAuthSessions.get(state); + const { auth, state: expectedState, pkce } = session; + + console.log('[Codex Auth] Processing OAuth callback...'); + + // 完成 OAuth 流程 + const result = await auth.completeOAuthFlow(code, state, expectedState, pkce); + + // 清理会话 + global.codexOAuthSessions.delete(state); + + // 广播认证成功事件(与 gemini 格式一致) + broadcastEvent('oauth_success', { + provider: 'openai-codex-oauth', + credPath: result.credPath, + relativePath: result.relativePath, + timestamp: new Date().toISOString(), + email: result.email, + accountId: result.account_id + }); + + // 自动关联新生成的凭据到 Pools + await autoLinkProviderConfigs(CONFIG); + + console.log('[Codex Auth] OAuth callback processed successfully'); + + return { + success: true, + message: 'Codex authentication successful', + credentials: result, + email: result.email, + accountId: result.account_id, + credPath: result.credPath, + relativePath: result.relativePath + }; + } catch (error) { + console.error('[Codex Auth] OAuth callback failed:', error.message); + + // 广播认证失败事件 + broadcastEvent({ + type: 'oauth-error', + provider: 'openai-codex-oauth', + error: error.message + }); + + return { + success: false, + error: error.message + }; + } +} + diff --git a/src/converters/register-converters.js b/src/converters/register-converters.js index c386cea..209e3ba 100644 --- a/src/converters/register-converters.js +++ b/src/converters/register-converters.js @@ -10,6 +10,7 @@ import { OpenAIResponsesConverter } from './strategies/OpenAIResponsesConverter. import { ClaudeConverter } from './strategies/ClaudeConverter.js'; import { GeminiConverter } from './strategies/GeminiConverter.js'; import { OllamaConverter } from './strategies/OllamaConverter.js'; +import { CodexConverter } from './strategies/CodexConverter.js'; /** * 注册所有转换器到工厂 @@ -21,6 +22,7 @@ export function registerAllConverters() { ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CLAUDE, ClaudeConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GEMINI, GeminiConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OLLAMA, OllamaConverter); + ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CODEX, CodexConverter); } // 自动注册所有转换器 diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js new file mode 100644 index 0000000..7663dbd --- /dev/null +++ b/src/converters/strategies/CodexConverter.js @@ -0,0 +1,489 @@ +/** + * Codex 转换器 + * 处理 OpenAI 协议与 Codex 协议之间的转换 + */ + +import crypto from 'crypto'; +import { BaseConverter } from '../BaseConverter.js'; +import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; + +export class CodexConverter extends BaseConverter { + constructor() { + super('codex'); + this.toolNameMap = new Map(); // 工具名称缩短/恢复映射 + this.reverseToolNameMap = new Map(); // 反向映射 + } + + /** + * 转换请求 + */ + convertRequest(data, targetProtocol) { + if (targetProtocol === 'codex') { + return this.toCodexRequest(data); + } else if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { + // Codex → OpenAI (通常不需要,因为 Codex 响应会直接转换) + return data; + } + throw new Error(`Unsupported target protocol: ${targetProtocol}`); + } + + /** + * 转换响应 + */ + convertResponse(data, targetProtocol, model) { + if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { + return this.toOpenAIResponse(data, model); + } + throw new Error(`Unsupported target protocol: ${targetProtocol}`); + } + + /** + * 转换流式响应块 + */ + convertStreamChunk(chunk, targetProtocol, model) { + if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { + return this.toOpenAIStreamChunk(chunk, model); + } + throw new Error(`Unsupported target protocol: ${targetProtocol}`); + } + + /** + * OpenAI → Codex 请求转换 + */ + toCodexRequest(data) { + const codexRequest = { + model: data.model, + instructions: this.buildInstructions(data), + input: this.convertMessages(data.messages || []), + stream: data.stream || false, + store: false, + reasoning: { + effort: 'medium', + summary: 'auto' + }, + parallel_tool_calls: data.parallel_tool_calls !== false, + include: ['reasoning.encrypted_content'] + }; + + // 添加工具 + if (data.tools && data.tools.length > 0) { + codexRequest.tools = this.convertTools(data.tools); + codexRequest.tool_choice = data.tool_choice || 'auto'; + } + + // 添加响应格式 + if (data.response_format) { + codexRequest.text = { + format: this.convertResponseFormat(data.response_format) + }; + } + + // 添加推理强度(如果指定) + if (data.reasoning_effort) { + codexRequest.reasoning.effort = data.reasoning_effort; + } + + // 添加温度和其他参数 + if (data.temperature !== undefined) { + codexRequest.temperature = data.temperature; + } + if (data.max_tokens !== undefined) { + codexRequest.max_output_tokens = data.max_tokens; + } + if (data.top_p !== undefined) { + codexRequest.top_p = data.top_p; + } + + return codexRequest; + } + + /** + * 构建指令 + */ + buildInstructions(data) { + // 提取系统消息 + const systemMessages = (data.messages || []).filter(m => m.role === 'system'); + if (systemMessages.length > 0) { + return systemMessages.map(m => { + if (typeof m.content === 'string') { + return m.content; + } else if (Array.isArray(m.content)) { + return m.content + .filter(part => part.type === 'text') + .map(part => part.text) + .join('\n'); + } + return ''; + }).join('\n'); + } + return 'You are a helpful assistant.'; + } + + /** + * 转换消息 + */ + convertMessages(messages) { + const input = []; + const nonSystemMessages = messages.filter(m => m.role !== 'system'); + + for (const msg of nonSystemMessages) { + if (msg.role === 'user' || msg.role === 'assistant') { + input.push({ + type: 'message', + role: msg.role, + content: this.convertMessageContent(msg.content, msg.role) + }); + + // 处理助手消息中的工具调用 + if (msg.role === 'assistant' && msg.tool_calls) { + for (const toolCall of msg.tool_calls) { + const shortName = this.getShortToolName(toolCall.function.name); + input.push({ + type: 'function_call', + call_id: toolCall.id, + name: shortName, + arguments: JSON.parse(toolCall.function.arguments) + }); + } + } + } else if (msg.role === 'tool') { + input.push({ + type: 'function_call_output', + call_id: msg.tool_call_id, + output: msg.content + }); + } + } + + return input; + } + + /** + * 转换消息内容 + */ + convertMessageContent(content, role) { + if (typeof content === 'string') { + return [{ + type: role === 'user' ? 'input_text' : 'output_text', + text: content + }]; + } + + if (Array.isArray(content)) { + return content.map(part => { + if (part.type === 'text') { + return { + type: role === 'user' ? 'input_text' : 'output_text', + text: part.text + }; + } else if (part.type === 'image_url') { + return { + type: 'input_image', + image_url: part.image_url.url + }; + } + return part; + }); + } + + return []; + } + + /** + * 转换工具 + */ + convertTools(tools) { + this.toolNameMap.clear(); + this.reverseToolNameMap.clear(); + + return tools.map(tool => { + const originalName = tool.function.name; + const shortName = this.shortenToolName(originalName); + + this.toolNameMap.set(originalName, shortName); + this.reverseToolNameMap.set(shortName, originalName); + + return { + type: 'function', + name: shortName, + description: tool.function.description, + parameters: tool.function.parameters + }; + }); + } + + /** + * 缩短工具名称(最多 64 字符) + */ + shortenToolName(name) { + if (name.length <= 64) { + return name; + } + + // 保留 mcp__ 前缀和最后一段 + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length > 2) { + const prefix = 'mcp__'; + const lastPart = parts[parts.length - 1]; + const maxLastPartLength = 64 - prefix.length - 1; // -1 for underscore + + if (lastPart.length <= maxLastPartLength) { + return prefix + lastPart; + } else { + return prefix + lastPart.slice(0, maxLastPartLength); + } + } + } + + // 使用哈希创建唯一的短名称 + const hash = crypto.createHash('md5').update(name).digest('hex').slice(0, 8); + return name.slice(0, 55) + '_' + hash; + } + + /** + * 获取短工具名称 + */ + getShortToolName(originalName) { + return this.toolNameMap.get(originalName) || originalName; + } + + /** + * 获取原始工具名称 + */ + getOriginalToolName(shortName) { + return this.reverseToolNameMap.get(shortName) || shortName; + } + + /** + * 转换响应格式 + */ + convertResponseFormat(responseFormat) { + if (responseFormat.type === 'json_schema') { + return { + type: 'json_schema', + name: responseFormat.json_schema?.name || 'response', + schema: responseFormat.json_schema?.schema || {} + }; + } else if (responseFormat.type === 'json_object') { + return { + type: 'json_object' + }; + } + return responseFormat; + } + + /** + * Codex → OpenAI 响应转换(非流式) + */ + toOpenAIResponse(data, model) { + const response = data.response || data; + + const message = { + role: 'assistant', + content: '' + }; + + // 提取文本内容和工具调用 + const textParts = []; + const toolCalls = []; + + if (response.output) { + for (const item of response.output) { + if (item.type === 'message') { + for (const content of item.content || []) { + if (content.type === 'output_text') { + textParts.push(content.text); + } + } + } else if (item.type === 'function_call') { + const originalName = this.getOriginalToolName(item.name); + toolCalls.push({ + id: item.call_id, + type: 'function', + function: { + name: originalName, + arguments: JSON.stringify(item.arguments) + } + }); + } + } + } + + message.content = textParts.join(''); + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + } + + // 提取推理内容 + let reasoningContent = ''; + if (response.output) { + for (const item of response.output) { + if (item.summary) { + reasoningContent = item.summary; + break; + } + } + } + + return { + id: response.id || `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + message: message, + finish_reason: this.mapFinishReason(response.status), + ...(reasoningContent && { reasoning_content: reasoningContent }) + }], + usage: { + prompt_tokens: response.usage?.input_tokens || 0, + completion_tokens: response.usage?.output_tokens || 0, + total_tokens: response.usage?.total_tokens || 0, + ...(response.usage?.input_tokens_details?.cached_tokens && { + prompt_tokens_details: { + cached_tokens: response.usage.input_tokens_details.cached_tokens + } + }), + ...(response.usage?.output_tokens_details?.reasoning_tokens && { + completion_tokens_details: { + reasoning_tokens: response.usage.output_tokens_details.reasoning_tokens + } + }) + } + }; + } + + /** + * Codex → OpenAI 流式响应块转换 + */ + toOpenAIStreamChunk(chunk, model) { + const type = chunk.type; + + // response.created - 存储元数据 + if (type === 'response.created') { + return { + id: chunk.response.id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + delta: { role: 'assistant' }, + finish_reason: null + }] + }; + } + + // response.output_text.delta - 文本内容 + if (type === 'response.output_text.delta') { + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + delta: { content: chunk.delta }, + finish_reason: null + }] + }; + } + + // response.reasoning_summary_text.delta - 推理内容 + if (type === 'response.reasoning_summary_text.delta') { + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + delta: { reasoning_content: chunk.delta }, + finish_reason: null + }] + }; + } + + // response.output_item.done - 工具调用完成 + if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { + const originalName = this.getOriginalToolName(chunk.item.name); + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: 0, + id: chunk.item.call_id, + type: 'function', + function: { + name: originalName, + arguments: JSON.stringify(chunk.item.arguments) + } + }] + }, + finish_reason: null + }] + }; + } + + // response.completed - 完成 + if (type === 'response.completed') { + return { + id: chunk.response.id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + delta: {}, + finish_reason: this.mapFinishReason(chunk.response.status) + }], + usage: { + prompt_tokens: chunk.response.usage?.input_tokens || 0, + completion_tokens: chunk.response.usage?.output_tokens || 0, + total_tokens: chunk.response.usage?.total_tokens || 0, + ...(chunk.response.usage?.input_tokens_details?.cached_tokens && { + prompt_tokens_details: { + cached_tokens: chunk.response.usage.input_tokens_details.cached_tokens + } + }), + ...(chunk.response.usage?.output_tokens_details?.reasoning_tokens && { + completion_tokens_details: { + reasoning_tokens: chunk.response.usage.output_tokens_details.reasoning_tokens + } + }) + } + }; + } + + // 其他事件类型暂时忽略 + return null; + } + + /** + * 映射完成原因 + */ + mapFinishReason(status) { + const mapping = { + 'completed': 'stop', + 'incomplete': 'length', + 'failed': 'error', + 'cancelled': 'stop' + }; + return mapping[status] || 'stop'; + } + + /** + * 转换模型列表 + */ + convertModelList(data, targetProtocol) { + // Codex 使用 OpenAI 格式的模型列表,无需转换 + return data; + } +} diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index 9a4f128..2bec45f 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { BaseConverter } from '../BaseConverter.js'; +import { CodexConverter } from './CodexConverter.js'; import { extractAndProcessSystemMessages as extractSystemMessages, extractTextFromMessageContent as extractText, @@ -41,6 +42,8 @@ import { export class OpenAIConverter extends BaseConverter { constructor() { super('openai'); + // 创建 CodexConverter 实例用于委托 + this.codexConverter = new CodexConverter(); } /** @@ -54,6 +57,8 @@ export class OpenAIConverter extends BaseConverter { return this.toGeminiRequest(data); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesRequest(data); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexRequest(data); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -1326,6 +1331,13 @@ export class OpenAIConverter extends BaseConverter { return result; } + /** + * OpenAI请求 -> Codex请求(委托给 CodexConverter) + */ + toCodexRequest(openaiRequest) { + return this.codexConverter.toCodexRequest(openaiRequest); + } + /** * 将OpenAI请求转换为OpenAI Responses格式 */ diff --git a/src/providers/adapter.js b/src/providers/adapter.js index d4cb0a7..9d0cf47 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -7,6 +7,7 @@ import { KiroApiService } from './claude/claude-kiro.js'; // 导入KiroApiServic import { OrchidsApiService } from './claude/claude-orchids.js'; // 导入OrchidsApiService import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService import { IFlowApiService } from './openai/iflow-core.js'; // 导入IFlowApiService +import { CodexApiService } from './openai/codex-core.js'; // 导入CodexApiService import { MODEL_PROVIDER } from '../utils/common.js'; // 导入 MODEL_PROVIDER // 定义AI服务适配器接口 @@ -444,6 +445,42 @@ export class IFlowApiServiceAdapter extends ApiServiceAdapter { } +// Codex API 服务适配器 +export class CodexApiServiceAdapter extends ApiServiceAdapter { + constructor(config) { + super(); + this.codexApiService = new CodexApiService(config); + } + + async generateContent(model, requestBody) { + if (!this.codexApiService.isInitialized) { + console.warn("codexApiService not initialized, attempting to re-initialize..."); + await this.codexApiService.initialize(); + } + return this.codexApiService.generateContent(model, requestBody); + } + + async *generateContentStream(model, requestBody) { + if (!this.codexApiService.isInitialized) { + console.warn("codexApiService not initialized, attempting to re-initialize..."); + await this.codexApiService.initialize(); + } + yield* this.codexApiService.generateContentStream(model, requestBody); + } + + async listModels() { + return this.codexApiService.listModels(); + } + + async refreshToken() { + if (this.codexApiService.isExpiryDateNear()) { + console.log(`[Codex] Expiry date is near, refreshing token...`); + await this.codexApiService.refreshAccessToken(); + } + return Promise.resolve(); + } +} + // 用于存储服务适配器单例的映射 export const serviceInstances = {}; @@ -482,6 +519,9 @@ export function getServiceAdapter(config) { case MODEL_PROVIDER.ORCHIDS_API: serviceInstances[providerKey] = new OrchidsApiServiceAdapter(config); break; + case MODEL_PROVIDER.CODEX_API: + serviceInstances[providerKey] = new CodexApiServiceAdapter(config); + break; default: throw new Error(`Unsupported model provider: ${provider}`); } diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js new file mode 100644 index 0000000..2784696 --- /dev/null +++ b/src/providers/openai/codex-core.js @@ -0,0 +1,413 @@ +import axios from 'axios'; +import crypto from 'crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { refreshTokensWithRetry } from '../../auth/codex-oauth.js'; + +/** + * Codex API 服务类 + * 处理与 Codex API 的通信 + */ +export class CodexApiService { + constructor(config) { + this.config = config; + this.baseUrl = config.CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex'; + this.accessToken = null; + this.refreshToken = null; + this.accountId = null; + this.email = null; + this.expiresAt = null; + this.isInitialized = false; + + // 会话缓存管理 + this.conversationCache = new Map(); // key: model-userId, value: {id, expire} + this.startCacheCleanup(); + } + + /** + * 初始化服务(加载凭据) + */ + async initialize() { + const email = this.config.CODEX_EMAIL || 'default'; + + try { + let creds; + + // 如果指定了具体路径,直接读取 + if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { + const credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; + const exists = await this.fileExists(credsPath); + if (!exists) { + throw new Error('Codex credentials not found. Please authenticate first using OAuth.'); + } + creds = JSON.parse(await fs.readFile(credsPath, 'utf8')); + } else { + // 从 configs/codex 目录扫描加载 + const projectDir = process.cwd(); + const targetDir = path.join(projectDir, 'configs', 'codex'); + const files = await fs.readdir(targetDir); + const matchingFile = files + .filter(f => f.includes(`codex-${email}`) && f.endsWith('.json')) + .sort() + .pop(); // 获取最新的文件 + + if (!matchingFile) { + throw new Error('Codex credentials not found. Please authenticate first using OAuth.'); + } + + const credsPath = path.join(targetDir, matchingFile); + creds = JSON.parse(await fs.readFile(credsPath, 'utf8')); + } + + this.accessToken = creds.access_token; + this.refreshToken = creds.refresh_token; + this.accountId = creds.account_id; + this.email = creds.email; + this.expiresAt = new Date(creds.expired); // 注意:字段名是 expired + + // 检查 token 是否需要刷新 + if (this.isExpiryDateNear()) { + console.log('[Codex] Token expiring soon, refreshing...'); + await this.refreshAccessToken(); + } + + this.isInitialized = true; + console.log(`[Codex] Initialized with account: ${this.email}`); + } catch (error) { + console.error('[Codex] Initialization failed:', error.message); + throw error; + } + } + + /** + * 生成内容(非流式) + */ + async generateContent(model, requestBody) { + if (!this.isInitialized) { + await this.initialize(); + } + + const url = `${this.baseUrl}/responses`; + const body = this.prepareRequestBody(model, requestBody, false); + const headers = this.buildHeaders(body.prompt_cache_key); + + try { + const response = await axios.post(url, body, { + headers, + timeout: 120000 // 2 分钟超时 + }); + + return this.parseNonStreamResponse(response.data); + } catch (error) { + if (error.response?.status === 401) { + // Token 过期,尝试刷新 + console.log('[Codex] 401 error, refreshing token...'); + await this.refreshAccessToken(); + // 重试请求 + const retryBody = this.prepareRequestBody(model, requestBody, false); + const retryHeaders = this.buildHeaders(retryBody.prompt_cache_key); + const retryResponse = await axios.post(url, retryBody, { + headers: retryHeaders, + timeout: 120000 + }); + return this.parseNonStreamResponse(retryResponse.data); + } + throw error; + } + } + + /** + * 流式生成内容 + */ + async *generateContentStream(model, requestBody) { + if (!this.isInitialized) { + await this.initialize(); + } + + const url = `${this.baseUrl}/responses`; + const body = this.prepareRequestBody(model, requestBody, true); + const headers = this.buildHeaders(body.prompt_cache_key); + + // 调试日志 + console.log('[Codex Debug] Request URL:', url); + console.log('[Codex Debug] Request Body:', JSON.stringify(body, null, 2)); + console.log('[Codex Debug] Request Headers:', JSON.stringify(headers, null, 2)); + + try { + const response = await axios.post(url, body, { + headers, + responseType: 'stream', + timeout: 120000 + }); + + yield* this.parseSSEStream(response.data); + } catch (error) { + // 打印详细错误信息 + if (error.response) { + console.error('[Codex Error] Status:', error.response.status); + console.error('[Codex Error] Headers:', error.response.headers); + if (error.response.data) { + const errorData = await new Promise((resolve) => { + let data = ''; + error.response.data.on('data', chunk => data += chunk); + error.response.data.on('end', () => resolve(data)); + }); + console.error('[Codex Error] Response:', errorData); + } + } + + if (error.response?.status === 401) { + // Token 过期,尝试刷新 + console.log('[Codex] 401 error, refreshing token...'); + await this.refreshAccessToken(); + // 重试请求 + const retryBody = this.prepareRequestBody(model, requestBody, true); + const retryHeaders = this.buildHeaders(retryBody.prompt_cache_key); + const retryResponse = await axios.post(url, retryBody, { + headers: retryHeaders, + responseType: 'stream', + timeout: 120000 + }); + yield* this.parseSSEStream(retryResponse.data); + } else { + throw error; + } + } + } + + /** + * 构建请求头 + */ + buildHeaders(cacheId) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}`, + 'Openai-Beta': 'responses=experimental', + 'Version': '0.21.0', + 'User-Agent': 'codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464', + 'Originator': 'codex_cli_rs', + 'Chatgpt-Account-Id': this.accountId, + 'Accept': 'text/event-stream', + 'Connection': 'Keep-Alive', + 'Conversation_id': cacheId, + 'Session_id': cacheId + }; + } + + /** + * 准备请求体 + */ + prepareRequestBody(model, requestBody, stream) { + // 添加会话缓存 ID + const cacheKey = `${model}-${requestBody.metadata?.user_id || 'default'}`; + let cache = this.conversationCache.get(cacheKey); + + if (!cache || cache.expire < Date.now()) { + cache = { + id: crypto.randomUUID(), + expire: Date.now() + 3600000 // 1 小时 + }; + this.conversationCache.set(cacheKey, cache); + } + + // 注意:requestBody 已经是转换后的 Codex 格式 + // 只需要添加 cache key 和 stream 参数 + return { + ...requestBody, + stream, + prompt_cache_key: cache.id + }; + } + + /** + * 刷新访问令牌 + */ + async refreshAccessToken() { + try { + const newTokens = await refreshTokensWithRetry(this.refreshToken, this.config); + + this.accessToken = newTokens.access_token; + this.refreshToken = newTokens.refresh_token; + this.accountId = newTokens.account_id; + this.email = newTokens.email; + this.expiresAt = new Date(newTokens.expire); + + // 保存更新的凭据 + await this.saveCredentials(); + + console.log('[Codex] Token refreshed successfully'); + } catch (error) { + console.error('[Codex] Failed to refresh token:', error.message); + throw new Error('Failed to refresh Codex token. Please re-authenticate.'); + } + } + + /** + * 检查 token 是否即将过期 + */ + isExpiryDateNear() { + if (!this.expiresAt) return true; + const now = Date.now(); + const expiry = this.expiresAt.getTime(); + const bufferMs = 5 * 60 * 1000; // 5 分钟缓冲 + return expiry <= now + bufferMs; + } + + /** + * 获取凭据文件路径 + */ + getCredentialsPath() { + const email = this.config.CODEX_EMAIL || this.email || 'default'; + + // 优先使用配置中指定的路径,否则使用项目目录 + if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { + return this.config.CODEX_OAUTH_CREDS_FILE_PATH; + } + + // 保存到项目目录的 .codex 文件夹 + const projectDir = process.cwd(); + return path.join(projectDir, '.codex', `codex-${email}.json`); + } + + /** + * 保存凭据 + */ + async saveCredentials() { + const credsPath = this.getCredentialsPath(); + const credsDir = path.dirname(credsPath); + + await fs.mkdir(credsDir, { recursive: true }); + await fs.writeFile(credsPath, JSON.stringify({ + id_token: this.idToken || '', + access_token: this.accessToken, + refresh_token: this.refreshToken, + account_id: this.accountId, + last_refresh: new Date().toISOString(), + email: this.email, + type: 'codex', + expired: this.expiresAt.toISOString() + }, null, 2), { mode: 0o600 }); + } + + /** + * 检查文件是否存在 + */ + async fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + /** + * 解析 SSE 流 + */ + async *parseSSEStream(stream) { + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop(); // 保留不完整的行 + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + if (data && data !== '[DONE]') { + try { + const parsed = JSON.parse(data); + yield parsed; + } catch (e) { + console.error('[Codex] Failed to parse SSE data:', e.message); + } + } + } + } + } + + // 处理剩余的 buffer + if (buffer.trim()) { + if (buffer.startsWith('data: ')) { + const data = buffer.slice(6).trim(); + if (data && data !== '[DONE]') { + try { + const parsed = JSON.parse(data); + yield parsed; + } catch (e) { + console.error('[Codex] Failed to parse final SSE data:', e.message); + } + } + } + } + } + + /** + * 解析非流式响应 + */ + parseNonStreamResponse(data) { + // 从 SSE 流中提取 response.completed 事件 + const lines = data.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonData = line.slice(6).trim(); + try { + const parsed = JSON.parse(jsonData); + if (parsed.type === 'response.completed') { + return parsed; + } + } catch (e) { + // 继续解析 + } + } + } + throw new Error('No completed response found in Codex response'); + } + + /** + * 列出可用模型 + */ + async listModels() { + return { + object: 'list', + data: [ + { id: 'gpt-5', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5-codex-mini', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5.1', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5.1-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5.1-codex-mini', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5.1-codex-max', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5.2', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, + { id: 'gpt-5.2-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' } + ] + }; + } + + /** + * 启动缓存清理 + */ + startCacheCleanup() { + // 每 15 分钟清理过期缓存 + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, cache] of this.conversationCache.entries()) { + if (cache.expire < now) { + this.conversationCache.delete(key); + } + } + }, 15 * 60 * 1000); + } + + /** + * 停止缓存清理 + */ + stopCacheCleanup() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } +} diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 7d7c605..f162e07 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -69,6 +69,17 @@ export const PROVIDER_MODELS = { 'deepseek-v3.2', 'deepseek-r1', 'deepseek-v3' + ], + 'openai-codex-oauth': [ + 'gpt-5', + 'gpt-5-codex', + 'gpt-5-codex-mini', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex-max', + 'gpt-5.2', + 'gpt-5.2-codex' ] }; diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js index 34ebb28..458e625 100644 --- a/src/ui-modules/oauth-api.js +++ b/src/ui-modules/oauth-api.js @@ -6,6 +6,7 @@ import { handleKiroOAuth, handleIFlowOAuth, handleOrchidsOAuth, + handleCodexOAuth, batchImportKiroRefreshTokensStream, importAwsCredentials, importOrchidsToken @@ -56,6 +57,11 @@ export async function handleGenerateAuthUrl(req, res, currentConfig, providerTyp const result = await handleOrchidsOAuth(currentConfig, options); authUrl = result.authUrl; authInfo = result.authInfo; + } else if (providerType === 'openai-codex-oauth') { + // Codex OAuth(OAuth2 + PKCE) + const result = await handleCodexOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; } else { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -93,7 +99,7 @@ export async function handleManualOAuthCallback(req, res) { try { const body = await getRequestBody(req); const { provider, callbackUrl, authMethod } = body; - + if (!provider || !callbackUrl) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -102,15 +108,16 @@ export async function handleManualOAuthCallback(req, res) { })); return true; } - + console.log(`[OAuth Manual Callback] Processing manual callback for ${provider}`); console.log(`[OAuth Manual Callback] Callback URL: ${callbackUrl}`); - + // 解析回调URL const url = new URL(callbackUrl); const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); const token = url.searchParams.get('token'); - + if (!code && !token) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -119,16 +126,26 @@ export async function handleManualOAuthCallback(req, res) { })); return true; } - + + // 特殊处理 Codex OAuth 回调 + if (provider === 'openai-codex-oauth' && code && state) { + const { handleCodexOAuthCallback } = await import('../auth/oauth-handlers.js'); + const result = await handleCodexOAuthCallback(code, state); + + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + return true; + } + // 通过fetch请求本地OAuth回调服务器处理 // 使用localhost而不是原始hostname,确保请求到达本地服务器 const localUrl = new URL(callbackUrl); localUrl.hostname = 'localhost'; localUrl.protocol = 'http:'; - + try { const response = await fetch(localUrl.href); - + if (response.ok) { console.log(`[OAuth Manual Callback] Successfully processed callback for ${provider}`); res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -153,7 +170,7 @@ export async function handleManualOAuthCallback(req, res) { error: `Failed to process callback: ${fetchError.message}` })); } - + return true; } catch (error) { console.error('[OAuth Manual Callback] Error:', error); diff --git a/src/utils/common.js b/src/utils/common.js index 80e37ef..0388c4d 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -55,6 +55,7 @@ export const MODEL_PROTOCOL_PREFIX = { OPENAI_RESPONSES: 'openaiResponses', CLAUDE: 'claude', OLLAMA: 'ollama', + CODEX: 'codex', } export const MODEL_PROVIDER = { @@ -68,6 +69,7 @@ export const MODEL_PROVIDER = { ORCHIDS_API: 'claude-orchids-oauth', QWEN_API: 'openai-qwen-oauth', IFLOW_API: 'openai-iflow', + CODEX_API: 'openai-codex-oauth', } /** @@ -77,6 +79,11 @@ export const MODEL_PROVIDER = { * @returns {string} The protocol prefix (e.g., 'gemini', 'openai', 'claude'). */ export function getProtocolPrefix(provider) { + // Special case for Codex - it needs its own protocol + if (provider === 'openai-codex-oauth') { + return 'codex'; + } + const hyphenIndex = provider.indexOf('-'); if (hyphenIndex !== -1) { return provider.substring(0, hyphenIndex); diff --git a/src/utils/provider-strategies.js b/src/utils/provider-strategies.js index 3567b6f..89599d0 100644 --- a/src/utils/provider-strategies.js +++ b/src/utils/provider-strategies.js @@ -18,6 +18,9 @@ class ProviderStrategyFactory { return new ResponsesAPIStrategy(); case MODEL_PROTOCOL_PREFIX.CLAUDE: return new ClaudeStrategy(); + case MODEL_PROTOCOL_PREFIX.CODEX: + // Codex 使用 OpenAI 策略(因为它基于 OpenAI 格式) + return new OpenAIStrategy(); default: throw new Error(`Unsupported provider protocol: ${providerProtocol}`); } diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 9cc48de..f3e4630 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -76,6 +76,17 @@ export const PROVIDER_MAPPINGS = [ displayName: 'Orchids OAuth', needsProjectId: false, urlKeys: ['ORCHIDS_BASE_URL'] + }, + { + // Codex OAuth 配置 + dirName: 'codex', + patterns: ['configs/codex/', '/codex/'], + providerType: 'openai-codex-oauth', + credPathKey: 'CODEX_OAUTH_CREDS_FILE_PATH', + defaultCheckModel: 'gpt-5.2-codex', + displayName: 'OpenAI Codex OAuth', + needsProjectId: false, + urlKeys: ['CODEX_BASE_URL'] } ]; diff --git a/static/app/models-manager.js b/static/app/models-manager.js index d0b7399..5c562b3 100644 --- a/static/app/models-manager.js +++ b/static/app/models-manager.js @@ -170,9 +170,10 @@ function getProviderDisplayName(providerType) { 'openai-custom': 'OpenAI Custom', 'openaiResponses-custom': 'OpenAI Responses Custom', 'openai-qwen-oauth': 'Qwen (OAuth)', - 'openai-iflow': 'iFlow' + 'openai-iflow': 'iFlow', + 'openai-codex-oauth': 'OpenAI Codex (OAuth)' }; - + return displayNames[providerType] || providerType; } diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 779d56e..712223d 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -215,7 +215,8 @@ function renderProviders(providers) { 'claude-orchids-oauth', 'openai-qwen-oauth', 'openaiResponses-custom', - 'openai-iflow' + 'openai-iflow', + 'openai-codex-oauth' ]; // 获取所有提供商类型并按指定顺序排序 @@ -424,12 +425,12 @@ async function openProviderManager(providerType) { */ function generateAuthButton(providerType) { // 只为支持OAuth的提供商显示授权按钮 - const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'claude-orchids-oauth', 'openai-iflow']; - + const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'claude-orchids-oauth', 'openai-iflow', 'openai-codex-oauth']; + if (!oauthProviders.includes(providerType)) { return ''; } - + // Orchids 提供商使用不同的按钮文本 if (providerType === 'claude-orchids-oauth') { return ` @@ -439,7 +440,17 @@ function generateAuthButton(providerType) { `; } - + + // Codex 提供商使用特殊图标 + if (providerType === 'openai-codex-oauth') { + return ` + + `; + } + return ` + 点击选择启动时初始化的模型提供商 (必须至少选择一个) @@ -117,6 +121,10 @@ Orchids OAuth + 点击选择需要通过代理访问的提供商,未选中的提供商将直接连接 diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html index 6de82da..c791136 100644 --- a/static/components/section-dashboard.html +++ b/static/components/section-dashboard.html @@ -509,7 +509,7 @@ - +
@@ -528,7 +528,7 @@ }'
- +
@@ -544,6 +544,59 @@ "model": "claude-sonnet-4-5", "max_tokens": 8192, "messages": [{"role": "user", "content": "Hello!"}] + }' +
+
+ + + +
+
+ +

OpenAI Codex OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /openai-codex-oauth/v1/chat/completions +
+
+ +
curl http://localhost:3000/openai-codex-oauth/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "gpt-4o",
+    "messages": [{"role": "user", "content": "写一个Python快速排序"}],
+    "stream": true
+  }'
+
+
+ + +
+
+ + /openai-codex-oauth/v1/messages +
+
+ +
curl http://localhost:3000/openai-codex-oauth/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "gpt-5",
+    "max_tokens": 4096,
+    "messages": [{"role": "user", "content": "解释PKCE认证流程"}]
   }'