refactor(stage-ui): support async provider definitions (#2360)
This commit is contained in:
@@ -939,6 +939,9 @@ pages:
|
||||
proceed.
|
||||
action: Save anyway
|
||||
config:
|
||||
loading: Loading provider settings...
|
||||
load-error: Failed to load provider settings.
|
||||
retry: Retry
|
||||
common:
|
||||
fields:
|
||||
field:
|
||||
|
||||
@@ -901,6 +901,9 @@ pages:
|
||||
如果你想要继续,你仍然可以保存当前的配置。
|
||||
action: 仍要保存
|
||||
config:
|
||||
loading: 正在加载服务来源设置……
|
||||
load-error: 无法加载服务来源设置。
|
||||
retry: 重试
|
||||
common:
|
||||
fields:
|
||||
field:
|
||||
|
||||
@@ -112,7 +112,7 @@ export function useTranscriptions(options: TranscriptionOptions) {
|
||||
|
||||
// Initialize the provider in the providers store first
|
||||
try {
|
||||
providersStore.initializeProvider('browser-web-speech-api')
|
||||
await providersStore.initializeProvider('browser-web-speech-api')
|
||||
hearingStore.activeTranscriptionProvider = 'browser-web-speech-api'
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consci
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { FieldCombobox } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -76,12 +77,12 @@ const {
|
||||
runManualTest,
|
||||
} = useProviderValidation(providerId)
|
||||
|
||||
const apiKeyPlaceholder = computed(() => {
|
||||
const apiKeyPlaceholder = computedAsync(async () => {
|
||||
const definition = providerDefinition.value ?? getDefinedProvider(providerId)
|
||||
if (!definition?.createProviderConfig)
|
||||
return 'sk-...'
|
||||
|
||||
const schema = definition.createProviderConfig({ t }) as any
|
||||
const schema = await definition.createProviderConfig({ t }) as any
|
||||
const shape = typeof schema?.shape === 'function' ? schema.shape() : schema?.shape
|
||||
const apiKeySchema = shape?.apiKey
|
||||
if (!apiKeySchema)
|
||||
@@ -89,7 +90,7 @@ const apiKeyPlaceholder = computed(() => {
|
||||
|
||||
const meta = typeof apiKeySchema.meta === 'function' ? apiKeySchema.meta() : undefined
|
||||
return typeof meta?.placeholderLocalized === 'string' ? meta.placeholderLocalized : 'sk-...'
|
||||
})
|
||||
}, 'sk-...')
|
||||
|
||||
function goToModelSelection() {
|
||||
activeProvider.value = providerId
|
||||
|
||||
@@ -8,6 +8,7 @@ import { selectProviderMetadata } from '@proj-airi/stage-ui/libs'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { Callout } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -19,7 +20,7 @@ const providersStore = useProviderStore()
|
||||
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
||||
|
||||
const providerId = 'official-provider'
|
||||
const providerMetadata = selectProviderMetadata(providersStore.getProviderDefinition(providerId), t, { id: providerId })
|
||||
const providerMetadata = computedAsync(() => selectProviderMetadata(providersStore.getProviderDefinition(providerId), t, { id: providerId }))
|
||||
const fluxPurchaseDisabled = isFluxPurchaseDisabled()
|
||||
|
||||
function handleLogin() {
|
||||
|
||||
@@ -119,8 +119,8 @@ async function refetch() {
|
||||
}
|
||||
|
||||
watch([baseUrl, thinkingMode, headers], refetch, { immediate: true, deep: true })
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultConfig.baseUrl || ''
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { Callout, ComboboxSelect } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -25,7 +26,7 @@ const speechStore = useSpeechStore()
|
||||
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
||||
|
||||
const providerId = 'official-provider-speech-streaming'
|
||||
const providerMetadata = computed(() => selectProviderMetadata(
|
||||
const providerMetadata = computedAsync(() => selectProviderMetadata(
|
||||
providersStore.getProviderDefinition(providerId),
|
||||
t,
|
||||
{ id: providerId },
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ import { selectProviderMetadata } from '@proj-airi/stage-ui/libs'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { Callout } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -19,7 +20,7 @@ const providersStore = useProviderStore()
|
||||
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
||||
|
||||
const providerId = 'official-provider-speech'
|
||||
const providerMetadata = selectProviderMetadata(providersStore.getProviderDefinition(providerId), t, { id: providerId })
|
||||
const providerMetadata = computedAsync(() => selectProviderMetadata(providersStore.getProviderDefinition(providerId), t, { id: providerId }))
|
||||
const fluxPurchaseDisabled = isFluxPurchaseDisabled()
|
||||
|
||||
function handleLogin() {
|
||||
|
||||
+4
-3
@@ -12,6 +12,7 @@ import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { FieldInput, FieldRange } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -144,12 +145,12 @@ const {
|
||||
forceValid,
|
||||
} = useProviderValidation(providerId)
|
||||
|
||||
const apiKeyPlaceholder = computed(() => {
|
||||
const apiKeyPlaceholder = computedAsync(async () => {
|
||||
const definition = getDefinedProvider(providerId)
|
||||
if (!definition?.createProviderConfig)
|
||||
return 'sk-...'
|
||||
|
||||
const schema = definition.createProviderConfig({ t }) as any
|
||||
const schema = await definition.createProviderConfig({ t }) as any
|
||||
const shape = typeof schema?.shape === 'function' ? schema.shape() : schema?.shape
|
||||
const apiKeySchema = shape?.apiKey
|
||||
if (!apiKeySchema)
|
||||
@@ -157,7 +158,7 @@ const apiKeyPlaceholder = computed(() => {
|
||||
|
||||
const meta = typeof apiKeySchema.meta === 'function' ? apiKeySchema.meta() : undefined
|
||||
return typeof meta?.placeholderLocalized === 'string' ? meta.placeholderLocalized : 'sk-...'
|
||||
})
|
||||
}, 'sk-...')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+4
-2
@@ -19,7 +19,7 @@ import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/con
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { Button, FieldCombobox, FieldInput } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from 'vue'
|
||||
|
||||
const providerId = 'aliyun-nls-transcription'
|
||||
const defaultModel = 'aliyun-nls-v1'
|
||||
@@ -39,7 +39,9 @@ const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
const { configs: providers } = storeToRefs(providerStore) as { configs: RemovableRef<Record<string, any>> }
|
||||
|
||||
providersStore.initializeProvider(providerId)
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(providerId)
|
||||
})
|
||||
|
||||
const credentials = reactive({
|
||||
get accessKeyId() {
|
||||
|
||||
+5
-3
@@ -15,7 +15,7 @@ import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/con
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { Button, FieldCombobox } from '@proj-airi/ui'
|
||||
import { until } from '@vueuse/core'
|
||||
import { computedAsync, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -30,9 +30,11 @@ const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
const { configs: providers } = storeToRefs(providerStore) as { configs: RemovableRef<Record<string, any>> }
|
||||
|
||||
providersStore.initializeProvider(providerId)
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(providerId)
|
||||
})
|
||||
|
||||
const providerMetadata = computed(() => selectProviderMetadata(
|
||||
const providerMetadata = computedAsync(() => selectProviderMetadata(
|
||||
providersStore.getProviderDefinition(providerId),
|
||||
t,
|
||||
{ id: providerId },
|
||||
|
||||
+4
-3
@@ -18,6 +18,7 @@ import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { FieldInput } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -85,12 +86,12 @@ const {
|
||||
forceValid,
|
||||
} = useProviderValidation(providerId)
|
||||
|
||||
const apiKeyPlaceholder = computed(() => {
|
||||
const apiKeyPlaceholder = computedAsync(async () => {
|
||||
const definition = getDefinedProvider(providerId)
|
||||
if (!definition?.createProviderConfig)
|
||||
return 'sk-...'
|
||||
|
||||
const schema = definition.createProviderConfig({ t }) as any
|
||||
const schema = await definition.createProviderConfig({ t }) as any
|
||||
const shape = typeof schema?.shape === 'function' ? schema.shape() : schema?.shape
|
||||
const apiKeySchema = shape?.apiKey
|
||||
if (!apiKeySchema)
|
||||
@@ -98,7 +99,7 @@ const apiKeyPlaceholder = computed(() => {
|
||||
|
||||
const meta = typeof apiKeySchema.meta === 'function' ? apiKeySchema.meta() : undefined
|
||||
return typeof meta?.placeholderLocalized === 'string' ? meta.placeholderLocalized : 'sk-...'
|
||||
})
|
||||
}, 'sk-...')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+5
-4
@@ -18,6 +18,7 @@ import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { FieldCombobox, FieldInput } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
@@ -110,12 +111,12 @@ const {
|
||||
forceValid,
|
||||
} = useProviderValidation(providerId)
|
||||
|
||||
const apiKeyPlaceholder = computed(() => {
|
||||
const apiKeyPlaceholder = computedAsync(async () => {
|
||||
const definition = getDefinedProvider(providerId)
|
||||
if (!definition?.createProviderConfig)
|
||||
return 'sk-...'
|
||||
|
||||
const schema = definition.createProviderConfig({ t }) as any
|
||||
const schema = await definition.createProviderConfig({ t }) as any
|
||||
const shape = typeof schema?.shape === 'function' ? schema.shape() : schema?.shape
|
||||
const apiKeySchema = shape?.apiKey
|
||||
if (!apiKeySchema)
|
||||
@@ -123,7 +124,7 @@ const apiKeyPlaceholder = computed(() => {
|
||||
|
||||
const meta = typeof apiKeySchema.meta === 'function' ? apiKeySchema.meta() : undefined
|
||||
return typeof meta?.placeholderLocalized === 'string' ? meta.placeholderLocalized : 'sk-...'
|
||||
})
|
||||
}, 'sk-...')
|
||||
|
||||
// Expand Advanced section if there's a base URL validation error
|
||||
const shouldExpandAdvanced = computed(() => {
|
||||
@@ -159,7 +160,7 @@ function isValidTranscriptionModel(modelName: string | undefined | null): boolea
|
||||
|
||||
// Initialize provider settings on mount
|
||||
onMounted(async () => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
await providersStore.initializeProvider(providerId)
|
||||
// Initialize baseUrl with default if not set
|
||||
if (!providers.value[providerId]?.baseUrl) {
|
||||
const defaultBaseUrl = providersStore.getDefaultProviderConfig(providerId).baseUrl as string | undefined
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provi
|
||||
import { getDefinedProvider } from '@proj-airi/stage-ui/libs'
|
||||
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -62,12 +63,12 @@ const {
|
||||
runManualTest,
|
||||
} = useProviderValidation(providerId)
|
||||
|
||||
const apiKeyPlaceholder = computed(() => {
|
||||
const apiKeyPlaceholder = computedAsync(async () => {
|
||||
const definition = getDefinedProvider(sourceProviderId)
|
||||
if (!definition?.createProviderConfig)
|
||||
return 'sk-...'
|
||||
|
||||
const schema = definition.createProviderConfig({ t }) as any
|
||||
const schema = await definition.createProviderConfig({ t }) as any
|
||||
const shape = typeof schema?.shape === 'function' ? schema.shape() : schema?.shape
|
||||
const apiKeySchema = shape?.apiKey
|
||||
if (!apiKeySchema)
|
||||
@@ -75,7 +76,7 @@ const apiKeyPlaceholder = computed(() => {
|
||||
|
||||
const meta = typeof apiKeySchema.meta === 'function' ? apiKeySchema.meta() : undefined
|
||||
return typeof meta?.placeholderLocalized === 'string' ? meta.placeholderLocalized : 'sk-...'
|
||||
})
|
||||
}, 'sk-...')
|
||||
|
||||
function goToModelSelection() {
|
||||
activeProvider.value = providerId
|
||||
|
||||
@@ -8,6 +8,7 @@ import { selectProviderMetadata } from '@proj-airi/stage-ui/libs'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { Callout } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -19,7 +20,7 @@ const providersStore = useProviderStore()
|
||||
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
||||
|
||||
const providerId = 'vision-official-provider'
|
||||
const providerMetadata = selectProviderMetadata(providersStore.getProviderDefinition(providerId), t, { id: providerId })
|
||||
const providerMetadata = computedAsync(() => selectProviderMetadata(providersStore.getProviderDefinition(providerId), t, { id: providerId }))
|
||||
const fluxPurchaseDisabled = isFluxPurchaseDisabled()
|
||||
|
||||
function handleLogin() {
|
||||
|
||||
@@ -119,8 +119,8 @@ async function refetch() {
|
||||
}
|
||||
|
||||
watch([baseUrl, thinkingMode, headers], refetch, { immediate: true, deep: true })
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultConfig.baseUrl || ''
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { $ZodType } from 'zod/v4/core'
|
||||
// TODO: https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import { merge } from '@moeru/std'
|
||||
import { errorMessageFrom, merge } from '@moeru/std'
|
||||
import {
|
||||
Alert,
|
||||
ProviderAccountIdInput,
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
import { getDefinedProvider, getSchemaDefault, getValidatorsOfProvider, validateProvider } from '@proj-airi/stage-ui/libs'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { Button, Callout, FieldCombobox, FieldInput, FieldKeyValues, GhostButton } from '@proj-airi/ui'
|
||||
import { useCloned, useDebounceFn } from '@vueuse/core'
|
||||
import { computedAsync, useCloned, useDebounceFn } from '@vueuse/core'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -32,17 +32,55 @@ const router = useRouter()
|
||||
const route = useRoute('v2/settings/providers/edit/[providerId]')
|
||||
|
||||
const providerStore = useProviderConfigStore()
|
||||
const emptyProviderConfig = Object.freeze({})
|
||||
const emptyProviderConfigValues = Object.freeze({})
|
||||
|
||||
const providerId = computed(() => route.params.providerId as string)
|
||||
const providerConfig = computed(() => providerStore.getProvider(providerId.value) || {})
|
||||
const providerConfig = computed(() => providerStore.getProvider(providerId.value) ?? emptyProviderConfig)
|
||||
const providerDefinition = computed(() => getDefinedProvider(providerConfig.value.definitionId))
|
||||
const providerSchema = computed(() => providerDefinition.value?.createProviderConfig({ t }) as $ZodType | undefined)
|
||||
const providerSchemaDefault = computed(() => getSchemaDefault(providerSchema.value))
|
||||
|
||||
// NOTICE: useCloned handles deep cloning and state isolation for the draft.
|
||||
// It provides a 'cloned' ref that we use for editing without affecting the original store state.
|
||||
const { cloned: providerConfigEdit, sync: syncProviderConfigEdit } = useCloned(providerConfig, { manual: true })
|
||||
|
||||
const isProviderSchemaLoading = ref(false)
|
||||
const providerSchemaError = ref<string | undefined>()
|
||||
const providerSchemaLoadAttempt = ref(0)
|
||||
const providerSchemaRequest = computed(() => {
|
||||
const currentConfig = providerConfigEdit.value?.config
|
||||
return {
|
||||
config: currentConfig ? merge({}, currentConfig) : undefined,
|
||||
definition: providerDefinition.value,
|
||||
loadAttempt: providerSchemaLoadAttempt.value,
|
||||
}
|
||||
})
|
||||
const providerSchema = computedAsync<$ZodType | undefined>(async (onCancel) => {
|
||||
// Read the complete request before the first await. computedAsync only tracks
|
||||
// dependencies accessed during this synchronous part of the evaluation.
|
||||
const { config, definition } = providerSchemaRequest.value
|
||||
|
||||
if (!definition)
|
||||
return undefined
|
||||
|
||||
const abortController = new AbortController()
|
||||
onCancel(() => abortController.abort())
|
||||
providerSchemaError.value = undefined
|
||||
|
||||
try {
|
||||
return await definition.createProviderConfig({
|
||||
t,
|
||||
config,
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (!abortController.signal.aborted)
|
||||
providerSchemaError.value = errorMessageFrom(error) ?? t('settings.pages.providers.catalog.edit.config.load-error')
|
||||
return undefined
|
||||
}
|
||||
}, undefined, { evaluating: isProviderSchemaLoading })
|
||||
const providerSchemaDefault = computed(() => getSchemaDefault(providerSchema.value))
|
||||
|
||||
watch(providerConfig, (newVal, oldVal) => {
|
||||
if (newVal && Object.keys(newVal).length > 0) {
|
||||
// Only sync the draft if the underlying data in the store has actually changed from an external source.
|
||||
@@ -53,8 +91,8 @@ watch(providerConfig, (newVal, oldVal) => {
|
||||
}, { immediate: true })
|
||||
|
||||
const isEdited = computed(() => {
|
||||
const currentConfig = providerConfigEdit.value?.config || {}
|
||||
const savedConfig = providerConfig.value?.config || {}
|
||||
const currentConfig = providerConfigEdit.value?.config ?? emptyProviderConfigValues
|
||||
const savedConfig = providerConfig.value?.config ?? emptyProviderConfigValues
|
||||
return JSON.stringify(currentConfig) !== JSON.stringify(savedConfig)
|
||||
})
|
||||
|
||||
@@ -223,7 +261,7 @@ async function runValidation() {
|
||||
if (!providerDefinition.value)
|
||||
return
|
||||
|
||||
const validationPlan = getValidationPlan()
|
||||
const validationPlan = await getValidationPlan()
|
||||
if (!validationPlan)
|
||||
return
|
||||
|
||||
@@ -273,12 +311,14 @@ async function runValidation() {
|
||||
const debouncedValidation = useDebounceFn(runValidation, 1500)
|
||||
let didInitValidation = false
|
||||
|
||||
watch([providerConfigEdit, providerDefinition], () => {
|
||||
watch([providerConfigEdit, providerDefinition, providerSchema], async () => {
|
||||
if (!providerConfig.value || !providerConfigEdit.value) {
|
||||
return
|
||||
}
|
||||
|
||||
getValidationPlan()
|
||||
const validationPlan = await getValidationPlan()
|
||||
if (!validationPlan)
|
||||
return
|
||||
|
||||
if (canSkipValidation.value)
|
||||
return
|
||||
@@ -287,32 +327,43 @@ watch([providerConfigEdit, providerDefinition], () => {
|
||||
didInitValidation = true
|
||||
return
|
||||
}
|
||||
debouncedValidation()
|
||||
void debouncedValidation()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (providerConfig.value.status !== 'configured') {
|
||||
providerConfigEdit.value.config = merge(providerSchemaDefault.value, providerConfigEdit.value?.config || {})
|
||||
}
|
||||
})
|
||||
let initializedSchemaProviderId: string | undefined
|
||||
watch([providerId, providerSchema], ([nextProviderId, schema]) => {
|
||||
if (!schema || initializedSchemaProviderId === nextProviderId)
|
||||
return
|
||||
|
||||
function getValidationPlan() {
|
||||
if (!providerDefinition.value)
|
||||
initializedSchemaProviderId = nextProviderId
|
||||
if (providerConfig.value.status !== 'configured')
|
||||
providerConfigEdit.value.config = merge(providerSchemaDefault.value, providerConfigEdit.value.config)
|
||||
}, { immediate: true })
|
||||
|
||||
let validationPlanRequestId = 0
|
||||
async function getValidationPlan() {
|
||||
const requestId = ++validationPlanRequestId
|
||||
const definition = providerDefinition.value
|
||||
if (!definition || !providerSchema.value || isProviderSchemaLoading.value)
|
||||
return undefined
|
||||
|
||||
const validationPlan = getValidatorsOfProvider({
|
||||
definition: providerDefinition.value,
|
||||
config: (providerConfigEdit.value?.config || {}) as Record<string, unknown>,
|
||||
const validationPlan = await getValidatorsOfProvider({
|
||||
definition,
|
||||
config: (providerConfigEdit.value?.config ?? emptyProviderConfigValues) as Record<string, unknown>,
|
||||
schemaDefaults: providerSchemaDefault.value as Record<string, unknown>,
|
||||
contextOptions: { t },
|
||||
})
|
||||
if (!validationPlan)
|
||||
if (requestId !== validationPlanRequestId)
|
||||
return undefined
|
||||
|
||||
validationSteps.value = validationPlan.steps
|
||||
return validationPlan
|
||||
}
|
||||
|
||||
function retryProviderSchema() {
|
||||
providerSchemaLoadAttempt.value++
|
||||
}
|
||||
|
||||
function syncValidationSteps() {
|
||||
validationSteps.value = [...validationSteps.value]
|
||||
}
|
||||
@@ -417,7 +468,30 @@ function handleDeleteProvider() {
|
||||
<div>{{ t('settings.pages.providers.catalog.edit.definition-id-not-found') }}</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="isProviderSchemaLoading" :class="['flex', 'flex-col', 'items-center', 'gap-3', 'py-12', 'text-neutral-500']">
|
||||
<div :class="['i-svg-spinners:ring-resize', 'text-3xl']" />
|
||||
<div :class="['text-sm']">
|
||||
{{ t('settings.pages.providers.catalog.edit.config.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-else-if="providerSchemaError" type="error">
|
||||
<template #title>
|
||||
{{ t('settings.pages.providers.catalog.edit.config.load-error') }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="['flex', 'flex-col', 'items-start', 'gap-3']">
|
||||
<span :class="['text-xs', 'text-neutral-600', 'dark:text-neutral-300']">
|
||||
{{ providerSchemaError }}
|
||||
</span>
|
||||
<Button size="sm" @click="retryProviderSchema">
|
||||
{{ t('settings.pages.providers.catalog.edit.config.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<template v-else-if="providerSchema">
|
||||
<ProviderBasicSettings
|
||||
:title="t('settings.pages.providers.common.section.basic.title')"
|
||||
:description="t('settings.pages.providers.common.section.basic.description')"
|
||||
|
||||
+7
-3
@@ -4,6 +4,7 @@ import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './typ
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Button, Callout, FieldCheckbox, FieldInput } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -86,9 +87,12 @@ const needsBaseUrl = computed(() => {
|
||||
return props.selectedProvider.id !== 'cloudflare-workers-ai'
|
||||
})
|
||||
|
||||
const showChatCheckOption = computed(() => {
|
||||
return props.selectedProvider ? providersStore.hasManualProviderValidators(props.selectedProvider.id) : false
|
||||
})
|
||||
const showChatCheckOption = computedAsync(
|
||||
async () => props.selectedProvider
|
||||
? await providersStore.hasManualProviderValidators(props.selectedProvider.id)
|
||||
: false,
|
||||
false,
|
||||
)
|
||||
|
||||
const canProceed = computed(() => {
|
||||
if (!props.selectedProviderId)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
providerName: string
|
||||
providerName?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
label?: string
|
||||
@@ -16,7 +16,11 @@ const { t } = useI18n()
|
||||
const modelValue = defineModel<string>({ required: false, default: '' })
|
||||
|
||||
const computedDescription = computed(() => {
|
||||
return props.description || `API Key for ${props.providerName}`
|
||||
if (props.description)
|
||||
return props.description
|
||||
if (props.providerName)
|
||||
return `API Key for ${props.providerName}`
|
||||
return t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
providerName: string
|
||||
providerName?: string
|
||||
providerIcon?: string
|
||||
providerIconColor?: string
|
||||
onBack?: () => void
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computedAsync, useDebounceFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -42,10 +42,10 @@ const providerStore = useProviderConfigStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const { configs: providers } = storeToRefs(providerStore)
|
||||
|
||||
const providerMetadata = computed(() => {
|
||||
const providerMetadata = computedAsync(async () => {
|
||||
const definition = providersStore.getProviderDefinition(props.providerId)
|
||||
return selectProviderMetadata(definition, t, { id: props.providerId })
|
||||
})
|
||||
return await selectProviderMetadata(definition, t, { id: props.providerId })
|
||||
}, undefined)
|
||||
|
||||
// Common provider settings
|
||||
const apiKey = computed({
|
||||
@@ -88,8 +88,8 @@ function initializeVoiceSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(props.providerId)
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(props.providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[props.providerId]?.apiKey as string | undefined || ''
|
||||
@@ -127,7 +127,7 @@ function handleResetVoiceSettings() {
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName"
|
||||
:provider-name="providerMetadata?.localizedName ?? ''"
|
||||
:provider-icon="providerMetadata?.icon"
|
||||
:provider-icon-color="providerMetadata?.iconColor"
|
||||
:on-back="() => router.back()"
|
||||
@@ -140,7 +140,7 @@ function handleResetVoiceSettings() {
|
||||
:description="t('settings.pages.providers.common.section.basic.description')"
|
||||
:on-reset="handleResetVoiceSettings"
|
||||
>
|
||||
<ProviderApiKeyInput v-model="apiKey" :provider-name="providerMetadata?.localizedName" :placeholder="props.placeholder || 'API Key'" />
|
||||
<ProviderApiKeyInput v-model="apiKey" :provider-name="providerMetadata?.localizedName ?? ''" :placeholder="props.placeholder || 'API Key'" />
|
||||
<!-- Slot for provider-specific basic settings -->
|
||||
<slot name="basic-settings" />
|
||||
</ProviderBasicSettings>
|
||||
|
||||
+8
-7
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -31,10 +32,10 @@ const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
const { configs: providers } = storeToRefs(providerStore)
|
||||
|
||||
const providerMetadata = computed(() => {
|
||||
const providerMetadata = computedAsync(async () => {
|
||||
const definition = providersStore.getProviderDefinition(props.providerId)
|
||||
return selectProviderMetadata(definition, t, { id: props.providerId })
|
||||
})
|
||||
return await selectProviderMetadata(definition, t, { id: props.providerId })
|
||||
}, undefined)
|
||||
|
||||
// Common provider settings
|
||||
const apiKey = computed({
|
||||
@@ -57,8 +58,8 @@ const baseUrl = computed({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(props.providerId)
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(props.providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[props.providerId]?.apiKey as string | undefined || ''
|
||||
@@ -73,7 +74,7 @@ function handleResetTranscriptionSettings() {
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName"
|
||||
:provider-name="providerMetadata?.localizedName ?? ''"
|
||||
:provider-icon="providerMetadata?.icon"
|
||||
:provider-icon-color="providerMetadata?.iconColor"
|
||||
:on-back="() => router.back()"
|
||||
@@ -86,7 +87,7 @@ function handleResetTranscriptionSettings() {
|
||||
:description="t('settings.pages.providers.common.section.basic.description')"
|
||||
:on-reset="handleResetTranscriptionSettings"
|
||||
>
|
||||
<ProviderApiKeyInput v-model="apiKey" :provider-name="providerMetadata?.localizedName" :placeholder="props.placeholder || 'API Key'" />
|
||||
<ProviderApiKeyInput v-model="apiKey" :provider-name="providerMetadata?.localizedName ?? ''" :placeholder="props.placeholder || 'API Key'" />
|
||||
<!-- Slot for provider-specific basic settings -->
|
||||
<slot name="basic-settings" />
|
||||
</ProviderBasicSettings>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RemovableRef } from '@vueuse/core'
|
||||
import type { ProviderMode } from './use-analytics'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computedAsync, useDebounceFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -37,13 +37,13 @@ export function useProviderValidation(providerId: string) {
|
||||
} = useAnalytics()
|
||||
const { configs: providers } = storeToRefs(providerStore) as { configs: RemovableRef<Record<string, any>> }
|
||||
|
||||
const providerMetadata = computed(() => {
|
||||
const providerMetadata = computedAsync(async () => {
|
||||
const definition = providersStore.getProviderDefinition(providerId)
|
||||
return selectProviderMetadata(definition, t, {
|
||||
return await selectProviderMetadata(definition, t, {
|
||||
id: providerId,
|
||||
configured: providerStore.getProvider(providerId)?.status === 'configured',
|
||||
})
|
||||
})
|
||||
}, undefined)
|
||||
|
||||
// --- Internal Computed Properties for Credentials ---
|
||||
const credentials = computed(() => providers.value[providerId] || {})
|
||||
@@ -82,7 +82,10 @@ export function useProviderValidation(providerId: string) {
|
||||
const validationMessage = ref('')
|
||||
|
||||
// Manual chat ping check state (settings pages only)
|
||||
const hasManualValidators = computed(() => providersStore.hasManualProviderValidators(providerId))
|
||||
const hasManualValidators = computedAsync(
|
||||
async () => await providersStore.hasManualProviderValidators(providerId),
|
||||
false,
|
||||
)
|
||||
const isManualTesting = ref(false)
|
||||
const manualTestPassed = ref(false)
|
||||
const manualTestMessage = ref('')
|
||||
@@ -197,13 +200,13 @@ export function useProviderValidation(providerId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldValidateConfiguration() {
|
||||
async function shouldValidateConfiguration() {
|
||||
const definition = providersStore.getProviderDefinition(providerId)
|
||||
return definition.validationRequiredWhen?.(credentials.value) ?? false
|
||||
return await definition.validationRequiredWhen?.(credentials.value) ?? false
|
||||
}
|
||||
|
||||
const debouncedValidateConfiguration = useDebounceFn(() => {
|
||||
if (!shouldValidateConfiguration()) {
|
||||
const debouncedValidateConfiguration = useDebounceFn(async () => {
|
||||
if (!await shouldValidateConfiguration()) {
|
||||
isValid.value = false
|
||||
providerStore.setProviderStatus(providerId, 'unconfigured')
|
||||
validationMessage.value = ''
|
||||
@@ -213,10 +216,10 @@ export function useProviderValidation(providerId: string) {
|
||||
validateConfiguration()
|
||||
}, debounceTime)
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(providerId)
|
||||
if (shouldValidateConfiguration()) {
|
||||
validateConfiguration()
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(providerId)
|
||||
if (await shouldValidateConfiguration()) {
|
||||
await validateConfiguration()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const definition = {
|
||||
description: 'Test provider description',
|
||||
descriptionLocalize: ({ t }) => t('description.key'),
|
||||
icon: 'i-test:provider',
|
||||
createProviderConfig: () => z.object({
|
||||
createProviderConfig: async () => z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().optional().default('https://example.com/v1/'),
|
||||
}),
|
||||
@@ -29,8 +29,8 @@ const definition = {
|
||||
} satisfies ProviderDefinition<{ apiKey: string, baseUrl?: string }>
|
||||
|
||||
describe('provider metadata selector', () => {
|
||||
it('selects schema defaults and localized display fields', () => {
|
||||
const metadata = selectProviderMetadata(definition, t)
|
||||
it('selects schema defaults from an async Provider schema', async () => {
|
||||
const metadata = await selectProviderMetadata(definition, t)
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
id: 'test-provider',
|
||||
@@ -45,8 +45,8 @@ describe('provider metadata selector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not copy executable definition fields', () => {
|
||||
const metadata = selectProviderMetadata(definition, t)
|
||||
it('does not copy executable definition fields', async () => {
|
||||
const metadata = await selectProviderMetadata(definition, t)
|
||||
|
||||
expect('createProvider' in metadata).toBe(false)
|
||||
expect('createProviderConfig' in metadata).toBe(false)
|
||||
@@ -55,8 +55,8 @@ describe('provider metadata selector', () => {
|
||||
expect(() => structuredClone(metadata)).not.toThrow()
|
||||
})
|
||||
|
||||
it('supports serializable view overrides without changing the definition', () => {
|
||||
const metadata = selectProviderMetadata(definition, t, {
|
||||
it('supports serializable view overrides without changing the definition', async () => {
|
||||
const metadata = await selectProviderMetadata(definition, t, {
|
||||
id: 'vision-test-provider',
|
||||
category: 'vision',
|
||||
tasks: ['chat', 'vision'],
|
||||
|
||||
@@ -60,7 +60,7 @@ export function getProviderCategory(tasks: string[]): ProviderCategory {
|
||||
}
|
||||
|
||||
/** Selects the serializable metadata fields of a provider definition. */
|
||||
export function selectProviderMetadata(
|
||||
export async function selectProviderMetadata(
|
||||
definition: ProviderDefinition,
|
||||
t: ComposerTranslation,
|
||||
options: {
|
||||
@@ -70,11 +70,16 @@ export function selectProviderMetadata(
|
||||
tasks?: string[]
|
||||
to?: string
|
||||
} = {},
|
||||
): ProviderMetadata {
|
||||
): Promise<ProviderMetadata> {
|
||||
const key = (input: string): string => input
|
||||
const tasks = options.tasks ?? definition.tasks
|
||||
const transcription = definition.capabilities?.transcription
|
||||
|
||||
const schema = await definition.createProviderConfig({ t })
|
||||
const onboardingFields = definition.onboardingFields
|
||||
? await definition.onboardingFields({ t })
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: options.id ?? definition.id,
|
||||
order: definition.order,
|
||||
@@ -88,12 +93,12 @@ export function selectProviderMetadata(
|
||||
descriptionKey: definition.descriptionLocalize({ t: key }),
|
||||
localizedDescription: definition.descriptionLocalize({ t }),
|
||||
configured: options.configured ?? false,
|
||||
defaultConfig: getSchemaDefault(definition.createProviderConfig({ t })) as Record<string, unknown>,
|
||||
defaultConfig: getSchemaDefault(schema) as Record<string, unknown>,
|
||||
...(definition.icon ? { icon: definition.icon } : {}),
|
||||
...(definition.iconColor ? { iconColor: definition.iconColor } : {}),
|
||||
...(definition.iconImage ? { iconImage: definition.iconImage } : {}),
|
||||
...(definition.requiresCredentials !== undefined ? { requiresCredentials: definition.requiresCredentials } : {}),
|
||||
...(definition.onboardingFields ? { onboardingFields: definition.onboardingFields({ t }) } : {}),
|
||||
...(onboardingFields ? { onboardingFields } : {}),
|
||||
...(transcription
|
||||
? {
|
||||
transcriptionFeatures: {
|
||||
@@ -108,9 +113,9 @@ export function selectProviderMetadata(
|
||||
}
|
||||
|
||||
/** Selects serializable metadata for a provider definition list. */
|
||||
export function selectProvidersMetadata(definitions: ProviderDefinition[], t: ComposerTranslation) {
|
||||
return Object.fromEntries(definitions.map(definition => [
|
||||
export async function selectProvidersMetadata(definitions: ProviderDefinition[], t: ComposerTranslation) {
|
||||
return Object.fromEntries(await Promise.all(definitions.map(async definition => [
|
||||
definition.id,
|
||||
selectProviderMetadata(definition, t),
|
||||
]))
|
||||
await selectProviderMetadata(definition, t),
|
||||
] as const)))
|
||||
}
|
||||
|
||||
@@ -12,36 +12,36 @@ describe('providerAmazonBedrock', () => {
|
||||
expect(providerAmazonBedrock.tasks).toContain('chat')
|
||||
})
|
||||
|
||||
it('should require validation when apiKey is provided', () => {
|
||||
expect(providerAmazonBedrock.validationRequiredWhen?.({
|
||||
it('should require validation when apiKey is provided', async () => {
|
||||
expect(await providerAmazonBedrock.validationRequiredWhen?.({
|
||||
apiKey: 'some-api-key',
|
||||
region: 'us-east-1',
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('should not require validation when apiKey is empty', () => {
|
||||
expect(providerAmazonBedrock.validationRequiredWhen?.({
|
||||
it('should not require validation when apiKey is empty', async () => {
|
||||
expect(await providerAmazonBedrock.validationRequiredWhen?.({
|
||||
apiKey: '',
|
||||
region: 'us-east-1',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('should not require validation when only region is provided', () => {
|
||||
expect(providerAmazonBedrock.validationRequiredWhen?.({
|
||||
it('should not require validation when only region is provided', async () => {
|
||||
expect(await providerAmazonBedrock.validationRequiredWhen?.({
|
||||
apiKey: '',
|
||||
} as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('should create provider with valid config', () => {
|
||||
const provider = providerAmazonBedrock.createProvider({
|
||||
it('should create provider with valid config', async () => {
|
||||
const provider = await providerAmazonBedrock.createProvider({
|
||||
apiKey: 'some-api-key',
|
||||
region: 'us-east-1',
|
||||
})
|
||||
expect(provider).toBeDefined()
|
||||
})
|
||||
|
||||
it('should use default us-east-1 region when not specified', () => {
|
||||
const provider = providerAmazonBedrock.createProvider({
|
||||
it('should use default us-east-1 region when not specified', async () => {
|
||||
const provider = await providerAmazonBedrock.createProvider({
|
||||
apiKey: 'some-api-key',
|
||||
} as any)
|
||||
expect(provider).toBeDefined()
|
||||
@@ -52,13 +52,14 @@ describe('providerAmazonBedrock', () => {
|
||||
ok: false,
|
||||
status: 401,
|
||||
}))
|
||||
const provider = await providerAmazonBedrock.createProvider({
|
||||
apiKey: 'invalid-key',
|
||||
region: 'us-east-1',
|
||||
})
|
||||
const models = await providerAmazonBedrock.extraMethods?.listModels?.({
|
||||
apiKey: 'invalid-key',
|
||||
region: 'us-east-1',
|
||||
}, providerAmazonBedrock.createProvider({
|
||||
apiKey: 'invalid-key',
|
||||
region: 'us-east-1',
|
||||
}))
|
||||
}, provider)
|
||||
expect(models).toBeDefined()
|
||||
expect(models!.length).toBeGreaterThan(0)
|
||||
expect(models!.some(m => m.id.includes('nova'))).toBe(true)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parse } from 'zod/v4/core'
|
||||
|
||||
const createOpenAIMock = vi.fn((apiKey: string, baseURL: string) => ({
|
||||
apiKey,
|
||||
@@ -27,14 +28,16 @@ describe('ark chat provider definitions', () => {
|
||||
const provider = getDefinedProvider('volcengine-coding-plan')
|
||||
expect(provider).toBeDefined()
|
||||
|
||||
const schema = provider!.createProviderConfig({ t: input => input }) as any
|
||||
const parsedConfig = schema.parse({
|
||||
const schema = await provider!.createProviderConfig({ t: input => input })
|
||||
const parsedConfig = parse(schema, {
|
||||
apiKey: 'test-key',
|
||||
})
|
||||
|
||||
expect(parsedConfig.baseUrl).toBe('https://ark.cn-beijing.volces.com/api/coding/v3')
|
||||
|
||||
const providerInstance = provider!.createProvider(parsedConfig) as any
|
||||
const providerInstance = await provider!.createProvider(parsedConfig)
|
||||
if (!('chat' in providerInstance))
|
||||
throw new Error('Volcengine coding plan provider must support chat')
|
||||
const chatConfig = providerInstance.chat('volcengine-coding-plan/doubao-seed-2.1-turbo')
|
||||
expect(chatConfig.model).toBe('doubao-seed-2.1-turbo')
|
||||
|
||||
@@ -95,14 +98,16 @@ describe('ark chat provider definitions', () => {
|
||||
expect(byteplus).toBeDefined()
|
||||
expect(byteplusCodingPlan).toBeDefined()
|
||||
|
||||
const byteplusConfig = (byteplus!.createProviderConfig({ t: input => input }) as any).parse({ apiKey: 'test-key' })
|
||||
const byteplusCodingPlanConfig = (byteplusCodingPlan!.createProviderConfig({ t: input => input }) as any).parse({ apiKey: 'test-key' })
|
||||
const byteplusConfig = parse(await byteplus!.createProviderConfig({ t: input => input }), { apiKey: 'test-key' })
|
||||
const byteplusCodingPlanConfig = parse(await byteplusCodingPlan!.createProviderConfig({ t: input => input }), { apiKey: 'test-key' })
|
||||
|
||||
expect(byteplusConfig.baseUrl).toBe('https://ark.ap-southeast.bytepluses.com/api/v3')
|
||||
expect(byteplusCodingPlanConfig.baseUrl).toBe('https://ark.ap-southeast.bytepluses.com/api/coding/v3')
|
||||
|
||||
const byteplusModels = await byteplus!.extraMethods!.listModels!(byteplusConfig, byteplus!.createProvider(byteplusConfig))
|
||||
const byteplusCodingPlanModels = await byteplusCodingPlan!.extraMethods!.listModels!(byteplusCodingPlanConfig, byteplusCodingPlan!.createProvider(byteplusCodingPlanConfig))
|
||||
const byteplusProvider = await byteplus!.createProvider(byteplusConfig)
|
||||
const byteplusCodingPlanProvider = await byteplusCodingPlan!.createProvider(byteplusCodingPlanConfig)
|
||||
const byteplusModels = await byteplus!.extraMethods!.listModels!(byteplusConfig, byteplusProvider)
|
||||
const byteplusCodingPlanModels = await byteplusCodingPlan!.extraMethods!.listModels!(byteplusCodingPlanConfig, byteplusCodingPlanProvider)
|
||||
|
||||
expect(byteplusModels.map(model => model.id)).toEqual([
|
||||
'byteplus/seed-2-0-pro-260328',
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('providerAzureOpenAI tool schemas', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const provider = providerAzureOpenAI.createProvider({
|
||||
const provider = await providerAzureOpenAI.createProvider({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://example.openai.azure.com/openai/',
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
export const providerCloudflareWorkersAI = defineProvider({
|
||||
export const providerCloudflareWorkersAI = defineProvider<{ accountId: string, apiKey: string }>({
|
||||
id: 'cloudflare-workers-ai',
|
||||
name: 'Cloudflare Workers AI',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.cloudflare-workers-ai.title'),
|
||||
|
||||
@@ -14,10 +14,10 @@ function isDeepSeekChatProvider(provider: ProviderInstance): provider is DeepSee
|
||||
return 'chat' in provider && typeof provider.chat === 'function'
|
||||
}
|
||||
|
||||
function createDeepSeekChatProvider(
|
||||
async function createDeepSeekChatProvider(
|
||||
thinkingMode: 'auto' | 'disable' | 'enable',
|
||||
): DeepSeekChatProvider {
|
||||
const provider = providerDeepSeek.createProvider({
|
||||
): Promise<DeepSeekChatProvider> {
|
||||
const provider = await providerDeepSeek.createProvider({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com/',
|
||||
thinkingMode,
|
||||
@@ -30,30 +30,30 @@ function createDeepSeekChatProvider(
|
||||
}
|
||||
|
||||
describe('providerDeepSeek.createProvider chat options', () => {
|
||||
it('should not set thinking when thinkingMode is auto', () => {
|
||||
const provider = createDeepSeekChatProvider('auto')
|
||||
it('should not set thinking when thinkingMode is auto', async () => {
|
||||
const provider = await createDeepSeekChatProvider('auto')
|
||||
|
||||
expect(provider.chat('deepseek-chat')).not.toHaveProperty('thinking')
|
||||
})
|
||||
|
||||
it('should set thinking disabled when thinkingMode is disable', () => {
|
||||
const provider = createDeepSeekChatProvider('disable')
|
||||
it('should set thinking disabled when thinkingMode is disable', async () => {
|
||||
const provider = await createDeepSeekChatProvider('disable')
|
||||
|
||||
expect(provider.chat('deepseek-chat')).toMatchObject({
|
||||
thinking: { type: 'disabled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should set thinking enabled when thinkingMode is enable', () => {
|
||||
const provider = createDeepSeekChatProvider('enable')
|
||||
it('should set thinking enabled when thinkingMode is enable', async () => {
|
||||
const provider = await createDeepSeekChatProvider('enable')
|
||||
|
||||
expect(provider.chat('deepseek-chat')).toMatchObject({
|
||||
thinking: { type: 'enabled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should prioritize request reasoning over the provider setting', () => {
|
||||
const provider = createDeepSeekChatProvider('enable')
|
||||
it('should prioritize request reasoning over the provider setting', async () => {
|
||||
const provider = await createDeepSeekChatProvider('enable')
|
||||
|
||||
expect(provider.chat('deepseek-chat', { reasoning: 'disabled' })).toMatchObject({
|
||||
thinking: { type: 'disabled' },
|
||||
|
||||
@@ -22,8 +22,8 @@ describe('official speech provider', () => {
|
||||
* @example
|
||||
* provider.speech('microsoft/v1', { speed: 1.2 })
|
||||
*/
|
||||
it('keeps speech extra options on the generated request config', () => {
|
||||
const provider = providerOfficialSpeech.createProvider({}) as SpeechProviderWithExtraOptions<string, OfficialSpeechOptions>
|
||||
it('keeps speech extra options on the generated request config', async () => {
|
||||
const provider = await providerOfficialSpeech.createProvider({}) as SpeechProviderWithExtraOptions<string, OfficialSpeechOptions>
|
||||
|
||||
const request = provider.speech('microsoft/v1', {
|
||||
speed: 1.2,
|
||||
@@ -48,8 +48,8 @@ describe('official speech provider', () => {
|
||||
* @example
|
||||
* provider.speech('volcengine/seed-tts-2.0', { extraBody: { airi_analytics: { source: 'manual_preview', voice_type: 'official_selected' } } })
|
||||
*/
|
||||
it('keeps streaming speech preview analytics on the generated request config', () => {
|
||||
const provider = providerOfficialSpeechStreaming.createProvider({}) as SpeechProviderWithExtraOptions<string, OfficialSpeechOptions>
|
||||
it('keeps streaming speech preview analytics on the generated request config', async () => {
|
||||
const provider = await providerOfficialSpeechStreaming.createProvider({}) as SpeechProviderWithExtraOptions<string, OfficialSpeechOptions>
|
||||
|
||||
const request = provider.speech('volcengine/seed-tts-2.0', {
|
||||
extraBody: {
|
||||
@@ -76,8 +76,8 @@ describe('official transcription provider', () => {
|
||||
* @example
|
||||
* provider.transcription('auto')
|
||||
*/
|
||||
it('builds an authenticated streaming transcription request for the server audio surface', () => {
|
||||
const provider = providerOfficialTranscription.createProvider({}) as {
|
||||
it('builds an authenticated streaming transcription request for the server audio surface', async () => {
|
||||
const provider = await providerOfficialTranscription.createProvider({}) as {
|
||||
transcription: (model: string) => {
|
||||
baseURL: URL
|
||||
fetch?: typeof fetch
|
||||
@@ -98,7 +98,8 @@ describe('official transcription provider', () => {
|
||||
* providerOfficialTranscription.extraMethods.listModels()
|
||||
*/
|
||||
it('lists the auto realtime model without calling a provider credential flow', async () => {
|
||||
const models = await providerOfficialTranscription.extraMethods?.listModels?.({}, providerOfficialTranscription.createProvider({}))
|
||||
const provider = await providerOfficialTranscription.createProvider({})
|
||||
const models = await providerOfficialTranscription.extraMethods?.listModels?.({}, provider)
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
|
||||
@@ -12,8 +12,8 @@ function isOllamaChatProvider(provider: ProviderInstance): provider is OllamaCha
|
||||
return 'chat' in provider && typeof provider.chat === 'function'
|
||||
}
|
||||
|
||||
function createOllamaChatProvider(thinkingMode: 'auto' | 'disable' | 'enable'): OllamaChatProvider {
|
||||
const provider = providerOllama.createProvider({
|
||||
async function createOllamaChatProvider(thinkingMode: 'auto' | 'disable' | 'enable'): Promise<OllamaChatProvider> {
|
||||
const provider = await providerOllama.createProvider({
|
||||
baseUrl: 'http://localhost:11434/v1/',
|
||||
thinkingMode,
|
||||
})
|
||||
@@ -45,32 +45,32 @@ describe('providerOllama.resolveOllamaReasoningEffort', () => {
|
||||
})
|
||||
|
||||
describe('providerOllama.createProvider chat options', () => {
|
||||
it('should not set reasoning effort when thinkingMode is auto', () => {
|
||||
const provider = createOllamaChatProvider('auto')
|
||||
it('should not set reasoning effort when thinkingMode is auto', async () => {
|
||||
const provider = await createOllamaChatProvider('auto')
|
||||
|
||||
expect(provider.chat('qwen3:8b')).not.toHaveProperty('reasoningEffort')
|
||||
})
|
||||
|
||||
it('should set reasoning effort to none for non gpt-oss when thinkingMode is disable', () => {
|
||||
const provider = createOllamaChatProvider('disable')
|
||||
it('should set reasoning effort to none for non gpt-oss when thinkingMode is disable', async () => {
|
||||
const provider = await createOllamaChatProvider('disable')
|
||||
|
||||
expect(provider.chat('qwen3:8b')).toMatchObject({ reasoningEffort: 'none' })
|
||||
})
|
||||
|
||||
it('should set reasoning effort to medium when thinkingMode is enable', () => {
|
||||
const provider = createOllamaChatProvider('enable')
|
||||
it('should set reasoning effort to medium when thinkingMode is enable', async () => {
|
||||
const provider = await createOllamaChatProvider('enable')
|
||||
|
||||
expect(provider.chat('gpt-oss:20b')).toMatchObject({ reasoningEffort: 'medium' })
|
||||
})
|
||||
|
||||
it('should set reasoning effort to none when thinkingMode is disable', () => {
|
||||
const provider = createOllamaChatProvider('disable')
|
||||
it('should set reasoning effort to none when thinkingMode is disable', async () => {
|
||||
const provider = await createOllamaChatProvider('disable')
|
||||
|
||||
expect(provider.chat('gpt-oss:20b')).toMatchObject({ reasoningEffort: 'none' })
|
||||
})
|
||||
|
||||
it('should apply request reasoning without checking the model name', () => {
|
||||
const provider = createOllamaChatProvider('auto')
|
||||
it('should apply request reasoning without checking the model name', async () => {
|
||||
const provider = await createOllamaChatProvider('auto')
|
||||
|
||||
expect(provider.chat('llama3.2', { reasoning: 'disabled' })).toMatchObject({ reasoningEffort: 'none' })
|
||||
expect(provider.chat('gpt-oss:20b', { reasoning: 'enabled' })).toMatchObject({ reasoningEffort: 'medium' })
|
||||
|
||||
@@ -36,8 +36,8 @@ describe('providerOpenRouterAI tool schemas', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('maps AIRI reasoning modes to OpenRouter request fields', () => {
|
||||
const provider = providerOpenRouterAI.createProvider({
|
||||
it('maps AIRI reasoning modes to OpenRouter request fields', async () => {
|
||||
const provider = await providerOpenRouterAI.createProvider({
|
||||
apiKey: 'test-key',
|
||||
}) as ChatProviderWithExtraOptions<string, ChatRequestOptions>
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('providerOpenRouterAI tool schemas', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const provider = providerOpenRouterAI.createProvider({
|
||||
const provider = await providerOpenRouterAI.createProvider({
|
||||
apiKey: 'test-key',
|
||||
})
|
||||
if (!('chat' in provider))
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('migrated provider definitions', () => {
|
||||
expect(getDefinedProvider(providerId), providerId).toBeDefined()
|
||||
})
|
||||
|
||||
it('creates the no-op speech provider through ProviderDefinition', () => {
|
||||
const provider = providerSpeechNoop.createProvider({})
|
||||
it('creates the no-op speech provider through ProviderDefinition', async () => {
|
||||
const provider = await providerSpeechNoop.createProvider({})
|
||||
|
||||
expect(provider).toHaveProperty('speech')
|
||||
expect('speech' in provider && provider.speech('unused')).toMatchObject({
|
||||
@@ -77,19 +77,19 @@ describe('migrated provider definitions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses all Aliyun NLS credentials to require automatic validation', () => {
|
||||
it('uses all Aliyun NLS credentials to require automatic validation', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The settings composable used a fixed list of common credential fields.
|
||||
// This list did not include the three Aliyun NLS credential fields.
|
||||
// The provider definition now owns the automatic validation condition.
|
||||
expect(providerAliyunNlsTranscription.validationRequiredWhen?.({
|
||||
expect(await providerAliyunNlsTranscription.validationRequiredWhen?.({
|
||||
accessKeyId: 'test-access-key-id',
|
||||
accessKeySecret: 'test-access-key-secret',
|
||||
appKey: '',
|
||||
region: 'cn-shanghai',
|
||||
})).toBe(false)
|
||||
expect(providerAliyunNlsTranscription.validationRequiredWhen?.({
|
||||
expect(await providerAliyunNlsTranscription.validationRequiredWhen?.({
|
||||
accessKeyId: 'test-access-key-id',
|
||||
accessKeySecret: 'test-access-key-secret',
|
||||
appKey: 'test-app-key',
|
||||
@@ -98,7 +98,7 @@ describe('migrated provider definitions', () => {
|
||||
})
|
||||
|
||||
it('keeps the local audio base URL validation in the definition', async () => {
|
||||
const validator = providerAppLocalAudioSpeech.validators?.validateConfig?.[0]({ t: translate })
|
||||
const validator = await providerAppLocalAudioSpeech.validators?.validateConfig?.[0]({ t: translate })
|
||||
|
||||
const missing = await validator?.validator({}, { t: translate })
|
||||
const configured = await validator?.validator({ baseUrl: 'http://localhost:1234/v1/' }, { t: translate })
|
||||
@@ -109,7 +109,7 @@ describe('migrated provider definitions', () => {
|
||||
})
|
||||
|
||||
it('describes Web Speech API streaming support without runtime state', async () => {
|
||||
const defaults = z.parse(providerBrowserWebSpeechApi.createProviderConfig({ t: translate }), {})
|
||||
const defaults = z.parse(await providerBrowserWebSpeechApi.createProviderConfig({ t: translate }), {})
|
||||
|
||||
expect(defaults).toEqual({
|
||||
language: 'en-US',
|
||||
@@ -127,8 +127,9 @@ describe('migrated provider definitions', () => {
|
||||
})
|
||||
|
||||
it('keeps ElevenLabs configuration and model discovery in the definition', async () => {
|
||||
const defaults = z.parse(providerElevenLabs.createProviderConfig({ t: translate }), { apiKey: 'test' })
|
||||
const models = await providerElevenLabs.extraMethods?.listModels?.(defaults, providerElevenLabs.createProvider(defaults))
|
||||
const defaults = z.parse(await providerElevenLabs.createProviderConfig({ t: translate }), { apiKey: 'test' })
|
||||
const provider = await providerElevenLabs.createProvider(defaults)
|
||||
const models = await providerElevenLabs.extraMethods?.listModels?.(defaults, provider)
|
||||
|
||||
expect(defaults).toMatchObject({
|
||||
baseUrl: 'https://unspeech.hyp3r.link/v1/',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
import type { MaybePromise } from 'clustr'
|
||||
import type { $ZodType } from 'zod/v4/core'
|
||||
|
||||
import type { ProviderDefinition } from '../types'
|
||||
import type { ProviderConfigContext, ProviderDefinition } from '../types'
|
||||
|
||||
import { orderBy } from 'es-toolkit'
|
||||
|
||||
@@ -17,7 +17,9 @@ export function getDefinedProvider(id: string): ProviderDefinition | undefined {
|
||||
return providerRegistry.get(id)
|
||||
}
|
||||
|
||||
export function defineProvider<T>(definition: { createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType<T> } & ProviderDefinition<T>): ProviderDefinition<T> {
|
||||
export function defineProvider<T>(definition: {
|
||||
createProviderConfig: (contextOptions: ProviderConfigContext<T>) => MaybePromise<$ZodType<T>>
|
||||
} & ProviderDefinition<T>): ProviderDefinition<T> {
|
||||
const provider = {
|
||||
...definition,
|
||||
}
|
||||
|
||||
@@ -63,6 +63,16 @@ export interface ProviderOnboardingField {
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
/** Inputs available while a Provider builds its configuration schema. */
|
||||
export interface ProviderConfigContext<TConfig> {
|
||||
/** Cancels runtime discovery that contributes schema metadata. */
|
||||
abortSignal?: AbortSignal
|
||||
/** Current draft values. Providers can use them to resolve dependent fields. */
|
||||
config?: Partial<TConfig>
|
||||
/** Translates labels and descriptions for the active interface locale. */
|
||||
t: ComposerTranslation
|
||||
}
|
||||
|
||||
export interface ProviderExtraMethods<TConfig> {
|
||||
listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise<ModelInfo[]>
|
||||
/**
|
||||
@@ -189,7 +199,7 @@ export interface ProviderDefinition<TConfig extends any = any> {
|
||||
* - may requires significant amount of memory to run, especially for those
|
||||
* non-WebGPU supported environments.
|
||||
*/
|
||||
isAvailableBy?: () => Promise<boolean> | boolean
|
||||
isAvailableBy?: () => MaybePromise<boolean>
|
||||
|
||||
/**
|
||||
* If false, the provider does not require user-provided credentials (e.g. API keys).
|
||||
@@ -204,9 +214,10 @@ export interface ProviderDefinition<TConfig extends any = any> {
|
||||
*/
|
||||
configuredBy?: ProviderConfiguredBy
|
||||
|
||||
createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType<TConfig>
|
||||
onboardingFields?: (ctx: { t: ComposerTranslation }) => ProviderOnboardingField[]
|
||||
createProvider: (config: TConfig) => ProviderInstance
|
||||
/** Builds the validation schema and its UI metadata for the current draft. */
|
||||
createProviderConfig: (contextOptions: ProviderConfigContext<TConfig>) => MaybePromise<$ZodType<TConfig>>
|
||||
onboardingFields?: (ctx: { t: ComposerTranslation }) => MaybePromise<ProviderOnboardingField[]>
|
||||
createProvider: (config: TConfig) => MaybePromise<ProviderInstance>
|
||||
extraMethods?: ProviderExtraMethods<TConfig>
|
||||
/**
|
||||
* Returns true when the configuration has enough input for automatic validation.
|
||||
@@ -214,10 +225,10 @@ export interface ProviderDefinition<TConfig extends any = any> {
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
validationRequiredWhen?: (config: TConfig) => boolean
|
||||
validationRequiredWhen?: (config: TConfig) => MaybePromise<boolean>
|
||||
validators?: {
|
||||
validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderConfigValidator<TConfig>>
|
||||
validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderRuntimeValidator<TConfig>>
|
||||
validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderConfigValidator<TConfig>>>
|
||||
validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderRuntimeValidator<TConfig>>>
|
||||
}
|
||||
capabilities?: {
|
||||
chat?: {
|
||||
|
||||
@@ -25,10 +25,10 @@ vi.mock('@xsai/model', () => ({
|
||||
|
||||
const mockT = vi.fn((key: string) => key) as unknown as ComposerTranslation
|
||||
|
||||
function getProviderValidators(options?: Parameters<typeof createOpenAICompatibleValidators>[0]) {
|
||||
async function getProviderValidators(options?: Parameters<typeof createOpenAICompatibleValidators>[0]) {
|
||||
const validators = createOpenAICompatibleValidators(options)
|
||||
|
||||
return (validators?.validateProvider || []).map(create => create({ t: mockT }))
|
||||
return await Promise.all((validators?.validateProvider || []).map(create => create({ t: mockT })))
|
||||
}
|
||||
|
||||
interface TestConfig { apiKey?: string, baseUrl?: string }
|
||||
@@ -59,7 +59,7 @@ describe('createOpenAICompatibleValidators', () => {
|
||||
})
|
||||
|
||||
it('connectivity check uses lightweight fetch instead of generateText', async () => {
|
||||
const [connectivityValidator] = getProviderValidators({
|
||||
const [connectivityValidator] = await getProviderValidators({
|
||||
checks: [ProviderValidationCheck.Connectivity],
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('createOpenAICompatibleValidators', () => {
|
||||
it('connectivity check fails on network error', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
const [connectivityValidator] = getProviderValidators({
|
||||
const [connectivityValidator] = await getProviderValidators({
|
||||
checks: [ProviderValidationCheck.Connectivity],
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('createOpenAICompatibleValidators', () => {
|
||||
it('does not probe chat completions with a synthetic fallback model', async () => {
|
||||
listModelsMock.mockResolvedValue([])
|
||||
|
||||
const [connectivityValidator, chatValidator] = getProviderValidators({
|
||||
const [connectivityValidator, chatValidator] = await getProviderValidators({
|
||||
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ChatCompletions],
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('createOpenAICompatibleValidators', () => {
|
||||
it('allows providers to skip chat probing when they do not expose model listing', async () => {
|
||||
listModelsMock.mockResolvedValue([])
|
||||
|
||||
const [connectivityValidator, chatValidator] = getProviderValidators({
|
||||
const [connectivityValidator, chatValidator] = await getProviderValidators({
|
||||
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ChatCompletions],
|
||||
allowValidationWithoutModel: true,
|
||||
})
|
||||
@@ -119,8 +119,8 @@ describe('createOpenAICompatibleValidators', () => {
|
||||
expect(generateTextMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('default checks do not include chat_completions', () => {
|
||||
const validators = getProviderValidators()
|
||||
it('default checks do not include chat_completions', async () => {
|
||||
const validators = await getProviderValidators()
|
||||
const ids = validators.map(v => v.id)
|
||||
|
||||
expect(ids).toContain('openai-compatible:check-connectivity')
|
||||
@@ -133,7 +133,7 @@ describe('createOpenAICompatibleValidators', () => {
|
||||
{ id: 'byteplus/seed-2-0-pro-260328' },
|
||||
])
|
||||
|
||||
const [, chatValidator] = getProviderValidators({
|
||||
const [, chatValidator] = await getProviderValidators({
|
||||
checks: [ProviderValidationCheck.Connectivity, ProviderValidationCheck.ChatCompletions],
|
||||
normalizeModelId: modelId => modelId.replace(/^byteplus\//, ''),
|
||||
})
|
||||
|
||||
@@ -5,11 +5,42 @@ import type { ProviderDefinition } from '../types'
|
||||
import { createChatProvider } from '@xsai-ext/providers/utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { validateProvider } from './run'
|
||||
import { getValidatorsOfProvider, validateProvider } from './run'
|
||||
|
||||
const mockT = ((key: string) => key) as unknown as ComposerTranslation
|
||||
|
||||
describe('validateProvider', () => {
|
||||
it('resolves async validator factories and validation requirements', async () => {
|
||||
const definition: ProviderDefinition<Record<string, unknown>> = {
|
||||
id: 'async-example',
|
||||
name: 'Async example',
|
||||
description: 'Async example provider',
|
||||
nameLocalize: input => input.t('async-example'),
|
||||
descriptionLocalize: input => input.t('async-example'),
|
||||
tasks: [],
|
||||
createProviderConfig: async () => ({}) as never,
|
||||
createProvider: async () => createChatProvider({ apiKey: 'test', baseURL: 'https://example.com/v1' }),
|
||||
validationRequiredWhen: async () => true,
|
||||
validators: {
|
||||
validateConfig: [async () => ({
|
||||
id: 'async-config',
|
||||
name: 'Async config',
|
||||
validator: async () => ({ valid: true, errors: [], reason: '', reasonKey: '' }),
|
||||
})],
|
||||
},
|
||||
}
|
||||
|
||||
const plan = await getValidatorsOfProvider({
|
||||
definition,
|
||||
config: {},
|
||||
schemaDefaults: {},
|
||||
contextOptions: { t: mockT },
|
||||
})
|
||||
|
||||
expect(plan.shouldValidate).toBe(true)
|
||||
expect(plan.configValidators.map(validator => validator.id)).toEqual(['async-config'])
|
||||
})
|
||||
|
||||
it('disposes the temporary provider after runtime validation', async () => {
|
||||
const dispose = vi.fn()
|
||||
const provider = Object.assign(
|
||||
@@ -24,7 +55,7 @@ describe('validateProvider', () => {
|
||||
descriptionLocalize: input => input.t('example'),
|
||||
tasks: [],
|
||||
createProviderConfig: () => ({}) as never,
|
||||
createProvider: () => provider,
|
||||
createProvider: async () => provider,
|
||||
}
|
||||
|
||||
await validateProvider({
|
||||
|
||||
@@ -56,12 +56,12 @@ export function createProviderValidationSteps(providerValidators: ProviderRuntim
|
||||
}))
|
||||
}
|
||||
|
||||
export function getProviderValidationIntervalMs(options: {
|
||||
export async function getProviderValidationIntervalMs(options: {
|
||||
definition: ProviderDefinition
|
||||
contextOptions: { t: ComposerTranslation }
|
||||
defaultIntervalMs?: number
|
||||
}) {
|
||||
const validators = (options.definition.validators?.validateProvider || []).map(creator => creator(options.contextOptions))
|
||||
const validators = await Promise.all((options.definition.validators?.validateProvider || []).map(creator => creator(options.contextOptions)))
|
||||
const defaultIntervalMs = options.defaultIntervalMs ?? 15_000
|
||||
const intervals = validators
|
||||
.filter(validator => validator.schedule?.mode === 'interval')
|
||||
@@ -74,16 +74,16 @@ export function getProviderValidationIntervalMs(options: {
|
||||
return Math.min(...intervals)
|
||||
}
|
||||
|
||||
export function getValidatorsOfProvider(options: {
|
||||
export async function getValidatorsOfProvider(options: {
|
||||
definition: ProviderDefinition
|
||||
config: Record<string, unknown>
|
||||
schemaDefaults: Record<string, unknown>
|
||||
contextOptions: { t: ComposerTranslation }
|
||||
}): ProviderValidationPlan {
|
||||
}): Promise<ProviderValidationPlan> {
|
||||
const { definition } = options
|
||||
|
||||
const configValidators = (definition.validators?.validateConfig || []).map(creator => creator(options.contextOptions))
|
||||
const allProviderValidators = (definition.validators?.validateProvider || []).map(creator => creator(options.contextOptions))
|
||||
const configValidators = await Promise.all((definition.validators?.validateConfig || []).map(creator => creator(options.contextOptions)))
|
||||
const allProviderValidators = await Promise.all((definition.validators?.validateProvider || []).map(creator => creator(options.contextOptions)))
|
||||
|
||||
const providerValidators = allProviderValidators
|
||||
|
||||
@@ -94,7 +94,7 @@ export function getValidatorsOfProvider(options: {
|
||||
|
||||
const normalizedConfig = merge(options.schemaDefaults, options.config)
|
||||
const validationRequired = definition.validationRequiredWhen || (<TConfig extends Record<string, any>>(_: TConfig) => false)
|
||||
const shouldValidate = validationRequired(normalizedConfig)
|
||||
const shouldValidate = await validationRequired(normalizedConfig)
|
||||
|
||||
return {
|
||||
steps,
|
||||
|
||||
@@ -35,8 +35,8 @@ describe('services inference-service-providers', () => {
|
||||
* @example
|
||||
* const provider = inferenceServiceProvidersService.buildLocal('atlascloud', { apiKey: '...' })
|
||||
*/
|
||||
it('lists Atlas Cloud as a built-in OpenAI-compatible provider', () => {
|
||||
const schema = providerAtlasCloud.createProviderConfig({ t: (key: string) => key })
|
||||
it('lists Atlas Cloud as a built-in OpenAI-compatible provider', async () => {
|
||||
const schema = await providerAtlasCloud.createProviderConfig({ t: (key: string) => key })
|
||||
|
||||
expect(providerAtlasCloud.name).toBe('Atlas Cloud')
|
||||
expect(parseSchema(schema, { apiKey: 'test-key' })).toEqual({
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('speech store helpers', () => {
|
||||
const providerConfigStore = useProviderConfigStore()
|
||||
vi.spyOn(providersStore, 'listProviderVoices').mockResolvedValue([])
|
||||
const speechStore = useSpeechStore()
|
||||
providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
providersStore.forceProviderConfigured(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID
|
||||
speechStore.activeSpeechModel = 'auto'
|
||||
@@ -223,13 +223,13 @@ describe('speech store helpers', () => {
|
||||
* @example
|
||||
* speechStore.ensureActiveSpeechModel()
|
||||
*/
|
||||
it('keeps a real Voice Pack TTS model selected for the regular official provider', () => {
|
||||
it('keeps a real Voice Pack TTS model selected for the regular official provider', async () => {
|
||||
const providersStore = useProviderStore()
|
||||
const speechStore = useSpeechStore()
|
||||
speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID
|
||||
speechStore.activeSpeechModel = 'volcengine/pool-a'
|
||||
speechStore.activeSpeechVoiceId = 'voice-a'
|
||||
providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = [
|
||||
{ id: 'volcengine/pool-a', name: 'volcengine/pool-a', provider: OFFICIAL_SPEECH_PROVIDER_ID },
|
||||
{ id: 'microsoft/v1', name: 'microsoft/v1', provider: OFFICIAL_SPEECH_PROVIDER_ID },
|
||||
@@ -280,10 +280,11 @@ describe('speech store helpers', () => {
|
||||
languages: [],
|
||||
}
|
||||
try {
|
||||
providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
const provider = await providerOfficialSpeech.createProvider({})
|
||||
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!(
|
||||
{},
|
||||
providerOfficialSpeech.createProvider({}),
|
||||
provider,
|
||||
)
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
@@ -339,10 +340,11 @@ describe('speech store helpers', () => {
|
||||
speechStore.activeSpeechVoiceId = 'old-model-voice'
|
||||
|
||||
try {
|
||||
providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
const provider = await providerOfficialSpeech.createProvider({})
|
||||
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!(
|
||||
{},
|
||||
providerOfficialSpeech.createProvider({}),
|
||||
provider,
|
||||
)
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
@@ -397,10 +399,11 @@ describe('speech store helpers', () => {
|
||||
speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID
|
||||
|
||||
try {
|
||||
providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
const provider = await providerOfficialSpeech.createProvider({})
|
||||
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!(
|
||||
{},
|
||||
providerOfficialSpeech.createProvider({}),
|
||||
provider,
|
||||
)
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ChatRequestOptions, ModelInfo, ProviderDefinition, ProviderInstanc
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
|
||||
import { computedAsync, useIntervalFn } from '@vueuse/core'
|
||||
import { computedAsync, useAsyncState, useIntervalFn } from '@vueuse/core'
|
||||
import { listModels } from '@xsai/model'
|
||||
import { uniqBy } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -114,28 +114,42 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
const providerDefinitions = Object.fromEntries(
|
||||
definedProviders.map(definition => [definition.id, definition]),
|
||||
) as Record<string, ProviderDefinition>
|
||||
const providerMetadata = selectProvidersMetadata(definedProviders, t)
|
||||
|
||||
const providerValidationIntervalMsById = new Map<string, number>()
|
||||
for (const definition of definedProviders) {
|
||||
const intervalMs = getProviderValidationIntervalMs({
|
||||
definition,
|
||||
contextOptions: { t },
|
||||
})
|
||||
if (intervalMs && intervalMs > 0) {
|
||||
const providerMetadataState = useAsyncState(async () => {
|
||||
const metadata = await selectProvidersMetadata(definedProviders, t)
|
||||
|
||||
await Promise.all(definedProviders.map(async (definition) => {
|
||||
const intervalMs = await getProviderValidationIntervalMs({
|
||||
definition,
|
||||
contextOptions: { t },
|
||||
})
|
||||
if (!intervalMs || intervalMs <= 0)
|
||||
return
|
||||
|
||||
providerValidationIntervalMsById.set(definition.id, intervalMs)
|
||||
providerValidationIntervalMsById.set(`${VISION_PROVIDER_ID_PREFIX}${definition.id}`, intervalMs)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
for (const definition of definedProviders.filter(definition => providerMetadata[definition.id]?.category === 'chat')) {
|
||||
const id = `${VISION_PROVIDER_ID_PREFIX}${definition.id}`
|
||||
providerMetadata[id] = selectProviderMetadata(definition, t, {
|
||||
id,
|
||||
to: `/settings/providers/vision/${definition.id}`,
|
||||
category: 'vision',
|
||||
tasks: Array.from(new Set([...definition.tasks, 'vision', 'image-understanding'])),
|
||||
})
|
||||
await Promise.all(definedProviders
|
||||
.filter(definition => metadata[definition.id]?.category === 'chat')
|
||||
.map(async (definition) => {
|
||||
const id = `${VISION_PROVIDER_ID_PREFIX}${definition.id}`
|
||||
metadata[id] = await selectProviderMetadata(definition, t, {
|
||||
id,
|
||||
to: `/settings/providers/vision/${definition.id}`,
|
||||
category: 'vision',
|
||||
tasks: Array.from(new Set([...definition.tasks, 'vision', 'image-understanding'])),
|
||||
})
|
||||
}))
|
||||
|
||||
return metadata
|
||||
}, {})
|
||||
const providerMetadata = providerMetadataState.state
|
||||
|
||||
async function waitForProviderMetadata() {
|
||||
await providerMetadataState
|
||||
if (providerMetadataState.error.value)
|
||||
throw providerMetadataState.error.value
|
||||
}
|
||||
|
||||
const providerRuntimeState = computed({
|
||||
@@ -202,9 +216,10 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
config: Record<string, unknown>,
|
||||
options: { onlyChatPingCheck?: boolean, skipChatPingCheck?: boolean } = {},
|
||||
) {
|
||||
await waitForProviderMetadata()
|
||||
const definition = getProviderDefinition(providerId)
|
||||
const schemaDefaults = getDefaultProviderConfig(providerId)
|
||||
const plan = getValidatorsOfProvider({
|
||||
const plan = await getValidatorsOfProvider({
|
||||
definition,
|
||||
config,
|
||||
schemaDefaults,
|
||||
@@ -245,12 +260,13 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function hasManualProviderValidators(providerId: string) {
|
||||
async function hasManualProviderValidators(providerId: string) {
|
||||
const definition = findProviderDefinition(providerId)
|
||||
if (!definition || definition.disableChatPingCheckUI)
|
||||
return false
|
||||
return (definition.validators?.validateProvider ?? [])
|
||||
.some(createValidator => createValidator({ t }).id.includes(CHAT_COMPLETIONS_VALIDATOR_ID))
|
||||
const validators = await Promise.all((definition.validators?.validateProvider ?? [])
|
||||
.map(createValidator => createValidator({ t })))
|
||||
return validators.some(validator => validator.id.includes(CHAT_COMPLETIONS_VALIDATOR_ID))
|
||||
}
|
||||
|
||||
function supportsModelListing(providerId: string) {
|
||||
@@ -259,6 +275,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
|
||||
// Configuration validation functions
|
||||
async function validateProvider(providerId: string, options: { force?: boolean } = {}): Promise<boolean> {
|
||||
await waitForProviderMetadata()
|
||||
const definition = findProviderDefinition(providerId)
|
||||
if (!definition)
|
||||
return false
|
||||
@@ -335,8 +352,8 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
|
||||
function getDefaultProviderConfig(providerId: string) {
|
||||
const definitionId = getProviderDefinitionId(providerId)
|
||||
const defaultOptions = providerMetadata[providerId]?.defaultConfig
|
||||
?? providerMetadata[definitionId]?.defaultConfig
|
||||
const defaultOptions = providerMetadata.value[providerId]?.defaultConfig
|
||||
?? providerMetadata.value[definitionId]?.defaultConfig
|
||||
?? {}
|
||||
return {
|
||||
...defaultOptions,
|
||||
@@ -355,7 +372,8 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
|
||||
// Initialize provider configurations
|
||||
function initializeProvider(providerId: string) {
|
||||
async function initializeProvider(providerId: string) {
|
||||
await waitForProviderMetadata()
|
||||
if (!providerCredentials.value[providerId]) {
|
||||
const definitionId = getProviderDefinitionId(providerId)
|
||||
providerConfigStore.ensureProvider(providerId, definitionId, getDefaultProviderConfig(providerId))
|
||||
@@ -372,7 +390,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
|
||||
function reconcileUnlistedProviders() {
|
||||
for (const providerId of Object.keys(providerMetadata)) {
|
||||
for (const providerId of Object.keys(providerMetadata.value)) {
|
||||
if (shouldListProvider(providerId))
|
||||
continue
|
||||
stopRevalidationLoop(providerId)
|
||||
@@ -386,7 +404,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
|
||||
function startPeriodicRuntimeValidation() {
|
||||
for (const [providerId, intervalMs] of providerValidationIntervalMsById.entries()) {
|
||||
if (!providerMetadata[providerId] || intervalMs <= 0)
|
||||
if (!providerMetadata.value[providerId] || intervalMs <= 0)
|
||||
continue
|
||||
|
||||
if (!shouldListProvider(providerId))
|
||||
@@ -406,7 +424,8 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
|
||||
// Update configuration status for listed providers only.
|
||||
async function updateConfigurationStatus() {
|
||||
await Promise.all(Object.entries(providerMetadata)
|
||||
await waitForProviderMetadata()
|
||||
await Promise.all(Object.entries(providerMetadata.value)
|
||||
.filter(([providerId]) => shouldListProvider(providerId) || providerId === 'browser-web-speech-api')
|
||||
.map(async ([providerId]) => {
|
||||
try {
|
||||
@@ -691,8 +710,8 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
|
||||
function projectProvider(providerId: string): ProviderMetadata | undefined {
|
||||
const configuredProvider = providerConfigStore.providers[providerId]
|
||||
const metadata = providerMetadata[providerId]
|
||||
?? providerMetadata[configuredProvider?.definitionId ?? '']
|
||||
const metadata = providerMetadata.value[providerId]
|
||||
?? providerMetadata.value[configuredProvider?.definitionId ?? '']
|
||||
|
||||
if (!metadata)
|
||||
return undefined
|
||||
@@ -713,13 +732,13 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
// Get all provider metadata in registry order for the settings page.
|
||||
const allProvidersMetadata = computed(() => {
|
||||
const definitions = definedProviders
|
||||
.filter(d => providerMetadata[d.id])
|
||||
.filter(d => providerMetadata.value[d.id])
|
||||
.map(d => projectProvider(d.id))
|
||||
.filter(metadata => metadata !== undefined)
|
||||
// Vision providers reuse chat definitions under separate instance ids.
|
||||
// Include these generated definitions before configured custom instances.
|
||||
const visionDefinitions = definedProviders
|
||||
.filter(definition => providerMetadata[definition.id]?.category === 'chat')
|
||||
.filter(definition => providerMetadata.value[definition.id]?.category === 'chat')
|
||||
.map(definition => projectProvider(`${VISION_PROVIDER_ID_PREFIX}${definition.id}`))
|
||||
.filter(metadata => metadata !== undefined)
|
||||
const definitionIds = new Set([...definitions, ...visionDefinitions].map(metadata => metadata.id))
|
||||
@@ -758,6 +777,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
| TranscriptionProvider
|
||||
| TranscriptionProviderWithExtraOptions,
|
||||
>(providerId: string): Promise<R> {
|
||||
await waitForProviderMetadata()
|
||||
const cached = providerInstanceCache.get(providerId) as R | undefined
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
Reference in New Issue
Block a user