refactor(stage-ui): support async provider definitions (#2360)

This commit is contained in:
Neko
2026-08-25 15:39:44 +08:00
committed by GitHub
parent eb5cdbe71a
commit 584cc96cd8
42 changed files with 425 additions and 240 deletions
@@ -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 || ''
@@ -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 },
@@ -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() {
@@ -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>
@@ -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() {
@@ -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 },
@@ -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>
@@ -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')"
@@ -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>
@@ -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,
}
+18 -7
View File
@@ -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