import axios from 'axios'; 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'; /** * 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 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(); 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; } } }