feat: add Codex provider support
This commit is contained in:
parent
011df6ee8e
commit
47ad305b4e
19 changed files with 1856 additions and 19 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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
589
src/auth/codex-oauth.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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<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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// 自动注册所有转换器
|
||||
|
|
|
|||
489
src/converters/strategies/CodexConverter.js
Normal file
489
src/converters/strategies/CodexConverter.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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格式
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
413
src/providers/openai/codex-core.js
Normal file
413
src/providers/openai/codex-core.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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] || [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue