refactor: health check timeout (#8837)

* refactor: enable model edit/remove on checking

* refactor: add timeout setting to healthcheck popup

* fix: type
This commit is contained in:
one 2025-08-05 11:49:46 +08:00 committed by GitHub
parent aac4adea1a
commit 63b126b530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 102 additions and 73 deletions

View File

@ -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)"
},

View File

@ -2990,6 +2990,7 @@
"select_api_key": "使用するAPIキーを選択",
"single": "単一",
"start": "開始",
"timeout": "タイムアウト",
"title": "モデル健康チェック",
"use_all_keys": "キー"
},

View File

@ -2990,6 +2990,7 @@
"select_api_key": "Выберите API ключ для использования:",
"single": "Один",
"start": "Начать",
"timeout": "Тайм-аут",
"title": "Проверка состояния моделей",
"use_all_keys": "Использовать все ключи"
},

View File

@ -2990,6 +2990,7 @@
"select_api_key": "选择要使用的 API 密钥:",
"single": "单个",
"start": "开始",
"timeout": "超时",
"title": "模型健康检测",
"use_all_keys": "使用密钥"
},

View File

@ -2990,6 +2990,7 @@
"select_api_key": "選擇要使用的 API 密鑰:",
"single": "單個",
"start": "開始",
"timeout": "超時",
"title": "模型健康檢查",
"use_all_keys": "使用密鑰"
},

View File

@ -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<Action>
) {
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<Props> = ({ 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<Props> = ({ 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 (
<Flex vertical gap={10}>
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
<Typography.Text strong>{t('settings.models.check.use_all_keys')}:</Typography.Text>
<Segmented
value={keyCheckMode}
onChange={(value) => 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') }
]}
/>
</Flex>
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
<Typography.Text strong>{t('settings.models.check.enable_concurrent')}:</Typography.Text>
<Segmented
value={isConcurrent ? 'enabled' : 'disabled'}
onChange={(value) => 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') }
]}
/>
</Flex>
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
<Typography.Text strong>{t('settings.models.check.timeout')}:</Typography.Text>
<InputNumber
value={timeoutSeconds}
onChange={(value) => dispatch({ type: 'SET_TIMEOUT_SECONDS', payload: value || 15 })}
min={5}
max={60}
size="small"
style={{ width: 90 }}
addonAfter="s"
/>
</Flex>
</Flex>
)
}, [isConcurrent, keyCheckMode, timeoutSeconds, t])
return (
<Modal
title={title}
open={open}
onOk={onStart}
onCancel={onCancel}
afterClose={onClose}
okText={t('settings.models.check.start')}
cancelText={t('common.cancel')}
centered
maskClosable={false}
width={500}
transitionName="animation-move-down"
footer={
<Space style={{ display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Space align="center">
<Typography.Text strong>{t('settings.models.check.use_all_keys')}</Typography.Text>
<Segmented
value={keyCheckMode}
onChange={(value) => 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') }
]}
/>
</Space>
<Space align="center">
<Typography.Text strong>{t('settings.models.check.enable_concurrent')}</Typography.Text>
<Segmented
value={isConcurrent ? 'enabled' : 'disabled'}
onChange={(value) => 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') }
]}
/>
</Space>
</Space>
<Button key="start" type="primary" onClick={onStart} size="small">
{t('settings.models.check.start')}
</Button>
</Space>
}>
<Alert message={t('settings.models.check.disclaimer')} type="warning" showIcon style={{ fontSize: 12 }} />
footer={(_, { OkBtn, CancelBtn }) => (
<>
{renderFooter}
<Flex justify="space-between" style={{ marginTop: 16 }}>
<div /> {/* Empty div for spacing */}
<Flex gap={8}>
<CancelBtn />
<OkBtn />
</Flex>
</Flex>
</>
)}>
<Alert
message={t('common.warning')}
description={t('settings.models.check.disclaimer')}
type="warning"
showIcon
style={{ fontSize: 12 }}
/>
{/* API key selection section - only shown for 'single' mode and multiple keys */}
{keyCheckMode === 'single' && hasMultipleKeys && (
<Box style={{ marginBottom: 16 }}>
<Box style={{ marginTop: 10, marginBottom: 10 }}>
<strong>{t('settings.models.check.select_api_key')}</strong>
<Radio.Group
value={selectedKeyIndex}

View File

@ -129,7 +129,6 @@ const ModelList: React.FC<ModelListProps> = ({ 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))}

View File

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

View File

@ -849,10 +849,9 @@ export function checkApiProvider(provider: Provider): void {
}
}
export async function checkApi(provider: Provider, model: Model): Promise<void> {
export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> {
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<void>
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 }
}

View File

@ -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<ApiKeyWithStatus[]> {
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<ModelWithStatus[]> {
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 = {

View File

@ -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<T>(
provider: Provider,
model: Model,
checkFn: (provider: Provider, model: Model) => Promise<T>
): 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)
})
}

View File

@ -49,4 +49,5 @@ export interface ModelCheckOptions {
models: Model[]
apiKeys: string[]
isConcurrent: boolean
timeout?: number
}