From 7a5ce6dbd55c055f65b8510dd59b6798e615043a Mon Sep 17 00:00:00 2001 From: unihon Date: Mon, 22 Dec 2025 01:24:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20=E6=B7=BB=E5=8A=A0provider?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/request-handler.js | 35 +++++++++++++++- src/service-manager.js | 94 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/request-handler.js b/src/request-handler.js index f9200aa..477143a 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -2,7 +2,7 @@ import deepmerge from 'deepmerge'; import { handleError, isAuthorized } from './common.js'; import { handleUIApiRequests, serveStaticFiles } from './ui-manager.js'; import { handleAPIRequests } from './api-manager.js'; -import { getApiService } from './service-manager.js'; +import { getApiService, getProviderStatus } from './service-manager.js'; import { getProviderPoolManager } from './service-manager.js'; import { MODEL_PROVIDER } from './common.js'; import { PROMPT_LOG_FILENAME } from './config-manager.js'; @@ -62,6 +62,39 @@ export function createRequestHandler(config, providerPoolManager) { return true; } + // providers health endpoint + // url params: provider[string], customName[string], unhealthRatioThreshold[float] + // 支持provider, customName过滤记录 + // 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false + if (method === 'GET' && path === '/provider_health') { + try { + const provider = requestUrl.searchParams.get('provider'); + const customName = requestUrl.searchParams.get('customName'); + let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold'); + unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold); + let provideStatus = await getProviderStatus(currentConfig, null, { provider, customName }); + let summaryHealth = true; + if (!isNaN(unhealthRatioThreshold)) { + summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + timestamp: new Date().toISOString(), + items: provideStatus.providerPoolsSlim, + count: provideStatus.count, + unhealthyCount: provideStatus.unhealthyCount, + unhealthyRatio: provideStatus.unhealthyRatio, + unhealthySummeryMessage: provideStatus.unhealthySummeryMessage, + summaryHealth + })); + return true; + } catch (error) { + console.log(`[Server] req provider_health error: ${error.message}`); + handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }); + return; + } + } + // Ignore count_tokens requests if (path.includes('/count_tokens')) { console.log(`[Server] Ignoring count_tokens request: ${path}`); diff --git a/src/service-manager.js b/src/service-manager.js index 974b9a1..93f3220 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -249,4 +249,98 @@ export function markProviderUnhealthy(provider, providerInfo) { if (providerPoolManager) { providerPoolManager.markProviderUnhealthy(provider, providerInfo); } +} + +/** + * Get providers status + * @param {Object} config - The current request configuration + * @param {Object} [options] - Optional. Additional options. + * @param {boolean} [options.provider] - Optional.provider filter by provider type + * @param {boolean} [options.customName] - Optional.customName filter by customName + * @returns {Promise} The API service adapter + */ +export async function getProviderStatus(config, options = {}) { + let providerPools = {}; + const filePath = config.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + try { + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } else if (filePath && fs.existsSync(filePath)) { + const poolsData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + providerPools = poolsData; + } + } catch (error) { + console.warn('[API Service] Failed to load provider pools:', error.message); + } + + // providerPoolsSlim 只保留顶级 key 及部分字段,过滤 isDisabled 为 true 的元素 + const slimFields = [ + 'customName', + 'isHealthy', + 'lastErrorTime', + 'lastErrorMessage' + ]; + // identify 字段映射表 + const identifyFieldMap = { + 'openai-custom': 'OPENAI_BASE_URL', + 'openaiResponses-custom': 'OPENAI_BASE_URL', + 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', + 'claude-custom': 'CLAUDE_BASE_URL', + 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', + 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', + 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' + }; + let providerPoolsSlim = []; + let unhealthyProvideIdentifyList = []; + let count = 0; + let unhealthyCount = 0; + let unhealthyRatio = 0; + const filterProvider = options && options.provider; + const filterCustomName = options && options.customName; + for (const key of Object.keys(providerPools)) { + if (!Array.isArray(providerPools[key])) continue; + if (filterProvider && key !== filterProvider) continue; + const identifyField = identifyFieldMap[key] || null; + const slimArr = providerPools[key] + .filter(item => { + if (item.isDisabled) return false; + if (filterCustomName && item.customName !== filterCustomName) return false; + return true; + }) + .map(item => { + const slim = {}; + for (const f of slimFields) { + slim[f] = item.hasOwnProperty(f) ? item[f] : null; + } + // identify 字段 + if (identifyField && item.hasOwnProperty(identifyField)) { + let tmpCustomName = item.customName ? `${item.customName}` : 'NoCustomName'; + let identifyStr = `${tmpCustomName}::${key}::${item[identifyField]}`; + slim.identify = identifyStr; + } else { + slim.identify = null; + } + slim.provider = key; + // 统计 + count++; + if (slim.isHealthy === false) { + unhealthyCount++; + if (slim.identify) unhealthyProvideIdentifyList.push(slim.identify); + } + return slim; + }); + providerPoolsSlim.push(...slimArr); + } + if (count > 0) { + unhealthyRatio = Number((unhealthyCount / count).toFixed(2)); + } + let unhealthySummeryMessage = unhealthyProvideIdentifyList.join('\n'); + if (unhealthySummeryMessage === '') unhealthySummeryMessage = null; + return { + providerPoolsSlim, + unhealthySummeryMessage, + count, + unhealthyCount, + unhealthyRatio + }; } \ No newline at end of file