feat: add managed model list picker for custom providers

This commit is contained in:
HenryZ-0302 2026-04-05 13:38:01 -07:00
parent ed9282b52e
commit 75f2676236
8 changed files with 871 additions and 63 deletions

View file

@ -1,8 +1,9 @@
/**
* 各提供商支持的模型列表
* 用于前端UI选择不支持的模型
*/
import { convertData } from '../convert/convert.js';
import { MODEL_PROVIDER } from '../utils/common.js';
/**
* Provider model catalogs used by the Web UI.
*/
export const PROVIDER_MODELS = {
'gemini-cli-oauth': [
'gemini-2.5-flash',
@ -48,9 +49,7 @@ export const PROVIDER_MODELS = {
'vision-model'
],
'openai-iflow': [
// iFlow 特有模型
'iflow-rome-30ba3b',
// Qwen 模型
'qwen3-coder-plus',
'qwen3-max',
'qwen3-vl-plus',
@ -59,16 +58,12 @@ export const PROVIDER_MODELS = {
'qwen3-235b-a22b-thinking-2507',
'qwen3-235b-a22b-instruct',
'qwen3-235b',
// Kimi 模型
'kimi-k2-0905',
'kimi-k2',
// GLM 模型
'glm-4.6',
// DeepSeek 模型
'deepseek-v3.2',
'deepseek-r1',
'deepseek-v3',
// 手动定义
'glm-4.7',
'glm-5',
'kimi-k2.5',
@ -110,30 +105,100 @@ 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 - 提供商类型
* @returns {Array<string>} 模型列表
* Gets models supported by a provider type.
* @param {string} providerType
* @returns {Array<string>}
*/
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 [];
}
/**
* 获取所有提供商的模型列表
* @returns {Object} 所有提供商的模型映射
*/
export function getAllProviderModels() {
return PROVIDER_MODELS;
}

View file

@ -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) {

View file

@ -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,14 @@ 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);
}
// 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 +356,4 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
}
return false;
}
}

View file

@ -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,30 @@ 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))
);
}
// 使用 Promise 链式队列,确保文件操作顺序执行
let _fileLockChain = Promise.resolve();
@ -228,26 +258,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 +292,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 +313,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 +430,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 +529,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,
@ -1252,4 +1366,4 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide
res.end(JSON.stringify({ error: { message: error.message } }));
return true;
}
}
}

View file

@ -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': '检查模型名称',
@ -817,6 +828,7 @@ const translations = {
// Common
'common.confirm': '确定',
'common.cancel': '取消',
'common.close': '关闭',
'common.success': '成功',
'common.enabled': '已启用',
'common.disabled': '已禁用',
@ -1379,6 +1391,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',
@ -1689,6 +1712,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',

View file

@ -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 => {
@ -647,6 +1018,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 +1061,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 +1169,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 +1221,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');
@ -891,29 +1288,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 });
@ -1610,6 +1989,7 @@ export {
performHealthCheck,
deleteUnhealthyProviders,
refreshUnhealthyUuids,
openSupportedModelsPicker,
loadModelsForProviderType,
renderNotSupportedModelsSelector,
goToProviderPage,
@ -1630,5 +2010,6 @@ window.resetAllProvidersHealth = resetAllProvidersHealth;
window.performHealthCheck = performHealthCheck;
window.deleteUnhealthyProviders = deleteUnhealthyProviders;
window.refreshUnhealthyUuids = refreshUnhealthyUuids;
window.openSupportedModelsPicker = openSupportedModelsPicker;
window.goToProviderPage = goToProviderPage;
window.refreshProviderUuid = refreshProviderUuid;
window.refreshProviderUuid = refreshProviderUuid;

View file

@ -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);
}
}

View 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']);
});
});