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 { handleError, isAuthorized } from './common.js';
|
||||||
import { handleUIApiRequests, serveStaticFiles } from './ui-manager.js';
|
import { handleUIApiRequests, serveStaticFiles } from './ui-manager.js';
|
||||||
import { handleAPIRequests } from './api-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 { getProviderPoolManager } from './service-manager.js';
|
||||||
import { MODEL_PROVIDER } from './common.js';
|
import { MODEL_PROVIDER } from './common.js';
|
||||||
import { PROMPT_LOG_FILENAME } from './config-manager.js';
|
import { PROMPT_LOG_FILENAME } from './config-manager.js';
|
||||||
|
|
@ -62,6 +62,39 @@ export function createRequestHandler(config, providerPoolManager) {
|
||||||
return true;
|
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
|
// Ignore count_tokens requests
|
||||||
if (path.includes('/count_tokens')) {
|
if (path.includes('/count_tokens')) {
|
||||||
console.log(`[Server] Ignoring count_tokens request: ${path}`);
|
console.log(`[Server] Ignoring count_tokens request: ${path}`);
|
||||||
|
|
|
||||||
|
|
@ -249,4 +249,98 @@ export function markProviderUnhealthy(provider, providerInfo) {
|
||||||
if (providerPoolManager) {
|
if (providerPoolManager) {
|
||||||
providerPoolManager.markProviderUnhealthy(provider, providerInfo);
|
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