feat(oauth): 新增 Codex Token 批量导入功能并优化 OAuth 服务器关闭逻辑
- 添加 Codex Token 批量导入功能,支持 SSE 实时进度显示 - 统一各 OAuth 服务器关闭逻辑,添加超时机制和错误处理 - 更新 Codex 支持的模型列表,添加 gpt-5.4 模型 - 优化 Codex API 版本管理,修复模型回退逻辑 - 添加前端批量导入界面及多语言支持
This commit is contained in:
parent
05fea676b9
commit
25bcb5a232
12 changed files with 810 additions and 75 deletions
|
|
@ -33,26 +33,37 @@ const activeServers = new Map();
|
|||
*/
|
||||
async function closeActiveServer(provider, port = null) {
|
||||
const existing = activeServers.get(provider);
|
||||
|
||||
if (existing) {
|
||||
await new Promise((resolve) => {
|
||||
existing.server.close(() => {
|
||||
activeServers.delete(provider);
|
||||
logger.info(`[Codex Auth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
resolve();
|
||||
try {
|
||||
// 1. 使用 Promise.race() 添加 2 秒超时
|
||||
const closePromise = new Promise((resolve, reject) => {
|
||||
existing.server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000);
|
||||
});
|
||||
|
||||
await Promise.race([closePromise, timeoutPromise]);
|
||||
logger.info(`[Codex Auth] ${provider} server closed successfully`);
|
||||
} catch (error) {
|
||||
// 2. try-catch 捕获错误
|
||||
logger.warn(`[Codex Auth] Server close failed or timed out: ${error.message}`);
|
||||
} finally {
|
||||
// 3. finally 块强制清理,防止阻塞
|
||||
activeServers.delete(provider);
|
||||
}
|
||||
}
|
||||
|
||||
if (port) {
|
||||
for (const [p, info] of activeServers.entries()) {
|
||||
if (info.port === port) {
|
||||
await new Promise((resolve) => {
|
||||
info.server.close(() => {
|
||||
activeServers.delete(p);
|
||||
logger.info(`[Codex Auth] 已关闭端口 ${port} 上被占用(提供商: ${p})的旧服务器`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// 递归调用处理端口冲突的情况
|
||||
await closeActiveServer(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -594,6 +605,170 @@ class CodexAuth {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查凭据是否已存在(基于 account_id 或 refresh_token)
|
||||
* @param {string} accountId
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<{isDuplicate: boolean, existingPath?: string}>}
|
||||
*/
|
||||
async checkDuplicate(accountId, refreshToken) {
|
||||
const projectDir = process.cwd();
|
||||
const targetDir = path.join(projectDir, 'configs', 'codex');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
return { isDuplicate: false };
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(targetDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const fullPath = path.join(targetDir, file);
|
||||
const content = await fs.promises.readFile(fullPath, 'utf8');
|
||||
const credentials = JSON.parse(content);
|
||||
|
||||
if ((accountId && credentials.account_id === accountId) || (refreshToken && credentials.refresh_token === refreshToken)) {
|
||||
const relativePath = path.relative(process.cwd(), fullPath);
|
||||
return {
|
||||
isDuplicate: true,
|
||||
existingPath: relativePath
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isDuplicate: false };
|
||||
} catch (error) {
|
||||
logger.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Error checking duplicates:`, error.message);
|
||||
return { isDuplicate: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入 Codex Token 并生成凭据文件(流式版本)
|
||||
* @param {Object[]} tokens - Token 对象数组
|
||||
* @param {Function} onProgress - 进度回调函数
|
||||
* @param {boolean} skipDuplicateCheck - 是否跳过重复检查
|
||||
* @returns {Promise<Object>} 批量处理结果
|
||||
*/
|
||||
export async function batchImportCodexTokensStream(tokens, onProgress = null, skipDuplicateCheck = false) {
|
||||
const auth = new CodexAuth({});
|
||||
const results = {
|
||||
total: tokens.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
details: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const tokenData = tokens[i];
|
||||
const progressData = {
|
||||
index: i + 1,
|
||||
total: tokens.length,
|
||||
current: null
|
||||
};
|
||||
|
||||
try {
|
||||
// 验证 token 数据
|
||||
if (!tokenData.access_token || !tokenData.id_token) {
|
||||
throw new Error('Token 缺少必需字段 (access_token 或 id_token)');
|
||||
}
|
||||
|
||||
// 解析 JWT 提取账户信息
|
||||
const claims = auth.parseJWT(tokenData.id_token);
|
||||
const accountId = claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub;
|
||||
const email = claims.email;
|
||||
|
||||
// 检查重复
|
||||
if (!skipDuplicateCheck) {
|
||||
const duplicateCheck = await auth.checkDuplicate(accountId, tokenData.refresh_token);
|
||||
if (duplicateCheck.isDuplicate) {
|
||||
progressData.current = {
|
||||
index: i + 1,
|
||||
success: false,
|
||||
error: 'duplicate',
|
||||
existingPath: duplicateCheck.existingPath
|
||||
};
|
||||
results.failed++;
|
||||
results.details.push(progressData.current);
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
...progressData,
|
||||
successCount: results.success,
|
||||
failedCount: results.failed
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建凭据对象
|
||||
const credentials = {
|
||||
id_token: tokenData.id_token,
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token,
|
||||
account_id: accountId,
|
||||
last_refresh: new Date().toISOString(),
|
||||
email: email,
|
||||
type: 'codex',
|
||||
expired: new Date(Date.now() + (tokenData.expires_in || 3600) * 1000).toISOString()
|
||||
};
|
||||
|
||||
// 保存凭据
|
||||
const saveResult = await auth.saveCredentials(credentials);
|
||||
const relativePath = saveResult.relativePath;
|
||||
|
||||
logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Token ${i + 1} imported: ${relativePath}`);
|
||||
|
||||
progressData.current = {
|
||||
index: i + 1,
|
||||
success: true,
|
||||
path: relativePath
|
||||
};
|
||||
results.success++;
|
||||
|
||||
// 自动关联到 Pools
|
||||
await autoLinkProviderConfigs(CONFIG, {
|
||||
onlyCurrentCred: true,
|
||||
credPath: relativePath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token ${i + 1} import failed:`, error.message);
|
||||
|
||||
progressData.current = {
|
||||
index: i + 1,
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
results.failed++;
|
||||
}
|
||||
|
||||
results.details.push(progressData.current);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
...progressData,
|
||||
successCount: results.success,
|
||||
failedCount: results.failed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (results.success > 0) {
|
||||
broadcastEvent('oauth_batch_success', {
|
||||
provider: 'openai-codex-oauth',
|
||||
count: results.success,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -668,7 +843,7 @@ export async function handleCodexOAuth(currentConfig, options = {}) {
|
|||
|
||||
// 轮询计数器
|
||||
let pollCount = 0;
|
||||
const maxPollCount = 200; // 增加到约 10 分钟 (200 * 3s = 600s)
|
||||
const maxPollCount = 100; // 增加到约 5 分钟 (100 * 3s = 300s)
|
||||
const pollInterval = 3000; // 轮询间隔(毫秒)
|
||||
let pollTimer = null;
|
||||
let isCompleted = false;
|
||||
|
|
|
|||
|
|
@ -72,26 +72,38 @@ async function closeActiveServer(provider, port = null) {
|
|||
// 1. 关闭该提供商之前的所有服务器
|
||||
const existing = activeServers.get(provider);
|
||||
if (existing) {
|
||||
await new Promise((resolve) => {
|
||||
existing.server.close(() => {
|
||||
activeServers.delete(provider);
|
||||
logger.info(`[OAuth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
resolve();
|
||||
// 清理轮询定时器
|
||||
if (existing.pollTimer) {
|
||||
clearInterval(existing.pollTimer);
|
||||
existing.pollTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const closePromise = new Promise((resolve, reject) => {
|
||||
existing.server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000);
|
||||
});
|
||||
|
||||
await Promise.race([closePromise, timeoutPromise]);
|
||||
logger.info(`[OAuth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
} catch (error) {
|
||||
logger.warn(`[OAuth] 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`);
|
||||
} finally {
|
||||
activeServers.delete(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果指定了端口,检查是否有其他提供商占用了该端口
|
||||
if (port) {
|
||||
for (const [p, info] of activeServers.entries()) {
|
||||
if (info.port === port) {
|
||||
await new Promise((resolve) => {
|
||||
info.server.close(() => {
|
||||
activeServers.delete(p);
|
||||
logger.info(`[OAuth] 已关闭端口 ${port} 上被占用(提供商: ${p})的旧服务器`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await closeActiveServer(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,6 +124,18 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
|||
await closeActiveServer(provider, port);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let pollCount = 0;
|
||||
const maxPollCount = 100; // 约 5 分钟 (100 * 3s = 300s)
|
||||
const pollInterval = 3000;
|
||||
let pollTimer = null;
|
||||
|
||||
const clearPollTimer = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, redirectUri);
|
||||
|
|
@ -119,6 +143,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
|||
const errorParam = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
clearPollTimer();
|
||||
logger.info(`${config.logPrefix} 收到来自 Google 的成功回调: ${req.url}`);
|
||||
|
||||
try {
|
||||
|
|
@ -167,6 +192,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
|||
});
|
||||
}
|
||||
} else if (errorParam) {
|
||||
clearPollTimer();
|
||||
const errorMessage = `授权失败。Google 返回错误: ${errorParam}`;
|
||||
logger.error(`${config.logPrefix}`, errorMessage);
|
||||
|
||||
|
|
@ -181,6 +207,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
|||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
clearPollTimer();
|
||||
logger.error(`${config.logPrefix} 处理回调时出错:`, error);
|
||||
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(generateResponsePage(false, `服务器错误: ${error.message}`));
|
||||
|
|
@ -194,6 +221,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
|||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
clearPollTimer();
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
logger.error(`${config.logPrefix} 端口 ${port} 已被占用`);
|
||||
reject(new Error(`端口 ${port} 已被占用`));
|
||||
|
|
@ -206,7 +234,24 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
|
|||
const host = '0.0.0.0';
|
||||
server.listen(port, host, () => {
|
||||
logger.info(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`);
|
||||
activeServers.set(provider, { server, port });
|
||||
|
||||
// 启动轮询日志
|
||||
pollTimer = setInterval(() => {
|
||||
pollCount++;
|
||||
if (pollCount <= maxPollCount) {
|
||||
logger.info(`${config.logPrefix} Waiting for callback... (${pollCount}/${maxPollCount})`);
|
||||
} else {
|
||||
clearPollTimer();
|
||||
logger.warn(`${config.logPrefix} Polling timeout, closing server...`);
|
||||
if (server.listening) {
|
||||
server.close(() => {
|
||||
activeServers.delete(provider);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, pollInterval);
|
||||
|
||||
activeServers.set(provider, { server, port, pollTimer });
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -253,25 +253,31 @@ async function fetchIFlowUserInfo(accessToken) {
|
|||
async function closeIFlowServer(provider, port = null) {
|
||||
const existing = activeIFlowServers.get(provider);
|
||||
if (existing) {
|
||||
await new Promise((resolve) => {
|
||||
existing.server.close(() => {
|
||||
activeIFlowServers.delete(provider);
|
||||
logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
resolve();
|
||||
try {
|
||||
const closePromise = new Promise((resolve, reject) => {
|
||||
existing.server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000);
|
||||
});
|
||||
|
||||
await Promise.race([closePromise, timeoutPromise]);
|
||||
logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
} catch (error) {
|
||||
logger.warn(`${IFLOW_OAUTH_CONFIG.logPrefix} 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`);
|
||||
} finally {
|
||||
activeIFlowServers.delete(provider);
|
||||
}
|
||||
}
|
||||
|
||||
if (port) {
|
||||
for (const [p, info] of activeIFlowServers.entries()) {
|
||||
if (info.port === port) {
|
||||
await new Promise((resolve) => {
|
||||
info.server.close(() => {
|
||||
activeIFlowServers.delete(p);
|
||||
logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await closeIFlowServer(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
export {
|
||||
refreshCodexTokensWithRetry,
|
||||
handleCodexOAuth,
|
||||
handleCodexOAuthCallback
|
||||
handleCodexOAuthCallback,
|
||||
batchImportCodexTokensStream
|
||||
} from './codex-oauth.js';
|
||||
|
||||
// Gemini OAuth
|
||||
|
|
|
|||
|
|
@ -487,25 +487,31 @@ async function startKiroCallbackServer(codeVerifier, expectedState, options = {}
|
|||
async function closeKiroServer(provider, port = null) {
|
||||
const existing = activeKiroServers.get(provider);
|
||||
if (existing) {
|
||||
await new Promise((resolve) => {
|
||||
existing.server.close(() => {
|
||||
activeKiroServers.delete(provider);
|
||||
logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
resolve();
|
||||
try {
|
||||
const closePromise = new Promise((resolve, reject) => {
|
||||
existing.server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000);
|
||||
});
|
||||
|
||||
await Promise.race([closePromise, timeoutPromise]);
|
||||
logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
|
||||
} catch (error) {
|
||||
logger.warn(`${KIRO_OAUTH_CONFIG.logPrefix} 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`);
|
||||
} finally {
|
||||
activeKiroServers.delete(provider);
|
||||
}
|
||||
}
|
||||
|
||||
if (port) {
|
||||
for (const [p, info] of activeKiroServers.entries()) {
|
||||
if (info.port === port) {
|
||||
await new Promise((resolve) => {
|
||||
info.server.close(() => {
|
||||
activeKiroServers.delete(p);
|
||||
logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await closeKiroServer(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export {
|
|||
refreshCodexTokensWithRetry,
|
||||
handleCodexOAuth,
|
||||
handleCodexOAuthCallback,
|
||||
batchImportCodexTokensStream,
|
||||
// Gemini OAuth
|
||||
handleGeminiCliOAuth,
|
||||
handleGeminiAntigravityOAuth,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ 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';
|
||||
import { getProviderModels } from '../provider-models.js';
|
||||
|
||||
const CODEX_MODELS = getProviderModels(MODEL_PROVIDER.CODEX_API);
|
||||
const CODEX_VERSION = '0.111.0';
|
||||
|
||||
/**
|
||||
* Codex API 服务类
|
||||
|
|
@ -22,6 +26,7 @@ export class CodexApiService {
|
|||
this.email = null;
|
||||
this.expiresAt = null;
|
||||
this.idToken = null;
|
||||
this.last_refresh = null;
|
||||
this.credsPath = null; // 记录本次加载/使用的凭据文件路径,确保刷新后写回同一文件
|
||||
this.uuid = config.uuid; // 保存 uuid 用于号池管理
|
||||
this.isInitialized = false;
|
||||
|
|
@ -89,6 +94,7 @@ export class CodexApiService {
|
|||
this.refreshToken = creds.refresh_token;
|
||||
this.accountId = creds.account_id;
|
||||
this.email = creds.email;
|
||||
this.last_refresh = creds.last_refresh || this.last_refresh;
|
||||
this.expiresAt = new Date(creds.expired); // 注意:字段名是 expired
|
||||
|
||||
// 检查 token 是否需要刷新
|
||||
|
|
@ -136,6 +142,13 @@ export class CodexApiService {
|
|||
await this.initialize();
|
||||
}
|
||||
|
||||
let selectedModel = model;
|
||||
if (!CODEX_MODELS.includes(model)) {
|
||||
const defaultModel = CODEX_MODELS[0] || 'gpt-5';
|
||||
logger.warn(`[Codex] Model '${model}' not found in supported list. Falling back to default: '${defaultModel}'`);
|
||||
selectedModel = defaultModel;
|
||||
}
|
||||
|
||||
// 临时存储 monitorRequestId
|
||||
if (requestBody._monitorRequestId) {
|
||||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
|
|
@ -157,7 +170,7 @@ export class CodexApiService {
|
|||
}
|
||||
|
||||
const url = `${this.baseUrl}/responses`;
|
||||
const body = this.prepareRequestBody(model, requestBody, true);
|
||||
const body = this.prepareRequestBody(selectedModel, requestBody, true);
|
||||
const headers = this.buildHeaders(body.prompt_cache_key, true);
|
||||
|
||||
try {
|
||||
|
|
@ -210,6 +223,13 @@ export class CodexApiService {
|
|||
await this.initialize();
|
||||
}
|
||||
|
||||
let selectedModel = model;
|
||||
if (!CODEX_MODELS.includes(model)) {
|
||||
const defaultModel = CODEX_MODELS[0] || 'gpt-5';
|
||||
logger.warn(`[Codex] Model '${model}' not found in supported list. Falling back to default: '${defaultModel}'`);
|
||||
selectedModel = defaultModel;
|
||||
}
|
||||
|
||||
// 临时存储 monitorRequestId
|
||||
if (requestBody._monitorRequestId) {
|
||||
this.config._monitorRequestId = requestBody._monitorRequestId;
|
||||
|
|
@ -231,7 +251,7 @@ export class CodexApiService {
|
|||
}
|
||||
|
||||
const url = `${this.baseUrl}/responses`;
|
||||
const body = this.prepareRequestBody(model, requestBody, true);
|
||||
const body = this.prepareRequestBody(selectedModel, requestBody, true);
|
||||
const headers = this.buildHeaders(body.prompt_cache_key, true);
|
||||
|
||||
try {
|
||||
|
|
@ -281,13 +301,13 @@ export class CodexApiService {
|
|||
*/
|
||||
buildHeaders(cacheId, stream = true) {
|
||||
const headers = {
|
||||
'version': '0.101.0',
|
||||
'version': CODEX_VERSION,
|
||||
'x-codex-beta-features': 'powershell_utf8',
|
||||
'x-oai-web-search-eligible': 'true',
|
||||
'authorization': `Bearer ${this.accessToken}`,
|
||||
'chatgpt-account-id': this.accountId,
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'codex_cli_rs/0.101.0 (Windows 10.0.26100; x86_64) WindowsTerminal',
|
||||
'user-agent': `codex_cli_rs/${CODEX_VERSION} (Windows 10.0.26100; x86_64) WindowsTerminal`,
|
||||
'originator': 'codex_cli_rs',
|
||||
'host': 'chatgpt.com',
|
||||
'Connection': 'Keep-Alive'
|
||||
|
|
@ -360,6 +380,7 @@ export class CodexApiService {
|
|||
this.refreshToken = newTokens.refresh_token;
|
||||
this.accountId = newTokens.account_id;
|
||||
this.email = newTokens.email;
|
||||
this.last_refresh = new Date().toISOString();
|
||||
|
||||
// 关键修复:refreshCodexTokensWithRetry 返回字段名是 `expired`(ISO string),不是 `expire`
|
||||
const expiredValue = newTokens.expired || newTokens.expire || newTokens.expires_at || newTokens.expiresAt;
|
||||
|
|
@ -445,7 +466,7 @@ export class CodexApiService {
|
|||
access_token: this.accessToken,
|
||||
refresh_token: this.refreshToken,
|
||||
account_id: this.accountId,
|
||||
last_refresh: new Date().toISOString(),
|
||||
last_refresh: this.last_refresh || new Date().toISOString(),
|
||||
email: this.email,
|
||||
type: 'codex',
|
||||
expired: this.expiresAt.toISOString()
|
||||
|
|
@ -552,19 +573,12 @@ export class CodexApiService {
|
|||
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' },
|
||||
{ id: 'gpt-5.3-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' },
|
||||
{ id: 'gpt-5.3-codex-spark', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }
|
||||
]
|
||||
data: CODEX_MODELS.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'openai'
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -605,7 +619,7 @@ export class CodexApiService {
|
|||
try {
|
||||
const url = 'https://chatgpt.com/backend-api/wham/usage';
|
||||
const headers = {
|
||||
'user-agent': 'codex_cli_rs/0.89.0 (Windows 10.0.26100; x86_64) WindowsTerminal',
|
||||
'user-agent': `codex_cli_rs/${CODEX_VERSION} (Windows 10.0.26100; x86_64) WindowsTerminal`,
|
||||
'authorization': `Bearer ${this.accessToken}`,
|
||||
'chatgpt-account-id': this.accountId,
|
||||
'accept': '*/*',
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ export const PROVIDER_MODELS = {
|
|||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-spark'
|
||||
'gpt-5.3-codex-spark',
|
||||
'gpt-5.4',
|
||||
],
|
||||
'forward-api': [],
|
||||
'grok-custom': [
|
||||
|
|
|
|||
|
|
@ -319,6 +319,10 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
return await oauthApi.handleBatchImportGeminiTokens(req, res);
|
||||
}
|
||||
|
||||
if (method === 'POST' && pathParam === '/api/codex/batch-import-tokens') {
|
||||
return await oauthApi.handleBatchImportCodexTokens(req, res);
|
||||
}
|
||||
|
||||
// Import AWS SSO credentials for Kiro
|
||||
if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') {
|
||||
return await oauthApi.handleImportAwsCredentials(req, res);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
handleKiroOAuth,
|
||||
handleIFlowOAuth,
|
||||
handleCodexOAuth,
|
||||
batchImportCodexTokensStream,
|
||||
batchImportKiroRefreshTokensStream,
|
||||
importAwsCredentials
|
||||
} from '../auth/oauth-handlers.js';
|
||||
|
|
@ -345,6 +346,82 @@ export async function handleBatchImportGeminiTokens(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入 Codex Token(带实时进度 SSE)
|
||||
*/
|
||||
export async function handleBatchImportCodexTokens(req, res) {
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const { tokens, skipDuplicateCheck } = body;
|
||||
|
||||
if (!tokens || !Array.isArray(tokens) || tokens.length === 0) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
error: 'tokens array is required and must not be empty'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info(`[Codex Batch Import] Starting batch import with ${tokens.length} tokens...`);
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// 发送 SSE 事件的辅助函数
|
||||
const sendSSE = (event, data) => {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// 发送开始事件
|
||||
sendSSE('start', { total: tokens.length });
|
||||
|
||||
// 执行流式批量导入
|
||||
const result = await batchImportCodexTokensStream(
|
||||
tokens,
|
||||
(progress) => {
|
||||
sendSSE('progress', progress);
|
||||
},
|
||||
skipDuplicateCheck !== false // 默认为 true
|
||||
);
|
||||
|
||||
logger.info(`[Codex Batch Import] Completed: ${result.success} success, ${result.failed} failed`);
|
||||
|
||||
// 发送完成事件
|
||||
sendSSE('complete', {
|
||||
success: true,
|
||||
total: result.total,
|
||||
successCount: result.success,
|
||||
failedCount: result.failed,
|
||||
details: result.details
|
||||
});
|
||||
|
||||
res.end();
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[Codex Batch Import] Error:', error);
|
||||
if (res.headersSent) {
|
||||
res.write(`event: error\n`);
|
||||
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 AWS SSO 凭据用于 Kiro(支持单个或批量导入)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -201,6 +201,22 @@ const translations = {
|
|||
'oauth.gemini.startImport': '开始导入',
|
||||
'oauth.gemini.jsonExample': '查看 JSON 格式示例',
|
||||
'oauth.gemini.jsonHint': '请确保 JSON 包含 access_token 和 refresh_token',
|
||||
'oauth.codex.batchImport': '批量导入 Codex Token',
|
||||
'oauth.codex.batchImportDesc': '批量导入多个 Codex Token JSON 数据',
|
||||
'oauth.codex.tokensLabel': 'Token 数据 (JSON 数组)',
|
||||
'oauth.codex.tokensPlaceholder': '请粘贴包含 access_token 和 id_token 的 JSON 数组...',
|
||||
'oauth.codex.importInstructions': '请粘贴从浏览器或 CLI 获取的 Codex Token JSON 数据。支持单个对象或对象数组。',
|
||||
'oauth.codex.noTokens': '请输入有效的 Token 数据',
|
||||
'oauth.codex.importing': '正在导入...',
|
||||
'oauth.codex.importingProgress': '正在处理: {current} / {total}',
|
||||
'oauth.codex.importSuccess': '成功导入 {count} 个凭据',
|
||||
'oauth.codex.importAllFailed': '所有 {count} 个凭据导入失败',
|
||||
'oauth.codex.importPartial': '部分导入成功: {success} 成功, {failed} 失败',
|
||||
'oauth.codex.importError': '导入过程中出错',
|
||||
'oauth.codex.tokenCount': 'Token 数量',
|
||||
'oauth.codex.startImport': '开始导入',
|
||||
'oauth.codex.jsonExample': '查看 JSON 格式示例',
|
||||
'oauth.codex.jsonHint': '请确保 JSON 包含 access_token 和 id_token',
|
||||
'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入',
|
||||
'oauth.kiro.builderIDStartURL': 'Builder ID Start URL',
|
||||
'oauth.kiro.builderIDStartURLHint': '如果您使用 AWS IAM Identity Center,请输入您的 Start URL',
|
||||
|
|
@ -1024,6 +1040,22 @@ const translations = {
|
|||
'oauth.gemini.startImport': 'Start Import',
|
||||
'oauth.gemini.jsonExample': 'View JSON Example',
|
||||
'oauth.gemini.jsonHint': 'Ensure JSON contains access_token and refresh_token',
|
||||
'oauth.codex.batchImport': 'Batch Import Codex Tokens',
|
||||
'oauth.codex.batchImportDesc': 'Import multiple Codex Token JSON objects',
|
||||
'oauth.codex.tokensLabel': 'Token Data (JSON Array)',
|
||||
'oauth.codex.tokensPlaceholder': 'Paste JSON array containing access_token and id_token...',
|
||||
'oauth.codex.importInstructions': 'Paste Codex Token JSON from browser or CLI. Supports single object or array.',
|
||||
'oauth.codex.noTokens': 'Please enter valid Token data',
|
||||
'oauth.codex.importing': 'Importing...',
|
||||
'oauth.codex.importingProgress': 'Processing: {current} / {total}',
|
||||
'oauth.codex.importSuccess': 'Successfully imported {count} credentials',
|
||||
'oauth.codex.importAllFailed': 'Failed to import all {count} credentials',
|
||||
'oauth.codex.importPartial': 'Partial success: {success} succeeded, {failed} failed',
|
||||
'oauth.codex.importError': 'Import error',
|
||||
'oauth.codex.tokenCount': 'Token Count',
|
||||
'oauth.codex.startImport': 'Start Import',
|
||||
'oauth.codex.jsonExample': 'View JSON Example',
|
||||
'oauth.codex.jsonHint': 'Ensure JSON contains access_token and id_token',
|
||||
'oauth.kiro.duplicateCredentials': 'This credential already exists, please do not import duplicates',
|
||||
'oauth.kiro.builderIDStartURL': 'Builder ID Start URL',
|
||||
'oauth.kiro.builderIDStartURLHint': 'If you use AWS IAM Identity Center, enter your Start URL',
|
||||
|
|
|
|||
|
|
@ -509,9 +509,382 @@ async function handleGenerateAuthUrl(providerType) {
|
|||
return;
|
||||
}
|
||||
|
||||
// 如果是 Codex OAuth,显示认证方式选择对话框
|
||||
if (providerType === 'openai-codex-oauth') {
|
||||
showCodexAuthMethodSelector(providerType);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeGenerateAuthUrl(providerType, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Codex OAuth 认证方式选择对话框
|
||||
* @param {string} providerType - 提供商类型
|
||||
*/
|
||||
function showCodexAuthMethodSelector(providerType) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.gemini.selectMethod">${t('oauth.gemini.selectMethod')}</span></h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="auth-method-options" style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<button class="auth-method-btn" data-method="oauth" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fab fa-google" style="font-size: 24px; color: #4285f4;"></i>
|
||||
<div style="text-align: left;">
|
||||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.gemini.oauth">${t('oauth.gemini.oauth')}</div>
|
||||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.gemini.oauthDesc">${t('oauth.gemini.oauthDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||||
<div style="text-align: left;">
|
||||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.codex.batchImport">${t('oauth.codex.batchImport')}</div>
|
||||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.codex.batchImportDesc">${t('oauth.codex.batchImportDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 关闭按钮事件
|
||||
const closeBtn = modal.querySelector('.modal-close');
|
||||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||||
[closeBtn, cancelBtn].forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// 认证方式选择按钮事件
|
||||
const methodBtns = modal.querySelectorAll('.auth-method-btn');
|
||||
methodBtns.forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.style.borderColor = '#4285f4';
|
||||
btn.style.background = '#f8faff';
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.style.borderColor = '#e0e0e0';
|
||||
btn.style.background = 'white';
|
||||
});
|
||||
btn.addEventListener('click', async () => {
|
||||
const method = btn.dataset.method;
|
||||
modal.remove();
|
||||
|
||||
if (method === 'batch-import') {
|
||||
showCodexBatchImportModal(providerType);
|
||||
} else {
|
||||
await executeGenerateAuthUrl(providerType, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Codex 批量导入模态框
|
||||
* @param {string} providerType - 提供商类型
|
||||
*/
|
||||
function showCodexBatchImportModal(providerType) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-file-import"></i> <span data-i18n="oauth.codex.batchImport">${t('oauth.codex.batchImport')}</span></h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="batch-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #1e40af;">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span data-i18n="oauth.codex.importInstructions">${t('oauth.codex.importInstructions')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batchCodexTokens" style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||||
<span data-i18n="oauth.codex.tokensLabel">${t('oauth.codex.tokensLabel')}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="batchCodexTokens"
|
||||
rows="10"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
|
||||
placeholder='${t('oauth.codex.tokensPlaceholder')}'
|
||||
data-i18n-placeholder="oauth.codex.tokensPlaceholder"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 12px; margin-bottom: 16px;">
|
||||
<details style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||
<summary style="padding: 12px; cursor: pointer; font-weight: 600; color: #374151; user-select: none;">
|
||||
<i class="fas fa-code" style="color: #4285f4; margin-right: 8px;"></i>
|
||||
<span data-i18n="oauth.codex.jsonExample">${t('oauth.codex.jsonExample')}</span>
|
||||
</summary>
|
||||
<div style="padding: 12px; background: #1f2937; border-radius: 0 0 8px 8px;">
|
||||
<div style="color: #10b981; font-family: monospace; font-size: 12px;">
|
||||
<div style="color: #9ca3af; margin-bottom: 8px;">// 单个凭据导入示例:</div>
|
||||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">{
|
||||
"access_token": "eyJhbG...",
|
||||
"id_token": "eyJhbG...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}</pre>
|
||||
</div>
|
||||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-top: 16px;">
|
||||
<div style="color: #9ca3af; margin-bottom: 8px;">// 批量导入示例(JSON数组):</div>
|
||||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">[
|
||||
{
|
||||
"access_token": "token1...",
|
||||
"id_token": "id1..."
|
||||
},
|
||||
{
|
||||
"access_token": "token2...",
|
||||
"id_token": "id2..."
|
||||
}
|
||||
]</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="batch-import-stats" id="codexBatchStats" style="display: none; margin-top: 12px; padding: 12px; background: #f3f4f6; border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span data-i18n="oauth.codex.tokenCount">${t('oauth.codex.tokenCount')}</span>
|
||||
<span id="codexTokenCountValue" style="font-weight: 600;">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-import-progress" id="codexBatchProgress" style="display: none; margin-top: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||||
<span data-i18n="oauth.codex.importing">${t('oauth.codex.importing')}</span>
|
||||
</div>
|
||||
<div class="progress-bar" style="margin-top: 8px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||||
<div id="codexImportProgressBar" style="height: 100%; width: 0%; background: #4285f4; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-import-result" id="codexBatchResult" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||||
<button class="btn btn-primary batch-import-submit" id="codexBatchSubmit">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span data-i18n="oauth.codex.startImport">${t('oauth.codex.startImport')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const textarea = modal.querySelector('#batchCodexTokens');
|
||||
const statsDiv = modal.querySelector('#codexBatchStats');
|
||||
const tokenCountValue = modal.querySelector('#codexTokenCountValue');
|
||||
const progressDiv = modal.querySelector('#codexBatchProgress');
|
||||
const progressBar = modal.querySelector('#codexImportProgressBar');
|
||||
const resultDiv = modal.querySelector('#codexBatchResult');
|
||||
const submitBtn = modal.querySelector('#codexBatchSubmit');
|
||||
const closeBtn = modal.querySelector('.modal-close');
|
||||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||||
|
||||
// 实时统计 token 数量
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
const val = textarea.value.trim();
|
||||
if (!val) {
|
||||
statsDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(val);
|
||||
const tokens = Array.isArray(data) ? data : [data];
|
||||
statsDiv.style.display = 'block';
|
||||
tokenCountValue.textContent = tokens.length;
|
||||
} catch (e) {
|
||||
statsDiv.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭按钮事件
|
||||
[closeBtn, cancelBtn].forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// 提交按钮事件
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
let tokens = [];
|
||||
try {
|
||||
const val = textarea.value.trim();
|
||||
const data = JSON.parse(val);
|
||||
tokens = Array.isArray(data) ? data : [data];
|
||||
} catch (e) {
|
||||
showToast(t('common.error'), t('oauth.codex.noTokens'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokens.length === 0) {
|
||||
showToast(t('common.warning'), t('oauth.codex.noTokens'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用输入和按钮
|
||||
textarea.disabled = true;
|
||||
submitBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
progressDiv.style.display = 'block';
|
||||
resultDiv.style.display = 'none';
|
||||
progressBar.style.width = '0%';
|
||||
|
||||
// 创建实时结果显示区域
|
||||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
|
||||
resultDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||||
<strong id="codexBatchProgressText">${t('oauth.codex.importingProgress', { current: 0, total: tokens.length })}</strong>
|
||||
</div>
|
||||
<div id="codexBatchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||||
`;
|
||||
|
||||
const progressText = resultDiv.querySelector('#codexBatchProgressText');
|
||||
const resultsList = resultDiv.querySelector('#codexBatchResultsList');
|
||||
|
||||
let importSuccess = false; // 标记是否导入成功
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/codex/batch-import-tokens', {
|
||||
method: 'POST',
|
||||
headers: window.apiClient ? window.apiClient.getAuthHeaders() : {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ tokens })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let eventType = '';
|
||||
let eventData = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
eventData = line.substring(6).trim();
|
||||
|
||||
if (eventType && eventData) {
|
||||
try {
|
||||
const data = JSON.parse(eventData);
|
||||
|
||||
if (eventType === 'progress') {
|
||||
const { index, total, current } = data;
|
||||
const percentage = Math.round((index / total) * 100);
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
progressText.textContent = t('oauth.codex.importingProgress', { current: index, total: total });
|
||||
|
||||
const resultItem = document.createElement('div');
|
||||
resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);';
|
||||
if (current.success) {
|
||||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||||
} else if (current.error === 'duplicate') {
|
||||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateToken')}</span>
|
||||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||||
} else {
|
||||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||||
}
|
||||
resultsList.appendChild(resultItem);
|
||||
resultsList.scrollTop = resultsList.scrollHeight;
|
||||
} else if (eventType === 'complete') {
|
||||
progressBar.style.width = '100%';
|
||||
progressDiv.style.display = 'none';
|
||||
|
||||
const isAllSuccess = data.failedCount === 0;
|
||||
const isAllFailed = data.successCount === 0;
|
||||
let resultClass, resultIcon, resultMessage;
|
||||
|
||||
if (isAllSuccess) {
|
||||
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||||
resultIcon = 'fa-check-circle';
|
||||
resultMessage = t('oauth.codex.importSuccess', { count: data.successCount });
|
||||
} else if (isAllFailed) {
|
||||
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||||
resultIcon = 'fa-times-circle';
|
||||
resultMessage = t('oauth.codex.importAllFailed', { count: data.failedCount });
|
||||
} else {
|
||||
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
|
||||
resultIcon = 'fa-exclamation-triangle';
|
||||
resultMessage = t('oauth.codex.importPartial', { success: data.successCount, failed: data.failedCount });
|
||||
}
|
||||
|
||||
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
|
||||
const headerDiv = resultDiv.querySelector('div:first-child');
|
||||
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||||
|
||||
if (data.successCount > 0) {
|
||||
importSuccess = true;
|
||||
loadProviders();
|
||||
loadConfigList();
|
||||
}
|
||||
} else if (eventType === 'error') {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse SSE data:', parseError);
|
||||
}
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Codex Batch Import] Failed:', error);
|
||||
progressDiv.style.display = 'none';
|
||||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||||
resultDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<strong>${t('oauth.codex.importError')}: ${error.message}</strong>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
cancelBtn.disabled = false;
|
||||
|
||||
if (!importSuccess) {
|
||||
textarea.disabled = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `<i class="fas fa-upload"></i> <span data-i18n="oauth.codex.startImport">${t('oauth.codex.startImport')}</span>`;
|
||||
} else {
|
||||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Kiro OAuth 认证方式选择对话框
|
||||
* @param {string} providerType - 提供商类型
|
||||
|
|
|
|||
Loading…
Reference in a new issue