From 63b126b5307ac58aae583afdc0b21ea66528933a Mon Sep 17 00:00:00 2001 From: one Date: Tue, 5 Aug 2025 11:49:46 +0800 Subject: [PATCH] refactor: health check timeout (#8837) * refactor: enable model edit/remove on checking * refactor: add timeout setting to healthcheck popup * fix: type --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../ModelList/HealthCheckPopup.tsx | 120 ++++++++++++------ .../ProviderSettings/ModelList/ModelList.tsx | 1 - .../ModelList/useHealthCheck.ts | 3 +- src/renderer/src/services/ApiService.ts | 9 +- .../src/services/HealthCheckService.ts | 11 +- src/renderer/src/services/ModelService.ts | 25 +--- src/renderer/src/types/healthCheck.ts | 1 + 12 files changed, 102 insertions(+), 73 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b0191d552d..ae2cc8a762 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2990,6 +2990,7 @@ "select_api_key": "Select the API key to use:", "single": "Single", "start": "Start", + "timeout": "Timeout", "title": "Model health check", "use_all_keys": "Key(s)" }, diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d2cb0530b1..8e3d2a9c11 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2990,6 +2990,7 @@ "select_api_key": "使用するAPIキーを選択:", "single": "単一", "start": "開始", + "timeout": "タイムアウト", "title": "モデル健康チェック", "use_all_keys": "キー" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index bf286aa06b..c959dda58d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2990,6 +2990,7 @@ "select_api_key": "Выберите API ключ для использования:", "single": "Один", "start": "Начать", + "timeout": "Тайм-аут", "title": "Проверка состояния моделей", "use_all_keys": "Использовать все ключи" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b845343ba4..78bd4143ca 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2990,6 +2990,7 @@ "select_api_key": "选择要使用的 API 密钥:", "single": "单个", "start": "开始", + "timeout": "超时", "title": "模型健康检测", "use_all_keys": "使用密钥" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 1eec826001..84869ab5c5 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2990,6 +2990,7 @@ "select_api_key": "選擇要使用的 API 密鑰:", "single": "單個", "start": "開始", + "timeout": "超時", "title": "模型健康檢查", "use_all_keys": "使用密鑰" }, diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx index 6257da0d89..4d36d14bed 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx @@ -2,7 +2,7 @@ import { Box } from '@renderer/components/Layout' import { TopView } from '@renderer/components/TopView' import { Provider } from '@renderer/types' import { maskApiKey } from '@renderer/utils/api' -import { Button, Modal, Radio, Segmented, Space, Typography } from 'antd' +import { Flex, InputNumber, Modal, Radio, Segmented, Typography } from 'antd' import { Alert } from 'antd' import { useCallback, useMemo, useReducer } from 'react' import { useTranslation } from 'react-i18next' @@ -17,6 +17,7 @@ interface ResolveData { apiKeys: string[] isConcurrent: boolean cancelled?: boolean + timeout?: number } interface Props extends ShowParams { @@ -31,6 +32,7 @@ type State = { selectedKeyIndex: number keyCheckMode: 'single' | 'all' // Whether to check with single key or all keys isConcurrent: boolean + timeoutSeconds: number // Timeout in seconds } /** @@ -41,6 +43,7 @@ type Action = | { type: 'SET_KEY_INDEX'; payload: number } | { type: 'SET_KEY_CHECK_MODE'; payload: 'single' | 'all' } | { type: 'SET_CONCURRENT'; payload: boolean } + | { type: 'SET_TIMEOUT_SECONDS'; payload: number } /** * Reducer function to handle state updates @@ -55,6 +58,8 @@ function reducer(state: State, action: Action): State { return { ...state, keyCheckMode: action.payload } case 'SET_CONCURRENT': return { ...state, isConcurrent: action.payload } + case 'SET_TIMEOUT_SECONDS': + return { ...state, timeoutSeconds: action.payload } default: return state } @@ -69,6 +74,7 @@ function useModalActions( selectedKeyIndex: number, keyCheckMode: 'single' | 'all', isConcurrent: boolean, + timeoutSeconds: number, dispatch: React.Dispatch ) { const onStart = useCallback(() => { @@ -78,11 +84,12 @@ function useModalActions( // Return config data resolve({ apiKeys: keysToUse, - isConcurrent + isConcurrent, + timeout: timeoutSeconds * 1000 // Convert seconds to milliseconds }) dispatch({ type: 'SET_OPEN', payload: false }) - }, [apiKeys, selectedKeyIndex, keyCheckMode, isConcurrent, resolve, dispatch]) + }, [apiKeys, selectedKeyIndex, keyCheckMode, isConcurrent, timeoutSeconds, resolve, dispatch]) const onCancel = useCallback(() => { dispatch({ type: 'SET_OPEN', payload: false }) @@ -106,10 +113,11 @@ const PopupContainer: React.FC = ({ title, apiKeys, resolve }) => { open: true, selectedKeyIndex: 0, keyCheckMode: 'all', - isConcurrent: true + isConcurrent: true, + timeoutSeconds: 15 }) - const { open, selectedKeyIndex, keyCheckMode, isConcurrent } = state + const { open, selectedKeyIndex, keyCheckMode, isConcurrent, timeoutSeconds } = state // Use custom hooks const { onStart, onCancel, onClose } = useModalActions( @@ -118,60 +126,92 @@ const PopupContainer: React.FC = ({ title, apiKeys, resolve }) => { selectedKeyIndex, keyCheckMode, isConcurrent, + timeoutSeconds, dispatch ) // Check if we have multiple API keys const hasMultipleKeys = useMemo(() => apiKeys.length > 1, [apiKeys.length]) + const renderFooter = useMemo(() => { + return ( + + + {t('settings.models.check.use_all_keys')}: + dispatch({ type: 'SET_KEY_CHECK_MODE', payload: value as 'single' | 'all' })} + size="small" + options={[ + { value: 'single', label: t('settings.models.check.single') }, + { value: 'all', label: t('settings.models.check.all') } + ]} + /> + + + {t('settings.models.check.enable_concurrent')}: + dispatch({ type: 'SET_CONCURRENT', payload: value === 'enabled' })} + size="small" + options={[ + { value: 'disabled', label: t('settings.models.check.disabled') }, + { value: 'enabled', label: t('settings.models.check.enabled') } + ]} + /> + + + {t('settings.models.check.timeout')}: + dispatch({ type: 'SET_TIMEOUT_SECONDS', payload: value || 15 })} + min={5} + max={60} + size="small" + style={{ width: 90 }} + addonAfter="s" + /> + + + ) + }, [isConcurrent, keyCheckMode, timeoutSeconds, t]) + return ( - - - {t('settings.models.check.use_all_keys')} - dispatch({ type: 'SET_KEY_CHECK_MODE', payload: value as 'single' | 'all' })} - size="small" - options={[ - { value: 'single', label: t('settings.models.check.single') }, - { value: 'all', label: t('settings.models.check.all') } - ]} - /> - - - {t('settings.models.check.enable_concurrent')} - dispatch({ type: 'SET_CONCURRENT', payload: value === 'enabled' })} - size="small" - options={[ - { value: 'disabled', label: t('settings.models.check.disabled') }, - { value: 'enabled', label: t('settings.models.check.enabled') } - ]} - /> - - - - - }> - + footer={(_, { OkBtn, CancelBtn }) => ( + <> + {renderFooter} + +
{/* Empty div for spacing */} + + + + + + + )}> + {/* API key selection section - only shown for 'single' mode and multiple keys */} {keyCheckMode === 'single' && hasMultipleKeys && ( - + {t('settings.models.check.select_api_key')} = ({ providerId }) => { models={displayedModelGroups[group]} modelStatuses={modelStatuses} defaultOpen={i <= 5} - disabled={isHealthChecking} onEditModel={(model) => EditModelPopup.show({ provider, model })} onRemoveModel={removeModel} onRemoveGroup={() => displayedModelGroups[group].forEach((model) => removeModel(model))} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/useHealthCheck.ts b/src/renderer/src/pages/settings/ProviderSettings/ModelList/useHealthCheck.ts index a8cae584e6..71b9bccb83 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/useHealthCheck.ts +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/useHealthCheck.ts @@ -62,7 +62,8 @@ export const useHealthCheck = (provider: Provider, models: Model[]) => { provider, models: modelsToCheck, apiKeys: result.apiKeys, - isConcurrent: result.isConcurrent + isConcurrent: result.isConcurrent, + timeout: result.timeout }, (checkResult, index) => { setModelStatuses((current) => { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index a824f96c20..d59d242252 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -849,10 +849,9 @@ export function checkApiProvider(provider: Provider): void { } } -export async function checkApi(provider: Provider, model: Model): Promise { +export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise { checkApiProvider(provider) - const timeout = 15000 const controller = new AbortController() const abortFn = () => controller.abort() const taskId = uuid() @@ -930,3 +929,9 @@ export async function checkApi(provider: Provider, model: Model): Promise removeAbortController(taskId, abortFn) } } + +export async function checkModel(provider: Provider, model: Model, timeout = 15000): Promise<{ latency: number }> { + const startTime = performance.now() + await checkApi(provider, model, timeout) + return { latency: performance.now() - startTime } +} diff --git a/src/renderer/src/services/HealthCheckService.ts b/src/renderer/src/services/HealthCheckService.ts index 000a678613..02c3217dcc 100644 --- a/src/renderer/src/services/HealthCheckService.ts +++ b/src/renderer/src/services/HealthCheckService.ts @@ -4,7 +4,7 @@ import { ApiKeyWithStatus, HealthStatus, ModelCheckOptions, ModelWithStatus } fr import { formatErrorMessage } from '@renderer/utils/error' import { aggregateApiKeyResults } from '@renderer/utils/healthCheck' -import { checkModel } from './ModelService' +import { checkModel } from './ApiService' const logger = loggerService.withContext('HealthCheckService') @@ -14,12 +14,13 @@ const logger = loggerService.withContext('HealthCheckService') export async function checkModelWithMultipleKeys( provider: Provider, model: Model, - apiKeys: string[] + apiKeys: string[], + timeout?: number ): Promise { const checkPromises = apiKeys.map(async (key) => { const startTime = Date.now() // 如果 checkModel 抛出错误,让这个 promise 失败 - await checkModel({ ...provider, apiKey: key }, model) + await checkModel({ ...provider, apiKey: key }, model, timeout) const latency = Date.now() - startTime return { @@ -51,12 +52,12 @@ export async function checkModelsHealth( options: ModelCheckOptions, onModelChecked?: (result: ModelWithStatus, index: number) => void ): Promise { - const { provider, models, apiKeys, isConcurrent } = options + const { provider, models, apiKeys, isConcurrent, timeout } = options const results: ModelWithStatus[] = [] try { const modelPromises = models.map(async (model, index) => { - const keyResults = await checkModelWithMultipleKeys(provider, model, apiKeys) + const keyResults = await checkModelWithMultipleKeys(provider, model, apiKeys, timeout) const analysis = aggregateApiKeyResults(keyResults) const result: ModelWithStatus = { diff --git a/src/renderer/src/services/ModelService.ts b/src/renderer/src/services/ModelService.ts index 743e511bab..a5b4cd5072 100644 --- a/src/renderer/src/services/ModelService.ts +++ b/src/renderer/src/services/ModelService.ts @@ -1,10 +1,8 @@ import store from '@renderer/store' -import { Model, Provider } from '@renderer/types' +import { Model } from '@renderer/types' import { getFancyProviderName } from '@renderer/utils' import { pick } from 'lodash' -import { checkApi } from './ApiService' - export const getModelUniqId = (m?: Model) => { return m?.id ? JSON.stringify(pick(m, ['id', 'provider'])) : '' } @@ -30,24 +28,3 @@ export function getModelName(model?: Model) { return modelName } - -// Generic function to perform model checks with exception handling -async function performModelCheck( - provider: Provider, - model: Model, - checkFn: (provider: Provider, model: Model) => Promise -): Promise<{ latency: number }> { - const startTime = performance.now() - await checkFn(provider, model) - const latency = performance.now() - startTime - - return { latency } -} - -// Unified model check function -// Automatically selects appropriate check method based on model type -export async function checkModel(provider: Provider, model: Model): Promise<{ latency: number }> { - return performModelCheck(provider, model, async (provider, model) => { - await checkApi(provider, model) - }) -} diff --git a/src/renderer/src/types/healthCheck.ts b/src/renderer/src/types/healthCheck.ts index 46815b9c82..840c4ef277 100644 --- a/src/renderer/src/types/healthCheck.ts +++ b/src/renderer/src/types/healthCheck.ts @@ -49,4 +49,5 @@ export interface ModelCheckOptions { models: Model[] apiKeys: string[] isConcurrent: boolean + timeout?: number }