feat(oauth): 新增 Codex Token 批量导入功能并优化 OAuth 服务器关闭逻辑

- 添加 Codex Token 批量导入功能,支持 SSE 实时进度显示
- 统一各 OAuth 服务器关闭逻辑,添加超时机制和错误处理
- 更新 Codex 支持的模型列表,添加 gpt-5.4 模型
- 优化 Codex API 版本管理,修复模型回退逻辑
- 添加前端批量导入界面及多语言支持
This commit is contained in:
hex2077 2026-03-06 12:46:12 +08:00
parent 05fea676b9
commit 25bcb5a232
12 changed files with 810 additions and 75 deletions

View file

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

View file

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

View file

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

View file

@ -2,7 +2,8 @@
export {
refreshCodexTokensWithRetry,
handleCodexOAuth,
handleCodexOAuthCallback
handleCodexOAuthCallback,
batchImportCodexTokensStream
} from './codex-oauth.js';
// Gemini OAuth

View file

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

View file

@ -7,6 +7,7 @@ export {
refreshCodexTokensWithRetry,
handleCodexOAuth,
handleCodexOAuthCallback,
batchImportCodexTokensStream,
// Gemini OAuth
handleGeminiCliOAuth,
handleGeminiAntigravityOAuth,

View file

@ -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': '*/*',

View file

@ -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': [

View file

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

View file

@ -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支持单个或批量导入
*/

View file

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

View file

@ -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">&times;</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">&times;</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 - 提供商类型