Merge pull request #253 from Yoahoug/feature/codex-support

添加对codex的支持,符合原项目的逻辑
This commit is contained in:
何夕2077 2026-01-16 17:39:45 +08:00 committed by GitHub
commit 8684437227
21 changed files with 1887 additions and 19 deletions

2
.gitignore vendored
View file

@ -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

4
package-lock.json generated
View file

@ -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",

589
src/auth/codex-oauth.js Normal file
View file

@ -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<Object>} 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<Object>} 返回 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<http.Server>}
*/
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(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #d32f2f; }
p { color: #666; }
</style>
</head>
<body>
<h1> Authentication Failed</h1>
<p>${errorDescription || error}</p>
<p>You can close this window and try again.</p>
</body>
</html>
`);
server.emit('auth-error', new Error(errorDescription || error));
} else if (code && state) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #4caf50; }
p { color: #666; }
.countdown { font-size: 24px; font-weight: bold; color: #2196f3; }
</style>
<script>
let countdown = 10;
setInterval(() => {
countdown--;
document.getElementById('countdown').textContent = countdown;
if (countdown <= 0) {
window.close();
}
}, 1000);
</script>
</head>
<body>
<h1> Authentication Successful!</h1>
<p>You can now close this window and return to the application.</p>
<p>This window will close automatically in <span id="countdown" class="countdown">10</span> seconds.</p>
</body>
</html>
`);
server.emit('auth-success', { code, state });
}
} else if (req.url === '/success') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>Success!</h1>');
}
});
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<Object>}
*/
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<Object>}
*/
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<void>}
*/
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<Object|null>}
*/
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<boolean>}
*/
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<Object>}
*/
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;
}

View file

@ -32,6 +32,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]'
}
};
@ -2327,3 +2334,148 @@ export async function handleOrchidsOAuth(currentConfig, options = {}) {
};
}
/**
* 处理 Codex OAuth 认证
* @param {Object} currentConfig - 当前配置
* @param {Object} options - 选项
* @returns {Promise<Object>} 返回认证结果
*/
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<Object>} 返回认证结果
*/
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
};
}
}

View file

@ -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);
}
// 自动注册所有转换器

View file

@ -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;
}
}

View file

@ -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格式
*/

View file

@ -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}`);
}

View file

@ -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;
}
}
}

View file

@ -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'
]
};

View file

@ -29,6 +29,7 @@ export async function scanConfigFiles(currentConfig, providerPoolManager) {
addToUsedPaths(usedPaths, currentConfig.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH);
addToUsedPaths(usedPaths, currentConfig.IFLOW_TOKEN_FILE_PATH);
addToUsedPaths(usedPaths, currentConfig.ORCHIDS_CREDS_FILE_PATH);
addToUsedPaths(usedPaths, currentConfig.CODEX_OAUTH_CREDS_FILE_PATH);
// 使用最新的提供商池数据
let providerPools = currentConfig.providerPools;
@ -46,6 +47,7 @@ export async function scanConfigFiles(currentConfig, providerPoolManager) {
addToUsedPaths(usedPaths, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH);
addToUsedPaths(usedPaths, provider.IFLOW_TOKEN_FILE_PATH);
addToUsedPaths(usedPaths, provider.ORCHIDS_CREDS_FILE_PATH);
addToUsedPaths(usedPaths, provider.CODEX_OAUTH_CREDS_FILE_PATH);
}
}
}
@ -227,6 +229,17 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
});
}
if (currentConfig.CODEX_OAUTH_CREDS_FILE_PATH &&
(pathsEqual(relativePath, currentConfig.CODEX_OAUTH_CREDS_FILE_PATH) ||
pathsEqual(relativePath, currentConfig.CODEX_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) {
usageInfo.usageType = 'main_config';
usageInfo.usageDetails.push({
type: 'Main Config',
location: 'Codex OAuth credentials file path',
configKey: 'CODEX_OAUTH_CREDS_FILE_PATH'
});
}
// 检查提供商池中的使用情况
if (currentConfig.providerPools) {
// 使用 flatMap 将双重循环优化为单层循环 O(n)
@ -309,6 +322,18 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
configKey: 'ORCHIDS_CREDS_FILE_PATH'
});
}
if (provider.CODEX_OAUTH_CREDS_FILE_PATH &&
(pathsEqual(relativePath, provider.CODEX_OAUTH_CREDS_FILE_PATH) ||
pathsEqual(relativePath, provider.CODEX_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) {
providerUsages.push({
type: 'Provider Pool',
location: `Codex OAuth credentials (node ${index + 1})`,
providerType: providerType,
providerIndex: index,
configKey: 'CODEX_OAUTH_CREDS_FILE_PATH'
});
}
if (providerUsages.length > 0) {
usageInfo.usageType = 'provider_pool';

View file

@ -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 OAuthOAuth2 + 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);

View file

@ -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);

View file

@ -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}`);
}

View file

@ -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']
}
];

View file

@ -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;
}

View file

@ -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) {
</button>
`;
}
// Codex 提供商使用特殊图标
if (providerType === 'openai-codex-oauth') {
return `
<button class="generate-auth-btn" title="生成 Codex OAuth 授权链接">
<i class="fas fa-code" style="color: #10b981;"></i>
<span data-i18n="providers.auth.generate">${t('providers.auth.generate')}</span>
</button>
`;
}
return `
<button class="generate-auth-btn" title="生成OAuth授权链接">
<i class="fas fa-key"></i>

View file

@ -849,6 +849,12 @@ function detectProviderFromPath(filePath) {
providerType: 'claude-orchids-oauth',
displayName: 'Orchids OAuth',
shortName: 'orchids-oauth'
},
{
patterns: ['configs/codex/', '/codex/'],
providerType: 'openai-codex-oauth',
displayName: 'OpenAI Codex OAuth',
shortName: 'codex-oauth'
}
];

View file

@ -252,9 +252,23 @@ function getProviderTypeFields(providerType) {
type: 'text',
placeholder: 'https://iflow.cn/api'
}
],
'openai-codex-oauth': [
{
id: 'CODEX_EMAIL',
label: isEn ? 'Email (Optional)' : '邮箱 (选填)',
type: 'email',
placeholder: isEn ? 'your-email@example.com' : '你的邮箱@example.com'
},
{
id: 'CODEX_BASE_URL',
label: `Codex Base URL <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://chatgpt.com/backend-api/codex'
}
]
};
return fieldConfigs[providerType] || [];
}

View file

@ -62,6 +62,10 @@
<i class="fas fa-seedling"></i>
<span>Orchids OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
</button>
</div>
<small class="form-text" data-i18n="config.modelProviderHelp">点击选择启动时初始化的模型提供商 (必须至少选择一个)</small>
</div>
@ -117,6 +121,10 @@
<i class="fas fa-seedling"></i>
<span>Orchids OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
</button>
</div>
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">点击选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
</div>

View file

@ -509,7 +509,7 @@
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
@ -528,7 +528,7 @@
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
@ -544,6 +544,59 @@
"model": "claude-sonnet-4-5",
"max_tokens": 8192,
"messages": [{"role": "user", "content": "Hello!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="openai-codex-oauth-card">
<div class="routing-card-header">
<i class="fas fa-code"></i>
<h4 data-i18n="dashboard.routing.nodeName.codex">OpenAI Codex OAuth</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.oauth">突破限制</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab active" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content active" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-codex-oauth/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-codex-oauth/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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认证流程"}]
}'</code></pre>
</div>
</div>