fix(oauth): 优化自动关联凭证逻辑以支持单个凭证关联

- 修改 autoLinkProviderConfigs 函数,增加 onlyCurrentCred 选项
- 当 onlyCurrentCred 为 true 时,仅关联当前生成的凭证文件
- 避免批量导入凭证时重复扫描所有配置文件
- 在 OAuth 回调中传递 credPath 参数,确保正确关联新凭证
- 统一 install-and-run 脚本中的包管理器检测逻辑
- 优化 Claude 提供商的 token 计数方法,提高准确性
This commit is contained in:
hex2077 2026-02-03 12:15:35 +08:00
parent d6c2bd7919
commit 3a54404e0e
11 changed files with 5014 additions and 118 deletions

View file

@ -18,9 +18,9 @@ echo.
if !FORCE_PULL! equ 1 (
echo [更新] 正在从远程仓库拉取最新代码...
git --version >nul 2>&1
if !errorlevel! equ 0 (
if %errorlevel% equ 0 (
git pull
if !errorlevel! neq 0 (
if %errorlevel% neq 0 (
echo [警告] Git pull 失败,请检查网络或手动处理冲突。
) else (
echo [成功] 代码已更新。
@ -55,14 +55,22 @@ if not exist "package.json" (
echo [成功] 找到package.json文件
echo [安装] 正在安装/更新依赖...
:: 检查 pnpm 是否安装
where pnpm >nul 2>&1
if %errorlevel% equ 0 (
set PKG_MANAGER=pnpm
) else (
set PKG_MANAGER=npm
)
echo [安装] 正在使用 !PKG_MANAGER! 安装/更新依赖...
echo 这可能需要几分钟时间,请耐心等待...
echo 正在执行: npm install...
:: 使用npm install并设置超时机制
call npm install --timeout=300000
if !errorlevel! neq 0 (
echo 正在执行: !PKG_MANAGER! install...
call !PKG_MANAGER! install
if %errorlevel% neq 0 (
echo [错误] 依赖安装失败
echo 请检查网络连接或手动运行 'npm install'
echo 请检查网络连接或手动运行 '!PKG_MANAGER! install'
pause
exit /b 1
)
@ -89,4 +97,4 @@ echo 按 Ctrl+C 停止服务器
echo.
:: 启动服务器
node src\core\master.js
node src\core\master.js

View file

@ -64,13 +64,21 @@ fi
echo "[成功] 找到package.json文件"
echo "[安装] 正在安装/更新依赖..."
# 检查 pnpm 是否安装
if command -v pnpm > /dev/null 2>&1; then
PKG_MANAGER=pnpm
else
PKG_MANAGER=npm
fi
echo "[安装] 正在使用 $PKG_MANAGER 安装/更新依赖..."
echo "这可能需要几分钟时间,请耐心等待..."
echo "正在执行: npm install..."
npm install
echo "正在执行: $PKG_MANAGER install..."
$PKG_MANAGER install
if [ $? -ne 0 ]; then
echo "[错误] 依赖安装失败"
echo "请检查网络连接或运行 'npm install' 手动安装"
echo "请检查网络连接或手动运行 '$PKG_MANAGER install'"
exit 1
fi
echo "[成功] 依赖安装/更新完成"

4766
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -740,7 +740,10 @@ export async function handleCodexOAuth(currentConfig, options = {}) {
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: credentials.relativePath
});
logger.info('[Codex Auth] OAuth flow completed successfully');
} catch (error) {
@ -844,7 +847,10 @@ export async function handleCodexOAuthCallback(code, state) {
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: result.relativePath
});
logger.info('[Codex Auth] OAuth callback processed successfully');

View file

@ -150,7 +150,10 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: relativePath
});
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(true, '您可以关闭此页面'));

View file

@ -375,7 +375,10 @@ function createIFlowCallbackServer(port, redirectUri, expectedState, options = {
});
// 6. 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: relativePath
});
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(true, `授权成功!账户: ${userInfo.email},您可以关闭此页面`));

View file

@ -413,7 +413,10 @@ async function pollKiroBuilderIDToken(clientId, clientSecret, deviceCode, interv
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: path.relative(process.cwd(), credPath)
});
return tokenData;
}
@ -594,7 +597,10 @@ function createKiroHttpCallbackServer(port, codeVerifier, expectedState, options
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: path.relative(process.cwd(), credPath)
});
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(true, '授权成功!您可以关闭此页面'));
@ -846,7 +852,7 @@ export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
// 自动关联新生成的凭据到 Pools(批量导入时扫描所有)
await autoLinkProviderConfigs(CONFIG);
}
@ -979,7 +985,7 @@ export async function batchImportKiroRefreshTokensStream(refreshTokens, region =
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
// 自动关联新生成的凭据到 Pools(批量导入时扫描所有)
await autoLinkProviderConfigs(CONFIG);
}
@ -1101,7 +1107,10 @@ export async function importAwsCredentials(credentials, skipDuplicateCheck = fal
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: relativePath
});
return {
success: true,

View file

@ -212,7 +212,10 @@ async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn =
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
await autoLinkProviderConfigs(CONFIG, {
onlyCurrentCred: true,
credPath: relativePath
});
return data;
}

View file

@ -711,6 +711,30 @@ async saveCredentialsToFile(filePath, newData) {
return String(message.content || message);
}
/**
* 统一处理内容将不同格式的内容转换为文本
* @param {any} content - 内容对象或数组
* @returns {string} 处理后的文本
*/
processContent(content) {
if (!content) return "";
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(part => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object') {
if (part.type === 'text') return part.text || "";
if (part.type === 'thinking') return part.thinking || part.text || "";
if (part.type === 'tool_result') return this.processContent(part.content);
if (part.type === 'tool_use' && part.input) return JSON.stringify(part.input);
if (part.text) return part.text;
}
return "";
}).join("");
}
return this.getContentText(content);
}
_normalizeThinkingBudgetTokens(budgetTokens) {
let value = Number(budgetTokens);
if (!Number.isFinite(value) || value <= 0) {
@ -2366,52 +2390,34 @@ async saveCredentialsToFile(filePath, newData) {
* Calculate input tokens from request body using Claude's official tokenizer
*/
estimateInputTokens(requestBody) {
let totalTokens = 0;
let allText = "";
// Count system prompt tokens
if (requestBody.system) {
const systemText = this.getContentText(requestBody.system);
totalTokens += this.countTextTokens(systemText);
allText += this.processContent(requestBody.system);
}
// Count thinking prefix tokens if thinking is enabled
if (requestBody.thinking?.type === 'enabled') {
const budget = this._normalizeThinkingBudgetTokens(requestBody.thinking.budget_tokens);
const prefixText = `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
totalTokens += this.countTextTokens(prefixText);
allText += `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
}
// Count all messages tokens
if (requestBody.messages && Array.isArray(requestBody.messages)) {
for (const message of requestBody.messages) {
if (message.content) {
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === 'text' && part.text) {
totalTokens += this.countTextTokens(part.text);
} else if (part.type === 'thinking' && part.thinking) {
totalTokens += this.countTextTokens(part.thinking);
} else if (part.type === 'tool_result') {
const resultContent = this.getContentText(part.content);
totalTokens += this.countTextTokens(resultContent);
} else if (part.type === 'tool_use' && part.input) {
totalTokens += this.countTextTokens(JSON.stringify(part.input));
}
}
} else {
const contentText = this.getContentText(message);
totalTokens += this.countTextTokens(contentText);
}
allText += this.processContent(message.content);
}
}
}
// Count tools definitions tokens if present
if (requestBody.tools && Array.isArray(requestBody.tools)) {
totalTokens += this.countTextTokens(JSON.stringify(requestBody.tools));
allText += JSON.stringify(requestBody.tools);
}
return totalTokens;
return this.countTextTokens(allText);
}
/**
@ -2659,45 +2665,37 @@ async saveCredentialsToFile(filePath, newData) {
* @returns {Object} { input_tokens: number }
*/
countTokens(requestBody) {
let totalTokens = 0;
let allText = "";
let extraTokens = 0;
// Count system prompt tokens
if (requestBody.system) {
const systemText = this.getContentText(requestBody.system);
totalTokens += this.countTextTokens(systemText);
allText += this.processContent(requestBody.system);
}
// Count all messages tokens
if (requestBody.messages && Array.isArray(requestBody.messages)) {
for (const message of requestBody.messages) {
if (message.content) {
if (typeof message.content === 'string') {
totalTokens += this.countTextTokens(message.content);
} else if (Array.isArray(message.content)) {
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'text' && block.text) {
totalTokens += this.countTextTokens(block.text);
} else if (block.type === 'tool_use') {
// Count tool use block tokens
totalTokens += this.countTextTokens(block.name || '');
totalTokens += this.countTextTokens(JSON.stringify(block.input || {}));
} else if (block.type === 'tool_result') {
// Count tool result block tokens
const resultContent = this.getContentText(block.content);
totalTokens += this.countTextTokens(resultContent);
} else if (block.type === 'image') {
if (block.type === 'image') {
// Images have a fixed token cost (approximately 1600 tokens for a typical image)
// This is an estimation as actual cost depends on image size
totalTokens += 1600;
extraTokens += 1600;
} else if (block.type === 'document') {
// Documents - estimate based on content if available
if (block.source?.data) {
// For base64 encoded documents, estimate tokens
const estimatedChars = block.source.data.length * 0.75; // base64 to bytes ratio
totalTokens += Math.ceil(estimatedChars / 4);
extraTokens += Math.ceil(estimatedChars / 4);
}
} else {
allText += this.processContent([block]);
}
}
} else {
allText += this.processContent(message.content);
}
}
}
@ -2705,18 +2703,10 @@ async saveCredentialsToFile(filePath, newData) {
// Count tools definitions tokens if present
if (requestBody.tools && Array.isArray(requestBody.tools)) {
for (const tool of requestBody.tools) {
// Count tool name and description
totalTokens += this.countTextTokens(tool.name || '');
totalTokens += this.countTextTokens(tool.description || '');
// Count input schema
if (tool.input_schema) {
totalTokens += this.countTextTokens(JSON.stringify(tool.input_schema));
}
}
allText += JSON.stringify(requestBody.tools);
}
return { input_tokens: totalTokens };
return { input_tokens: this.countTextTokens(allText) + extraTokens };
}
/**

View file

@ -20,9 +20,12 @@ let providerPoolManager = null;
/**
* 扫描 configs 目录并自动关联未关联的配置文件到对应的提供商
* @param {Object} config - 服务器配置对象
* @param {Object} options - 可选参数
* @param {boolean} options.onlyCurrentCred - true 只自动关联当前凭证
* @param {string} options.credPath - 当前凭证的路径 onlyCurrentCred true 时必需
* @returns {Promise<Object>} 更新后的 providerPools 对象
*/
export async function autoLinkProviderConfigs(config) {
export async function autoLinkProviderConfigs(config, options = {}) {
// 确保 providerPools 对象存在
if (!config.providerPools) {
config.providerPools = {};
@ -31,43 +34,52 @@ export async function autoLinkProviderConfigs(config) {
let totalNewProviders = 0;
const allNewProviders = {};
// 遍历所有提供商映射
for (const mapping of PROVIDER_MAPPINGS) {
const configsPath = path.join(process.cwd(), 'configs', mapping.dirName);
const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = mapping;
// 确保提供商类型数组存在
if (!config.providerPools[providerType]) {
config.providerPools[providerType] = [];
// 如果只关联当前凭证
if (options.onlyCurrentCred && options.credPath) {
const result = await linkSingleCredential(config, options.credPath);
if (result) {
totalNewProviders = 1;
allNewProviders[result.displayName] = [result.provider];
}
// 检查目录是否存在
if (!fs.existsSync(configsPath)) {
continue;
}
// 获取已关联的配置文件路径集合
const linkedPaths = new Set();
for (const provider of config.providerPools[providerType]) {
if (provider[credPathKey]) {
// 使用公共方法添加路径的所有变体格式
addToUsedPaths(linkedPaths, provider[credPathKey]);
} else {
// 遍历所有提供商映射
for (const mapping of PROVIDER_MAPPINGS) {
const configsPath = path.join(process.cwd(), 'configs', mapping.dirName);
const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = mapping;
// 确保提供商类型数组存在
if (!config.providerPools[providerType]) {
config.providerPools[providerType] = [];
}
// 检查目录是否存在
if (!fs.existsSync(configsPath)) {
continue;
}
// 获取已关联的配置文件路径集合
const linkedPaths = new Set();
for (const provider of config.providerPools[providerType]) {
if (provider[credPathKey]) {
// 使用公共方法添加路径的所有变体格式
addToUsedPaths(linkedPaths, provider[credPathKey]);
}
}
// 递归扫描目录
const newProviders = [];
await scanProviderDirectory(configsPath, linkedPaths, newProviders, {
credPathKey,
defaultCheckModel,
needsProjectId
});
// 如果有新的配置文件需要关联
if (newProviders.length > 0) {
config.providerPools[providerType].push(...newProviders);
totalNewProviders += newProviders.length;
allNewProviders[displayName] = newProviders;
}
}
// 递归扫描目录
const newProviders = [];
await scanProviderDirectory(configsPath, linkedPaths, newProviders, {
credPathKey,
defaultCheckModel,
needsProjectId
});
// 如果有新的配置文件需要关联
if (newProviders.length > 0) {
config.providerPools[providerType].push(...newProviders);
totalNewProviders += newProviders.length;
allNewProviders[displayName] = newProviders;
}
}
@ -104,6 +116,94 @@ export async function autoLinkProviderConfigs(config) {
return config.providerPools;
}
/**
* 关联单个凭证文件到对应的提供商
* @param {Object} config - 服务器配置对象
* @param {string} credPath - 凭证文件路径相对或绝对路径
* @returns {Promise<Object|null>} 返回关联结果或 null
*/
async function linkSingleCredential(config, credPath) {
try {
// 规范化路径
const absolutePath = path.isAbsolute(credPath) ? credPath : path.join(process.cwd(), credPath);
const relativePath = path.relative(process.cwd(), absolutePath);
// 检查文件是否存在
if (!fs.existsSync(absolutePath)) {
logger.warn(`[Auto-Link] Credential file not found: ${relativePath}`);
return null;
}
// 检查文件扩展名
const ext = path.extname(absolutePath).toLowerCase();
if (ext !== '.json') {
logger.warn(`[Auto-Link] Only JSON files are supported: ${relativePath}`);
return null;
}
// 根据文件路径确定提供商类型
let matchedMapping = null;
for (const mapping of PROVIDER_MAPPINGS) {
const configsPath = path.join(process.cwd(), 'configs', mapping.dirName);
// 检查文件是否在该提供商的配置目录下
if (absolutePath.startsWith(configsPath)) {
matchedMapping = mapping;
break;
}
}
if (!matchedMapping) {
logger.warn(`[Auto-Link] Could not determine provider type for: ${relativePath}`);
return null;
}
const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = matchedMapping;
// 确保提供商类型数组存在
if (!config.providerPools[providerType]) {
config.providerPools[providerType] = [];
}
// 检查是否已关联
const linkedPaths = new Set();
for (const provider of config.providerPools[providerType]) {
if (provider[credPathKey]) {
addToUsedPaths(linkedPaths, provider[credPathKey]);
}
}
const fileName = getFileName(absolutePath);
const isLinked = isPathUsed(relativePath, fileName, linkedPaths);
if (isLinked) {
logger.info(`[Auto-Link] Credential already linked: ${relativePath}`);
return null;
}
// 创建新的提供商配置
const newProvider = createProviderConfig({
credPathKey,
credPath: formatSystemPath(relativePath),
defaultCheckModel,
needsProjectId
});
// 添加到配置
config.providerPools[providerType].push(newProvider);
logger.info(`[Auto-Link] Successfully linked credential: ${relativePath} to ${displayName}`);
return {
provider: newProvider,
displayName,
providerType
};
} catch (error) {
logger.error(`[Auto-Link] Failed to link credential ${credPath}: ${error.message}`);
return null;
}
}
/**
* 递归扫描提供商配置目录
* @param {string} dirPath - 目录路径

View file

@ -19,12 +19,12 @@
<label for="configProviderFilter" data-i18n="upload.providerFilter">提供商类型</label>
<select id="configProviderFilter" class="form-control">
<option value="" data-i18n="upload.providerFilter.all">全部提供商</option>
<option value="claude-kiro-oauth" data-i18n="upload.providerFilter.kiro">Kiro OAuth</option>
<option value="gemini-cli-oauth" data-i18n="upload.providerFilter.gemini">Gemini OAuth</option>
<option value="openai-qwen-oauth" data-i18n="upload.providerFilter.qwen">Qwen OAuth</option>
<option value="gemini-antigravity" data-i18n="upload.providerFilter.antigravity">Antigravity</option>
<option value="openai-codex-oauth" data-i18n="upload.providerFilter.codex">Codex OAuth</option>
<option value="openai-iflow-oauth" data-i18n="upload.providerFilter.iflow">iFlow OAuth</option>
<option value="gemini-cli-oauth" data-i18n="upload.providerFilter.gemini">Gemini CLI OAuth</option>
<option value="gemini-antigravity" data-i18n="upload.providerFilter.antigravity">Gemini Antigravity</option>
<option value="claude-kiro-oauth" data-i18n="upload.providerFilter.kiro">Claude Kiro OAuth</option>
<option value="openai-qwen-oauth" data-i18n="upload.providerFilter.qwen">OpenAI Qwen OAuth</option>
<option value="openai-iflow" data-i18n="upload.providerFilter.iflow">OpenAI iFlow</option>
<option value="openai-codex-oauth" data-i18n="upload.providerFilter.codex">OpenAI Codex OAuth</option>
<option value="other" data-i18n="upload.providerFilter.other">其他/未识别</option>
</select>
</div>