refactor: replace status check string literal with enum; add chat pin… (#1486)

* refactor: replace status check string literal with enum; add chat ping check option in provider configurations for anthropic, and if it's good, generalize to all openai based provider

* fix: merging conflicts

* refactor: remove manual validation logic and deprecated ProviderValidationAlerts component

* docs: mark 'Health' enum as deprecated with additional usage context

* feat: add manual test validation flow back

* refactor: reintroduce and integrate `ProviderValidationAlerts` component for streamlined validation logic

* chore: mark error msg

* refactor: extract `CHAT_COMPLETIONS_VALIDATOR_ID` constant; enforce skipping chat ping check in background validations

* chore: comments
This commit is contained in:
MisakaKumomi 御坂云见
2026-03-29 13:22:15 +08:00
committed by GitHub
parent 00d075204f
commit 372695458e
54 changed files with 383 additions and 261 deletions
+1
View File
@@ -124,3 +124,4 @@ plugins-development
apps/stage-pocket/ios/buildServer.json
.ghfs/
.docs/superpowers
apps/stage-tamagotchi/electron.vite.config.*.mjs
@@ -19,13 +19,31 @@ dialogs:
baseUrl: Base URL
baseUrlHelp: API endpoint URL (use default if unsure)
accountId: Account ID
validationRunning: Running tests
validationSuccess: Configuration validation success
validationPartial: Configuration partially validated
validationFailed: Configuration validation failed
validationError: 'Validation error: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping test failed
retryPingCheck: Retry
skipForNow: Skip for now
saveAndContinue: Save and Continue
next: Next
retry: Retry
credentialsSafeLabel: Keep your API keys and credentials safe!
credentialsSafeLocal: >-
AIRI is running pure locally in your PC, and we will never steal
your credentials for AI / LLM providers. But keep in mind that your API
keys are sensitive information. Make sure to keep them safe and do not
share them with anyone.
credentialsSafeOpenSource: >-
AIRI is open sourced at {github}, if you want to check how we handle
your credentials, feel free to inspect our code.
enableChatCheck: >-
Allow airi to send a "ping" message to the first available chat model to
check availability
start: Let's do it!
loginPrompt: Sign in to use the official AIRI provider for the best experience.
loginAction: Sign in
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: URL del endpoint de la API (usa el predeterminado si no estás seguro)
accountId: ID de Cuenta
validationSuccess: La validación de la configuración fue exitosa
validationPartial: Configuración parcialmente validada
validationFailed: La validación de la configuración falló
validationError: 'Error de validación: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping falló
skipForNow: Omitir por ahora
saveAndContinue: Guardar y Continuar
next: Siguiente
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: URL de l'endpoint API (utilisez la valeur par défaut si vous n’êtes pas sûr)
accountId: ID du compte
validationSuccess: Validation de la configuration réussie
validationPartial: Configuration partiellement validée
validationFailed: La validation de la configuration a échouée.
validationError: 'Erreur de validation: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping échoué
skipForNow: Passer pour l'instant
saveAndContinue: Sauvegarder et Continuer
next: Suivant
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: APIエンドポイントURL(不明な場合はデフォルトを使用)
accountId: アカウントID
validationSuccess: 設定検証に成功
validationPartial: 設定が部分的に検証されました
validationFailed: 設定検証に失敗
validationError: '検証エラー: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping テストに失敗
skipForNow: 今はスキップ
saveAndContinue: 保存して続行
next: 次へ
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: API 엔드포인트 URL(확실하지 않은 경우 기본값 사용)
accountId: 계정 ID
validationSuccess: 구성 검증 성공
validationPartial: 구성이 부분적으로 검증됨
validationFailed: 구성 검증 실패
validationError: '검증 오류: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping 테스트 실패
skipForNow: 지금은 건너뛰기
saveAndContinue: 저장 및 계속
next: 다음
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: URL конечной точки API (оставьте по умолчанию, если не уверены)
accountId: ID аккаунта
validationSuccess: Успешная проверка конфигурации
validationPartial: Конфигурация частично проверена
validationFailed: Проверка конфигурации не удалась
validationError: 'Ошибка проверки: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping не удался
skipForNow: Пропустить на сейчас
saveAndContinue: Сохранить и продолжить
next: Далее
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: Địa chỉ API đầu cuối (dùng mặc định nếu không chắc)
accountId: ID tài khoản
validationSuccess: Xác thực cấu hình thành công
validationPartial: Cấu hình được xác thực một phần
validationFailed: Xác thực cấu hình thất bại
validationError: 'Lỗi xác thực: {error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping thất bại
skipForNow: Bỏ qua
saveAndContinue: Lưu và tiếp tục
next: Tiếp
@@ -18,13 +18,26 @@ dialogs:
baseUrl: 基础 URL
baseUrlHelp: API 端点 URL(如果不确定请使用默认值)
accountId: 账户 ID
validationRunning: 正在验证配置
validationSuccess: 配置验证成功
validationPartial: 配置部分验证通过
validationFailed: 配置验证失败
validationError: '验证错误:{error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping 测试失败
retryPingCheck: 再试一次
skipForNow: 暂时跳过
saveAndContinue: 保存并继续
next: 下一步
retry: 重试
credentialsSafeLabel: 请妥善保管你的 API 密钥和凭据!
credentialsSafeLocal: >-
AIRI 完全在你的计算机中本地运行,我们绝不会窃取你的 AI / LLM
服务商凭据。但请注意,你的 API 密钥是敏感信息,请妥善保管,不要与任何人分享。
credentialsSafeOpenSource: >-
AIRI 已在 {github} 开源,如果你想了解我们如何处理你的凭据,欢迎查阅我们的代码。
enableChatCheck: 允许 airi 向第一个可用的聊天模型发送 "ping" 消息来检查可用性
start: 开始吧!
loginPrompt: Sign in to use the official AIRI provider for the best experience.
loginAction: Sign in
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: API 端點 URL(如果不確定請使用預設值)
accountId: 帳號 ID
validationSuccess: 設定驗證成功
validationPartial: 設定部分驗證通過
validationFailed: 設定驗證失敗
validationError: '驗證錯誤:{error}'
testGeneration: Ping API
testGenerationRunning: Ping...
testGenerationFailed: Ping 測試失敗
skipForNow: 先略過
saveAndContinue: 儲存並繼續
next: 下一步
@@ -109,7 +109,6 @@ function goToModelSelection() {
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
@@ -117,7 +117,6 @@ const {
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
@@ -82,7 +82,6 @@ const {
/>
</ProviderBasicSettings>
<!-- Validation Status -->
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
@@ -64,7 +64,6 @@ const {
/>
</ProviderBasicSettings>
<!-- Validation Status -->
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
@@ -180,7 +180,6 @@ onMounted(() => {
/>
</ProviderAdvancedSettings>
<!-- Validation Status -->
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
@@ -2,7 +2,8 @@
import type { ProviderMetadata } from '../../../../stores/providers'
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { Button, Callout, FieldInput } from '@proj-airi/ui'
import { errorMessageFrom } from '@moeru/std'
import { Button, Callout, FieldCheckbox, FieldInput } from '@proj-airi/ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -24,6 +25,7 @@ const providersStore = useProvidersStore()
const apiKey = ref('')
const baseUrl = ref('')
const accountId = ref('')
const enableChatCheck = ref(true)
const validation = ref<'unchecked' | 'pending' | 'succeed' | 'failed'>('unchecked')
const validationError = ref<any>()
@@ -39,9 +41,10 @@ function initializeForm() {
apiKey.value = ''
accountId.value = ''
// Reset validation
// Reset validation and chat check
validation.value = 'unchecked'
validationError.value = undefined
enableChatCheck.value = true
}
// Watch for provider changes
@@ -67,6 +70,10 @@ const needsBaseUrl = computed(() => {
return props.selectedProvider.id !== 'cloudflare-workers-ai'
})
const showChatCheckOption = computed(() => {
return props.selectedProvider?.validators.chatPingCheckAvailable
})
const canProceed = computed(() => {
if (!props.selectedProviderId)
return false
@@ -103,7 +110,9 @@ async function validateConfiguration() {
// Validate using provider's validator
const metadata = providersStore.getProviderMetadata(props.selectedProvider.id)
const validationResult = await metadata.validators.validateProviderConfig(config)
const validationResult = await metadata.validators.validateProviderConfig(config, {
skipChatPingCheck: !enableChatCheck.value,
})
validation.value = validationResult.valid ? 'succeed' : 'failed'
if (validation.value === 'failed') {
validationError.value = validationResult.reason
@@ -112,11 +121,10 @@ async function validateConfiguration() {
catch (error) {
validation.value = 'failed'
validationError.value = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
error: errorMessageFrom(error) ?? 'Unknown error',
})
}
}
async function handleNext() {
await validateConfiguration()
if (validation.value === 'succeed') {
@@ -184,15 +192,19 @@ initializeForm()
<div h-5 w-5 />
</div>
<div v-if="props.selectedProvider" flex-1 overflow-y-auto space-y-4>
<Callout label="Keep your API keys and credentials safe!" theme="violet">
<Callout :label="t('settings.dialogs.onboarding.credentialsSafeLabel')" theme="violet">
<div>
<div>
AIRI is running pure locally in your browser, and we will never steal your credentials for AI / LLM providers. But keep in mind that your API keys are sensitive information. Make sure to keep them safe and do not share them with anyone.
{{ t('settings.dialogs.onboarding.credentialsSafeLocal') }}
</div>
<div>
AIRI is open sourced at <div inline-flex translate-y-1 items-center gap-1>
<div i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
</div>, if you want to check how we handle your credentials, feel free to inspect our code.
<i18n-t keypath="settings.dialogs.onboarding.credentialsSafeOpenSource" tag="span">
<template #github>
<span inline-flex translate-y-1 items-center gap-1>
<span i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
</span>
</template>
</i18n-t>
</div>
</div>
</Callout>
@@ -226,6 +238,14 @@ initializeForm()
</div>
</div>
<!-- Chat Ping Check Option -->
<FieldCheckbox
v-if="showChatCheckOption"
v-model="enableChatCheck"
:label="t('settings.dialogs.onboarding.enableChatCheck')"
placement="left"
/>
<!-- Validation Status -->
<Alert v-if="validation === 'failed'" type="error">
<template #title>
@@ -20,14 +20,20 @@ const { t } = useI18n()
</script>
<template>
<!-- Validation Error -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<!-- Validation Running -->
<Alert v-if="isValidating > 0" type="loading">
<template #title>
<div class="w-full flex items-center justify-between">
{{ t('settings.dialogs.onboarding.validationRunning') }}
</template>
</Alert>
<!-- Validation Error -->
<Alert v-else-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
<div :class="['w-full flex items-center justify-between']">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
:class="['ml-2 rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-red-100 text-red-600 hover:bg-red-200', 'dark:bg-red-800/30 dark:text-red-300 dark:hover:bg-red-700/40']"
@click="props.onForceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
@@ -35,28 +41,28 @@ const { t } = useI18n()
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
<div :class="['whitespace-pre-wrap break-all']">
{{ validationMessage }}
</div>
</template>
</Alert>
<!-- Partial Validation: manual validators exist, no test attempted yet -->
<!-- Partial: auto validation passed, manual test not yet attempted -->
<Alert v-else-if="isValid && isValidating === 0 && hasManualValidators && !manualTestPassed && !manualTestMessage" type="info">
<template #title>
<div class="w-full flex items-center justify-between">
<div :class="['w-full flex items-center justify-between']">
<span>{{ t('settings.dialogs.onboarding.validationPartial') }}</span>
<div class="flex items-center gap-2">
<div :class="['flex items-center gap-2']">
<button
type="button"
:disabled="isManualTesting"
:class="['rounded px-2 py-0.5 text-xs font-medium transition-colors', isManualTesting ? 'opacity-50 cursor-not-allowed' : '', 'bg-blue-100 text-blue-600 hover:bg-blue-200', 'dark:bg-blue-800/30 dark:text-blue-300 dark:hover:bg-blue-700/40']"
:class="['rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-blue-100 text-blue-600 hover:bg-blue-200', 'dark:bg-blue-800/30 dark:text-blue-300 dark:hover:bg-blue-700/40', isManualTesting ? 'cursor-not-allowed opacity-50' : '']"
@click="props.onRunTest"
>
{{ isManualTesting ? t('settings.dialogs.onboarding.testGenerationRunning') : t('settings.dialogs.onboarding.testGeneration') }}
</button>
<button
type="button"
class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-600 font-medium transition-colors dark:bg-blue-800/30 hover:bg-blue-200 dark:text-blue-300 dark:hover:bg-blue-700/40"
:class="['rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-blue-100 text-blue-600 hover:bg-blue-200', 'dark:bg-blue-800/30 dark:text-blue-300 dark:hover:bg-blue-700/40']"
@click="props.onGoToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
@@ -65,14 +71,14 @@ const { t } = useI18n()
</div>
</template>
</Alert>
<!-- Full Validation Success -->
<!-- Full success -->
<Alert v-else-if="isValid && isValidating === 0 && (!hasManualValidators || manualTestPassed)" type="success">
<template #title>
<div class="w-full flex items-center justify-between">
<div :class="['w-full flex items-center justify-between']">
<span>{{ t('settings.dialogs.onboarding.validationSuccess') }}</span>
<button
type="button"
class="ml-2 rounded bg-green-100 px-2 py-0.5 text-xs text-green-600 font-medium transition-colors dark:bg-green-800/30 hover:bg-green-200 dark:text-green-300 dark:hover:bg-green-700/40"
:class="['ml-2 rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-green-100 text-green-600 hover:bg-green-200', 'dark:bg-green-800/30 dark:text-green-300 dark:hover:bg-green-700/40']"
@click="props.onGoToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
@@ -80,22 +86,31 @@ const { t } = useI18n()
</div>
</template>
</Alert>
<!-- Manual Test Failed -->
<!-- Manual test failed -->
<Alert v-else-if="hasManualValidators && !manualTestPassed && manualTestMessage && !isManualTesting" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<div :class="['w-full flex items-center justify-between']">
<span>{{ t('settings.dialogs.onboarding.testGenerationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="props.onForceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
<div :class="['flex items-center gap-2']">
<button
type="button"
:class="['rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-red-100 text-red-600 hover:bg-red-200', 'dark:bg-red-800/30 dark:text-red-300 dark:hover:bg-red-700/40']"
@click="props.onRunTest"
>
{{ t('settings.dialogs.onboarding.retryPingCheck') }}
</button>
<button
type="button"
:class="['rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-red-100 text-red-600 hover:bg-red-200', 'dark:bg-red-800/30 dark:text-red-300 dark:hover:bg-red-700/40']"
@click="props.onForceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</div>
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
<div :class="['whitespace-pre-wrap break-all']">
{{ manualTestMessage }}
</div>
</template>
@@ -53,7 +53,8 @@ export function useProviderValidation(providerId: string) {
const isValid = ref(false)
const validationMessage = ref('')
const hasManualValidators = computed(() => !!providerMetadata.value?.validators.runManualValidation)
// Manual chat ping check state (settings pages only)
const hasManualValidators = computed(() => !!providerMetadata.value?.validators.chatPingCheckAvailable)
const isManualTesting = ref(false)
const manualTestPassed = ref(false)
const manualTestMessage = ref('')
@@ -74,7 +75,11 @@ export function useProviderValidation(providerId: string) {
if (config.baseUrl)
config.baseUrl = config.baseUrl.trim()
const validationResult = await providerMetadata.value.validators.validateProviderConfig(config)
// Settings pages always skip chat ping check during automatic validation
// to avoid unexpected API billing. Users can trigger it manually.
const validationResult = await providerMetadata.value.validators.validateProviderConfig(config, {
skipChatPingCheck: true,
})
isValid.value = validationResult.valid
if (!isValid.value)
@@ -91,7 +96,7 @@ export function useProviderValidation(providerId: string) {
catch (error) {
isValid.value = false
finalValidationMessage = t('settings.dialogs.onboarding.validationError', {
error: errorMessageFrom(error),
error: errorMessageFrom(error) ?? 'Generic error (993b5ad7)',
})
}
finally {
@@ -102,6 +107,36 @@ export function useProviderValidation(providerId: string) {
}
}
async function runManualTest() {
if (!providerMetadata.value)
return
isManualTesting.value = true
manualTestMessage.value = ''
try {
const config = { ...credentials.value }
if (config.apiKey)
config.apiKey = config.apiKey.trim()
if (config.baseUrl)
config.baseUrl = config.baseUrl.trim()
const result = await providerMetadata.value.validators.validateProviderConfig(config, {
onlyChatPingCheck: true,
})
manualTestPassed.value = result.valid
if (!result.valid)
manualTestMessage.value = result.reason
}
catch (error) {
manualTestPassed.value = false
manualTestMessage.value = errorMessageFrom(error) ?? 'Generic error (e56ae24f)'
}
finally {
isManualTesting.value = false
}
}
const debouncedValidateConfiguration = useDebounceFn(() => {
const config = credentials.value
const hasApiKey = 'apiKey' in config && !!config.apiKey?.trim()
@@ -131,34 +166,6 @@ export function useProviderValidation(providerId: string) {
manualTestMessage.value = ''
}, { deep: true })
async function runManualTest() {
if (!providerMetadata.value?.validators.runManualValidation)
return
isManualTesting.value = true
manualTestMessage.value = ''
try {
const config = { ...credentials.value }
if (config?.apiKey)
config.apiKey = config.apiKey.trim()
if (config?.baseUrl)
config.baseUrl = config.baseUrl.trim()
const result = await providerMetadata.value.validators.runManualValidation(config)
manualTestPassed.value = result.valid
if (!result.valid)
manualTestMessage.value = result.reason
}
catch (error) {
manualTestPassed.value = false
manualTestMessage.value = errorMessageFrom(error) ?? 'Unknown error'
}
finally {
isManualTesting.value = false
}
}
function handleResetSettings() {
const defaultOptions = providerMetadata.value?.defaultOptions ? providerMetadata.value.defaultOptions() : {}
providers.value[providerId] = { ...defaultOptions }
@@ -1,7 +1,8 @@
import { createChatProvider, createEmbedProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const ai302ConfigSchema = z.object({
@@ -52,7 +53,7 @@ export const provider302AI = defineProvider<AI302Config>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['model_list'],
checks: [ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createChatProvider, createEmbedProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const aihubmixConfigSchema = z.object({
@@ -52,7 +53,7 @@ export const providerAIHubMix = defineProvider<AIHubMixConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['model_list'],
checks: [ProviderValidationCheck.ModelList],
}),
},
})
@@ -3,7 +3,8 @@ import type { ModelInfo } from '../../types'
import { createChatProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const anthropicConfigSchema = z.object({
@@ -91,7 +92,7 @@ export const providerAnthropic = defineProvider<AnthropicConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ChatCompletions],
additionalHeaders: {
'anthropic-dangerous-direct-browser-access': 'true',
},
@@ -1,7 +1,8 @@
import { createCerebras } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const cerebrasConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerCerebrasAI = defineProvider<CerebrasConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createChatProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const cometApiConfigSchema = z.object({
@@ -50,7 +51,7 @@ export const providerCometAPI = defineProvider<CometApiConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['model_list'],
checks: [ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createDeepSeek } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const deepSeekConfigSchema = z.object({
@@ -48,7 +49,7 @@ export const providerDeepSeek = defineProvider<DeepSeekConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const featherlessConfigSchema = z.object({
@@ -46,7 +47,7 @@ export const providerFeatherlessAI = defineProvider<FeatherlessConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createFireworks } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const fireworksConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerFireworksAI = defineProvider<FireworksConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createGoogleGenerativeAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const googleGenerativeConfigSchema = z.object({
@@ -48,7 +49,7 @@ export const providerGoogleGenerativeAI = defineProvider<GoogleGenerativeConfig>
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const groqConfigSchema = z.object({
@@ -46,7 +47,7 @@ export const providerGroq = defineProvider<GroqConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['model_list'],
checks: [ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createChatProvider, createEmbedProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const lmStudioConfigSchema = z.object({
@@ -53,7 +54,7 @@ export const providerLmStudio = defineProvider<LMStudioConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
skipApiKeyCheck: true,
schedule: {
mode: 'interval',
@@ -3,7 +3,8 @@ import type { ModelInfo } from '../../types'
import { createMinimax, createMinimaxCn } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const minimaxCnConfigSchema = z.object({
@@ -114,7 +115,7 @@ export const providerMinimax = defineProvider<MinimaxCnConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity'],
checks: [ProviderValidationCheck.Connectivity],
}),
},
})
@@ -154,7 +155,7 @@ export const providerMinimaxGlobal = defineProvider<MinimaxGlobalConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity'],
checks: [ProviderValidationCheck.Connectivity],
}),
},
})
@@ -1,7 +1,8 @@
import { createMistral } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const mistralConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerMistralAI = defineProvider<MistralConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const modelscopeConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerModelScope = defineProvider<ModelscopeConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createMoonshotai } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const moonshotConfigSchema = z.object({
@@ -46,7 +47,7 @@ export const providerMoonshotAI = defineProvider<MoonshotConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const n1nConfigSchema = z.object({
@@ -48,7 +49,7 @@ export const providerN1N = defineProvider<N1NConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list', 'chat_completions'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList, ProviderValidationCheck.ChatCompletions],
}),
},
})
@@ -1,7 +1,8 @@
import { createNovita } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const novitaConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerNovitaAI = defineProvider<NovitaConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -2,7 +2,8 @@ import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const nvidiaConfigSchema = z.object({
@@ -48,7 +49,7 @@ export const providerNvidia = defineProvider<NvidiaConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,6 +1,7 @@
import { createOllama } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
@@ -166,7 +167,7 @@ export const providerOllama = defineProvider<OllamaConfig>({
}),
],
validateProvider: createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
schedule: {
mode: 'interval',
intervalMs: 15_000,
@@ -1,7 +1,8 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const openAICompatibleConfigSchema = z.object({
@@ -48,7 +49,7 @@ export const providerOpenAICompatible = defineProvider<OpenAICompatibleConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list', 'chat_completions'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList, ProviderValidationCheck.ChatCompletions],
}),
},
})
@@ -1,7 +1,8 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const openAICompatibleConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerOpenAI = defineProvider<OpenAICompatibleConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list', 'chat_completions'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList, ProviderValidationCheck.ChatCompletions],
}),
},
})
@@ -1,7 +1,8 @@
import { createOpenRouter } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
export const OPENROUTER_ATTRIBUTION_HEADERS: Record<string, string> = {
@@ -64,7 +65,7 @@ export const providerOpenRouterAI = defineProvider<OpenRouterConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
additionalHeaders: OPENROUTER_ATTRIBUTION_HEADERS,
}),
},
@@ -3,7 +3,8 @@ import type { ModelInfo } from '../../types'
import { createPerplexity } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const perplexityConfigSchema = z.object({
@@ -83,7 +84,7 @@ export const providerPerplexityAI = defineProvider<PerplexityConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity'],
checks: [ProviderValidationCheck.Connectivity],
}),
},
})
@@ -1,7 +1,8 @@
import { createTogetherAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const togetherConfigSchema = z.object({
@@ -47,7 +48,7 @@ export const providerTogetherAI = defineProvider<TogetherConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createXai } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const xaiConfigSchema = z.object({
@@ -46,7 +47,7 @@ export const providerXAI = defineProvider<XAIConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
@@ -1,7 +1,8 @@
import { createZai } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
import { ProviderValidationCheck } from '../../types'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
const zaiConfigSchema = z.object({
@@ -46,7 +47,7 @@ export const providerZai = defineProvider<ZaiConfig>({
},
validators: {
...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList],
}),
},
})
+33 -6
View File
@@ -48,6 +48,29 @@ export interface ProviderValidationResult {
valid: boolean
}
/**
* Validator ID fragment for the chat completions probe.
* Matched via `.includes()` against validator instance ids
* (e.g. `openai-compatible:check-chat-completions`).
*/
export const CHAT_COMPLETIONS_VALIDATOR_ID = 'check-chat-completions'
export enum ProviderValidationCheck {
/** Lightweight GET to /models endpoint to check reachability (definition system) */
Connectivity = 'connectivity',
/** Fetch model list and verify non-empty */
ModelList = 'model_list',
/** Send generateText ping with fine-grained error handling and caching (definition system) */
ChatCompletions = 'chat_completions',
/**
* @deprecated
* Being used in builder system (a deprecated provider creation protocol),
* currently used by only OpenAI TTS && OpenAI Transcription.
* Send generateText ping with simple pass/fail, fallback to 'test' model (builder system)
*/
Health = 'health',
}
export interface ProviderValidatorSchedule {
mode: 'once' | 'interval'
intervalMs?: number
@@ -65,12 +88,6 @@ export interface ProviderRuntimeValidator<TConfig> {
name: string
validator: (config: TConfig, provider: ProviderInstance, providerExtra: ProviderExtraMethods<TConfig>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult>
schedule?: ProviderValidatorSchedule
/**
* When true the validator is excluded from automatic validation runs.
* It must be triggered explicitly by the user (e.g. a "Test Generation" button).
* This is used for costly checks like chat-completion probes that bill per request.
*/
manualOnly?: boolean
}
export interface ModelInfo {
@@ -163,6 +180,16 @@ export interface ProviderDefinition<TConfig extends any = any> {
streamInput: boolean
}
}
/**
* When true, hides the "skip chat ping check" checkbox in the UI even
* when the provider defines a ChatCompletions validator.
*
* By default, the checkbox is shown automatically whenever a provider
* includes a ChatCompletions runtime validator. Set this to `true` for
* providers where skipping that check is not meaningful or has not been
* verified yet.
*/
disableChatPingCheckUI?: boolean
business?: (contextOptions: { t: ComposerTranslation }) => {
troubleshooting?: {
validators?: {
@@ -4,6 +4,7 @@ import type { ProviderExtraMethods, ProviderInstance } from '../types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ProviderValidationCheck } from '../types'
import { createOpenAICompatibleValidators } from './openai-compatible'
const {
@@ -59,7 +60,7 @@ describe('createOpenAICompatibleValidators', () => {
it('connectivity check uses lightweight fetch instead of generateText', async () => {
const [connectivityValidator] = getProviderValidators({
checks: ['connectivity'],
checks: [ProviderValidationCheck.Connectivity],
})
const result = await connectivityValidator.validator(config, provider, providerExtra, { t: mockT })
@@ -76,7 +77,7 @@ describe('createOpenAICompatibleValidators', () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
const [connectivityValidator] = getProviderValidators({
checks: ['connectivity'],
checks: [ProviderValidationCheck.Connectivity],
})
const result = await connectivityValidator.validator(config, provider, providerExtra, { t: mockT })
@@ -90,7 +91,7 @@ describe('createOpenAICompatibleValidators', () => {
listModelsMock.mockResolvedValue([])
const [connectivityValidator, chatValidator] = getProviderValidators({
checks: ['connectivity', 'chat_completions'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ChatCompletions],
})
const connectivityResult = await connectivityValidator.validator(config, provider, providerExtra, { t: mockT })
@@ -106,7 +107,7 @@ describe('createOpenAICompatibleValidators', () => {
listModelsMock.mockResolvedValue([])
const [connectivityValidator, chatValidator] = getProviderValidators({
checks: ['connectivity', 'chat_completions'],
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ChatCompletions],
allowValidationWithoutModel: true,
})
@@ -8,12 +8,10 @@ import { listModels } from '@xsai/model'
import { message } from '@xsai/utils-chat'
import { Mutex } from 'es-toolkit'
import { isModelProvider } from '../types'
type OpenAICompatibleValidationCheck = 'connectivity' | 'model_list' | 'chat_completions'
import { isModelProvider, ProviderValidationCheck } from '../types'
interface OpenAICompatibleValidationOptions<TConfig extends { apiKey?: string, baseUrl?: string }> {
checks?: OpenAICompatibleValidationCheck[]
checks?: ProviderValidationCheck[]
additionalHeaders?: Record<string, string>
allowValidationWithoutModel?: boolean
schedule?: {
@@ -103,7 +101,7 @@ async function pickValidationModel<TConfig extends { apiKey?: string | null, bas
export function createOpenAICompatibleValidators<TConfig extends { apiKey?: string, baseUrl?: string }>(
options?: OpenAICompatibleValidationOptions<TConfig>,
): ProviderDefinition<TConfig>['validators'] {
const checks = options?.checks ?? ['connectivity', 'model_list']
const checks = options?.checks ?? [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ModelList]
const additionalHeaders = options?.additionalHeaders
const missingValidationModelReason = 'No model available for validation. Configure a model manually and try again.'
@@ -235,7 +233,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
},
}))
if (checks.includes('connectivity')) {
if (checks.includes(ProviderValidationCheck.Connectivity)) {
validatorConfig.validateProvider?.push(({ t }) => ({
id: 'openai-compatible:check-connectivity',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
@@ -286,12 +284,11 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
}))
}
if (checks.includes('chat_completions')) {
if (checks.includes(ProviderValidationCheck.ChatCompletions)) {
validatorConfig.validateProvider?.push(({ t }) => ({
id: 'openai-compatible:check-chat-completions',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-chat-completion.title'),
schedule: options?.schedule,
manualOnly: true,
validator: async (config, provider, providerExtra, contextOptions) => {
const errors: Array<{ error: unknown }> = []
const result = await getChatCheckResult(
@@ -314,7 +311,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
}))
}
if (checks.includes('model_list')) {
if (checks.includes(ProviderValidationCheck.ModelList)) {
validatorConfig.validateProvider?.push(({ t }) => ({
id: 'openai-compatible:check-model-list',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-model-listing.title'),
@@ -26,8 +26,6 @@ export interface ProviderValidationPlan {
definition: ProviderDefinition
configValidators: ProviderConfigValidator<Record<string, unknown>>[]
providerValidators: ProviderRuntimeValidator<Record<string, unknown>>[]
/** Provider validators that require explicit user action (e.g. "Test Generation" button). */
manualProviderValidators: ProviderRuntimeValidator<Record<string, unknown>>[]
providerExtra: ProviderExtraMethods<Record<string, unknown>> | undefined
shouldValidate: boolean
}
@@ -87,9 +85,7 @@ export function getValidatorsOfProvider(options: {
const configValidators = (definition.validators?.validateConfig || []).map(creator => creator(options.contextOptions))
const allProviderValidators = (definition.validators?.validateProvider || []).map(creator => creator(options.contextOptions))
// Separate automatic validators from manual-only ones (e.g. chat completion probes)
const providerValidators = allProviderValidators.filter(v => !v.manualOnly)
const manualProviderValidators = allProviderValidators.filter(v => v.manualOnly)
const providerValidators = allProviderValidators
const steps: ProviderValidationStep[] = [
...createConfigValidationSteps(configValidators),
@@ -106,7 +102,6 @@ export function getValidatorsOfProvider(options: {
definition,
configValidators: configValidators as ProviderValidationPlan['configValidators'],
providerValidators: providerValidators as ProviderValidationPlan['providerValidators'],
manualProviderValidators: manualProviderValidators as ProviderValidationPlan['manualProviderValidators'],
providerExtra: definition.extraMethods as ProviderValidationPlan['providerExtra'],
shouldValidate,
}
@@ -189,66 +184,3 @@ export async function validateProvider(
return steps
}
/**
* Run only the manual-only validators (e.g. chat completion probes).
* These are excluded from automatic validation to avoid costly API calls.
* Must be triggered explicitly by the user.
*/
export async function validateProviderManual(
plan: ProviderValidationPlan,
contextOptions: { t: ComposerTranslation },
callbacks: ProviderValidationCallbacks = {},
) {
const { manualProviderValidators, config, definition, providerExtra } = plan
if (manualProviderValidators.length === 0)
return []
const runContext = {
...contextOptions,
validationCache: new Map<string, unknown>(),
}
const { onValidatorError, onValidatorStart, onValidatorSuccess } = callbacks
let providerInstance: ProviderInstance
try {
providerInstance = await definition.createProvider(config)
}
catch (error) {
return manualProviderValidators.map(v => ({
id: v.id,
label: v.name,
status: 'invalid' as ProviderValidationStepStatus,
reason: errorMessageFrom(error) ?? 'Unknown error',
kind: 'provider' as ProviderValidationStepKind,
}))
}
const steps: ProviderValidationStep[] = manualProviderValidators.map(v => ({
id: v.id,
label: v.name,
status: 'idle' as ProviderValidationStepStatus,
reason: '',
kind: 'provider' as ProviderValidationStepKind,
}))
await Promise.all(manualProviderValidators.map(async (validatorDefinition, index) => {
const step = steps[index]
step.status = 'validating'
step.reason = ''
onValidatorStart?.({ kind: 'provider', index, step })
try {
const result = await validatorDefinition.validator(config, providerInstance, providerExtra as any, runContext)
step.status = result.valid ? 'valid' : 'invalid'
step.reason = result.valid ? '' : result.reason
onValidatorSuccess?.({ kind: 'provider', index, step, result })
}
catch (error) {
step.status = 'invalid'
step.reason = errorMessageFrom(error) ?? 'Unknown error'
onValidatorError?.({ kind: 'provider', index, step, error })
}
}))
return steps
}
+41 -15
View File
@@ -47,8 +47,7 @@ import {
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { listProviders as listDefinedProviders } from '../libs/providers'
import { getProviderValidationIntervalMs } from '../libs/providers/validators/run'
import { getProviderValidationIntervalMs, listProviders as listDefinedProviders, ProviderValidationCheck } from '../libs/providers'
import { getKokoroWorker } from '../workers/kokoro'
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
import { useAuthStore } from './auth'
@@ -140,7 +139,14 @@ export interface ProviderMetadata {
loadModel?: (config: Record<string, unknown>, hooks?: { onProgress?: (progress: ProgressInfo) => Promise<void> | void }) => Promise<void>
}
validators: {
validateProviderConfig: (config: Record<string, unknown>) => Promise<{
/**
* Validate a provider's configuration.
*
* PITFALL: When `skipChatPingCheck` is not set, the ChatCompletions validator
* (if present) may send a real `generateText("ping")` request that consumes
* API tokens. All automatic/background callers may consider pass `skipChatPingCheck: true`.
*/
validateProviderConfig: (config: Record<string, unknown>, options?: { skipChatPingCheck?: boolean, onlyChatPingCheck?: boolean }) => Promise<{
errors: unknown[]
reason: string
valid: boolean
@@ -150,14 +156,12 @@ export interface ProviderMetadata {
valid: boolean
}
/**
* Run only the manual-only validators. Returns validation result.
* Only available when the provider has manual validators.
* Whether the "skip chat ping check" checkbox should be shown in the UI.
*
* Automatically derived: `true` when the provider has a ChatCompletions
* runtime validator AND `disableChatPingCheckUI` is not set on the definition.
*/
runManualValidation?: (config: Record<string, unknown>) => Promise<{
errors: unknown[]
reason: string
valid: boolean
}>
chatPingCheckAvailable: boolean
}
/**
* If true, the provider does not require user-provided credentials (e.g. API keys).
@@ -277,6 +281,7 @@ export const useProvidersStore = defineStore('providers', () => {
listVoices: async () => [],
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: () => ({
errors: [],
reason: '',
@@ -297,6 +302,7 @@ export const useProvidersStore = defineStore('providers', () => {
creator: createOpenAI,
validation: [],
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
if (!config.baseUrl) {
return {
@@ -327,6 +333,7 @@ export const useProvidersStore = defineStore('providers', () => {
creator: createOpenAI,
validation: [],
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
if (!config.baseUrl) {
return {
@@ -357,6 +364,7 @@ export const useProvidersStore = defineStore('providers', () => {
creator: createOpenAI,
validation: [],
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
if (!config.baseUrl) {
return {
@@ -387,6 +395,7 @@ export const useProvidersStore = defineStore('providers', () => {
creator: createOpenAI,
validation: [],
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
if (!config.baseUrl) {
return {
@@ -415,7 +424,7 @@ export const useProvidersStore = defineStore('providers', () => {
tasks: ['text-to-speech'],
defaultBaseUrl: 'https://api.openai.com/v1/',
creator: createOpenAI,
validation: ['health'],
validation: [ProviderValidationCheck.Health],
capabilities: {
// NOTE: OpenAI does not provide an API endpoint to retrieve available voices.
// Voices are hardcoded here - this is a provider limitation, not an application limitation.
@@ -560,6 +569,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API Key is required'),
@@ -646,7 +656,7 @@ export const useProvidersStore = defineStore('providers', () => {
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
defaultBaseUrl: 'https://api.openai.com/v1/',
creator: createOpenAI,
validation: ['health'],
validation: [ProviderValidationCheck.Health],
capabilities: {
listModels: async () => {
// OpenAI transcription models are hardcoded (no API endpoint to list them)
@@ -695,6 +705,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API Key is required'),
@@ -796,6 +807,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors: Error[] = []
const toString = (value: unknown) => typeof value === 'string' ? value.trim() : ''
@@ -875,6 +887,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: () => {
// Web Speech API requires no configuration, just browser support
// Always return valid if browser supports it, so it auto-configures
@@ -968,6 +981,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API key is required.'),
@@ -1052,6 +1066,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors: Error[] = []
if (!config.apiKey) {
@@ -1117,6 +1132,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API key is required.'),
@@ -1193,6 +1209,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: async (config) => {
const errors = [
!config.baseUrl && new Error('Base URL is required. Default to http://localhost:11996/tts/ for Index-TTS.'),
@@ -1282,6 +1299,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API key is required.'),
@@ -1347,6 +1365,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API key is required.'),
@@ -1382,7 +1401,7 @@ export const useProvidersStore = defineStore('providers', () => {
createModelProvider({ apiKey, baseURL }),
createSpeechProvider({ apiKey, baseURL }),
),
validation: ['model_list'],
validation: [ProviderValidationCheck.ModelList],
}),
'comet-api-transcription': buildOpenAICompatibleProvider({
id: 'comet-api-transcription',
@@ -1398,7 +1417,7 @@ export const useProvidersStore = defineStore('providers', () => {
createModelProvider({ apiKey, baseURL }),
createTranscriptionProvider({ apiKey, baseURL }),
),
validation: ['model_list'],
validation: [ProviderValidationCheck.ModelList],
}),
'player2-speech': {
id: 'player2-speech',
@@ -1481,6 +1500,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: async (config) => {
const errors = [
!config.baseUrl && new Error('Base URL is required. Default to http://localhost:4315/v1/'),
@@ -1681,6 +1701,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: async (config: any) => {
const model = config.model as string
@@ -1803,7 +1824,12 @@ export const useProvidersStore = defineStore('providers', () => {
}
const runValidation = async () => {
const validationResult = await metadata.validators.validateProviderConfig(config || {})
// PITFALL: Please consider skip chat ping check during automatic/background validation,
// since this can consume API tokens and may only be triggered
// by user action (e.g. "Ping API" button on settings pages) or other user intentions.
const validationResult = await metadata.validators.validateProviderConfig(config || {}, {
skipChatPingCheck: true,
})
if (providerRuntimeState.value[providerId]) {
providerRuntimeState.value[providerId].isConfigured = validationResult.valid
@@ -99,7 +99,7 @@ describe('providers converters', () => {
} as any
const metadata = convertProviderDefinitionToMetadata(definition, ((key: string) => key) as any)
const result = await metadata.validators.validateProviderConfig({ apiKey: 'k' })
const result = await metadata.validators.validateProviderConfig({ apiKey: 'k' }, { skipChatPingCheck: true })
expect(result.valid).toBe(false)
expect(result.reason).toContain('Base URL is required.')
@@ -1,13 +1,12 @@
import type { ComposerTranslation } from 'vue-i18n'
import type { ProviderDefinition } from '../../libs/providers/types'
import type { ProviderValidationPlan } from '../../libs/providers/validators/run'
import type { ProviderDefinition, ProviderValidationPlan } from '../../libs'
import type { ProviderMetadata } from '../providers'
import { listModels } from '@xsai/model'
import { isModelProvider } from '../../libs/providers/types'
import { getValidatorsOfProvider, validateProvider, validateProviderManual } from '../../libs/providers/validators/run'
import { CHAT_COMPLETIONS_VALIDATOR_ID, isModelProvider } from '../../libs/providers/types'
import { getValidatorsOfProvider, validateProvider } from '../../libs/providers/validators/run'
function getCategoryFromTasks(tasks: string[]): ProviderMetadata['category'] {
if (tasks.some(task => ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'].includes(task.toLowerCase()))) {
@@ -100,9 +99,6 @@ export function convertProviderDefinitionToMetadata(
const keyExtractor = (input: string): string => input
const category = getCategoryFromTasks(definition.tasks)
const schemaDefaults = extractSchemaDefaults(definition, t)
const allValidators = (definition.validators?.validateProvider || []).map(creator => creator({ t }))
const hasManualValidators = allValidators.some(v => v.manualOnly)
return {
id: definition.id,
order: definition.order,
@@ -187,7 +183,36 @@ export function convertProviderDefinitionToMetadata(
: undefined,
},
validators: {
validateProviderConfig: async (config) => {
chatPingCheckAvailable: !definition.disableChatPingCheckUI
&& (definition.validators?.validateProvider || [])
.some(creator => creator({ t }).id.includes(CHAT_COMPLETIONS_VALIDATOR_ID)),
validateProviderConfig: async (config, options) => {
// onlyChatPingCheck: skip all validators except chat completions.
// Used by the manual "Test Generation" button on settings pages.
if (options?.onlyChatPingCheck) {
const plan = getValidatorsOfProvider({
definition,
config,
schemaDefaults,
contextOptions: { t },
})
plan.configValidators = []
plan.providerValidators = plan.providerValidators.filter(v => v.id.includes(CHAT_COMPLETIONS_VALIDATOR_ID))
plan.steps = plan.steps.filter(s => s.id.includes(CHAT_COMPLETIONS_VALIDATOR_ID))
if (plan.providerValidators.length === 0) {
return { errors: [], reason: '', valid: true }
}
await validateProvider(plan, { t })
const invalidSteps = plan.steps.filter(step => step.status === 'invalid')
return {
errors: invalidSteps.map(step => new Error(step.reason || `${step.id} is invalid`)),
reason: invalidSteps.map(step => step.reason).filter(Boolean).join('; '),
valid: invalidSteps.length === 0,
}
}
const plan = getValidatorsOfProvider({
definition,
config,
@@ -195,10 +220,13 @@ export function convertProviderDefinitionToMetadata(
contextOptions: { t },
})
if (options?.skipChatPingCheck) {
plan.providerValidators = plan.providerValidators.filter(v => !v.id.includes(CHAT_COMPLETIONS_VALIDATOR_ID))
plan.steps = plan.steps.filter(s => !s.id.includes(CHAT_COMPLETIONS_VALIDATOR_ID))
}
// Run full validation pipeline (config + provider validators) only when required.
// This preserves strict config checks while avoiding unnecessary network checks.
// NOTICE: manualOnly validators (e.g. chat completion probes) are already excluded
// from the plan's providerValidators by getValidatorsOfProvider.
if (plan.shouldValidate) {
await validateProvider(plan, { t })
const invalidSteps = plan.steps.filter(step => step.status === 'invalid')
@@ -235,26 +263,6 @@ export function convertProviderDefinitionToMetadata(
await validateProvider(plan, { t })
return buildConfigValidationResult(plan)
},
runManualValidation: hasManualValidators
? async (config) => {
const plan = getValidatorsOfProvider({
definition,
config,
schemaDefaults,
contextOptions: { t },
})
const steps = await validateProviderManual(plan, { t })
const invalidSteps = steps.filter(step => step.status === 'invalid')
if (invalidSteps.length === 0) {
return { errors: [], reason: '', valid: true }
}
return {
errors: invalidSteps.map(step => new Error(step.reason || `${step.id} is invalid`)),
reason: invalidSteps.map(step => step.reason).filter(Boolean).join('; '),
valid: false,
}
}
: undefined,
},
transcriptionFeatures: definition.capabilities?.transcription
? {
@@ -4,6 +4,8 @@ import { generateText } from '@xsai/generate-text'
import { listModels } from '@xsai/model'
import { message } from '@xsai/utils-chat'
import { ProviderValidationCheck } from '../../libs/providers'
type ProviderCreator = (apiKey: string, baseUrl: string) => any
// Lightweight normalization utilities and conditional logging
@@ -47,7 +49,7 @@ export function buildOpenAICompatibleProvider(
creator: ProviderCreator
capabilities?: ProviderMetadata['capabilities']
validators?: ProviderMetadata['validators']
validation?: ('health' | 'model_list' | 'chat_completions')[]
validation?: ProviderValidationCheck[]
additionalHeaders?: Record<string, string>
transcriptionFeatures?: ProviderMetadata['transcriptionFeatures']
},
@@ -114,6 +116,7 @@ export function buildOpenAICompatibleProvider(
}
const finalValidators = validators || {
chatPingCheckAvailable: false,
validateProviderConfig: async (config: Record<string, unknown>) => {
const errors: Error[] = []
let baseUrl = normalizeString(config.baseUrl)
@@ -191,7 +194,7 @@ export function buildOpenAICompatibleProvider(
// Health check = try generating text (was: fetch(`${baseUrl}chat/completions`))
const asyncChecks: Promise<Error | null>[] = []
if (validationChecks.includes('health') && hasApiKey) {
if (validationChecks.includes(ProviderValidationCheck.Health) && hasApiKey) {
asyncChecks.push((async () => {
try {
const model = await modelPromise
@@ -212,7 +215,7 @@ export function buildOpenAICompatibleProvider(
}
// Model list validation (was: fetch(`${baseUrl}models`))
if (validationChecks.includes('model_list') && hasApiKey) {
if (validationChecks.includes(ProviderValidationCheck.ModelList) && hasApiKey) {
asyncChecks.push((async () => {
try {
const models = await listModels({
@@ -250,6 +250,7 @@ export function buildOpenRouterAudioSpeechProvider(
listVoices: async () => listVoices(),
},
validators: {
chatPingCheckAvailable: false,
validateProviderConfig: (config) => {
const errors: Error[] = []
if (!config.apiKey)
@@ -1,18 +1,22 @@
<script setup lang="ts">
import { Checkbox } from '../checkbox'
const props = defineProps<{
const props = withDefaults(defineProps<{
label?: string
description?: string
disabled?: boolean
}>()
/** Controls whether the switch is placed on the left or right side of the label. */
placement?: 'left' | 'right'
}>(), {
placement: 'right',
})
const modelValue = defineModel<boolean>({ required: true })
</script>
<template>
<label class="flex flex-col gap-4">
<div class="flex flex-row items-center gap-2">
<div :class="['flex items-center gap-2', props.placement === 'left' ? 'flex-row-reverse' : 'flex-row']">
<div class="flex-1">
<div class="flex items-center gap-1 text-sm font-medium">
<slot name="label">