Merge pull request #451 from HenryZ-0302/codex/custom-provider-model-picker
feat: (补全原本的注释)支持为兼容 provider 管理模型列表,并增加单节点健康检查入口
This commit is contained in:
commit
5b42aeaa2d
9 changed files with 1159 additions and 46 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import { convertData } from '../convert/convert.js';
|
||||
import { MODEL_PROVIDER } from '../utils/common.js';
|
||||
|
||||
/**
|
||||
* 各提供商支持的模型列表
|
||||
* 用于前端UI选择不支持的模型
|
||||
*/
|
||||
|
||||
export const PROVIDER_MODELS = {
|
||||
'gemini-cli-oauth': [
|
||||
'gemini-2.5-flash',
|
||||
|
|
@ -110,6 +112,81 @@ export const PROVIDER_MODELS = {
|
|||
]
|
||||
};
|
||||
|
||||
export const MANAGED_MODEL_LIST_PROVIDERS = [
|
||||
MODEL_PROVIDER.OPENAI_CUSTOM,
|
||||
MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES
|
||||
];
|
||||
|
||||
export function getManagedModelListProviderType(providerType) {
|
||||
return MANAGED_MODEL_LIST_PROVIDERS.find(baseType =>
|
||||
providerType === baseType || providerType.startsWith(baseType + '-')
|
||||
) || null;
|
||||
}
|
||||
|
||||
export function usesManagedModelList(providerType) {
|
||||
return getManagedModelListProviderType(providerType) !== null;
|
||||
}
|
||||
|
||||
export function normalizeModelIds(models = []) {
|
||||
return [...new Set(
|
||||
(Array.isArray(models) ? models : [])
|
||||
.filter(model => typeof model === 'string')
|
||||
.map(model => model.trim())
|
||||
.filter(Boolean)
|
||||
)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function extractModelIdsFromListShape(modelList) {
|
||||
if (!modelList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(modelList)) {
|
||||
return modelList.map(item => {
|
||||
if (typeof item === 'string') return item;
|
||||
return item?.id || item?.name || item?.model || null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
if (Array.isArray(modelList.data)) {
|
||||
return modelList.data.map(item => item?.id || item?.name || item?.model || null).filter(Boolean);
|
||||
}
|
||||
|
||||
if (Array.isArray(modelList.models)) {
|
||||
return modelList.models.map(item => {
|
||||
if (typeof item === 'string') return item;
|
||||
return item?.id || item?.name || item?.model || null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function extractModelIdsFromNativeList(modelList, providerType) {
|
||||
let convertedModelList = modelList;
|
||||
|
||||
try {
|
||||
convertedModelList = convertData(modelList, 'modelList', providerType, MODEL_PROVIDER.OPENAI_CUSTOM);
|
||||
} catch {
|
||||
convertedModelList = modelList;
|
||||
}
|
||||
|
||||
const convertedIds = normalizeModelIds(extractModelIdsFromListShape(convertedModelList));
|
||||
if (convertedIds.length > 0) {
|
||||
return convertedIds;
|
||||
}
|
||||
|
||||
return normalizeModelIds(extractModelIdsFromListShape(modelList));
|
||||
}
|
||||
|
||||
export function getConfiguredSupportedModels(providerType, providerConfig = {}) {
|
||||
if (!usesManagedModelList(providerType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizeModelIds(providerConfig?.supportedModels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定提供商类型支持的模型列表
|
||||
* @param {string} providerType - 提供商类型
|
||||
|
|
@ -119,14 +196,14 @@ export function getProviderModels(providerType) {
|
|||
if (PROVIDER_MODELS[providerType]) {
|
||||
return PROVIDER_MODELS[providerType];
|
||||
}
|
||||
|
||||
|
||||
// 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom)
|
||||
for (const key of Object.keys(PROVIDER_MODELS)) {
|
||||
if (providerType.startsWith(key + '-')) {
|
||||
return PROVIDER_MODELS[key];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ import * as fs from 'fs';
|
|||
import { getServiceAdapter, getRegisteredProviders } from './adapter.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js';
|
||||
import { getProviderModels } from './provider-models.js';
|
||||
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
|
||||
import { convertData } from '../convert/convert.js';
|
||||
import {
|
||||
getConfiguredSupportedModels,
|
||||
getProviderModels,
|
||||
normalizeModelIds
|
||||
} from './provider-models.js';
|
||||
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
|
||||
import { ENDPOINT_TYPE } from '../utils/common.js';
|
||||
|
||||
/**
|
||||
|
|
@ -885,6 +889,10 @@ export class ProviderPoolManager {
|
|||
// 如果指定了模型,则排除不支持该模型的提供商
|
||||
if (requestedModel) {
|
||||
const modelFilteredProviders = availableAndHealthyProviders.filter(p => {
|
||||
const supportedModels = getConfiguredSupportedModels(providerType, p.config);
|
||||
if (supportedModels.length > 0) {
|
||||
return supportedModels.includes(requestedModel);
|
||||
}
|
||||
// 如果提供商没有配置 notSupportedModels,则认为它支持所有模型
|
||||
if (!p.config.notSupportedModels || !Array.isArray(p.config.notSupportedModels)) {
|
||||
return true;
|
||||
|
|
@ -1282,6 +1290,15 @@ export class ProviderPoolManager {
|
|||
for (const providerType of allProviderTypes) {
|
||||
if (this.providerStatus[providerType]) {
|
||||
let models = getProviderModels(providerType);
|
||||
const configuredSupportedModels = normalizeModelIds(
|
||||
this.providerStatus[providerType].flatMap(providerStatus =>
|
||||
getConfiguredSupportedModels(providerType, providerStatus.config)
|
||||
)
|
||||
);
|
||||
|
||||
if (configuredSupportedModels.length > 0) {
|
||||
models = configuredSupportedModels;
|
||||
}
|
||||
|
||||
// 如果硬编码的模型列表为空,或者该类型的提供商在号池中没有配置节点,尝试从服务获取
|
||||
if (models.length === 0) {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
const providerModelsMatch = pathParam.match(/^\/api\/provider-models\/([^\/]+)$/);
|
||||
if (method === 'GET' && providerModelsMatch) {
|
||||
const providerType = decodeURIComponent(providerModelsMatch[1]);
|
||||
return await providerApi.handleGetProviderTypeModels(req, res, providerType);
|
||||
return await providerApi.handleGetProviderTypeModels(req, res, currentConfig, providerPoolManager, providerType);
|
||||
}
|
||||
|
||||
// Add new provider configuration
|
||||
|
|
@ -168,6 +168,22 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
return await providerApi.handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType);
|
||||
}
|
||||
|
||||
// Detect available models for a specific provider node
|
||||
const detectModelsMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/detect-models$/);
|
||||
if (method === 'POST' && detectModelsMatch) {
|
||||
const providerType = decodeURIComponent(detectModelsMatch[1]);
|
||||
const providerUuid = detectModelsMatch[2];
|
||||
return await providerApi.handleDetectProviderModels(req, res, currentConfig, providerPoolManager, providerType, providerUuid);
|
||||
}
|
||||
|
||||
// Perform health check for a specific provider node
|
||||
const singleHealthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/health-check$/);
|
||||
if (method === 'POST' && singleHealthCheckMatch) {
|
||||
const providerType = decodeURIComponent(singleHealthCheckMatch[1]);
|
||||
const providerUuid = singleHealthCheckMatch[2];
|
||||
return await providerApi.handleSingleProviderHealthCheck(req, res, currentConfig, providerPoolManager, providerType, providerUuid);
|
||||
}
|
||||
|
||||
// Delete all unhealthy providers for a specific type
|
||||
// NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'delete-unhealthy' as UUID
|
||||
const deleteUnhealthyMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/delete-unhealthy$/);
|
||||
|
|
@ -348,4 +364,4 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import logger from '../utils/logger.js';
|
||||
import { getRequestBody } from '../utils/common.js';
|
||||
import { getAllProviderModels, getProviderModels } from '../providers/provider-models.js';
|
||||
import {
|
||||
extractModelIdsFromNativeList,
|
||||
getConfiguredSupportedModels,
|
||||
getProviderModels,
|
||||
normalizeModelIds,
|
||||
usesManagedModelList
|
||||
} from '../providers/provider-models.js';
|
||||
import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js';
|
||||
import { broadcastEvent } from './event-broadcast.js';
|
||||
import { getRegisteredProviders } from '../providers/adapter.js';
|
||||
import { getRegisteredProviders, getServiceAdapter, serviceInstances } from '../providers/adapter.js';
|
||||
|
||||
// 文件级互斥锁:防止并发读写导致数据丢失
|
||||
// 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等),
|
||||
|
|
@ -87,6 +93,110 @@ function filterMaskedData(data) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function getProviderPoolsFilePath(currentConfig) {
|
||||
return currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
}
|
||||
|
||||
function loadProviderPools(currentConfig, providerPoolManager) {
|
||||
const filePath = getProviderPoolsFilePath(currentConfig);
|
||||
|
||||
if (providerPoolManager?.providerPools) {
|
||||
return providerPoolManager.providerPools;
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
function getManagedSupportedModels(providerType, providers = []) {
|
||||
return normalizeModelIds(
|
||||
providers.flatMap(provider => getConfiguredSupportedModels(providerType, provider))
|
||||
);
|
||||
}
|
||||
|
||||
function persistProviderStatusToFile(currentConfig, providerPoolManager) {
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
const providerPools = {};
|
||||
|
||||
for (const providerType in providerPoolManager.providerStatus) {
|
||||
providerPools[providerType] = providerPoolManager.providerStatus[providerType].map(providerStatus => providerStatus.config);
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function isAuthHealthCheckError(errorMessage = '') {
|
||||
return /\b(401|403)\b/.test(errorMessage) ||
|
||||
/\b(Unauthorized|Forbidden|AccessDenied|InvalidToken|ExpiredToken)\b/i.test(errorMessage);
|
||||
}
|
||||
|
||||
async function runProviderHealthCheck(providerPoolManager, providerType, providerStatus) {
|
||||
const providerConfig = providerStatus.config;
|
||||
|
||||
try {
|
||||
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig);
|
||||
|
||||
if (healthResult.success) {
|
||||
providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName);
|
||||
return {
|
||||
uuid: providerConfig.uuid,
|
||||
success: true,
|
||||
healthy: true,
|
||||
modelName: healthResult.modelName,
|
||||
message: 'Healthy'
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = healthResult.errorMessage || 'Check failed';
|
||||
const isAuthError = isAuthHealthCheckError(errorMessage);
|
||||
|
||||
if (isAuthError) {
|
||||
providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage);
|
||||
logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`);
|
||||
} else {
|
||||
providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage);
|
||||
}
|
||||
|
||||
providerStatus.config.lastHealthCheckTime = new Date().toISOString();
|
||||
if (healthResult.modelName) {
|
||||
providerStatus.config.lastHealthCheckModel = healthResult.modelName;
|
||||
}
|
||||
|
||||
return {
|
||||
uuid: providerConfig.uuid,
|
||||
success: false,
|
||||
healthy: false,
|
||||
modelName: healthResult.modelName,
|
||||
message: errorMessage,
|
||||
isAuthError
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
const isAuthError = isAuthHealthCheckError(errorMessage);
|
||||
|
||||
if (isAuthError) {
|
||||
providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage);
|
||||
logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`);
|
||||
} else {
|
||||
providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage);
|
||||
}
|
||||
|
||||
providerStatus.config.lastHealthCheckTime = new Date().toISOString();
|
||||
|
||||
return {
|
||||
uuid: providerConfig.uuid,
|
||||
success: false,
|
||||
healthy: false,
|
||||
message: errorMessage,
|
||||
isAuthError
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Promise 链式队列,确保文件操作顺序执行
|
||||
let _fileLockChain = Promise.resolve();
|
||||
|
||||
|
|
@ -228,26 +338,27 @@ export async function handleGetSupportedProviders(req, res, currentConfig, provi
|
|||
*/
|
||||
export async function handleGetProviderModels(req, res, currentConfig, providerPoolManager) {
|
||||
const registeredProviders = getRegisteredProviders();
|
||||
let poolTypes = [];
|
||||
let providerPools = {};
|
||||
|
||||
// 获取所有存在的类型(基础 + 动态)
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
try {
|
||||
if (providerPoolManager && providerPoolManager.providerPools) {
|
||||
poolTypes = Object.keys(providerPoolManager.providerPools);
|
||||
} else if (existsSync(filePath)) {
|
||||
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
poolTypes = Object.keys(poolsData);
|
||||
}
|
||||
providerPools = loadProviderPools(currentConfig, providerPoolManager);
|
||||
} catch (error) {
|
||||
logger.warn('[UI API] Failed to load provider pools for models:', error.message);
|
||||
}
|
||||
|
||||
const poolTypes = Object.keys(providerPools);
|
||||
const allTypes = [...new Set([...registeredProviders, ...poolTypes])];
|
||||
const allModels = {};
|
||||
|
||||
allTypes.forEach(type => {
|
||||
const models = getProviderModels(type);
|
||||
let models = getProviderModels(type);
|
||||
if (usesManagedModelList(type)) {
|
||||
const managedModels = getManagedSupportedModels(type, providerPools[type] || []);
|
||||
if (managedModels.length > 0) {
|
||||
models = managedModels;
|
||||
}
|
||||
}
|
||||
if (models && models.length > 0) {
|
||||
allModels[type] = models;
|
||||
}
|
||||
|
|
@ -261,8 +372,19 @@ export async function handleGetProviderModels(req, res, currentConfig, providerP
|
|||
/**
|
||||
* 获取特定提供商类型的可用模型
|
||||
*/
|
||||
export async function handleGetProviderTypeModels(req, res, providerType) {
|
||||
const models = getProviderModels(providerType);
|
||||
export async function handleGetProviderTypeModels(req, res, currentConfig, providerPoolManager, providerType) {
|
||||
let models = getProviderModels(providerType);
|
||||
if (usesManagedModelList(providerType)) {
|
||||
try {
|
||||
const providerPools = loadProviderPools(currentConfig, providerPoolManager);
|
||||
const managedModels = getManagedSupportedModels(providerType, providerPools[providerType] || []);
|
||||
if (managedModels.length > 0) {
|
||||
models = managedModels;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[UI API] Failed to load managed provider models:', error.message);
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
providerType,
|
||||
|
|
@ -271,6 +393,70 @@ export async function handleGetProviderTypeModels(req, res, providerType) {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available models for a specific provider node.
|
||||
*/
|
||||
export async function handleDetectProviderModels(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
if (!usesManagedModelList(providerType)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Model detection is not supported for provider type: ${providerType}` } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const providerPools = loadProviderPools(currentConfig, providerPoolManager);
|
||||
const providers = providerPools[providerType] || [];
|
||||
const existingProvider = providers.find(provider => provider.uuid === providerUuid);
|
||||
|
||||
if (!existingProvider) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Provider not found' } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await getRequestBody(req);
|
||||
const draftConfig = filterMaskedData(body?.providerConfig || {});
|
||||
const detectionUuid = `${providerUuid}-detect-models`;
|
||||
const instanceKey = `${providerType}${detectionUuid}`;
|
||||
const tempConfig = {
|
||||
...currentConfig,
|
||||
...existingProvider,
|
||||
...draftConfig,
|
||||
MODEL_PROVIDER: providerType,
|
||||
uuid: detectionUuid
|
||||
};
|
||||
|
||||
let models = [];
|
||||
try {
|
||||
delete serviceInstances[instanceKey];
|
||||
const serviceAdapter = getServiceAdapter(tempConfig);
|
||||
if (typeof serviceAdapter.listModels !== 'function') {
|
||||
throw new Error(`Provider ${providerType} does not support model detection`);
|
||||
}
|
||||
|
||||
const nativeModels = await serviceAdapter.listModels();
|
||||
models = extractModelIdsFromNativeList(nativeModels, providerType);
|
||||
} finally {
|
||||
delete serviceInstances[instanceKey];
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
providerType,
|
||||
uuid: providerUuid,
|
||||
count: models.length,
|
||||
models,
|
||||
selectedModels: getConfiguredSupportedModels(providerType, existingProvider)
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新的提供商配置
|
||||
*/
|
||||
|
|
@ -324,6 +510,10 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
|
|||
|
||||
// 过滤掉脱敏字段
|
||||
const filteredConfig = filterMaskedData(providerConfig);
|
||||
if (usesManagedModelList(providerType)) {
|
||||
filteredConfig.supportedModels = normalizeModelIds(filteredConfig.supportedModels);
|
||||
filteredConfig.notSupportedModels = [];
|
||||
}
|
||||
providerPools[providerType].push(filteredConfig);
|
||||
|
||||
// Save to file
|
||||
|
|
@ -419,6 +609,10 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
|
|||
|
||||
// 过滤掉传入配置中的脱敏占位符,避免覆盖真实数据
|
||||
const filteredConfig = filterMaskedData(providerConfig);
|
||||
if (usesManagedModelList(providerType)) {
|
||||
filteredConfig.supportedModels = normalizeModelIds(filteredConfig.supportedModels);
|
||||
filteredConfig.notSupportedModels = [];
|
||||
}
|
||||
|
||||
const updatedProvider = {
|
||||
...existingProvider,
|
||||
|
|
@ -1030,6 +1224,59 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
|
|||
* 快速链接配置文件到对应的提供商
|
||||
* 支持单个文件路径或文件路径数组
|
||||
*/
|
||||
export async function handleSingleProviderHealthCheck(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
if (!providerPoolManager) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const providers = providerPoolManager.providerStatus[providerType] || [];
|
||||
const providerStatus = providers.find(item => item.config?.uuid === providerUuid);
|
||||
|
||||
if (!providerStatus) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Provider not found' } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info(`[UI API] Starting single health check for provider ${providerUuid} in ${providerType}`);
|
||||
|
||||
const result = await runProviderHealthCheck(providerPoolManager, providerType, providerStatus);
|
||||
const filePath = persistProviderStatusToFile(currentConfig, providerPoolManager);
|
||||
|
||||
broadcastEvent('config_update', {
|
||||
action: 'health_check_single',
|
||||
filePath,
|
||||
providerType,
|
||||
providerUuid,
|
||||
result: {
|
||||
...result,
|
||||
message: sanitizeProviderData({ message: result.message }).message
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
providerType,
|
||||
uuid: providerUuid,
|
||||
healthy: result.healthy,
|
||||
modelName: result.modelName || null,
|
||||
message: result.message,
|
||||
isAuthError: result.isAuthError || false
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[UI API] Single health check error:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleQuickLinkProvider(req, res, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
|
|
@ -1252,4 +1499,4 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide
|
|||
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,41 @@ export const MODEL_PROVIDER = {
|
|||
AUTO: 'auto',
|
||||
}
|
||||
|
||||
const MANAGED_MODEL_LIST_PROVIDER_TYPES = new Set([
|
||||
'openai-custom',
|
||||
'openaiResponses-custom'
|
||||
]);
|
||||
|
||||
function usesManagedModelList(providerType = '') {
|
||||
return [...MANAGED_MODEL_LIST_PROVIDER_TYPES].some(baseType =>
|
||||
providerType === baseType || providerType.startsWith(baseType + '-')
|
||||
);
|
||||
}
|
||||
|
||||
function getConfiguredSupportedModels(providerType, providerConfig = {}) {
|
||||
if (!usesManagedModelList(providerType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(
|
||||
(Array.isArray(providerConfig?.supportedModels) ? providerConfig.supportedModels : [])
|
||||
.filter(model => typeof model === 'string')
|
||||
.map(model => model.trim())
|
||||
.filter(Boolean)
|
||||
)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function getConfiguredSupportedModelsFromPool(providerPoolManager, providerType) {
|
||||
if (!providerPoolManager?.providerStatus?.[providerType]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(
|
||||
providerPoolManager.providerStatus[providerType]
|
||||
.flatMap(providerStatus => getConfiguredSupportedModels(providerType, providerStatus.config))
|
||||
)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the protocol prefix from a given model provider string.
|
||||
* This is used to determine if two providers belong to the same underlying protocol (e.g., gemini, openai, claude).
|
||||
|
|
@ -822,6 +857,35 @@ export async function handleModelListRequest(req, res, service, endpointType, CO
|
|||
|
||||
let clientModelList;
|
||||
|
||||
const buildConfiguredModelListResponse = (models, providerType, listEndpointType) => {
|
||||
if (listEndpointType === ENDPOINT_TYPE.OPENAI_MODEL_LIST) {
|
||||
return {
|
||||
object: 'list',
|
||||
data: models.map(model => ({
|
||||
id: model,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: providerType
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
if (listEndpointType === ENDPOINT_TYPE.GEMINI_MODEL_LIST) {
|
||||
return {
|
||||
models: models.map(model => ({
|
||||
name: `models/${model}`,
|
||||
baseModelId: model,
|
||||
version: 'v1',
|
||||
displayName: model,
|
||||
description: `Model ${model} provided by ${providerType}`,
|
||||
supportedGenerationMethods: ['generateContent', 'countTokens']
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return { data: [] };
|
||||
};
|
||||
|
||||
// --- 核心逻辑: auto 路由模式下的模型聚合 ---
|
||||
if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.AUTO && providerPoolManager) {
|
||||
logger.info(`[ModelList] Aggregating models for 'auto' mode...`);
|
||||
|
|
@ -829,6 +893,15 @@ export async function handleModelListRequest(req, res, service, endpointType, CO
|
|||
} else {
|
||||
// --- 单提供商逻辑 ---
|
||||
const toProvider = CONFIG.MODEL_PROVIDER;
|
||||
const pooledSupportedModels = getConfiguredSupportedModelsFromPool(providerPoolManager, toProvider);
|
||||
const configuredSupportedModels = pooledSupportedModels.length > 0
|
||||
? pooledSupportedModels
|
||||
: getConfiguredSupportedModels(toProvider, CONFIG);
|
||||
|
||||
if (usesManagedModelList(toProvider) && configuredSupportedModels.length > 0) {
|
||||
logger.info(`[ModelList] Returning configured supported models for ${toProvider}: ${configuredSupportedModels.join(', ')}`);
|
||||
clientModelList = buildConfiguredModelListResponse(configuredSupportedModels, toProvider, endpointType);
|
||||
} else {
|
||||
|
||||
// service 可能未在上层预先注入(例如仅改了路径 provider 前缀),这里兜底获取
|
||||
let resolvedService = service;
|
||||
|
|
@ -852,6 +925,7 @@ export async function handleModelListRequest(req, res, service, endpointType, CO
|
|||
} else {
|
||||
logger.info(`[ModelList Convert] Model list format matches. No conversion needed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logger.info(`[ModelList Response] Sending model list to client: ${JSON.stringify(clientModelList)}`);
|
||||
|
|
|
|||
|
|
@ -508,6 +508,17 @@ const translations = {
|
|||
'modal.provider.loadingModels': '加载模型列表...',
|
||||
'modal.provider.unsupportedModels': '不支持的模型',
|
||||
'modal.provider.unsupportedModelsHelp': '选择此提供商不支持的模型,系统会自动排除这些模型',
|
||||
'modal.provider.supportedModels': '模型列表',
|
||||
'modal.provider.supportedModelsHelp': '仅保留你想让这个节点使用的模型,健康检查模型也可以直接从这里挑选',
|
||||
'modal.provider.supportedModelsEmpty': '还没有选择模型。进入编辑后可点击“检测可用模型”获取候选列表。',
|
||||
'modal.provider.detectModels': '检测可用模型',
|
||||
'modal.provider.detectModelsFailed': '检测可用模型失败',
|
||||
'modal.provider.detectModelsNoResults': '没有检测到可用模型',
|
||||
'modal.provider.modelPickerTitle': '{type} 可用模型',
|
||||
'modal.provider.modelPickerSearchPlaceholder': '搜索模型名称',
|
||||
'modal.provider.modelPickerSelectAll': '全选当前结果',
|
||||
'modal.provider.modelPickerClearAll': '清空已选',
|
||||
'modal.provider.modelPickerSelected': '已选择 {count} 个模型',
|
||||
'modal.provider.addTitle': '添加新提供商配置',
|
||||
'modal.provider.customName': '自定义名称',
|
||||
'modal.provider.checkModelName': '检查模型名称',
|
||||
|
|
@ -581,6 +592,11 @@ const translations = {
|
|||
'modal.provider.refreshUnhealthyUuids.failed': '刷新失败',
|
||||
'modal.provider.kiroAuthHint': '使用 AWS Builder ID 登录方式时,需要 <code>clientId</code> 和 <code>clientSecret</code> 字段,可在同文件夹下的另一个 JSON 文件中获取',
|
||||
|
||||
'modal.provider.healthCheckCurrentTitle': '对当前节点立即执行一次健康检查',
|
||||
'modal.provider.healthCheckSingleSuccess': '健康检查通过',
|
||||
'modal.provider.healthCheckSingleSuccessWithModel': '健康检查通过,使用模型: {model}',
|
||||
'modal.provider.healthCheckSingleFailed': '健康检查失败: {message}',
|
||||
|
||||
// Pagination
|
||||
'pagination.showing': '显示 {start}-{end} / 共 {total} 条',
|
||||
'pagination.jumpTo': '跳转到',
|
||||
|
|
@ -817,6 +833,7 @@ const translations = {
|
|||
// Common
|
||||
'common.confirm': '确定',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '关闭',
|
||||
'common.success': '成功',
|
||||
'common.enabled': '已启用',
|
||||
'common.disabled': '已禁用',
|
||||
|
|
@ -1379,6 +1396,17 @@ const translations = {
|
|||
'modal.provider.loadingModels': 'Loading models...',
|
||||
'modal.provider.unsupportedModels': 'Unsupported Models',
|
||||
'modal.provider.unsupportedModelsHelp': 'Select models not supported by this provider; they will be excluded automatically',
|
||||
'modal.provider.supportedModels': 'Model List',
|
||||
'modal.provider.supportedModelsHelp': 'Keep only the models you want this node to use, including a convenient health-check model choice',
|
||||
'modal.provider.supportedModelsEmpty': 'No models selected yet. Enter edit mode and click "Detect Available Models" to load candidates.',
|
||||
'modal.provider.detectModels': 'Detect Available Models',
|
||||
'modal.provider.detectModelsFailed': 'Failed to detect available models',
|
||||
'modal.provider.detectModelsNoResults': 'No available models were detected',
|
||||
'modal.provider.modelPickerTitle': '{type} Available Models',
|
||||
'modal.provider.modelPickerSearchPlaceholder': 'Search model names',
|
||||
'modal.provider.modelPickerSelectAll': 'Select visible results',
|
||||
'modal.provider.modelPickerClearAll': 'Clear selected',
|
||||
'modal.provider.modelPickerSelected': '{count} models selected',
|
||||
'modal.provider.addTitle': 'Add New Provider Config',
|
||||
'modal.provider.customName': 'Custom Name',
|
||||
'modal.provider.checkModelName': 'Check Model Name',
|
||||
|
|
@ -1451,6 +1479,10 @@ const translations = {
|
|||
'modal.provider.refreshUnhealthyUuids.success': 'Refreshed {count} UUID(s)',
|
||||
'modal.provider.refreshUnhealthyUuids.failed': 'Refresh failed',
|
||||
'modal.provider.kiroAuthHint': 'When using AWS Builder ID login, <code>clientId</code> and <code>clientSecret</code> fields are required, which can be found in another JSON file in the same folder',
|
||||
'modal.provider.healthCheckCurrentTitle': 'Run a health check for this node now',
|
||||
'modal.provider.healthCheckSingleSuccess': 'Health check passed',
|
||||
'modal.provider.healthCheckSingleSuccessWithModel': 'Health check passed using model: {model}',
|
||||
'modal.provider.healthCheckSingleFailed': 'Health check failed: {message}',
|
||||
|
||||
// Pagination
|
||||
'pagination.showing': 'Showing {start}-{end} of {total}',
|
||||
|
|
@ -1689,6 +1721,7 @@ const translations = {
|
|||
'common.togglePassword': 'Show/Hide Password',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.success': 'Success',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
|
|
|
|||
|
|
@ -1,16 +1,383 @@
|
|||
// 模态框管理模块
|
||||
|
||||
import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js';
|
||||
import { escapeHtml, showToast, getFieldLabel, getProviderTypeFields } from './utils.js';
|
||||
import { handleProviderPasswordToggle } from './event-handlers.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
const MANAGED_MODEL_LIST_PROVIDERS = new Set(['openai-custom', 'openaiResponses-custom']);
|
||||
|
||||
// 分页配置
|
||||
const PROVIDERS_PER_PAGE = 5;
|
||||
let currentPage = 1;
|
||||
let currentProviders = [];
|
||||
let currentProviderType = '';
|
||||
|
||||
function usesManagedModelList(providerType = '') {
|
||||
return Array.from(MANAGED_MODEL_LIST_PROVIDERS).some(baseType =>
|
||||
providerType === baseType || providerType.startsWith(`${baseType}-`)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeModelList(models = []) {
|
||||
return [...new Set(
|
||||
(Array.isArray(models) ? models : [])
|
||||
.filter(model => typeof model === 'string')
|
||||
.map(model => model.trim())
|
||||
.filter(Boolean)
|
||||
)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function serializeModelsData(models = []) {
|
||||
return encodeURIComponent(JSON.stringify(normalizeModelList(models)));
|
||||
}
|
||||
|
||||
function parseModelsData(rawValue = '') {
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeModelList(JSON.parse(decodeURIComponent(rawValue)));
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse models data:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderSupportedModelsValue(models = []) {
|
||||
const selectedModels = normalizeModelList(models);
|
||||
if (selectedModels.length === 0) {
|
||||
return `<div class="supported-models-empty">${escapeHtml(t('modal.provider.supportedModelsEmpty'))}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="supported-models-list">
|
||||
${selectedModels.map(model => `
|
||||
<span class="supported-model-tag" title="${escapeHtml(model)}">${escapeHtml(model)}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getSupportedModelsContainer(uuid) {
|
||||
return document.querySelector(`.supported-models-container[data-uuid="${uuid}"]`);
|
||||
}
|
||||
|
||||
function setSupportedModelsSelection(uuid, models, options = {}) {
|
||||
const container = getSupportedModelsContainer(uuid);
|
||||
if (!container) return;
|
||||
|
||||
const normalizedModels = normalizeModelList(models);
|
||||
const encodedModels = serializeModelsData(normalizedModels);
|
||||
container.dataset.selectedModels = encodedModels;
|
||||
|
||||
if (options.updateOriginal) {
|
||||
container.dataset.originalModels = encodedModels;
|
||||
}
|
||||
|
||||
const valueContainer = container.querySelector('.supported-models-values');
|
||||
if (valueContainer) {
|
||||
valueContainer.innerHTML = renderSupportedModelsValue(normalizedModels);
|
||||
}
|
||||
|
||||
const summary = container.querySelector('.supported-models-summary');
|
||||
if (summary) {
|
||||
summary.textContent = t('modal.provider.modelPickerSelected', { count: normalizedModels.length });
|
||||
}
|
||||
}
|
||||
|
||||
function resetSupportedModelsSelection(uuid) {
|
||||
const container = getSupportedModelsContainer(uuid);
|
||||
if (!container) return;
|
||||
setSupportedModelsSelection(uuid, parseModelsData(container.dataset.originalModels || ''));
|
||||
}
|
||||
|
||||
function renderSupportedModelsSection(provider) {
|
||||
const selectedModels = normalizeModelList(provider.supportedModels || []);
|
||||
const encodedModels = serializeModelsData(selectedModels);
|
||||
|
||||
return `
|
||||
<div class="config-item supported-models-section">
|
||||
<label>
|
||||
<i class="fas fa-layer-group"></i> <span data-i18n="modal.provider.supportedModels">${t('modal.provider.supportedModels')}</span>
|
||||
<span class="help-text" data-i18n="modal.provider.supportedModelsHelp">${t('modal.provider.supportedModelsHelp')}</span>
|
||||
</label>
|
||||
<div class="supported-models-container"
|
||||
data-uuid="${provider.uuid}"
|
||||
data-selected-models="${encodedModels}"
|
||||
data-original-models="${encodedModels}">
|
||||
<div class="supported-models-toolbar">
|
||||
<span class="supported-models-summary">${escapeHtml(t('modal.provider.modelPickerSelected', { count: selectedModels.length }))}</span>
|
||||
<button type="button"
|
||||
class="btn btn-outline detect-models-btn"
|
||||
onclick="window.openSupportedModelsPicker('${currentProviderType}', '${provider.uuid}', event)"
|
||||
disabled>
|
||||
<i class="fas fa-wand-magic-sparkles"></i>
|
||||
<span data-i18n="modal.provider.detectModels">${t('modal.provider.detectModels')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="supported-models-values">
|
||||
${renderSupportedModelsValue(selectedModels)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function collectDraftProviderConfig(providerDetail, providerType, uuid) {
|
||||
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
|
||||
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
|
||||
const providerConfig = {};
|
||||
|
||||
configInputs.forEach(input => {
|
||||
const key = input.dataset.configKey;
|
||||
let value = input.value;
|
||||
if (key === 'concurrencyLimit' || key === 'queueLimit') {
|
||||
value = parseInt(value || '0', 10);
|
||||
}
|
||||
providerConfig[key] = value;
|
||||
});
|
||||
|
||||
configSelects.forEach(select => {
|
||||
const key = select.dataset.configKey;
|
||||
providerConfig[key] = select.value === 'true';
|
||||
});
|
||||
|
||||
if (usesManagedModelList(providerType)) {
|
||||
const supportedModels = parseModelsData(getSupportedModelsContainer(uuid)?.dataset.selectedModels || '');
|
||||
providerConfig.supportedModels = supportedModels;
|
||||
providerConfig.notSupportedModels = [];
|
||||
} else {
|
||||
const modelCheckboxes = providerDetail.querySelectorAll(`.model-checkbox[data-uuid="${uuid}"]:checked`);
|
||||
providerConfig.notSupportedModels = Array.from(modelCheckboxes).map(checkbox => checkbox.value);
|
||||
}
|
||||
|
||||
return providerConfig;
|
||||
}
|
||||
let cachedModels = []; // 缓存模型列表
|
||||
|
||||
function closeSupportedModelsPicker(overlay) {
|
||||
if (!overlay) return;
|
||||
|
||||
if (overlay.escapeHandler) {
|
||||
document.removeEventListener('keydown', overlay.escapeHandler);
|
||||
}
|
||||
|
||||
overlay.remove();
|
||||
}
|
||||
|
||||
function showSupportedModelsPickerModal(providerType, uuid, detectedModels, currentSelectedModels = []) {
|
||||
const existingOverlay = document.querySelector('.provider-model-picker-overlay');
|
||||
if (existingOverlay) {
|
||||
closeSupportedModelsPicker(existingOverlay);
|
||||
}
|
||||
|
||||
const allModels = normalizeModelList([...detectedModels, ...currentSelectedModels]);
|
||||
const selectedModels = new Set(normalizeModelList(currentSelectedModels));
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay provider-model-picker-overlay';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content provider-model-picker-modal">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<i class="fas fa-cubes"></i>
|
||||
${escapeHtml(t('modal.provider.modelPickerTitle', { type: providerType }))}
|
||||
</h3>
|
||||
<button class="modal-close" type="button" aria-label="${escapeHtml(t('common.close'))}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="provider-model-picker-toolbar">
|
||||
<input type="search"
|
||||
class="provider-model-picker-search"
|
||||
placeholder="${escapeHtml(t('modal.provider.modelPickerSearchPlaceholder'))}">
|
||||
<label class="provider-model-picker-select-all">
|
||||
<input type="checkbox" class="provider-model-picker-select-all-input">
|
||||
<span>${escapeHtml(t('modal.provider.modelPickerSelectAll'))}</span>
|
||||
</label>
|
||||
<button type="button" class="btn btn-secondary provider-model-picker-clear">
|
||||
${escapeHtml(t('modal.provider.modelPickerClearAll'))}
|
||||
</button>
|
||||
</div>
|
||||
<div class="provider-model-picker-summary"></div>
|
||||
<div class="provider-model-picker-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary provider-model-picker-cancel">
|
||||
${escapeHtml(t('common.cancel'))}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary provider-model-picker-confirm">
|
||||
${escapeHtml(t('common.confirm'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const searchInput = overlay.querySelector('.provider-model-picker-search');
|
||||
const listContainer = overlay.querySelector('.provider-model-picker-list');
|
||||
const summary = overlay.querySelector('.provider-model-picker-summary');
|
||||
const selectAllInput = overlay.querySelector('.provider-model-picker-select-all-input');
|
||||
const clearButton = overlay.querySelector('.provider-model-picker-clear');
|
||||
const cancelButton = overlay.querySelector('.provider-model-picker-cancel');
|
||||
const confirmButton = overlay.querySelector('.provider-model-picker-confirm');
|
||||
const closeButton = overlay.querySelector('.modal-close');
|
||||
|
||||
const getVisibleModels = () => {
|
||||
const keyword = searchInput.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return allModels;
|
||||
}
|
||||
|
||||
return allModels.filter(model => model.toLowerCase().includes(keyword));
|
||||
};
|
||||
|
||||
const updateSelectAllState = () => {
|
||||
const visibleModels = getVisibleModels();
|
||||
if (visibleModels.length === 0) {
|
||||
selectAllInput.checked = false;
|
||||
selectAllInput.indeterminate = false;
|
||||
selectAllInput.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
selectAllInput.disabled = false;
|
||||
const checkedCount = visibleModels.filter(model => selectedModels.has(model)).length;
|
||||
selectAllInput.checked = checkedCount === visibleModels.length;
|
||||
selectAllInput.indeterminate = checkedCount > 0 && checkedCount < visibleModels.length;
|
||||
};
|
||||
|
||||
const updateSummary = () => {
|
||||
summary.textContent = t('modal.provider.modelPickerSelected', { count: selectedModels.size });
|
||||
};
|
||||
|
||||
const renderList = () => {
|
||||
const visibleModels = getVisibleModels();
|
||||
|
||||
if (visibleModels.length === 0) {
|
||||
listContainer.innerHTML = `
|
||||
<div class="provider-model-picker-empty">
|
||||
${escapeHtml(allModels.length === 0 ? t('modal.provider.detectModelsNoResults') : t('modal.provider.supportedModelsEmpty'))}
|
||||
</div>
|
||||
`;
|
||||
updateSelectAllState();
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
listContainer.innerHTML = visibleModels.map(model => `
|
||||
<label class="provider-model-picker-item">
|
||||
<input type="checkbox"
|
||||
value="${escapeHtml(model)}"
|
||||
${selectedModels.has(model) ? 'checked' : ''}>
|
||||
<span>${escapeHtml(model)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
listContainer.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
selectedModels.add(checkbox.value);
|
||||
} else {
|
||||
selectedModels.delete(checkbox.value);
|
||||
}
|
||||
updateSelectAllState();
|
||||
updateSummary();
|
||||
});
|
||||
});
|
||||
|
||||
updateSelectAllState();
|
||||
updateSummary();
|
||||
};
|
||||
|
||||
const handleClose = () => closeSupportedModelsPicker(overlay);
|
||||
|
||||
overlay.escapeHandler = event => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', overlay.escapeHandler);
|
||||
overlay.addEventListener('click', event => {
|
||||
if (event.target === overlay) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', renderList);
|
||||
selectAllInput.addEventListener('change', () => {
|
||||
const visibleModels = getVisibleModels();
|
||||
visibleModels.forEach(model => {
|
||||
if (selectAllInput.checked) {
|
||||
selectedModels.add(model);
|
||||
} else {
|
||||
selectedModels.delete(model);
|
||||
}
|
||||
});
|
||||
renderList();
|
||||
});
|
||||
clearButton.addEventListener('click', () => {
|
||||
selectedModels.clear();
|
||||
renderList();
|
||||
});
|
||||
cancelButton.addEventListener('click', handleClose);
|
||||
closeButton.addEventListener('click', handleClose);
|
||||
confirmButton.addEventListener('click', () => {
|
||||
setSupportedModelsSelection(uuid, Array.from(selectedModels));
|
||||
handleClose();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
renderList();
|
||||
searchInput.focus();
|
||||
}
|
||||
|
||||
async function openSupportedModelsPicker(providerType, uuid, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!usesManagedModelList(providerType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerDetail = event.target.closest('.provider-item-detail');
|
||||
if (!providerDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detectButton = providerDetail.querySelector('.detect-models-btn');
|
||||
const originalHtml = detectButton?.innerHTML;
|
||||
const draftProviderConfig = collectDraftProviderConfig(providerDetail, providerType, uuid);
|
||||
|
||||
try {
|
||||
if (detectButton) {
|
||||
detectButton.disabled = true;
|
||||
detectButton.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(t('common.loading'))}`;
|
||||
}
|
||||
|
||||
const response = await window.apiClient.post(
|
||||
`/providers/${encodeURIComponent(providerType)}/${uuid}/detect-models`,
|
||||
{ providerConfig: draftProviderConfig }
|
||||
);
|
||||
|
||||
showSupportedModelsPickerModal(
|
||||
providerType,
|
||||
uuid,
|
||||
response.models || [],
|
||||
draftProviderConfig.supportedModels || response.selectedModels || []
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to detect provider models:', error);
|
||||
showToast(t('common.error'), t('modal.provider.detectModelsFailed') + ': ' + error.message, 'error');
|
||||
} finally {
|
||||
if (detectButton) {
|
||||
detectButton.innerHTML = originalHtml;
|
||||
detectButton.disabled = !providerDetail.classList.contains('editing');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示提供商管理模态框
|
||||
* @param {Object} data - 提供商数据
|
||||
|
|
@ -202,11 +569,11 @@ function goToProviderPage(page) {
|
|||
const pageProviders = currentProviders.slice(startIndex, endIndex);
|
||||
|
||||
// 如果已缓存模型列表,直接使用
|
||||
if (cachedModels.length > 0) {
|
||||
if (!usesManagedModelList(currentProviderType) && cachedModels.length > 0) {
|
||||
pageProviders.forEach(provider => {
|
||||
renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []);
|
||||
});
|
||||
} else {
|
||||
} else if (!usesManagedModelList(currentProviderType)) {
|
||||
loadModelsForProviderType(currentProviderType, pageProviders);
|
||||
}
|
||||
}
|
||||
|
|
@ -232,6 +599,10 @@ function renderProviderListPaginated(providers, page) {
|
|||
*/
|
||||
async function loadModelsForProviderType(providerType, providers) {
|
||||
try {
|
||||
if (usesManagedModelList(providerType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已有缓存,直接使用
|
||||
if (cachedModels.length > 0) {
|
||||
providers.forEach(provider => {
|
||||
|
|
@ -425,6 +796,9 @@ function renderProviderList(providers) {
|
|||
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
|
||||
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit">编辑</span>
|
||||
</button>
|
||||
<button class="btn-small btn-info btn-provider-health-check" onclick="window.performSingleHealthCheck('${provider.uuid}', event)" title="${t('modal.provider.healthCheckCurrentTitle')}">
|
||||
<i class="fas fa-stethoscope"></i> <span data-i18n="modal.provider.healthCheck">${t('modal.provider.healthCheck')}</span>
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="window.deleteProvider('${provider.uuid}', event)">
|
||||
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete">删除</span>
|
||||
</button>
|
||||
|
|
@ -647,6 +1021,13 @@ function renderProviderConfig(provider) {
|
|||
}
|
||||
|
||||
// 添加 notSupportedModels 配置区域
|
||||
if (usesManagedModelList(currentProviderType)) {
|
||||
html += '<div class="form-grid full-width">';
|
||||
html += renderSupportedModelsSection(provider);
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
html += '<div class="form-grid full-width">';
|
||||
html += `
|
||||
<div class="config-item not-supported-models-section">
|
||||
|
|
@ -683,7 +1064,7 @@ function getFieldOrder(provider) {
|
|||
const excludedFields = [
|
||||
'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime',
|
||||
'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage',
|
||||
'notSupportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq'
|
||||
'notSupportedModels', 'supportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq'
|
||||
];
|
||||
|
||||
// 尝试从当前模态框上下文中获取提供商类型
|
||||
|
|
@ -791,6 +1172,11 @@ function editProvider(uuid, event) {
|
|||
modelCheckboxes.forEach(checkbox => {
|
||||
checkbox.disabled = false;
|
||||
});
|
||||
|
||||
const detectModelsButton = providerDetail.querySelector('.detect-models-btn');
|
||||
if (detectModelsButton) {
|
||||
detectModelsButton.disabled = false;
|
||||
}
|
||||
|
||||
// 添加编辑状态类
|
||||
providerDetail.classList.add('editing');
|
||||
|
|
@ -838,6 +1224,20 @@ function cancelEdit(uuid, event) {
|
|||
modelCheckboxes.forEach(checkbox => {
|
||||
checkbox.disabled = true;
|
||||
});
|
||||
|
||||
const detectModelsButton = providerDetail.querySelector('.detect-models-btn');
|
||||
if (detectModelsButton) {
|
||||
detectModelsButton.disabled = true;
|
||||
}
|
||||
|
||||
if (usesManagedModelList(currentProviderType)) {
|
||||
resetSupportedModelsSelection(uuid);
|
||||
} else {
|
||||
const currentProviderData = currentProviders.find(provider => provider.uuid === uuid);
|
||||
if (currentProviderData) {
|
||||
renderNotSupportedModelsSelector(uuid, cachedModels, currentProviderData.notSupportedModels || []);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除编辑状态类
|
||||
providerDetail.classList.remove('editing');
|
||||
|
|
@ -871,6 +1271,9 @@ function cancelEdit(uuid, event) {
|
|||
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
|
||||
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit">${t('modal.provider.edit')}</span>
|
||||
</button>
|
||||
<button class="btn-small btn-info btn-provider-health-check" onclick="window.performSingleHealthCheck('${uuid}', event)" title="${t('modal.provider.healthCheckCurrentTitle')}">
|
||||
<i class="fas fa-stethoscope"></i> <span data-i18n="modal.provider.healthCheck">${t('modal.provider.healthCheck')}</span>
|
||||
</button>
|
||||
<button class="btn-small btn-delete" onclick="window.deleteProvider('${uuid}', event)">
|
||||
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete">${t('modal.provider.delete')}</span>
|
||||
</button>
|
||||
|
|
@ -891,29 +1294,11 @@ async function saveProvider(uuid, event) {
|
|||
const providerDetail = event.target.closest('.provider-item-detail');
|
||||
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
|
||||
|
||||
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
|
||||
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
|
||||
const providerConfig = {};
|
||||
const providerConfig = collectDraftProviderConfig(providerDetail, providerType, uuid);
|
||||
|
||||
configInputs.forEach(input => {
|
||||
const key = input.dataset.configKey;
|
||||
let value = input.value;
|
||||
if (key === 'concurrencyLimit' || key === 'queueLimit') {
|
||||
value = parseInt(value || '0');
|
||||
}
|
||||
providerConfig[key] = value;
|
||||
});
|
||||
|
||||
configSelects.forEach(select => {
|
||||
const key = select.dataset.configKey;
|
||||
const value = select.value === 'true';
|
||||
providerConfig[key] = value;
|
||||
});
|
||||
|
||||
// 收集不支持的模型列表
|
||||
const modelCheckboxes = providerDetail.querySelectorAll(`.model-checkbox[data-uuid="${uuid}"]:checked`);
|
||||
const notSupportedModels = Array.from(modelCheckboxes).map(checkbox => checkbox.value);
|
||||
providerConfig.notSupportedModels = notSupportedModels;
|
||||
|
||||
try {
|
||||
await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig });
|
||||
|
|
@ -1434,6 +1819,67 @@ async function performHealthCheck(providerType) {
|
|||
* @param {string} uuid - 提供商UUID
|
||||
* @param {Event} event - 事件对象
|
||||
*/
|
||||
async function performSingleHealthCheck(uuid, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const button = event.currentTarget || event.target.closest('button');
|
||||
const providerDetail = event.target.closest('.provider-item-detail');
|
||||
const providerType = providerDetail?.closest('.provider-modal')?.getAttribute('data-provider-type');
|
||||
|
||||
if (!providerDetail || !providerType) {
|
||||
showToast(t('common.error'), t('modal.provider.healthCheckSingleFailed', { message: t('common.error') }), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHtml = button ? button.innerHTML : '';
|
||||
|
||||
try {
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> <span>${t('modal.provider.healthCheck')}</span>`;
|
||||
}
|
||||
|
||||
showToast(t('common.info'), t('modal.provider.healthCheck') + '...', 'info');
|
||||
|
||||
const response = await window.apiClient.post(
|
||||
`/providers/${encodeURIComponent(providerType)}/${uuid}/health-check`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
showToast(t('common.error'), t('modal.provider.healthCheckSingleFailed', { message: t('common.error') }), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = response.healthy
|
||||
? (response.modelName
|
||||
? t('modal.provider.healthCheckSingleSuccessWithModel', { model: response.modelName })
|
||||
: t('modal.provider.healthCheckSingleSuccess'))
|
||||
: t('modal.provider.healthCheckSingleFailed', { message: response.message || t('common.error') });
|
||||
|
||||
showToast(
|
||||
response.healthy ? t('common.success') : t('common.warning'),
|
||||
message,
|
||||
response.healthy ? 'success' : 'warning'
|
||||
);
|
||||
|
||||
await window.apiClient.post('/reload-config');
|
||||
await refreshProviderConfig(providerType);
|
||||
} catch (error) {
|
||||
console.error('Single provider health check failed:', error);
|
||||
showToast(
|
||||
t('common.error'),
|
||||
t('modal.provider.healthCheckSingleFailed', { message: error.message }),
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
if (button && button.isConnected) {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProviderUuid(uuid, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
|
|
@ -1610,9 +2056,11 @@ export {
|
|||
performHealthCheck,
|
||||
deleteUnhealthyProviders,
|
||||
refreshUnhealthyUuids,
|
||||
openSupportedModelsPicker,
|
||||
loadModelsForProviderType,
|
||||
renderNotSupportedModelsSelector,
|
||||
goToProviderPage,
|
||||
performSingleHealthCheck,
|
||||
refreshProviderUuid
|
||||
};
|
||||
|
||||
|
|
@ -1628,7 +2076,9 @@ window.addProvider = addProvider;
|
|||
window.toggleProviderStatus = toggleProviderStatus;
|
||||
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
||||
window.performHealthCheck = performHealthCheck;
|
||||
window.performSingleHealthCheck = performSingleHealthCheck;
|
||||
window.deleteUnhealthyProviders = deleteUnhealthyProviders;
|
||||
window.refreshUnhealthyUuids = refreshUnhealthyUuids;
|
||||
window.openSupportedModelsPicker = openSupportedModelsPicker;
|
||||
window.goToProviderPage = goToProviderPage;
|
||||
window.refreshProviderUuid = refreshProviderUuid;
|
||||
window.refreshProviderUuid = refreshProviderUuid;
|
||||
|
|
|
|||
|
|
@ -1121,6 +1121,172 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.supported-models-section {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.supported-models-section label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--neutral-700);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.supported-models-section .help-text {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: var(--neutral-500);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.supported-models-container {
|
||||
background: var(--neutral-100);
|
||||
border: 1px solid var(--neutral-300);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.supported-models-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.supported-models-summary {
|
||||
font-size: 12px;
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.supported-models-values {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.supported-models-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.supported-model-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--neutral-300);
|
||||
background: var(--white);
|
||||
color: var(--neutral-700);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.supported-models-empty {
|
||||
color: var(--neutral-500);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detect-models-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-model-picker-modal {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.provider-model-picker-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-model-picker-search {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-model-picker-select-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-model-picker-summary {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--neutral-600);
|
||||
}
|
||||
|
||||
.provider-model-picker-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-model-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--neutral-300);
|
||||
border-radius: 8px;
|
||||
background: var(--white);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-model-picker-item:hover {
|
||||
border-color: var(--neutral-400);
|
||||
background: var(--neutral-200);
|
||||
}
|
||||
|
||||
.provider-model-picker-item span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--neutral-700);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.provider-model-picker-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--neutral-500);
|
||||
border: 1px dashed var(--neutral-300);
|
||||
border-radius: 8px;
|
||||
background: var(--neutral-100);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.provider-model-picker-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-model-picker-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 授权按钮样式 */
|
||||
.generate-auth-btn {
|
||||
display: inline-flex;
|
||||
|
|
@ -1482,4 +1648,4 @@
|
|||
|
||||
[data-theme="dark"] .highlight-note i {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
tests/provider-models.unit.test.js
Normal file
33
tests/provider-models.unit.test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, test } from '@jest/globals';
|
||||
import {
|
||||
extractModelIdsFromNativeList,
|
||||
getConfiguredSupportedModels,
|
||||
usesManagedModelList
|
||||
} from '../src/providers/provider-models.js';
|
||||
|
||||
describe('provider-models helpers', () => {
|
||||
test('recognizes managed model list providers', () => {
|
||||
expect(usesManagedModelList('openai-custom')).toBe(true);
|
||||
expect(usesManagedModelList('openaiResponses-custom-lab')).toBe(true);
|
||||
expect(usesManagedModelList('gemini-cli-oauth')).toBe(false);
|
||||
});
|
||||
|
||||
test('normalizes supported models for managed providers', () => {
|
||||
expect(getConfiguredSupportedModels('openai-custom', {
|
||||
supportedModels: [' gpt-4o-mini ', '', 'gpt-4o-mini', 'gpt-4.1']
|
||||
})).toEqual(['gpt-4.1', 'gpt-4o-mini']);
|
||||
|
||||
expect(getConfiguredSupportedModels('gemini-cli-oauth', {
|
||||
supportedModels: ['gemini-2.5-flash']
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
test('extracts model ids from openai-style model lists', () => {
|
||||
expect(extractModelIdsFromNativeList({
|
||||
data: [
|
||||
{ id: 'gpt-4o-mini' },
|
||||
{ id: 'gpt-4.1' }
|
||||
]
|
||||
}, 'openai-custom')).toEqual(['gpt-4.1', 'gpt-4o-mini']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue