AIClient-2-API/src/providers/openai/codex-core.js
2026-01-25 23:09:45 +08:00

492 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios';
import logger from '../../utils/logger.js';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { refreshCodexTokensWithRetry } from '../../auth/oauth-handlers.js';
import { getProviderPoolManager } from '../../services/service-manager.js';
import { MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js';
import { getProxyConfigForProvider } from '../../utils/proxy-utils.js';
/**
* 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.uuid = config.uuid; // 保存 uuid 用于号池管理
this.isInitialized = false;
// 会话缓存管理
this.conversationCache = new Map(); // key: model-userId, value: {id, expire}
this.startCacheCleanup();
}
/**
* 初始化服务(加载凭据)
*/
async initialize() {
if (this.isInitialized) return;
logger.info('[Codex] Initializing Codex API Service...');
// 注意V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑
// 仅执行基础的凭证加载
await this.loadCredentials();
this.isInitialized = true;
logger.info(`[Codex] Initialization complete. Account: ${this.email || 'unknown'}`);
}
/**
* 加载凭证信息(不执行刷新)
*/
async loadCredentials() {
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()) {
logger.info('[Codex] Token expiring soon, refreshing...');
await this.refreshAccessToken();
}
this.isInitialized = true;
logger.info(`[Codex] Initialized with account: ${this.email}`);
} catch (error) {
logger.warn(`[Codex Auth] Failed to load credentials: ${error.message}`);
}
}
/**
* 初始化认证并执行必要刷新
*/
async initializeAuth(forceRefresh = false) {
// 首先执行基础凭证加载
await this.loadCredentials();
// 检查 token 是否需要刷新
const needsRefresh = forceRefresh;
if (this.accessToken && !needsRefresh) {
return;
}
// 只有在明确要求刷新,或者 AccessToken 缺失时,才执行刷新
if (needsRefresh || !this.accessToken) {
if (!this.refreshToken) {
throw new Error('Codex credentials not found. Please authenticate first using OAuth.');
}
logger.info('[Codex] Token expiring soon or refresh requested, refreshing...');
await this.refreshAccessToken();
// 刷新成功,重置 PoolManager 中的刷新状态并标记为健康
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.CODEX_API, this.uuid);
}
}
}
/**
* 生成内容(非流式)
*/
async generateContent(model, requestBody) {
if (!this.isInitialized) {
await this.initialize();
}
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Codex] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
}
}
const url = `${this.baseUrl}/responses`;
const body = this.prepareRequestBody(model, requestBody, false);
const headers = this.buildHeaders(body.prompt_cache_key);
try {
const config = {
headers,
timeout: 120000 // 2 分钟超时
};
// 配置代理
const proxyConfig = getProxyConfigForProvider(this.config, 'codex');
if (proxyConfig) {
config.httpAgent = proxyConfig.httpAgent;
config.httpsAgent = proxyConfig.httpsAgent;
}
const response = await axios.post(url, body, config);
return this.parseNonStreamResponse(response.data);
} catch (error) {
if (error.response?.status === 401) {
logger.info('[Codex] Received 401. Triggering background refresh via PoolManager...');
// 标记当前凭证为不健康(会自动进入刷新队列)
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Codex] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
}
// Mark error for credential switch without recording error count
error.shouldSwitchCredential = true;
error.skipErrorCount = true;
throw error;
}
throw error;
}
}
/**
* 流式生成内容
*/
async *generateContentStream(model, requestBody) {
if (!this.isInitialized) {
await this.initialize();
}
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Codex] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
}
}
const url = `${this.baseUrl}/responses`;
const body = this.prepareRequestBody(model, requestBody, true);
const headers = this.buildHeaders(body.prompt_cache_key);
try {
const config = {
headers,
responseType: 'stream',
timeout: 120000
};
// 配置代理
const proxyConfig = getProxyConfigForProvider(this.config, 'codex');
if (proxyConfig) {
config.httpAgent = proxyConfig.httpAgent;
config.httpsAgent = proxyConfig.httpsAgent;
}
const response = await axios.post(url, body, config);
yield* this.parseSSEStream(response.data);
} catch (error) {
if (error.response?.status === 401) {
logger.info('[Codex] Received 401 during stream. Triggering background refresh via PoolManager...');
// 标记当前凭证为不健康
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Codex] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized in stream`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
}
// Mark error for credential switch without recording error count
error.shouldSwitchCredential = true;
error.skipErrorCount = true;
throw error;
} 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 refreshCodexTokensWithRetry(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();
logger.info('[Codex] Token refreshed successfully');
} catch (error) {
logger.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 expiry = this.expiresAt.getTime();
const nearMinutes = 20;
const { message, isNearExpiry } = formatExpiryLog('Codex', expiry, nearMinutes);
logger.info(message);
return isNearExpiry;
}
/**
* 获取凭据文件路径
*/
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) {
logger.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) {
logger.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;
}
}
}