diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 8c815a0..a007db4 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -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 []; } diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index ab87f0a..25609cd 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -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) { diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index cd71bb9..f1d6bea 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -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; -} \ No newline at end of file +} diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index d6692ad..7d680ca 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -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; } -} \ No newline at end of file +} diff --git a/src/utils/common.js b/src/utils/common.js index 3582277..f6b801f 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -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)}`); diff --git a/static/app/i18n.js b/static/app/i18n.js index e51e1ad..7b496f5 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -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 登录方式时,需要 clientIdclientSecret 字段,可在同文件夹下的另一个 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, clientId and clientSecret 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', diff --git a/static/app/modal.js b/static/app/modal.js index 16132eb..404df5f 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -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 `
${escapeHtml(t('modal.provider.supportedModelsEmpty'))}
`; + } + + return ` +
+ ${selectedModels.map(model => ` + ${escapeHtml(model)} + `).join('')} +
+ `; +} + +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 ` +
+ +
+
+ ${escapeHtml(t('modal.provider.modelPickerSelected', { count: selectedModels.length }))} + +
+
+ ${renderSupportedModelsValue(selectedModels)} +
+
+
+ `; +} + +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 = ` + + `; + + 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 = ` +
+ ${escapeHtml(allModels.length === 0 ? t('modal.provider.detectModelsNoResults') : t('modal.provider.supportedModelsEmpty'))} +
+ `; + updateSelectAllState(); + updateSummary(); + return; + } + + listContainer.innerHTML = visibleModels.map(model => ` + + `).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 = ` ${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) { + @@ -647,6 +1021,13 @@ function renderProviderConfig(provider) { } // 添加 notSupportedModels 配置区域 + if (usesManagedModelList(currentProviderType)) { + html += '
'; + html += renderSupportedModelsSection(provider); + html += '
'; + return html; + } + html += '
'; html += `
@@ -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) { + @@ -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 = ` ${t('modal.provider.healthCheck')}`; + } + + 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; \ No newline at end of file +window.refreshProviderUuid = refreshProviderUuid; diff --git a/static/components/section-providers.css b/static/components/section-providers.css index f30523b..8349743 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -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); -} \ No newline at end of file +} diff --git a/tests/provider-models.unit.test.js b/tests/provider-models.unit.test.js new file mode 100644 index 0000000..f65f7f0 --- /dev/null +++ b/tests/provider-models.unit.test.js @@ -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']); + }); +});