feat(provider): 添加自定义名称字段并优化OAuth处理流程
添加自定义名称字段以允许用户为节点设置个性化名称 重构OAuth处理逻辑,统一各提供商的授权流程 增加超时处理并优化错误提示 调整UI显示顺序和字段布局
This commit is contained in:
parent
3887243528
commit
d5417d9890
8 changed files with 211 additions and 230 deletions
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"openai-custom": [
|
||||
{
|
||||
"customName": "OpenAI节点1",
|
||||
"OPENAI_API_KEY": "sk-openai-key1",
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"checkModelName": null,
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
"lastErrorTime": null
|
||||
},
|
||||
{
|
||||
"customName": "OpenAI节点2",
|
||||
"OPENAI_API_KEY": "sk-openai-key2",
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"checkModelName": null,
|
||||
|
|
@ -31,6 +33,7 @@
|
|||
],
|
||||
"openaiResponses-custom": [
|
||||
{
|
||||
"customName": "OpenAI Responses节点",
|
||||
"OPENAI_API_KEY": "sk-openai-key",
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"checkModelName": null,
|
||||
|
|
@ -46,6 +49,7 @@
|
|||
],
|
||||
"gemini-cli-oauth": [
|
||||
{
|
||||
"customName": "Gemini OAuth节点1",
|
||||
"GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials1.json",
|
||||
"PROJECT_ID": "your-project-id-1",
|
||||
"checkModelName": null,
|
||||
|
|
@ -59,6 +63,7 @@
|
|||
"lastErrorTime": null
|
||||
},
|
||||
{
|
||||
"customName": "Gemini OAuth节点2",
|
||||
"GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials2.json",
|
||||
"PROJECT_ID": "your-project-id-2",
|
||||
"checkModelName": null,
|
||||
|
|
@ -74,6 +79,7 @@
|
|||
],
|
||||
"claude-custom": [
|
||||
{
|
||||
"customName": "Claude节点1",
|
||||
"CLAUDE_API_KEY": "sk-claude-key1",
|
||||
"CLAUDE_BASE_URL": "https://api.anthropic.com",
|
||||
"checkModelName": null,
|
||||
|
|
@ -87,6 +93,7 @@
|
|||
"lastErrorTime": null
|
||||
},
|
||||
{
|
||||
"customName": "Claude节点2",
|
||||
"CLAUDE_API_KEY": "sk-claude-key2",
|
||||
"CLAUDE_BASE_URL": "https://api.anthropic.com",
|
||||
"checkModelName": null,
|
||||
|
|
@ -102,6 +109,7 @@
|
|||
],
|
||||
"claude-kiro-oauth": [
|
||||
{
|
||||
"customName": "Kiro OAuth节点1",
|
||||
"KIRO_OAUTH_CREDS_FILE_PATH": "./kiro_creds1.json",
|
||||
"uuid": "2c69d0ac-b86f-43d8-9d17-0d300afc5cfd",
|
||||
"checkModelName": null,
|
||||
|
|
@ -114,6 +122,7 @@
|
|||
"lastErrorTime": null
|
||||
},
|
||||
{
|
||||
"customName": "Kiro OAuth节点2",
|
||||
"KIRO_OAUTH_CREDS_FILE_PATH": "./kiro_creds2.json",
|
||||
"uuid": "7482abe6-8083-4288-bb7d-d8ecb7c461e2",
|
||||
"checkModelName": null,
|
||||
|
|
@ -128,6 +137,7 @@
|
|||
],
|
||||
"openai-qwen-oauth": [
|
||||
{
|
||||
"customName": "Qwen OAuth节点",
|
||||
"QWEN_OAUTH_CREDS_FILE_PATH": "./qwen_creds.json",
|
||||
"uuid": "658a2114-c4c9-d713-b8d4-ceabf0e0bf18",
|
||||
"checkModelName": null,
|
||||
|
|
@ -142,6 +152,7 @@
|
|||
],
|
||||
"gemini-antigravity": [
|
||||
{
|
||||
"customName": "Antigravity节点1",
|
||||
"ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds1.json",
|
||||
"PROJECT_ID": "antigravity-project-1",
|
||||
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
|
|
@ -155,6 +166,7 @@
|
|||
"lastErrorTime": null
|
||||
},
|
||||
{
|
||||
"customName": "Antigravity节点2",
|
||||
"ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds2.json",
|
||||
"PROJECT_ID": "antigravity-project-2",
|
||||
"uuid": "f0e9d8c7-b6a5-4321-fedc-ba9876543210",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const KIRO_CONSTANTS = {
|
|||
AMAZON_Q_URL: 'https://codewhisperer.{{region}}.amazonaws.com/SendMessageStreaming',
|
||||
USAGE_LIMITS_URL: 'https://q.{{region}}.amazonaws.com/getUsageLimits',
|
||||
DEFAULT_MODEL_NAME: 'claude-opus-4-5',
|
||||
AXIOS_TIMEOUT: 120000, // 2 minutes timeout
|
||||
AXIOS_TIMEOUT: 300000, // 5 minutes timeout (increased from 2 minutes)
|
||||
USER_AGENT: 'KiroIDE',
|
||||
KIRO_VERSION: '0.7.5',
|
||||
CONTENT_TYPE_JSON: 'application/json',
|
||||
|
|
@ -312,13 +312,13 @@ export class KiroApiService {
|
|||
keepAlive: true,
|
||||
maxSockets: 100, // 每个主机最多 10 个连接
|
||||
maxFreeSockets: 5, // 最多保留 5 个空闲连接
|
||||
timeout: 120000, // 空闲连接 60 秒后关闭
|
||||
timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT,
|
||||
});
|
||||
const httpsAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: 100,
|
||||
maxFreeSockets: 5,
|
||||
timeout: 120000,
|
||||
timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT,
|
||||
});
|
||||
|
||||
const axiosConfig = {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { promises as fs } from 'fs';
|
|||
import * as path from 'path';
|
||||
import * as http from 'http'; // Add http for IncomingMessage and ServerResponse types
|
||||
import * as crypto from 'crypto'; // Import crypto for MD5 hashing
|
||||
import { ApiServiceAdapter } from './adapter.js'; // Import ApiServiceAdapter
|
||||
import { convertData, getOpenAIStreamChunkStop, getOpenAIResponsesStreamChunkBegin, getOpenAIResponsesStreamChunkEnd } from './convert.js';
|
||||
import { convertData, getOpenAIStreamChunkStop } from './convert.js';
|
||||
import { ProviderStrategyFactory } from './provider-strategies.js';
|
||||
|
||||
export const API_ACTIONS = {
|
||||
|
|
@ -317,7 +316,6 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP
|
|||
* and sends the JSON response.
|
||||
* @param {http.IncomingMessage} req The HTTP request object.
|
||||
* @param {http.ServerResponse} res The HTTP response object.
|
||||
* @param {ApiServiceAdapter} service The API service adapter.
|
||||
* @param {string} endpointType The type of endpoint being called (e.g., OPENAI_MODEL_LIST).
|
||||
* @param {Object} CONFIG - The server configuration object.
|
||||
*/
|
||||
|
|
@ -368,7 +366,6 @@ export async function handleModelListRequest(req, res, service, endpointType, CO
|
|||
* logging, and dispatching to the appropriate stream or unary handler.
|
||||
* @param {http.IncomingMessage} req The HTTP request object.
|
||||
* @param {http.ServerResponse} res The HTTP response object.
|
||||
* @param {ApiServiceAdapter} service The API service adapter.
|
||||
* @param {string} endpointType The type of endpoint being called (e.g., OPENAI_CHAT).
|
||||
* @param {Object} CONFIG - The server configuration object.
|
||||
* @param {string} PROMPT_LOG_FILENAME - The prompt log filename.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import open from 'open';
|
||||
import { formatExpiryTime } from '../common.js';
|
||||
import { getProviderModels } from '../provider-models.js';
|
||||
import { handleGeminiAntigravityOAuth } from '../oauth-handlers.js';
|
||||
|
||||
// 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏
|
||||
const httpAgent = new http.Agent({
|
||||
|
|
@ -305,85 +306,51 @@ export class AntigravityApiService {
|
|||
}
|
||||
|
||||
async getNewToken(credPath) {
|
||||
let host = this.host;
|
||||
if (!host || host === 'undefined') {
|
||||
host = '127.0.0.1';
|
||||
}
|
||||
const redirectUri = `http://${host}:${AUTH_REDIRECT_PORT}`;
|
||||
this.authClient.redirectUri = redirectUri;
|
||||
// 使用统一的 OAuth 处理方法
|
||||
const { authUrl, authInfo } = await handleGeminiAntigravityOAuth(this.config);
|
||||
|
||||
console.log('\n[Antigravity Auth] 正在自动打开浏览器进行授权...');
|
||||
console.log('[Antigravity Auth] 授权链接:', authUrl, '\n');
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const authUrl = this.authClient.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: ['https://www.googleapis.com/auth/cloud-platform']
|
||||
});
|
||||
// 自动打开浏览器
|
||||
const showFallbackMessage = () => {
|
||||
console.log('[Antigravity Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开');
|
||||
};
|
||||
|
||||
console.log('\n[Antigravity Auth] 正在自动打开浏览器进行授权...');
|
||||
console.log('[Antigravity Auth] 授权链接:', authUrl, '\n');
|
||||
|
||||
// 自动打开浏览器
|
||||
const showFallbackMessage = () => {
|
||||
console.log('[Antigravity Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开');
|
||||
};
|
||||
|
||||
if (this.config) {
|
||||
try {
|
||||
const childProcess = await open(authUrl);
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => showFallbackMessage());
|
||||
}
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
if (this.config) {
|
||||
try {
|
||||
const childProcess = await open(authUrl);
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => showFallbackMessage());
|
||||
}
|
||||
} else {
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
}
|
||||
} else {
|
||||
showFallbackMessage();
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// 等待 OAuth 回调完成并读取保存的凭据
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const url = new URL(req.url, redirectUri);
|
||||
const code = url.searchParams.get('code');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
console.log(`[Antigravity Auth] Received successful callback from Google: ${req.url}`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Authentication successful! You can close this browser tab.');
|
||||
server.close();
|
||||
|
||||
const { tokens } = await this.authClient.getToken(code);
|
||||
await fs.mkdir(path.dirname(credPath), { recursive: true });
|
||||
await fs.writeFile(credPath, JSON.stringify(tokens, null, 2));
|
||||
console.log('[Antigravity Auth] New token received and saved to file.');
|
||||
resolve(tokens);
|
||||
} else if (errorParam) {
|
||||
const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`;
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end(errorMessage);
|
||||
server.close();
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
console.log(`[Antigravity Auth] Ignoring irrelevant request: ${req.url}`);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
const data = await fs.readFile(credPath, 'utf8');
|
||||
const credentials = JSON.parse(data);
|
||||
if (credentials.access_token) {
|
||||
clearInterval(checkInterval);
|
||||
console.log('[Antigravity Auth] New token obtained successfully.');
|
||||
resolve(credentials);
|
||||
}
|
||||
} catch (e) {
|
||||
if (server.listening) server.close();
|
||||
reject(e);
|
||||
} catch (error) {
|
||||
// 文件尚未创建或无效,继续等待
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
const errorMessage = `[Antigravity Auth] Port ${AUTH_REDIRECT_PORT} on ${host} is already in use.`;
|
||||
console.error(errorMessage);
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(AUTH_REDIRECT_PORT, host);
|
||||
// 设置超时(5分钟)
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error('[Antigravity Auth] OAuth 授权超时'));
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as readline from 'readline';
|
|||
import open from 'open';
|
||||
import { API_ACTIONS, formatExpiryTime } from '../common.js';
|
||||
import { getProviderModels } from '../provider-models.js';
|
||||
import { handleGeminiCliOAuth } from '../oauth-handlers.js';
|
||||
|
||||
// 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏
|
||||
const httpAgent = new http.Agent({
|
||||
|
|
@ -274,75 +275,51 @@ export class GeminiApiService {
|
|||
}
|
||||
|
||||
async getNewToken(credPath) {
|
||||
let host = this.host;
|
||||
if (!host || host === 'undefined') {
|
||||
host = '127.0.0.1';
|
||||
}
|
||||
const redirectUri = `http://${host}:${AUTH_REDIRECT_PORT}`;
|
||||
this.authClient.redirectUri = redirectUri;
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const authUrl = this.authClient.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/cloud-platform'] });
|
||||
console.log('\n[Gemini Auth] 正在自动打开浏览器进行授权...');
|
||||
|
||||
// 自动打开浏览器
|
||||
const showFallbackMessage = () => {
|
||||
console.log('[Gemini Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开');
|
||||
};
|
||||
|
||||
if (this.config) {
|
||||
try {
|
||||
const childProcess = await open(authUrl);
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => showFallbackMessage());
|
||||
}
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
// 使用统一的 OAuth 处理方法
|
||||
const { authUrl, authInfo } = await handleGeminiCliOAuth(this.config);
|
||||
|
||||
console.log('\n[Gemini Auth] 正在自动打开浏览器进行授权...');
|
||||
console.log('[Gemini Auth] 授权链接:', authUrl, '\n');
|
||||
|
||||
// 自动打开浏览器
|
||||
const showFallbackMessage = () => {
|
||||
console.log('[Gemini Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开');
|
||||
};
|
||||
|
||||
if (this.config) {
|
||||
try {
|
||||
const childProcess = await open(authUrl);
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => showFallbackMessage());
|
||||
}
|
||||
} else {
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
}
|
||||
console.log('[Gemini Auth] 授权链接:', authUrl, '\n');
|
||||
const server = http.createServer(async (req, res) => {
|
||||
} else {
|
||||
showFallbackMessage();
|
||||
}
|
||||
|
||||
// 等待 OAuth 回调完成并读取保存的凭据
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const url = new URL(req.url, redirectUri);
|
||||
const code = url.searchParams.get('code');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
if (code) {
|
||||
console.log(`[Gemini Auth] Received successful callback from Google: ${req.url}`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Authentication successful! You can close this browser tab.');
|
||||
server.close();
|
||||
const { tokens } = await this.authClient.getToken(code);
|
||||
await fs.mkdir(path.dirname(credPath), { recursive: true });
|
||||
await fs.writeFile(credPath, JSON.stringify(tokens, null, 2));
|
||||
console.log('[Gemini Auth] New token received and saved to file.');
|
||||
resolve(tokens);
|
||||
} else if (errorParam) {
|
||||
const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`;
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end(errorMessage);
|
||||
server.close();
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
console.log(`[Gemini Auth] Ignoring irrelevant request: ${req.url}`);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
const data = await fs.readFile(credPath, 'utf8');
|
||||
const credentials = JSON.parse(data);
|
||||
if (credentials.access_token) {
|
||||
clearInterval(checkInterval);
|
||||
console.log('[Gemini Auth] New token obtained successfully.');
|
||||
resolve(credentials);
|
||||
}
|
||||
} catch (e) {
|
||||
if (server.listening) server.close();
|
||||
reject(e);
|
||||
} catch (error) {
|
||||
// 文件尚未创建或无效,继续等待
|
||||
}
|
||||
});
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
const errorMessage = `[Gemini Auth] Port ${AUTH_REDIRECT_PORT} on ${this.host} is already in use.`;
|
||||
console.error(errorMessage);
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
server.listen(AUTH_REDIRECT_PORT, this.host);
|
||||
}, 1000);
|
||||
|
||||
// 设置超时(5分钟)
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error('[Gemini Auth] OAuth 授权超时'));
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import open from 'open';
|
|||
import { EventEmitter } from 'events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getProviderModels } from '../provider-models.js';
|
||||
import { handleQwenOAuth } from '../oauth-handlers.js';
|
||||
|
||||
// --- Constants ---
|
||||
const QWEN_DIR = '.qwen';
|
||||
|
|
@ -284,39 +285,30 @@ export class QwenApiService {
|
|||
}
|
||||
|
||||
async _authWithQwenDeviceFlow(client, config) {
|
||||
let isCancelled = false;
|
||||
const cancelHandler = () => { isCancelled = true; };
|
||||
const sigintHandler = () => {
|
||||
isCancelled = true;
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
||||
};
|
||||
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||
process.once('SIGINT', sigintHandler);
|
||||
|
||||
try {
|
||||
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||
const deviceAuth = await client.requestDeviceAuthorization({
|
||||
scope: QWEN_OAUTH_SCOPE,
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
// 使用统一的 OAuth 处理方法
|
||||
const { authUrl, authInfo } = await handleQwenOAuth(config);
|
||||
|
||||
// 发送授权 URI 事件
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, {
|
||||
verification_uri_complete: authUrl,
|
||||
user_code: authInfo.userCode,
|
||||
verification_uri: authInfo.verificationUri,
|
||||
device_code: authInfo.deviceCode,
|
||||
expires_in: authInfo.expiresIn,
|
||||
interval: authInfo.interval
|
||||
});
|
||||
|
||||
if (!isDeviceAuthorizationSuccess(deviceAuth)) {
|
||||
throw new Error(`Device authorization failed: ${deviceAuth?.error || 'Unknown error'} - ${deviceAuth?.error_description || 'No details'}`);
|
||||
}
|
||||
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
||||
|
||||
const showFallbackMessage = () => {
|
||||
console.log('\n=== Qwen OAuth Device Authorization ===');
|
||||
console.log('Please visit the following URL in your browser to authorize:');
|
||||
console.log(`\n${deviceAuth.verification_uri_complete}\n`);
|
||||
console.log(`\n${authUrl}\n`);
|
||||
console.log('Waiting for authorization to complete...\n');
|
||||
};
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const childProcess = await open(deviceAuth.verification_uri_complete);
|
||||
const childProcess = await open(authUrl);
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => showFallbackMessage());
|
||||
}
|
||||
|
|
@ -330,70 +322,37 @@ export class QwenApiService {
|
|||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'polling', 'Waiting for authorization...');
|
||||
console.debug('Waiting for authorization...\n');
|
||||
|
||||
let pollInterval = 2000;
|
||||
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (isCancelled) {
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.');
|
||||
return { success: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await client.pollDeviceToken({ device_code: deviceAuth.device_code, code_verifier });
|
||||
if (isDeviceTokenSuccess(tokenResponse)) {
|
||||
const credentials = {
|
||||
access_token: tokenResponse.access_token,
|
||||
refresh_token: tokenResponse.refresh_token || undefined,
|
||||
token_type: tokenResponse.token_type,
|
||||
resource_url: tokenResponse.resource_url,
|
||||
expiry_date: tokenResponse.expires_in ? Date.now() + tokenResponse.expires_in * 1000 : undefined,
|
||||
};
|
||||
client.setCredentials(credentials);
|
||||
await this._cacheQwenCredentials(credentials);
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (isDeviceTokenPending(tokenResponse)) {
|
||||
if (tokenResponse.slowDown) {
|
||||
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
||||
} else {
|
||||
pollInterval = 2000;
|
||||
// 等待 OAuth 回调完成并读取保存的凭据
|
||||
const credPath = this._getQwenCachedCredentialPath();
|
||||
const credentials = await new Promise((resolve, reject) => {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const data = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(data);
|
||||
if (creds.access_token) {
|
||||
clearInterval(checkInterval);
|
||||
console.log('[Qwen Auth] New token obtained successfully.');
|
||||
resolve(creds);
|
||||
}
|
||||
// Fall through to wait and continue
|
||||
} else if (isErrorResponse(tokenResponse)) {
|
||||
console.warn(`Token polling failed with error: ${tokenResponse?.error || 'Unknown error'}`);
|
||||
// Fall through to wait and continue
|
||||
} catch (error) {
|
||||
// 文件尚未创建或无效,继续等待
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Token polling threw an exception: ${error.message}`);
|
||||
// Fall through to wait for the next attempt
|
||||
}
|
||||
|
||||
// Wait for the polling interval before the next attempt
|
||||
await new Promise(resolve => {
|
||||
const timeoutId = setTimeout(resolve, pollInterval);
|
||||
// If cancelled during wait, clear timeout and resolve immediately
|
||||
if (isCancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// Check again after waiting
|
||||
if (isCancelled) {
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.');
|
||||
return { success: false, reason: 'cancelled' };
|
||||
}
|
||||
}
|
||||
return { success: false, reason: 'timeout' };
|
||||
}, 1000);
|
||||
|
||||
// 设置超时(5分钟)
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error('[Qwen Auth] OAuth 授权超时'));
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
client.setCredentials(credentials);
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Device authorization flow failed:', error.message);
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', error.message);
|
||||
return { success: false, reason: 'error' };
|
||||
} finally {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||
process.off('SIGINT', sigintHandler);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ function renderProviderList(providers) {
|
|||
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
|
||||
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
|
||||
<div class="provider-info">
|
||||
<div class="provider-name">${provider.uuid}</div>
|
||||
<div class="provider-name">${provider.customName || provider.uuid}</div>
|
||||
<div class="provider-meta">
|
||||
<span class="health-status">
|
||||
<i class="${healthIcon}"></i>
|
||||
|
|
@ -438,17 +438,29 @@ function renderProviderConfig(provider) {
|
|||
// 获取字段映射,确保顺序一致
|
||||
const fieldOrder = getFieldOrder(provider);
|
||||
|
||||
// 先渲染基础配置字段(checkModelName 和 checkHealth)
|
||||
// 先渲染基础配置字段(customName、checkModelName 和 checkHealth)
|
||||
let html = '<div class="form-grid">';
|
||||
const baseFields = ['checkModelName', 'checkHealth'];
|
||||
const baseFields = ['customName', 'checkModelName', 'checkHealth'];
|
||||
|
||||
baseFields.forEach(fieldKey => {
|
||||
const displayLabel = getFieldLabel(fieldKey);
|
||||
const value = provider[fieldKey];
|
||||
const displayValue = value || '';
|
||||
|
||||
// 如果是 checkHealth 字段,使用下拉选择框
|
||||
if (fieldKey === 'checkHealth') {
|
||||
// 如果是 customName 字段,使用普通文本输入框
|
||||
if (fieldKey === 'customName') {
|
||||
html += `
|
||||
<div class="config-item">
|
||||
<label>${displayLabel}</label>
|
||||
<input type="text"
|
||||
value="${displayValue}"
|
||||
readonly
|
||||
data-config-key="${fieldKey}"
|
||||
data-config-value="${value || ''}"
|
||||
placeholder="节点自定义名称">
|
||||
</div>
|
||||
`;
|
||||
} else if (fieldKey === 'checkHealth') {
|
||||
// 如果没有值,默认为 false
|
||||
const actualValue = value !== undefined ? value : false;
|
||||
const isEnabled = actualValue === true || actualValue === 'true';
|
||||
|
|
@ -626,22 +638,71 @@ function renderProviderConfig(provider) {
|
|||
* @returns {Array} 字段键数组
|
||||
*/
|
||||
function getFieldOrder(provider) {
|
||||
const orderedFields = ['checkModelName', 'checkHealth'];
|
||||
const orderedFields = ['customName', 'checkModelName', 'checkHealth'];
|
||||
|
||||
// 需要排除的内部状态字段
|
||||
const excludedFields = [
|
||||
'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime',
|
||||
'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage'
|
||||
'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage',
|
||||
'notSupportedModels'
|
||||
];
|
||||
|
||||
// 从 getProviderTypeFields 获取字段顺序映射
|
||||
const fieldOrderMap = {
|
||||
'openai-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'],
|
||||
'openaiResponses-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'],
|
||||
'claude-custom': ['CLAUDE_API_KEY', 'CLAUDE_BASE_URL'],
|
||||
'gemini-cli-oauth': ['PROJECT_ID', 'GEMINI_OAUTH_CREDS_FILE_PATH'],
|
||||
'claude-kiro-oauth': ['KIRO_OAUTH_CREDS_FILE_PATH'],
|
||||
'openai-qwen-oauth': ['QWEN_OAUTH_CREDS_FILE_PATH'],
|
||||
'gemini-antigravity': ['PROJECT_ID', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH']
|
||||
};
|
||||
|
||||
// 获取所有其他配置项
|
||||
const otherFields = Object.keys(provider).filter(key =>
|
||||
!excludedFields.includes(key) && !orderedFields.includes(key)
|
||||
);
|
||||
|
||||
// 按字母顺序排序其他字段
|
||||
otherFields.sort();
|
||||
// 尝试从 provider 中推断提供商类型
|
||||
let providerType = null;
|
||||
if (provider.OPENAI_API_KEY && provider.OPENAI_BASE_URL) {
|
||||
providerType = 'openai-custom'; // 或 openaiResponses-custom,顺序相同
|
||||
} else if (provider.CLAUDE_API_KEY && provider.CLAUDE_BASE_URL) {
|
||||
providerType = 'claude-custom';
|
||||
} else if (provider.GEMINI_OAUTH_CREDS_FILE_PATH) {
|
||||
providerType = 'gemini-cli-oauth';
|
||||
} else if (provider.KIRO_OAUTH_CREDS_FILE_PATH) {
|
||||
providerType = 'claude-kiro-oauth';
|
||||
} else if (provider.QWEN_OAUTH_CREDS_FILE_PATH) {
|
||||
providerType = 'openai-qwen-oauth';
|
||||
} else if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) {
|
||||
providerType = 'gemini-antigravity';
|
||||
}
|
||||
|
||||
// 如果能识别提供商类型,使用预定义的顺序
|
||||
if (providerType && fieldOrderMap[providerType]) {
|
||||
const predefinedOrder = fieldOrderMap[providerType];
|
||||
const orderedOtherFields = [];
|
||||
const remainingFields = [...otherFields];
|
||||
|
||||
// 先按预定义顺序添加字段
|
||||
predefinedOrder.forEach(fieldKey => {
|
||||
const index = remainingFields.indexOf(fieldKey);
|
||||
if (index !== -1) {
|
||||
orderedOtherFields.push(fieldKey);
|
||||
remainingFields.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 剩余字段按字母顺序添加
|
||||
remainingFields.sort();
|
||||
orderedOtherFields.push(...remainingFields);
|
||||
|
||||
return [...orderedFields, ...orderedOtherFields].filter(key => provider.hasOwnProperty(key));
|
||||
}
|
||||
|
||||
// 如果无法识别提供商类型,按字母顺序排序
|
||||
otherFields.sort();
|
||||
return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key));
|
||||
}
|
||||
|
||||
|
|
@ -962,6 +1023,10 @@ function showAddProviderForm(providerType) {
|
|||
form.innerHTML = `
|
||||
<h4><i class="fas fa-plus"></i> 添加新提供商配置</h4>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>自定义名称 <span class="optional-mark">(选填)</span></label>
|
||||
<input type="text" id="newCustomName" placeholder="例如: 我的节点1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>检查模型名称 <span class="optional-mark">(选填)</span></label>
|
||||
<input type="text" id="newCheckModelName" placeholder="例如: gpt-3.5-turbo">
|
||||
|
|
@ -1140,10 +1205,12 @@ function bindAddFormPasswordToggleListeners(form) {
|
|||
* @param {string} providerType - 提供商类型
|
||||
*/
|
||||
async function addProvider(providerType) {
|
||||
const customName = document.getElementById('newCustomName')?.value;
|
||||
const checkModelName = document.getElementById('newCheckModelName')?.value;
|
||||
const checkHealth = document.getElementById('newCheckHealth')?.value === 'true';
|
||||
|
||||
const providerConfig = {
|
||||
customName: customName || '', // 允许为空
|
||||
checkModelName: checkModelName || '', // 允许为空
|
||||
checkHealth
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ function showToast(message, type = 'info') {
|
|||
*/
|
||||
function getFieldLabel(key) {
|
||||
const labelMap = {
|
||||
'customName': '自定义名称 (选填)',
|
||||
'checkModelName': '检查模型名称 (选填)',
|
||||
'checkHealth': '健康检查',
|
||||
'OPENAI_API_KEY': 'OpenAI API Key',
|
||||
|
|
@ -63,7 +64,8 @@ function getFieldLabel(key) {
|
|||
'PROJECT_ID': '项目ID',
|
||||
'GEMINI_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径',
|
||||
'KIRO_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径',
|
||||
'QWEN_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径'
|
||||
'QWEN_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径',
|
||||
'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径'
|
||||
};
|
||||
|
||||
return labelMap[key] || key;
|
||||
|
|
|
|||
Loading…
Reference in a new issue