feat(stage-pages,stage-ui,i18n): disable auto trigger for chatcompletion check and use explict button (#1336)

Co-authored-by-agent: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Rin
2026-03-15 04:39:30 +08:00
committed by GitHub
co-authored by Neko
parent 72925c1372
commit 623f35a7e3
22 changed files with 393 additions and 139 deletions
@@ -20,8 +20,12 @@ dialogs:
baseUrlHelp: API endpoint URL (use default if unsure)
accountId: Account ID
validationSuccess: Configuration validation success
validationPartial: Configuration partially validated
validationFailed: Configuration validation failed
validationError: 'Validation error: {error}'
testGeneration: Test Generation
testGenerationRunning: Testing...
testGenerationFailed: Generation test failed
skipForNow: Skip for now
saveAndContinue: Save and Continue
next: Next
@@ -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: Prueba de Generación
testGenerationRunning: Probando...
testGenerationFailed: Prueba de generación fallida
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: Test de génération
testGenerationRunning: Test en cours...
testGenerationFailed: Test de génération é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: 生成テスト
testGenerationRunning: テスト中...
testGenerationFailed: 生成テストに失敗
skipForNow: 今はスキップ
saveAndContinue: 保存して続行
next: 次へ
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: API 엔드포인트 URL(확실하지 않은 경우 기본값 사용)
accountId: 계정 ID
validationSuccess: 구성 검증 성공
validationPartial: 구성이 부분적으로 검증됨
validationFailed: 구성 검증 실패
validationError: '검증 오류: {error}'
testGeneration: 생성 테스트
testGenerationRunning: 테스트 중...
testGenerationFailed: 생성 테스트 실패
skipForNow: 지금은 건너뛰기
saveAndContinue: 저장 및 계속
next: 다음
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: URL конечной точки API (оставьте по умолчанию, если не уверены)
accountId: ID аккаунта
validationSuccess: Configuration validation success
validationPartial: Конфигурация частично проверена
validationFailed: Проверка конфигурации не удалась
validationError: 'Ошибка проверки: {error}'
testGeneration: Тест генерации
testGenerationRunning: Тестирование...
testGenerationFailed: Тест генерации не удался
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: Kiểm tra tạo
testGenerationRunning: Đang kiểm tra...
testGenerationFailed: Kiểm tra tạo thất bại
skipForNow: Bỏ qua
saveAndContinue: Lưu và tiếp tục
next: Tiếp
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: API 端点 URL(如果不确定请使用默认值)
accountId: 账户 ID
validationSuccess: 配置验证成功
validationPartial: 配置部分验证通过
validationFailed: 配置验证失败
validationError: '验证错误:{error}'
testGeneration: 测试生成
testGenerationRunning: 测试中...
testGenerationFailed: 生成测试失败
skipForNow: 暂时跳过
saveAndContinue: 保存并继续
next: 下一步
@@ -19,8 +19,12 @@ dialogs:
baseUrlHelp: API 端點 URL(如果不確定請使用預設值)
accountId: 帳號 ID
validationSuccess: 設定驗證成功
validationPartial: 設定部分驗證通過
validationFailed: 設定驗證失敗
validationError: '驗證錯誤:{error}'
testGeneration: 測試生成
testGenerationRunning: 測試中...
testGenerationFailed: 生成測試失敗
skipForNow: 先略過
saveAndContinue: 儲存並繼續
next: 下一步
@@ -2,13 +2,13 @@
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
@@ -53,6 +53,11 @@ const {
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
function goToModelSelection() {
@@ -88,39 +93,18 @@ function goToModelSelection() {
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-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"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationSuccess') }}</span>
<button
type="button"
: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="goToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
</button>
</div>
</template>
</Alert>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="goToModelSelection"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -2,13 +2,13 @@
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -66,6 +66,11 @@ const {
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
</script>
@@ -113,30 +118,18 @@ const {
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-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"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/consciousness')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -2,12 +2,12 @@
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -47,6 +47,11 @@ const {
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
</script>
@@ -78,30 +83,18 @@ const {
</ProviderBasicSettings>
<!-- Validation Status -->
<Alert v-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"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/consciousness')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -2,11 +2,11 @@
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -38,6 +38,11 @@ const {
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
</script>
@@ -60,30 +65,18 @@ const {
</ProviderBasicSettings>
<!-- Validation Status -->
<Alert v-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"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/consciousness')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -2,12 +2,12 @@
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAdvancedSettings,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
ProviderValidationAlerts,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -39,6 +39,11 @@ const {
validationMessage,
handleResetSettings,
forceValid,
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
} = useProviderValidation(providerId)
const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers || {}).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }])
@@ -176,30 +181,18 @@ onMounted(() => {
</ProviderAdvancedSettings>
<!-- Validation Status -->
<Alert v-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"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
{{ t('settings.dialogs.onboarding.validationSuccess') }}
</template>
</Alert>
<ProviderValidationAlerts
:is-valid="isValid"
:is-validating="isValidating"
:validation-message="validationMessage"
:has-manual-validators="hasManualValidators"
:is-manual-testing="isManualTesting"
:manual-test-passed="manualTestPassed"
:manual-test-message="manualTestMessage"
:on-run-test="runManualTest"
:on-force-valid="forceValid"
:on-go-to-model-selection="() => router.push('/settings/modules/consciousness')"
/>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
@@ -5,6 +5,7 @@ export { default as ProviderBaseUrlInput } from './provider-base-url-input.vue'
export { default as ProviderBasicSettings } from './provider-basic-settings.vue'
export { default as ProviderSettingsContainer } from './provider-settings-container.vue'
export { default as ProviderSettingsLayout } from './provider-settings-layout.vue'
export { default as ProviderValidationAlerts } from './provider-validation-alerts.vue'
export { default as SpeechPlaygroundOpenAICompatible } from './speech-playground-openai-compatible.vue'
export { default as SpeechPlayground } from './speech-playground.vue'
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Alert from '../../misc/alert.vue'
const props = defineProps<{
isValid: boolean
isValidating: number
validationMessage: string
hasManualValidators: boolean
isManualTesting: boolean
manualTestPassed: boolean
manualTestMessage: string
onRunTest: () => void
onForceValid: () => void
onGoToModelSelection: () => void
}>()
const { t } = useI18n()
</script>
<template>
<!-- Validation Error -->
<Alert v-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"
@click="props.onForceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<!-- Partial Validation: manual validators exist, no test attempted yet -->
<Alert v-else-if="isValid && isValidating === 0 && hasManualValidators && !manualTestPassed && !manualTestMessage" type="info">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationPartial') }}</span>
<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']"
@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"
@click="props.onGoToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
</button>
</div>
</div>
</template>
</Alert>
<!-- Full Validation Success -->
<Alert v-else-if="isValid && isValidating === 0 && (!hasManualValidators || manualTestPassed)" type="success">
<template #title>
<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"
@click="props.onGoToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
</button>
</div>
</template>
</Alert>
<!-- Manual Test Failed -->
<Alert v-else-if="hasManualValidators && !manualTestPassed && manualTestMessage && !isManualTesting" type="error">
<template #title>
<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>
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
{{ manualTestMessage }}
</div>
</template>
</Alert>
</template>
@@ -1,5 +1,6 @@
import type { RemovableRef } from '@vueuse/core'
import { errorMessageFrom } from '@moeru/std'
import { useDebounceFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
@@ -52,6 +53,11 @@ export function useProviderValidation(providerId: string) {
const isValid = ref(false)
const validationMessage = ref('')
const hasManualValidators = computed(() => !!providerMetadata.value?.validators.runManualValidation)
const isManualTesting = ref(false)
const manualTestPassed = ref(false)
const manualTestMessage = ref('')
async function validateConfiguration() {
if (!providerMetadata.value)
return
@@ -85,7 +91,7 @@ export function useProviderValidation(providerId: string) {
catch (error) {
isValid.value = false
finalValidationMessage = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
error: errorMessageFrom(error),
})
}
finally {
@@ -120,19 +126,54 @@ export function useProviderValidation(providerId: string) {
watch(credentials, () => {
debouncedValidateConfiguration()
// Reset manual test state when credentials change
manualTestPassed.value = false
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)
}
finally {
isManualTesting.value = false
}
}
function handleResetSettings() {
const defaultOptions = providerMetadata.value?.defaultOptions ? providerMetadata.value.defaultOptions() : {}
providers.value[providerId] = { ...defaultOptions }
isValid.value = false
validationMessage.value = ''
isValidating.value = 0
manualTestPassed.value = false
manualTestMessage.value = ''
}
function forceValid() {
isValid.value = true
validationMessage.value = ''
manualTestPassed.value = true
manualTestMessage.value = ''
providersStore.forceProviderConfigured(providerId)
}
@@ -148,5 +189,11 @@ export function useProviderValidation(providerId: string) {
validationMessage,
handleResetSettings,
forceValid,
// Manual test generation
hasManualValidators,
isManualTesting,
manualTestPassed,
manualTestMessage,
runManualTest,
}
}
@@ -65,6 +65,12 @@ 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 {
@@ -291,6 +291,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
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(
@@ -26,6 +26,8 @@ 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
}
@@ -83,7 +85,12 @@ export function getValidatorsOfProvider(options: {
const { definition } = options
const configValidators = (definition.validators?.validateConfig || []).map(creator => creator(options.contextOptions))
const providerValidators = (definition.validators?.validateProvider || []).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 steps: ProviderValidationStep[] = [
...createConfigValidationSteps(configValidators),
...createProviderValidationSteps(providerValidators),
@@ -99,6 +106,7 @@ 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,
}
@@ -156,7 +164,7 @@ export async function validateProvider(
for (let i = 0; i < providerValidators.length; i++) {
const step = steps[providerStepOffset + i]
step.status = 'invalid'
step.reason = error instanceof Error ? error.message : String(error)
step.reason = errorMessageFrom(error)
}
return steps
}
@@ -174,7 +182,70 @@ export async function validateProvider(
}
catch (error) {
step.status = 'invalid'
step.reason = error instanceof Error ? error.message : String(error)
step.reason = errorMessageFrom(error)
onValidatorError?.({ kind: 'provider', index, step, error })
}
}))
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 as any)
}
catch (error) {
return manualProviderValidators.map(v => ({
id: v.id,
label: v.name,
status: 'invalid' as ProviderValidationStepStatus,
reason: errorMessageFrom(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 as any, 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)
onValidatorError?.({ kind: 'provider', index, step, error })
}
}))
@@ -147,6 +147,15 @@ export interface ProviderMetadata {
reason: string
valid: boolean
}
/**
* Run only the manual-only validators. Returns validation result.
* Only available when the provider has manual validators.
*/
runManualValidation?: (config: Record<string, unknown>) => Promise<{
errors: unknown[]
reason: string
valid: boolean
}>
}
transcriptionFeatures?: {
supportsGenerate: boolean
@@ -7,7 +7,7 @@ import type { ProviderMetadata } from '../providers'
import { listModels } from '@xsai/model'
import { isModelProvider } from '../../libs/providers/types'
import { getValidatorsOfProvider, validateProvider } from '../../libs/providers/validators/run'
import { getValidatorsOfProvider, validateProvider, validateProviderManual } 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,6 +100,8 @@ 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,
@@ -194,6 +196,8 @@ export function convertProviderDefinitionToMetadata(
// 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')
@@ -230,6 +234,26 @@ 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
? {