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