diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/configs/config.json.example b/configs/config.json.example index 180d4db..6e461b1 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -3,6 +3,8 @@ "SERVER_PORT": 3000, "HOST": "0.0.0.0", "MODEL_PROVIDER": "gemini-cli-oauth", + "PROXY_URL": null, + "PROXY_ENABLED_PROVIDERS": [], "OPENAI_API_KEY": "xxx", "OPENAI_BASE_URL": "https://openai/v1", "CLAUDE_API_KEY": "xxx", @@ -13,6 +15,7 @@ "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": null, "KIRO_OAUTH_CREDS_BASE64": null, "KIRO_OAUTH_CREDS_FILE_PATH": null, + "KIRO_MAX_CONTEXT_TOKENS": 180000, "QWEN_OAUTH_CREDS_FILE_PATH": null, "SYSTEM_PROMPT_FILE_PATH": "configs/input_system_prompt.txt", "SYSTEM_PROMPT_MODE": "overwrite", diff --git a/package-lock.json b/package-lock.json index 80ddcb2..c0f4b57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "AIClient2API", + "name": "AIClient-2-API", "lockfileVersion": 3, "requires": true, "packages": { @@ -11,9 +11,12 @@ "deepmerge": "^4.3.1", "dotenv": "^16.4.5", "google-auth-library": "^10.1.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "lodash": "^4.17.21", "multer": "^2.0.2", "open": "^10.2.0", + "socks-proxy-agent": "^8.0.5", "undici": "^7.12.0", "uuid": "^11.1.0" }, @@ -97,6 +100,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2955,6 +2959,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -4060,6 +4065,19 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -4131,6 +4149,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6028,6 +6055,44 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 4a6368b..f6c1f57 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ "deepmerge": "^4.3.1", "dotenv": "^16.4.5", "google-auth-library": "^10.1.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "lodash": "^4.17.21", "multer": "^2.0.2", "open": "^10.2.0", + "socks-proxy-agent": "^8.0.5", "undici": "^7.12.0", "uuid": "^11.1.0" }, diff --git a/src/claude/claude-core.js b/src/claude/claude-core.js index 01789d2..7dd8ea2 100644 --- a/src/claude/claude-core.js +++ b/src/claude/claude-core.js @@ -1,6 +1,7 @@ import axios from 'axios'; import * as http from 'http'; import * as https from 'https'; +import { configureAxiosProxy } from '../proxy-utils.js'; /** * Claude API Core Service Class. @@ -59,6 +60,9 @@ export class ClaudeApiService { axiosConfig.proxy = false; } + // 配置自定义代理 + configureAxiosProxy(axiosConfig, this.config, 'claude-custom'); + return axios.create(axiosConfig); } diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js index afec11f..b79714d 100644 --- a/src/claude/claude-kiro.js +++ b/src/claude/claude-kiro.js @@ -9,6 +9,7 @@ import * as https from 'https'; import { getProviderModels } from '../provider-models.js'; import { countTokens } from '@anthropic-ai/tokenizer'; import { json } from 'stream/consumers'; +import { configureAxiosProxy, getProxyConfigForProvider } from '../proxy-utils.js'; const KIRO_CONSTANTS = { REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', @@ -357,6 +358,9 @@ export class KiroApiService { axiosConfig.proxy = false; } + // 配置自定义代理 + configureAxiosProxy(axiosConfig, this.config, 'claude-kiro-oauth'); + this.axiosInstance = axios.create(axiosConfig); this.isInitialized = true; } @@ -530,6 +534,96 @@ async initializeAuth(forceRefresh = false) { } } + /** + * 清理不完整的工具调用 + * 确保每个 tool_use 都有对应的 tool_result,否则 Kiro API 会返回 400 错误 + * @param {Array} messages - 消息数组 + * @returns {Array} 清理后的消息数组 + */ + cleanIncompleteToolCalls(messages) { + if (!messages || messages.length === 0) return messages; + + // 收集所有 tool_use 的 ID + const toolUseIds = new Set(); + // 收集所有 tool_result 对应的 tool_use_id + const toolResultIds = new Set(); + + for (const msg of messages) { + if (!msg.content || !Array.isArray(msg.content)) continue; + + for (const part of msg.content) { + if (part.type === 'tool_use' && part.id) { + toolUseIds.add(part.id); + } else if (part.type === 'tool_result' && part.tool_use_id) { + toolResultIds.add(part.tool_use_id); + } + } + } + + // 找出没有对应 tool_result 的 tool_use ID + const orphanedToolUseIds = new Set(); + for (const id of toolUseIds) { + if (!toolResultIds.has(id)) { + orphanedToolUseIds.add(id); + } + } + + // 找出没有对应 tool_use 的 tool_result ID + const orphanedToolResultIds = new Set(); + for (const id of toolResultIds) { + if (!toolUseIds.has(id)) { + orphanedToolResultIds.add(id); + } + } + + if (orphanedToolUseIds.size === 0 && orphanedToolResultIds.size === 0) { + return messages; // 没有孤立的工具调用,直接返回 + } + + console.log(`[Kiro] Cleaning incomplete tool calls: ${orphanedToolUseIds.size} orphaned tool_use, ${orphanedToolResultIds.size} orphaned tool_result`); + + // 过滤掉孤立的工具调用 + const cleanedMessages = []; + for (const msg of messages) { + if (!msg.content || !Array.isArray(msg.content)) { + cleanedMessages.push(msg); + continue; + } + + const cleanedContent = msg.content.filter(part => { + if (part.type === 'tool_use' && orphanedToolUseIds.has(part.id)) { + console.log(`[Kiro] Removing orphaned tool_use: ${part.name} (${part.id})`); + return false; + } + if (part.type === 'tool_result' && orphanedToolResultIds.has(part.tool_use_id)) { + console.log(`[Kiro] Removing orphaned tool_result for: ${part.tool_use_id}`); + return false; + } + return true; + }); + + // 如果消息内容被清空,检查是否需要保留 + if (cleanedContent.length === 0) { + // 如果是 assistant 消息且内容被完全清空,添加一个占位文本 + if (msg.role === 'assistant') { + cleanedMessages.push({ + ...msg, + content: [{ type: 'text', text: '(continued)' }] + }); + } + // user 消息如果被清空则跳过 + continue; + } + + cleanedMessages.push({ + ...msg, + content: cleanedContent + }); + } + + return cleanedMessages; + } + /** * Extract text content from OpenAI message format */ @@ -560,7 +654,70 @@ async initializeAuth(forceRefresh = false) { const conversationId = uuidv4(); let systemPrompt = this.getContentText(inSystemPrompt); - const processedMessages = messages; + let processedMessages = messages; + + // 上下文截断:限制请求体大小,避免 400 错误 + // Kiro API 对请求体大小有硬性限制,需要按字节数截断而不是 token 数 + const MAX_REQUEST_SIZE_KB = (this.config && this.config.KIRO_MAX_REQUEST_SIZE_KB) || 240; // 默认 240KB + const MAX_REQUEST_SIZE = MAX_REQUEST_SIZE_KB * 1024; + + // 估算每条消息的字节大小 + const estimateMessageSize = (msg) => { + let size = 0; + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text' && part.text) { + size += part.text.length; + } else if (part.type === 'image' && part.source?.data) { + size += part.source.data.length; // base64 字符串长度 + } else if (part.type === 'tool_use' || part.type === 'tool_result') { + size += JSON.stringify(part).length; + } + } + } else if (typeof msg.content === 'string') { + size += msg.content.length; + } + return size; + }; + + let totalSize = systemPrompt ? systemPrompt.length : 0; + console.log(`[Kiro] Max request size: ${MAX_REQUEST_SIZE_KB}KB, messages count: ${processedMessages.length}`); + + // 从后往前计算大小,保留最近的消息 + const messagesToKeep = []; + for (let i = processedMessages.length - 1; i >= 0; i--) { + const msg = processedMessages[i]; + const msgSize = estimateMessageSize(msg); + + if (totalSize + msgSize > MAX_REQUEST_SIZE && messagesToKeep.length > 0) { + console.log(`[Kiro] Size truncation: keeping ${messagesToKeep.length} messages, dropping ${i + 1} older messages (size would exceed ${MAX_REQUEST_SIZE_KB}KB)`); + break; + } + + totalSize += msgSize; + messagesToKeep.unshift(msg); + } + + console.log(`[Kiro] Total estimated size: ${Math.round(totalSize / 1024)}KB`); + + // 确保至少保留最后一条消息 + if (messagesToKeep.length === 0 && processedMessages.length > 0) { + messagesToKeep.push(processedMessages[processedMessages.length - 1]); + console.log('[Kiro] Context truncation: keeping only the last message due to size limit'); + } + + processedMessages = messagesToKeep; + + // 清理不完整的工具调用:确保每个 tool_use 都有对应的 tool_result + // Kiro API 要求工具调用必须完整,否则会返回 "Improperly formed request" 错误 + processedMessages = this.cleanIncompleteToolCalls(processedMessages); + + // 确保第一条消息是 user 类型(Kiro API 要求) + // 如果截断后第一条是 assistant,需要移除它 + while (processedMessages.length > 0 && processedMessages[0].role === 'assistant') { + console.log('[Kiro] Removing leading assistant message to ensure first message is user'); + processedMessages.shift(); + } if (processedMessages.length === 0) { throw new Error('No user messages found'); diff --git a/src/config-manager.js b/src/config-manager.js index d54841c..64aea3f 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -88,6 +88,8 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP KIRO_BASE_URL: null, SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value SYSTEM_PROMPT_MODE: 'append', + PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 + PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth'] PROMPT_LOG_BASE_NAME: "prompt_log", PROMPT_LOG_MODE: "none", REQUEST_MAX_RETRIES: 3, diff --git a/src/gemini/antigravity-core.js b/src/gemini/antigravity-core.js index e7f4cfc..b546f26 100644 --- a/src/gemini/antigravity-core.js +++ b/src/gemini/antigravity-core.js @@ -10,6 +10,7 @@ import open from 'open'; import { formatExpiryTime } from '../common.js'; import { getProviderModels } from '../provider-models.js'; import { handleGeminiAntigravityOAuth } from '../oauth-handlers.js'; +import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../proxy-utils.js'; // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ @@ -225,14 +226,25 @@ function ensureRolesInContents(requestBody) { export class AntigravityApiService { constructor(config) { + // 检查是否需要使用代理 + const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-antigravity'); + // 配置 OAuth2Client 使用自定义的 HTTP agent - this.authClient = new OAuth2Client({ + const oauth2Options = { clientId: OAUTH_CLIENT_ID, clientSecret: OAUTH_CLIENT_SECRET, - transporterOptions: { + }; + + if (proxyConfig) { + oauth2Options.transporterOptions = proxyConfig; + console.log('[Antigravity] Using proxy for OAuth2Client'); + } else { + oauth2Options.transporterOptions = { agent: httpsAgent, - }, - }); + }; + } + + this.authClient = new OAuth2Client(oauth2Options); this.availableModels = []; this.isInitialized = false; @@ -252,6 +264,9 @@ export class AntigravityApiService { this.baseUrlAutopush // ANTIGRAVITY_BASE_URL_PROD // 生产环境已注释 ]; + + // 保存代理配置供后续使用 + this.proxyConfig = getProxyConfigForProvider(config, 'gemini-antigravity'); } async initialize() { diff --git a/src/gemini/gemini-core.js b/src/gemini/gemini-core.js index 6ca6954..cd14ac3 100644 --- a/src/gemini/gemini-core.js +++ b/src/gemini/gemini-core.js @@ -9,6 +9,7 @@ import open from 'open'; import { API_ACTIONS, formatExpiryTime } from '../common.js'; import { getProviderModels } from '../provider-models.js'; import { handleGeminiCliOAuth } from '../oauth-handlers.js'; +import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../proxy-utils.js'; // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ @@ -193,14 +194,25 @@ async function* apply_anti_truncation_to_stream(service, model, requestBody) { export class GeminiApiService { constructor(config) { + // 检查是否需要使用代理 + const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-cli-oauth'); + // 配置 OAuth2Client 使用自定义的 HTTP agent - this.authClient = new OAuth2Client({ + const oauth2Options = { clientId: OAUTH_CLIENT_ID, clientSecret: OAUTH_CLIENT_SECRET, - transporterOptions: { + }; + + if (proxyConfig) { + oauth2Options.transporterOptions = proxyConfig; + console.log('[Gemini] Using proxy for OAuth2Client'); + } else { + oauth2Options.transporterOptions = { agent: httpsAgent, - }, - }); + }; + } + + this.authClient = new OAuth2Client(oauth2Options); this.availableModels = []; this.isInitialized = false; @@ -212,6 +224,9 @@ export class GeminiApiService { this.codeAssistEndpoint = config.GEMINI_BASE_URL || DEFAULT_CODE_ASSIST_ENDPOINT; this.apiVersion = DEFAULT_CODE_ASSIST_API_VERSION; + + // 保存代理配置供后续使用 + this.proxyConfig = getProxyConfigForProvider(config, 'gemini-cli-oauth'); } async initialize() { diff --git a/src/openai/openai-core.js b/src/openai/openai-core.js index ad77b90..c22a164 100644 --- a/src/openai/openai-core.js +++ b/src/openai/openai-core.js @@ -1,6 +1,7 @@ import axios from 'axios'; import * as http from 'http'; import * as https from 'https'; +import { configureAxiosProxy } from '../proxy-utils.js'; // Assumed OpenAI API specification service for interacting with third-party models export class OpenAIApiService { @@ -43,6 +44,9 @@ export class OpenAIApiService { axiosConfig.proxy = false; } + // 配置自定义代理 + configureAxiosProxy(axiosConfig, config, 'openai-custom'); + this.axiosInstance = axios.create(axiosConfig); } diff --git a/src/openai/openai-responses-core.js b/src/openai/openai-responses-core.js index 80e15c5..b753bc6 100644 --- a/src/openai/openai-responses-core.js +++ b/src/openai/openai-responses-core.js @@ -1,6 +1,7 @@ import axios from 'axios'; import * as http from 'http'; import * as https from 'https'; +import { configureAxiosProxy } from '../proxy-utils.js'; // OpenAI Responses API specification service for interacting with third-party models export class OpenAIResponsesApiService { @@ -42,6 +43,9 @@ export class OpenAIResponsesApiService { if (!this.useSystemProxy) { axiosConfig.proxy = false; } + + // 配置自定义代理 (使用 openai-custom 的代理配置) + configureAxiosProxy(axiosConfig, config, 'openai-custom'); this.axiosInstance = axios.create(axiosConfig); } diff --git a/src/openai/qwen-core.js b/src/openai/qwen-core.js index cfda2b0..7ff8e36 100644 --- a/src/openai/qwen-core.js +++ b/src/openai/qwen-core.js @@ -10,6 +10,7 @@ import { EventEmitter } from 'events'; import { randomUUID } from 'node:crypto'; import { getProviderModels } from '../provider-models.js'; import { handleQwenOAuth } from '../oauth-handlers.js'; +import { configureAxiosProxy } from '../proxy-utils.js'; // --- Constants --- const QWEN_DIR = '.qwen'; @@ -222,6 +223,9 @@ export class QwenApiService { axiosConfig.proxy = false; } + // 配置自定义代理 + configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); + this.currentAxiosInstance = axios.create(axiosConfig); this.isInitialized = true; @@ -512,6 +516,9 @@ export class QwenApiService { axiosConfig.proxy = false; } + // 配置自定义代理 + configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); + this.currentAxiosInstance = axios.create(axiosConfig); // Process message content before sending the request diff --git a/src/proxy-utils.js b/src/proxy-utils.js new file mode 100644 index 0000000..d1fb27c --- /dev/null +++ b/src/proxy-utils.js @@ -0,0 +1,128 @@ +/** + * 代理工具模块 + * 支持 HTTP、HTTPS 和 SOCKS5 代理 + */ + +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; + +/** + * 解析代理URL并返回相应的代理配置 + * @param {string} proxyUrl - 代理URL,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 + * @returns {Object|null} 代理配置对象,包含 httpAgent 和 httpsAgent + */ +export function parseProxyUrl(proxyUrl) { + if (!proxyUrl || typeof proxyUrl !== 'string') { + return null; + } + + const trimmedUrl = proxyUrl.trim(); + if (!trimmedUrl) { + return null; + } + + try { + const url = new URL(trimmedUrl); + const protocol = url.protocol.toLowerCase(); + + if (protocol === 'socks5:' || protocol === 'socks4:' || protocol === 'socks:') { + // SOCKS 代理 + const socksAgent = new SocksProxyAgent(trimmedUrl); + return { + httpAgent: socksAgent, + httpsAgent: socksAgent, + proxyType: 'socks' + }; + } else if (protocol === 'http:' || protocol === 'https:') { + // HTTP/HTTPS 代理 + return { + httpAgent: new HttpProxyAgent(trimmedUrl), + httpsAgent: new HttpsProxyAgent(trimmedUrl), + proxyType: 'http' + }; + } else { + console.warn(`[Proxy] Unsupported proxy protocol: ${protocol}`); + return null; + } + } catch (error) { + console.error(`[Proxy] Failed to parse proxy URL: ${error.message}`); + return null; + } +} + +/** + * 检查指定的提供商是否启用了代理 + * @param {Object} config - 配置对象 + * @param {string} providerType - 提供商类型 + * @returns {boolean} 是否启用代理 + */ +export function isProxyEnabledForProvider(config, providerType) { + if (!config || !config.PROXY_URL || !config.PROXY_ENABLED_PROVIDERS) { + return false; + } + + const enabledProviders = config.PROXY_ENABLED_PROVIDERS; + if (!Array.isArray(enabledProviders)) { + return false; + } + + return enabledProviders.includes(providerType); +} + +/** + * 获取指定提供商的代理配置 + * @param {Object} config - 配置对象 + * @param {string} providerType - 提供商类型 + * @returns {Object|null} 代理配置对象或 null + */ +export function getProxyConfigForProvider(config, providerType) { + if (!isProxyEnabledForProvider(config, providerType)) { + return null; + } + + const proxyConfig = parseProxyUrl(config.PROXY_URL); + if (proxyConfig) { + console.log(`[Proxy] Using ${proxyConfig.proxyType} proxy for ${providerType}: ${config.PROXY_URL}`); + } + return proxyConfig; +} + +/** + * 为 axios 配置代理 + * @param {Object} axiosConfig - axios 配置对象 + * @param {Object} config - 应用配置对象 + * @param {string} providerType - 提供商类型 + * @returns {Object} 更新后的 axios 配置 + */ +export function configureAxiosProxy(axiosConfig, config, providerType) { + const proxyConfig = getProxyConfigForProvider(config, providerType); + + if (proxyConfig) { + // 使用代理 agent + axiosConfig.httpAgent = proxyConfig.httpAgent; + axiosConfig.httpsAgent = proxyConfig.httpsAgent; + // 禁用 axios 内置的代理配置,使用我们的 agent + axiosConfig.proxy = false; + } + + return axiosConfig; +} + +/** + * 为 google-auth-library 配置代理 + * @param {Object} config - 应用配置对象 + * @param {string} providerType - 提供商类型 + * @returns {Object|null} transporter 配置对象或 null + */ +export function getGoogleAuthProxyConfig(config, providerType) { + const proxyConfig = getProxyConfigForProvider(config, providerType); + + if (proxyConfig) { + return { + agent: proxyConfig.httpsAgent + }; + } + + return null; +} diff --git a/src/ui-manager.js b/src/ui-manager.js index f9e22d7..6509a74 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -721,6 +721,10 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; if (newConfig.MAX_ERROR_COUNT !== undefined) currentConfig.MAX_ERROR_COUNT = newConfig.MAX_ERROR_COUNT; if (newConfig.providerFallbackChain !== undefined) currentConfig.providerFallbackChain = newConfig.providerFallbackChain; + + // Proxy settings + if (newConfig.PROXY_URL !== undefined) currentConfig.PROXY_URL = newConfig.PROXY_URL; + if (newConfig.PROXY_ENABLED_PROVIDERS !== undefined) currentConfig.PROXY_ENABLED_PROVIDERS = newConfig.PROXY_ENABLED_PROVIDERS; // Handle system prompt update if (newConfig.systemPrompt !== undefined) { @@ -784,7 +788,9 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, - providerFallbackChain: currentConfig.providerFallbackChain + providerFallbackChain: currentConfig.providerFallbackChain, + PROXY_URL: currentConfig.PROXY_URL, + PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS }; writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 40ae07d..bba5c0c 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -114,6 +114,17 @@ async function loadConfiguration() { providerFallbackChainEl.value = ''; } } + + // 加载代理配置 + const proxyUrlEl = document.getElementById('proxyUrl'); + if (proxyUrlEl) proxyUrlEl.value = data.PROXY_URL || ''; + + // 加载启用代理的提供商 + const proxyProviderCheckboxes = document.querySelectorAll('input[name="proxyProvider"]'); + const enabledProviders = data.PROXY_ENABLED_PROVIDERS || []; + proxyProviderCheckboxes.forEach(checkbox => { + checkbox.checked = enabledProviders.includes(checkbox.value); + }); // 触发提供商配置显示 handleProviderChange(); @@ -246,6 +257,13 @@ async function saveConfiguration() { } else { config.providerFallbackChain = {}; } + + // 保存代理配置 + config.PROXY_URL = document.getElementById('proxyUrl')?.value?.trim() || null; + + // 获取启用代理的提供商列表 + const proxyProviderCheckboxes = document.querySelectorAll('input[name="proxyProvider"]:checked'); + config.PROXY_ENABLED_PROVIDERS = Array.from(proxyProviderCheckboxes).map(cb => cb.value); try { await window.apiClient.post('/config', config); diff --git a/static/app/i18n.js b/static/app/i18n.js index e7e2793..a115369 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -209,6 +209,12 @@ const translations = { 'config.advanced.adminPassword': '后台登录密码', 'config.advanced.adminPasswordPlaceholder': '设置后台登录密码(留空则不修改)', 'config.advanced.adminPasswordNote': '用于保护管理控制台的访问,修改后需要重新登录', + 'config.proxy.title': '代理设置', + 'config.proxy.url': '代理地址', + 'config.proxy.urlPlaceholder': '例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080', + 'config.proxy.urlNote': '支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理', + 'config.proxy.enabledProviders': '启用代理的提供商', + 'config.proxy.enabledProvidersNote': '选择需要通过代理访问的提供商,未选中的提供商将直接连接', 'config.save': '保存配置', 'config.reset': '重置', 'config.placeholder.nodeName': '例如: 我的节点1', @@ -614,6 +620,12 @@ const translations = { 'config.advanced.adminPassword': 'Admin Password', 'config.advanced.adminPasswordPlaceholder': 'Set admin password (leave empty to keep unchanged)', 'config.advanced.adminPasswordNote': 'Used to protect management console access, requires re-login after modification', + 'config.proxy.title': 'Proxy Settings', + 'config.proxy.url': 'Proxy URL', + 'config.proxy.urlPlaceholder': 'e.g.: http://127.0.0.1:7890 or socks5://127.0.0.1:1080', + 'config.proxy.urlNote': 'Supports HTTP, HTTPS and SOCKS5 proxies. Leave empty to disable proxy', + 'config.proxy.enabledProviders': 'Providers Using Proxy', + 'config.proxy.enabledProvidersNote': 'Select providers that should use the proxy. Unselected providers will connect directly', 'config.save': 'Save Configuration', 'config.reset': 'Reset', 'config.placeholder.nodeName': 'e.g.: My Node 1', diff --git a/static/app/styles.css b/static/app/styles.css index a6fef34..6289a79 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -517,6 +517,65 @@ textarea.form-control { color: var(--primary-color); } +/* 代理配置区域 */ +.proxy-config-section { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1rem 1.5rem; + margin-bottom: 1.5rem; + background: var(--bg-primary); +} + +.proxy-config-section h4 { + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.proxy-config-section h4 i { + color: var(--primary-color); +} + +/* 复选框组样式 */ +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--bg-secondary); + transition: all 0.2s ease; +} + +.checkbox-label:hover { + border-color: var(--primary-color); + background: var(--bg-hover); +} + +.checkbox-label input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--primary-color); + cursor: pointer; +} + +.checkbox-label input[type="checkbox"]:checked + span { + color: var(--primary-color); + font-weight: 500; +} + .config-row { display: grid; grid-template-columns: 1fr 1fr; diff --git a/static/index.html b/static/index.html index bb2dfdb..1d249f7 100644 --- a/static/index.html +++ b/static/index.html @@ -751,6 +751,46 @@