Merge pull request #123 from unihon/feature/provider-health-api
feat(usage): 添加provider健康检查接口
This commit is contained in:
commit
8d020d83ce
2 changed files with 128 additions and 1 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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<Object>} 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
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue