From ea80efdb5f7f9dc1a0b40d8081d176041ca0e55b Mon Sep 17 00:00:00 2001 From: Ilya Bogdanov <34226834+skirkru@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:53:05 +0300 Subject: [PATCH] feat(stage-ui): Refactor providers and add credential validation (#474) --- .../pages/settings/providers/anthropic.vue | 106 +- .../settings/providers/azure-ai-foundry.vue | 139 ++ .../providers/cloudflare-workers-ai.vue | 77 +- .../src/pages/settings/providers/deepseek.vue | 79 +- .../settings/providers/featherless-ai.vue | 81 +- .../pages/settings/providers/fireworks-ai.vue | 79 +- .../providers/google-generative-ai.vue | 89 +- .../pages/settings/providers/lm-studio.vue | 85 +- .../pages/settings/providers/mistral-ai.vue | 79 +- .../pages/settings/providers/modelscope.vue | 40 +- .../pages/settings/providers/moonshot-ai.vue | 81 +- .../pages/settings/providers/novita-ai.vue | 79 +- .../src/pages/settings/providers/ollama.vue | 75 +- .../openai-compatible-audio-speech.vue | 83 +- .../openai-compatible-audio-transcription.vue | 109 +- .../settings/providers/openai-compatible.vue | 84 +- .../src/pages/settings/providers/openai.vue | 89 +- .../settings/providers/openrouter-ai.vue | 82 +- .../settings/providers/player2-speech.vue | 38 +- .../src/pages/settings/providers/player2.vue | 39 +- .../pages/settings/providers/together-ai.vue | 100 +- .../src/pages/settings/providers/vllm.vue | 79 + .../src/pages/settings/providers/xai.vue | 100 +- packages/i18n/src/locales/en/settings.yaml | 4 + packages/i18n/src/locales/es/settings.yaml | 4 + .../i18n/src/locales/zh-Hans/settings.yaml | 7 + .../Dialogs/Onboarding/Onboarding.vue | 7 +- .../src/composables/useProviderValidation.ts | 137 ++ packages/stage-ui/src/stores/providers.ts | 1317 ++++------------- .../providers/openai-compatible-builder.ts | 159 ++ 30 files changed, 1625 insertions(+), 1902 deletions(-) create mode 100644 apps/stage-tamagotchi/src/pages/settings/providers/azure-ai-foundry.vue create mode 100644 apps/stage-tamagotchi/src/pages/settings/providers/vllm.vue create mode 100644 packages/stage-ui/src/composables/useProviderValidation.ts create mode 100644 packages/stage-ui/src/stores/providers/openai-compatible-builder.ts diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/anthropic.vue b/apps/stage-tamagotchi/src/pages/settings/providers/anthropic.vue index a3c51c061..b1d86c866 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/anthropic.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/anthropic.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,92 +10,52 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'anthropic' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'anthropic' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) const baseUrl = computed({ - get: () => providers.value[providerId]?.baseUrl || 'https://api.anthropic.com/v1/', + get: () => providers.value[providerId]?.baseUrl || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - if (!providers.value[providerId]) { - providers.value[providerId] = { - baseUrl: 'https://api.anthropic.com/v1/', - } - } - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || 'https://api.anthropic.com/v1/' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || 'https://api.anthropic.com/v1/', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - baseUrl: 'https://api.anthropic.com/v1/', - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/azure-ai-foundry.vue b/apps/stage-tamagotchi/src/pages/settings/providers/azure-ai-foundry.vue new file mode 100644 index 000000000..f9f1c9f54 --- /dev/null +++ b/apps/stage-tamagotchi/src/pages/settings/providers/azure-ai-foundry.vue @@ -0,0 +1,139 @@ + + + + + + meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/cloudflare-workers-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/cloudflare-workers-ai.vue index b6c20aa3a..2bcbe94a8 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/cloudflare-workers-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/cloudflare-workers-ai.vue @@ -2,34 +2,28 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAccountIdInput, ProviderApiKeyInput, ProviderBasicSettings, ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'cloudflare-workers-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'cloudflare-workers-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -39,34 +33,20 @@ const accountId = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].accountId = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - accountId.value = providers.value[providerId]?.accountId || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, accountId], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - accountId: accountId.value, - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/deepseek.vue b/apps/stage-tamagotchi/src/pages/settings/providers/deepseek.vue index 8b679f1af..330b739f5 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/deepseek.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/deepseek.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'deepseek' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'deepseek' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,34 +34,20 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || '', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/featherless-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/featherless-ai.vue index ee5c265ce..9aaa22845 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/featherless-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/featherless-ai.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'featherless-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'featherless-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,40 +34,26 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || '', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/fireworks-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/fireworks-ai.vue index b0376361f..1f7204371 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/fireworks-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/fireworks-ai.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'fireworks-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'fireworks-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,40 +34,26 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || '', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/google-generative-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/google-generative-ai.vue index b8a06a2b7..64cb22a92 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/google-generative-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/google-generative-ai.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'google-generative-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'google-generative-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,44 +34,26 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - if (!providers.value[providerId]) { - providers.value[providerId] = { - baseUrl: 'https://api.anthropic.com/v1/', - } - } - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || 'https://generativelanguage.googleapis.com/v1beta/openai/' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || 'https://generativelanguage.googleapis.com/v1beta/openai/', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/lm-studio.vue b/apps/stage-tamagotchi/src/pages/settings/providers/lm-studio.vue index 285af937d..3e976e546 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/lm-studio.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/lm-studio.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,60 +10,50 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'lm-studio' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'lm-studio' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) const baseUrl = computed({ - get: () => providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || '', + get: () => providers.value[providerId]?.baseUrl || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || '' -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/mistral-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/mistral-ai.vue index cb1bf5807..8eed3a977 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/mistral-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/mistral-ai.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'mistral-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'mistral-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,34 +34,20 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || '', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/modelscope.vue b/apps/stage-tamagotchi/src/pages/settings/providers/modelscope.vue index 208c4e1ef..7206be6fb 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/modelscope.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/modelscope.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,20 +10,26 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' -const { t } = useI18n() -const router = useRouter() const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } // Get provider metadata const providerId = 'modelscope' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) + +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) // Use computed properties for settings const apiKey = computed({ @@ -62,12 +69,6 @@ watch([apiKey, baseUrl], () => { baseUrl: baseUrl.value || '', } }) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/moonshot-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/moonshot-ai.vue index 3175505b4..0c70c165d 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/moonshot-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/moonshot-ai.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'moonshot-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'moonshot-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,40 +34,26 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || '', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/novita-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/novita-ai.vue index 57b94ac65..20b03319e 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/novita-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/novita-ai.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/core' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -9,28 +10,21 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed } from 'vue' -const { t } = useI18n() -const router = useRouter() +const providerId = 'novita-ai' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'novita-ai' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -// Use computed properties for settings +// Define computed properties for credentials const apiKey = computed({ get: () => providers.value[providerId]?.apiKey || '', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].apiKey = value }, }) @@ -40,34 +34,20 @@ const baseUrl = computed({ set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) -onMounted(() => { - // Initialize provider if it doesn't exist - providersStore.initializeProvider(providerId) - - // Initialize refs with current values - apiKey.value = providers.value[providerId]?.apiKey || '' - baseUrl.value = providers.value[providerId]?.baseUrl || '' -}) - -// Watch settings and update the provider configuration -watch([apiKey, baseUrl], () => { - providers.value[providerId] = { - ...providers.value[providerId], - apiKey: apiKey.value, - baseUrl: baseUrl.value || '', - } -}) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/ollama.vue b/apps/stage-tamagotchi/src/pages/settings/providers/ollama.vue index 39103231d..58e9334be 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/ollama.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/ollama.vue @@ -9,34 +9,37 @@ import { ProviderSettingsContainer, ProviderSettingsLayout, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { FieldKeyValues } from '@proj-airi/ui' import { storeToRefs } from 'pinia' import { computed, onMounted, ref, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' -const { t } = useI18n() -const router = useRouter() +const providerId = 'ollama' const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -// Get provider metadata -const providerId = 'ollama' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) - -const validationMessage = ref('') - +// Define computed properties for credentials const baseUrl = computed({ - get: () => providers.value[providerId]?.baseUrl || providerMetadata.value?.defaultOptions?.().baseUrl || '', + get: () => providers.value[providerId]?.baseUrl || 'http://localhost:11434/v1/', set: (value) => { if (!providers.value[providerId]) providers.value[providerId] = {} - providers.value[providerId].baseUrl = value }, }) +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) + const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }]) function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) { @@ -113,28 +116,12 @@ onMounted(() => { headers.value = [{ key: '', value: '' }] } }) - -function handleResetSettings() { - providers.value[providerId] = { - ...(providerMetadata.value?.defaultOptions as any), - } -} - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/openai-compatible-audio-speech.vue b/apps/stage-tamagotchi/src/pages/settings/providers/openai-compatible-audio-speech.vue index 127dd1ef0..d940f3f76 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/openai-compatible-audio-speech.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/openai-compatible-audio-speech.vue @@ -3,6 +3,7 @@ import type { RemovableRef } from '@vueuse/core' import type { SpeechProvider } from '@xsai-ext/shared-providers' import { + Alert, ProviderAdvancedSettings, ProviderApiKeyInput, ProviderBaseUrlInput, @@ -11,19 +12,16 @@ import { ProviderSettingsLayout, SpeechPlaygroundOpenAICompatible, } from '@proj-airi/stage-ui/components' +import { useProviderValidation } from '@proj-airi/stage-ui/composables/useProviderValidation' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { FieldRange } from '@proj-airi/ui' import { storeToRefs } from 'pinia' -import { computed, onMounted, ref, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { computed, ref } from 'vue' const speechStore = useSpeechStore() const providersStore = useProvidersStore() const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } -const { t } = useI18n() -const router = useRouter() const defaultVoiceSettings = { speed: 1.0, @@ -31,7 +29,6 @@ const defaultVoiceSettings = { // Get provider metadata const providerId = 'openai-compatible-audio-speech' -const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) // Settings refs const apiKey = computed({ @@ -92,43 +89,22 @@ async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: bo ) } -onMounted(() => { - providersStore.initializeProvider(providerId) - const config = providers.value[providerId] || {} - apiKey.value = config.apiKey || '' - baseUrl.value = config.baseUrl || '' - model.value = config.model || 'tts-1' - voice.value = config.voice || 'alloy' - speed.value = config.speed || 1.0 -}) - -watch(speed, (newSpeed) => { - if (providers.value[providerId]) - providers.value[providerId].speed = newSpeed -}) - -function handleResetSettings() { - const defaults = providerMetadata.value?.defaultOptions?.() || {} - providers.value[providerId] = { - apiKey: '', - baseUrl: defaults.baseUrl || '', - model: 'tts-1', - voice: 'alloy', - speed: 1.0, - } - // Force update refs - apiKey.value = '' - baseUrl.value = defaults.baseUrl || '' - model.value = 'tts-1' - voice.value = 'alloy' - speed.value = 1.0 -} +// Use the composable to get validation logic and state +const { + t, + router, + providerMetadata, + isValidating, + isValid, + validationMessage, + handleResetSettings, +} = useProviderValidation(providerId) diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/player2.vue b/apps/stage-tamagotchi/src/pages/settings/providers/player2.vue index 2d3c3339f..190d00120 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/player2.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/player2.vue @@ -2,6 +2,7 @@ import type { RemovableRef } from '@vueuse/shared' import { + Alert, ProviderBaseUrlInput, ProviderSettingsContainer, ProviderSettingsLayout, @@ -67,22 +68,6 @@ function handleResetSettings() { diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/together-ai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/together-ai.vue index b8aeaeec8..07adae877 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/together-ai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/together-ai.vue @@ -1,7 +1,6 @@ - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/vllm.vue b/apps/stage-tamagotchi/src/pages/settings/providers/vllm.vue new file mode 100644 index 000000000..31b7252df --- /dev/null +++ b/apps/stage-tamagotchi/src/pages/settings/providers/vllm.vue @@ -0,0 +1,79 @@ + + + + + +meta: + layout: settings + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/pages/settings/providers/xai.vue b/apps/stage-tamagotchi/src/pages/settings/providers/xai.vue index 306b8a5d3..65af9e544 100644 --- a/apps/stage-tamagotchi/src/pages/settings/providers/xai.vue +++ b/apps/stage-tamagotchi/src/pages/settings/providers/xai.vue @@ -1,7 +1,6 @@ - meta: - layout: settings - stageTransition: - name: slide - +meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index cb620a7d8..d4bb1a932 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -17,6 +17,7 @@ dialogs: baseUrl: Base URL baseUrlHelp: API endpoint URL (use default if unsure) accountId: Account ID + validationSuccess: Configuration validation success validationFailed: Configuration validation failed validationError: 'Validation error: {error}' skipForNow: Skip for now @@ -410,6 +411,9 @@ pages: index-tts-vllm: description: https://index-tts.github.io/ title: Bilibili / IndexTTS + azure-ai-foundry: + description: Azure AI Foundry + title: Azure AI Foundry mistral: description: mistral.ai title: Mistral diff --git a/packages/i18n/src/locales/es/settings.yaml b/packages/i18n/src/locales/es/settings.yaml index dc0620ff7..0fec81e0f 100644 --- a/packages/i18n/src/locales/es/settings.yaml +++ b/packages/i18n/src/locales/es/settings.yaml @@ -17,6 +17,7 @@ dialogs: baseUrl: URL Base baseUrlHelp: URL del endpoint de la API (usa el predeterminado si no estás seguro) accountId: ID de Cuenta + validationSuccess: La validación de la configuración fue exitosa validationFailed: La validación de la configuración falló validationError: 'Error de validación: {error}' skipForNow: Omitir por ahora @@ -397,6 +398,9 @@ pages: index-tts-vllm: description: https://index-tts.github.io/ title: Bilibili / IndexTTS + azure-ai-foundry: + description: Azure AI Foundry + title: Azure AI Foundry mistral: description: mistral.ai title: Mistral diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 1eebf539f..4d3e8e9f7 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -15,6 +15,7 @@ dialogs: baseUrl: 基础 URL baseUrlHelp: API 端点 URL(如果不确定请使用默认值) accountId: 账户 ID + validationSuccess: 配置验证成功 validationFailed: 配置验证失败 validationError: 验证错误:{error} skipForNow: 暂时跳过 @@ -372,6 +373,12 @@ pages: description: 服务 Endpoint 地区(比如亚太 eastasia 区域) label: Endpoint 地区 title: Microsoft / Azure 语音服务 + index-tts-vllm: + description: https://index-tts.github.io/ + title: Bilibili / IndexTTS + azure-ai-foundry: + description: Azure AI Foundry + title: Azure AI Foundry mistral: description: mistral.ai title: Mistral diff --git a/packages/stage-ui/src/components/Scenarios/Dialogs/Onboarding/Onboarding.vue b/packages/stage-ui/src/components/Scenarios/Dialogs/Onboarding/Onboarding.vue index ac6f44827..7e7f7613b 100644 --- a/packages/stage-ui/src/components/Scenarios/Dialogs/Onboarding/Onboarding.vue +++ b/packages/stage-ui/src/components/Scenarios/Dialogs/Onboarding/Onboarding.vue @@ -41,7 +41,7 @@ const { // Popular providers for first-time setup const popularProviders = computed(() => { - const popular = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'player2'] + const popular = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible'] return allChatProvidersMetadata.value .filter(provider => popular.includes(provider.id)) .sort((a, b) => popular.indexOf(a.id) - popular.indexOf(b.id)) @@ -410,6 +410,11 @@ onMounted(() => { + + + diff --git a/packages/stage-ui/src/composables/useProviderValidation.ts b/packages/stage-ui/src/composables/useProviderValidation.ts new file mode 100644 index 000000000..546d06365 --- /dev/null +++ b/packages/stage-ui/src/composables/useProviderValidation.ts @@ -0,0 +1,137 @@ +import type { RemovableRef } from '@vueuse/core' + +import { useDebounceFn } from '@vueuse/core' +import { storeToRefs } from 'pinia' +import { computed, onMounted, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' + +import { useProvidersStore } from '../stores/providers' + +export function useProviderValidation(providerId: string) { + const { t } = useI18n() + const router = useRouter() + const providersStore = useProvidersStore() + const { providers } = storeToRefs(providersStore) as { providers: RemovableRef> } + + const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId)) + + // --- Internal Computed Properties for Credentials --- + const credentials = computed(() => providers.value[providerId] || {}) + + const apiKey = computed({ + get: () => credentials.value.apiKey || '', + set: (value) => { + if (!providers.value[providerId]) + providers.value[providerId] = {} + providers.value[providerId].apiKey = value + }, + }) + + const baseUrl = computed({ + get: () => credentials.value.baseUrl || '', + set: (value) => { + if (!providers.value[providerId]) + providers.value[providerId] = {} + providers.value[providerId].baseUrl = value + }, + }) + + const accountId = computed({ + get: () => credentials.value.accountId || '', + set: (value) => { + if (!providers.value[providerId]) + providers.value[providerId] = {} + providers.value[providerId].accountId = value + }, + }) + // --- End of Internal Computed Properties --- + + const debounceTime = 500 + const isValidating = ref(0) + const isValid = ref(false) + const validationMessage = ref('') + + async function validateConfiguration() { + if (!providerMetadata.value) + return + + isValidating.value++ + validationMessage.value = '' + const startValidationTimestamp = performance.now() + let finalValidationMessage = '' + + try { + const config = { ...credentials.value } + if (config.apiKey) + config.apiKey = config.apiKey.trim() + if (config.baseUrl) + config.baseUrl = config.baseUrl.trim() + + const validationResult = await providerMetadata.value.validators.validateProviderConfig(config) + isValid.value = validationResult.valid + + if (!isValid.value) + finalValidationMessage = validationResult.reason + } + catch (error) { + isValid.value = false + finalValidationMessage = t('settings.dialogs.onboarding.validationError', { + error: error instanceof Error ? error.message : String(error), + }) + } + finally { + setTimeout(() => { + isValidating.value-- + validationMessage.value = finalValidationMessage + }, Math.max(0, debounceTime - (performance.now() - startValidationTimestamp))) + } + } + + const debouncedValidateConfiguration = useDebounceFn(() => { + const config = credentials.value + const hasApiKey = 'apiKey' in config && !!config.apiKey?.trim() + const hasBaseUrl = 'baseUrl' in config && !!config.baseUrl?.trim() + const hasAccountId = 'accountId' in config && !!config.accountId?.trim() + + if (!hasApiKey && !hasBaseUrl && !hasAccountId) { + isValid.value = false + validationMessage.value = '' + isValidating.value = 0 + return + } + validateConfiguration() + }, debounceTime) + + onMounted(() => { + providersStore.initializeProvider(providerId) + if (Object.keys(credentials.value).some(key => !!credentials.value[key])) { + validateConfiguration() + } + }) + + watch(credentials, () => { + debouncedValidateConfiguration() + }, { deep: true }) + + function handleResetSettings() { + const defaultOptions = providerMetadata.value?.defaultOptions ? providerMetadata.value.defaultOptions() : {} + providers.value[providerId] = { ...defaultOptions } + isValid.value = false + validationMessage.value = '' + isValidating.value = 0 + } + + return { + t, + router, + providerMetadata, + apiKey, + baseUrl, + accountId, + isValidating, + isValid, + validationMessage, + handleResetSettings, + } +} diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 8e62e13dc..d4d01cd2d 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -19,7 +19,6 @@ import type { import { computedAsync, useLocalStorage } from '@vueuse/core' import { - createAnthropic, createAzure, createDeepSeek, createFireworks, @@ -35,6 +34,12 @@ import { createXAI, } from '@xsai-ext/providers-cloud' import { createOllama, createPlayer2 } from '@xsai-ext/providers-local' +import { + createChatProvider, + createMetadataProvider, + createModelProvider, + merge, +} from '@xsai-ext/shared-providers' import { listModels } from '@xsai/model' import { isWebGPUSupported } from 'gpuu/webgpu' import { defineStore } from 'pinia' @@ -50,6 +55,7 @@ import { useI18n } from 'vue-i18n' import { isUrl } from '../utils/url' import { models as elevenLabsModels } from './providers/elevenlabs/list-models' +import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder' export interface ProviderMetadata { id: string @@ -157,6 +163,26 @@ export interface VoiceInfo { }[] } +function createAnthropic(apiKey: string, baseURL: string = 'https://api.anthropic.com/v1/') { + const anthropicFetch = async (input: any, init: any) => { + init.headers ??= {} + if (Array.isArray(init.headers)) + init.headers.push(['anthropic-dangerous-direct-browser-access', 'true']) + else if (init.headers instanceof Headers) + init.headers.append('anthropic-dangerous-direct-browser-access', 'true') + else + init.headers['anthropic-dangerous-direct-browser-access'] = 'true' + return fetch(input, init) + } + + return merge( + createMetadataProvider('anthropic'), + /** @see {@link https://docs.anthropic.com/en/docs/about-claude/models/all-models} */ + createChatProvider({ apiKey, fetch: anthropicFetch, baseURL }), + createModelProvider({ apiKey, fetch: anthropicFetch, baseURL }), + ) +} + export const useProvidersStore = defineStore('providers', () => { const providerCredentials = useLocalStorage>>('settings/credentials/providers', {}) const { t } = useI18n() @@ -184,227 +210,62 @@ export const useProvidersStore = defineStore('providers', () => { return null }) - // Helper function to fetch OpenRouter models manually - async function fetchOpenRouterModels(config: Record): Promise { - try { - const response = await fetch('https://openrouter.ai/api/v1/models', { - headers: { - 'Authorization': `Bearer ${(config.apiKey as string).trim()}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`Failed to fetch OpenRouter models: ${response.statusText}`) + async function isTamagotchi() { + if ('window' in globalThis && globalThis.window != null) { + if (('__TAURI_INTERNALS__' in globalThis.window && globalThis.window.__TAURI_INTERNALS__ != null) || location.host === 'tauri.localhost') { + return true } + } + return false + } - const data = await response.json() - return data.data.map((model: any) => ({ - id: model.id, - name: model.name || model.id, - provider: 'openrouter-ai', - description: model.description || '', - contextLength: model.context_length, - deprecated: false, - })) + async function isBrowserAndMemoryEnough() { + const isInApp = await isTamagotchi() + + if (isInApp) + return false + + const webGPUAvailable = await isWebGPUSupported() + if (webGPUAvailable) { + return true } - catch (error) { - console.error('Error fetching OpenRouter models:', error) - throw error + + if ('navigator' in globalThis && globalThis.navigator != null && 'deviceMemory' in globalThis.navigator && typeof globalThis.navigator.deviceMemory === 'number') { + const memory = globalThis.navigator.deviceMemory + // Check if the device has at least 8GB of RAM + if (memory >= 8) { + return true + } } + + return false } // Centralized provider metadata with provider factory functions const providerMetadata: Record = { - 'openrouter-ai': { + 'openrouter-ai': buildOpenAICompatibleProvider({ id: 'openrouter-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.openrouter.title', name: 'OpenRouter', + nameKey: 'settings.pages.providers.provider.openrouter.title', descriptionKey: 'settings.pages.providers.provider.openrouter.description', - description: 'openrouter.ai', icon: 'i-lobe-icons:openrouter', - defaultOptions: () => ({ - baseUrl: 'https://openrouter.ai/api/v1/', - }), - createProvider: async config => createOpenRouter((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return fetchOpenRouterModels(config) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required'), - !config.baseUrl && new Error('Base URL is required'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'app-local-audio-speech': { + description: 'openrouter.ai', + defaultBaseUrl: 'https://openrouter.ai/api/v1/', + creator: createOpenRouter, + validation: ['health', 'model_list', 'chat_completions'], + }), + 'app-local-audio-speech': buildOpenAICompatibleProvider({ id: 'app-local-audio-speech', - category: 'speech', - tasks: ['text-to-speech', 'tts'], - isAvailableBy: async () => { - if ('window' in globalThis && globalThis.window != null) { - if ('__TAURI__' in globalThis.window && globalThis.window.__TAURI__ != null) { - return true - } - } - - return false - }, + name: 'App (Local)', nameKey: 'settings.pages.providers.provider.app-local-audio-speech.title', - name: 'App (Local)', descriptionKey: 'settings.pages.providers.provider.app-local-audio-speech.description', - description: 'https://github.com/huggingface/candle', icon: 'i-lobe-icons:huggingface', - defaultOptions: () => ({}), - createProvider: async config => createOpenAI((config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'app-local-candle', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - if (!config.baseUrl) { - return { - errors: [new Error('Base URL is required.')], - reason: 'Base URL is required. This is likely a bug, report to developers on https://github.com/moeru-ai/airi/issues.', - valid: false, - } - } - - return { - errors: [], - reason: '', - valid: true, - } - }, - }, - }, - 'app-local-audio-transcription': { - id: 'app-local-audio-transcription', - category: 'transcription', - tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'], - isAvailableBy: async () => { - if ('window' in globalThis && globalThis.window != null) { - if ('__TAURI__' in globalThis.window && globalThis.window.__TAURI__ != null) { - return true - } - } - - return false - }, - nameKey: 'settings.pages.providers.provider.app-local-audio-transcription.title', - name: 'App (Local)', - descriptionKey: 'settings.pages.providers.provider.app-local-audio-transcription.description', description: 'https://github.com/huggingface/candle', - icon: 'i-lobe-icons:huggingface', - defaultOptions: () => ({}), - createProvider: async config => createOpenAI((config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'app-local-candle', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - if (!config.baseUrl) { - return { - errors: [new Error('Base URL is required.')], - reason: 'Base URL is required. This is likely a bug, report to developers on https://github.com/moeru-ai/airi/issues.', - valid: false, - } - } - - return { - errors: [], - reason: '', - valid: true, - } - }, - }, - }, - 'browser-local-audio-speech': { - id: 'browser-local-audio-speech', category: 'speech', tasks: ['text-to-speech', 'tts'], - isAvailableBy: async () => { - const webGPUAvailable = await isWebGPUSupported() - if (webGPUAvailable) { - return true - } - - if ('navigator' in globalThis && globalThis.navigator != null && 'deviceMemory' in globalThis.navigator && typeof globalThis.navigator.deviceMemory === 'number') { - const memory = globalThis.navigator.deviceMemory - // Check if the device has at least 8GB of RAM - if (memory >= 8) { - return true - } - } - - return false - }, - nameKey: 'settings.pages.providers.provider.browser-local-audio-speech.title', - name: 'Browser (Local)', - descriptionKey: 'settings.pages.providers.provider.browser-local-audio-speech.description', - description: 'https://github.com/moeru-ai/xsai-transformers', - icon: 'i-lobe-icons:huggingface', - defaultOptions: () => ({}), - createProvider: async config => createOpenAI((config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'browser-local-transformers', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, + isAvailableBy: isTamagotchi, + creator: createOpenAI, + validation: [], validators: { validateProviderConfig: (config) => { if (!config.baseUrl) { @@ -422,50 +283,19 @@ export const useProvidersStore = defineStore('providers', () => { } }, }, - }, - 'browser-local-audio-transcription': { - id: 'browser-local-audio-transcription', + }), + 'app-local-audio-transcription': buildOpenAICompatibleProvider({ + id: 'app-local-audio-transcription', + name: 'App (Local)', + nameKey: 'settings.pages.providers.provider.app-local-audio-transcription.title', + descriptionKey: 'settings.pages.providers.provider.app-local-audio-transcription.description', + icon: 'i-lobe-icons:huggingface', + description: 'https://github.com/huggingface/candle', category: 'transcription', tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'], - isAvailableBy: async () => { - const webGPUAvailable = await isWebGPUSupported() - if (webGPUAvailable) { - return true - } - - if ('navigator' in globalThis && globalThis.navigator != null && 'deviceMemory' in globalThis.navigator && typeof globalThis.navigator.deviceMemory === 'number') { - const memory = globalThis.navigator.deviceMemory - // Check if the device has at least 8GB of RAM - if (memory >= 8) { - return true - } - } - - return false - }, - nameKey: 'settings.pages.providers.provider.browser-local-audio-transcription.title', - name: 'Browser (Local)', - descriptionKey: 'settings.pages.providers.provider.browser-local-audio-transcription.description', - description: 'https://github.com/moeru-ai/xsai-transformers', - icon: 'i-lobe-icons:huggingface', - defaultOptions: () => ({}), - createProvider: async config => createOpenAI((config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'browser-local-transformers', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, + isAvailableBy: isTamagotchi, + creator: createOpenAI, + validation: [], validators: { validateProviderConfig: (config) => { if (!config.baseUrl) { @@ -483,7 +313,67 @@ export const useProvidersStore = defineStore('providers', () => { } }, }, - }, + }), + 'browser-local-audio-speech': buildOpenAICompatibleProvider({ + id: 'browser-local-audio-speech', + name: 'Browser (Local)', + nameKey: 'settings.pages.providers.provider.browser-local-audio-speech.title', + descriptionKey: 'settings.pages.providers.provider.browser-local-audio-speech.description', + icon: 'i-lobe-icons:huggingface', + description: 'https://github.com/moeru-ai/xsai-transformers', + category: 'speech', + tasks: ['text-to-speech', 'tts'], + isAvailableBy: isBrowserAndMemoryEnough, + creator: createOpenAI, + validation: [], + validators: { + validateProviderConfig: (config) => { + if (!config.baseUrl) { + return { + errors: [new Error('Base URL is required.')], + reason: 'Base URL is required. This is likely a bug, report to developers on https://github.com/moeru-ai/airi/issues.', + valid: false, + } + } + + return { + errors: [], + reason: '', + valid: true, + } + }, + }, + }), + 'browser-local-audio-transcription': buildOpenAICompatibleProvider({ + id: 'browser-local-audio-transcription', + name: 'Browser (Local)', + nameKey: 'settings.pages.providers.provider.browser-local-audio-transcription.title', + descriptionKey: 'settings.pages.providers.provider.browser-local-audio-transcription.description', + icon: 'i-lobe-icons:huggingface', + description: 'https://github.com/moeru-ai/xsai-transformers', + category: 'transcription', + tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'], + isAvailableBy: isBrowserAndMemoryEnough, + creator: createOpenAI, + validation: [], + validators: { + validateProviderConfig: (config) => { + if (!config.baseUrl) { + return { + errors: [new Error('Base URL is required.')], + reason: 'Base URL is required. This is likely a bug, report to developers on https://github.com/moeru-ai/airi/issues.', + valid: false, + } + } + + return { + errors: [], + reason: '', + valid: true, + } + }, + }, + }), 'ollama': { id: 'ollama', category: 'chat', @@ -544,7 +434,7 @@ export const useProvidersStore = defineStore('providers', () => { .catch((err) => { return { errors: [err], - reason: `Failed to reach Ollama server, error: ${String(err)} occurred.\n\nIf you are using Ollama locally, this is likely the CORS (Cross-Origin Resource Sharing) security issue, where you will need to set OLLAMA_ORIGINS=* or OLLAMA_ORIGINS=https://airi.moeru.ai,http://localhost environment variable before launching Ollama server to make this work.`, + reason: `Failed to reach Ollama server, error: ${String(err)} occurred.\n\nIf you are using Ollama locally, this is likely the CORS (Cross-Origin Resource Sharing) security issue, where you will need to set OLLAMA_ORIGINS=* or OLLAMA_ORIGINS=https://airi.moeru.ai,${location.origin} environment variable before launching Ollama server to make this work.`, valid: false, } }) @@ -792,131 +682,40 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, - 'openai': { + 'openai': buildOpenAICompatibleProvider({ id: 'openai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.openai.title', name: 'OpenAI', + nameKey: 'settings.pages.providers.provider.openai.title', descriptionKey: 'settings.pages.providers.provider.openai.description', + icon: 'i-lobe-icons:openai', description: 'openai.com', - icon: 'i-lobe-icons:openai', - defaultOptions: () => ({ - baseUrl: 'https://api.openai.com/v1/', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'openai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.baseUrl && new Error('Base URL is required. Default to https://api.openai.com/v1/ for official OpenAI API.'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.baseUrl, - } - }, - }, - }, - 'openai-compatible': { + defaultBaseUrl: 'https://api.openai.com/v1/', + creator: createOpenAI, + validation: ['health', 'model_list'], + }), + 'openai-compatible': buildOpenAICompatibleProvider({ id: 'openai-compatible', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.openai-compatible.title', name: 'OpenAI Compatible', + nameKey: 'settings.pages.providers.provider.openai-compatible.title', descriptionKey: 'settings.pages.providers.provider.openai-compatible.description', - description: 'Connect to any API that follows the OpenAI specification.', icon: 'i-lobe-icons:openai', - defaultOptions: () => ({ - baseUrl: '', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'openai-compatible', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required'), - !config.baseUrl && new Error('Base URL is required'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'openai-audio-speech': { + description: 'Connect to any API that follows the OpenAI specification.', + creator: createOpenAI, + validation: ['health'], + }), + 'openai-audio-speech': buildOpenAICompatibleProvider({ id: 'openai-audio-speech', + name: 'OpenAI', + nameKey: 'settings.pages.providers.provider.openai.title', + descriptionKey: 'settings.pages.providers.provider.openai.description', + icon: 'i-lobe-icons:openai', + description: 'openai.com', category: 'speech', tasks: ['text-to-speech'], - nameKey: 'settings.pages.providers.provider.openai.title', - name: 'OpenAI', - descriptionKey: 'settings.pages.providers.provider.openai.description', - description: 'openai.com', - icon: 'i-lobe-icons:openai', - defaultOptions: () => ({ - baseUrl: 'https://api.openai.com/v1/', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), + defaultBaseUrl: 'https://api.openai.com/v1/', + creator: createOpenAI, + validation: ['health'], capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'openai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, listVoices: async () => { return [ { @@ -1006,88 +805,35 @@ export const useProvidersStore = defineStore('providers', () => { } }, }, - }, - 'openai-compatible-audio-speech': { + }), + 'openai-compatible-audio-speech': buildOpenAICompatibleProvider({ id: 'openai-compatible-audio-speech', + name: 'OpenAI Compatible', + nameKey: 'settings.pages.providers.provider.openai-compatible.title', + descriptionKey: 'settings.pages.providers.provider.openai-compatible.description', + icon: 'i-lobe-icons:openai', + description: 'Connect to any API that follows the OpenAI specification.', category: 'speech', tasks: ['text-to-speech'], - nameKey: 'settings.pages.providers.provider.openai-compatible.title', - name: 'OpenAI Compatible', - descriptionKey: 'settings.pages.providers.provider.openai-compatible.description', - description: 'Connect to any API that follows the OpenAI specification.', - icon: 'i-lobe-icons:openai', - defaultOptions: () => ({ - baseUrl: '', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'openai-compatible-audio-speech', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, listVoices: async () => { return [] }, }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required'), - !config.baseUrl && new Error('Base URL is required'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'openai-audio-transcription': { + creator: createOpenAI, + }), + 'openai-audio-transcription': buildOpenAICompatibleProvider({ id: 'openai-audio-transcription', + name: 'OpenAI', + nameKey: 'settings.pages.providers.provider.openai.title', + descriptionKey: 'settings.pages.providers.provider.openai.description', + icon: 'i-lobe-icons:openai', + description: 'openai.com', category: 'transcription', tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'], - nameKey: 'settings.pages.providers.provider.openai.title', - name: 'OpenAI', - descriptionKey: 'settings.pages.providers.provider.openai.description', - description: 'openai.com', - icon: 'i-lobe-icons:openai', - defaultOptions: () => ({ - baseUrl: 'https://api.openai.com/v1/', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'openai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, + defaultBaseUrl: 'https://api.openai.com/v1/', + creator: createOpenAI, + validation: ['health'], validators: { validateProviderConfig: (config) => { const errors = [ @@ -1106,63 +852,25 @@ export const useProvidersStore = defineStore('providers', () => { } }, }, - }, - 'openai-compatible-audio-transcription': { + }), + 'openai-compatible-audio-transcription': buildOpenAICompatibleProvider({ id: 'openai-compatible-audio-transcription', + name: 'OpenAI Compatible', + nameKey: 'settings.pages.providers.provider.openai-compatible.title', + descriptionKey: 'settings.pages.providers.provider.openai-compatible.description', + icon: 'i-lobe-icons:openai', + description: 'Connect to any API that follows the OpenAI specification.', category: 'transcription', tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'], - nameKey: 'settings.pages.providers.provider.openai-compatible.title', - name: 'OpenAI Compatible', - descriptionKey: 'settings.pages.providers.provider.openai-compatible.description', - description: 'Connect to any API that follows the OpenAI specification.', - icon: 'i-lobe-icons:openai', - defaultOptions: () => ({ - baseUrl: '', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'openai-compatible-audio-transcription', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required'), - !config.baseUrl && new Error('Base URL is required'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, + creator: createOpenAI, + }), 'azure-ai-foundry': { id: 'azure-ai-foundry', category: 'chat', tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.azure_ai_foundry.title', + nameKey: 'settings.pages.providers.provider.azure-ai-foundry.title', name: 'Azure AI Foundry', - descriptionKey: 'settings.pages.providers.provider.azure_ai_foundry.description', + descriptionKey: 'settings.pages.providers.provider.azure-ai-foundry.description', description: 'azure.com', icon: 'i-lobe-icons:microsoft', defaultOptions: () => ({}), @@ -1205,232 +913,53 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, - 'anthropic': { + 'anthropic': buildOpenAICompatibleProvider({ id: 'anthropic', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.anthropic.title', name: 'Anthropic', + nameKey: 'settings.pages.providers.provider.anthropic.title', descriptionKey: 'settings.pages.providers.provider.anthropic.description', - description: 'anthropic.com', icon: 'i-lobe-icons:anthropic', - defaultOptions: () => ({ - baseUrl: 'https://api.anthropic.com/v1/', - }), - createProvider: async config => createAnthropic((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async () => { - return [ - { - id: 'claude-3-7-sonnet-20250219', - name: 'Claude 3.7 Sonnet', - provider: 'anthropic', - description: '', - contextLength: 0, - deprecated: false, - }, - { - id: 'claude-3-5-sonnet-20241022', - name: 'Claude 3.5 Sonnet (New)', - provider: 'anthropic', - description: '', - contextLength: 0, - deprecated: false, - }, - { - id: 'claude-3-5-haiku-20241022', - name: 'Claude 3.5 Haiku', - provider: 'anthropic', - description: '', - contextLength: 0, - deprecated: false, - }, - { - id: 'claude-3-5-sonnet-20240620', - name: 'Claude 3.5 Sonnet (Old)', - provider: 'anthropic', - description: '', - contextLength: 0, - deprecated: false, - }, - { - id: 'claude-3-haiku-20240307', - name: 'Claude 3 Haiku', - provider: 'anthropic', - description: '', - contextLength: 0, - deprecated: false, - }, - { - id: 'claude-3-opus-20240229', - name: 'Claude 3 Opus', - provider: 'anthropic', - description: '', - contextLength: 0, - deprecated: false, - }, - ] satisfies ModelInfo[] - }, + description: 'anthropic.com', + defaultBaseUrl: 'https://api.anthropic.com/v1/', + creator: createAnthropic, + validation: ['health', 'model_list'], + additionalHeaders: { + 'anthropic-dangerous-direct-browser-access': 'true', }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required. Default to https://api.anthropic.com/v1/ for official Claude API with OpenAI compatibility.'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'google-generative-ai': { + }), + 'google-generative-ai': buildOpenAICompatibleProvider({ id: 'google-generative-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.google-generative-ai.title', name: 'Google Gemini', + nameKey: 'settings.pages.providers.provider.google-generative-ai.title', descriptionKey: 'settings.pages.providers.provider.google-generative-ai.description', - description: 'ai.google.dev', icon: 'i-lobe-icons:gemini', - defaultOptions: () => ({ - baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', - }), - createProvider: async config => createGoogleGenerativeAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createGoogleGenerativeAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'google-generative-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required. Default to https://generativelanguage.googleapis.com/v1beta/openai/ for official Google Gemini API with OpenAI compatibility.'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'xai': { + description: 'ai.google.dev', + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', + creator: createGoogleGenerativeAI, + validation: ['health', 'model_list'], + }), + 'xai': buildOpenAICompatibleProvider({ id: 'xai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.xai.title', name: 'xAI', + nameKey: 'settings.pages.providers.provider.xai.title', descriptionKey: 'settings.pages.providers.provider.xai.description', - description: 'x.ai', icon: 'i-lobe-icons:xai', - createProvider: async config => createXAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createXAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'xai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'deepseek': { + description: 'x.ai', + defaultBaseUrl: 'https://api.x.ai/v1/', + creator: createXAI, + validation: ['health', 'model_list'], + }), + 'deepseek': buildOpenAICompatibleProvider({ id: 'deepseek', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.deepseek.title', name: 'DeepSeek', + nameKey: 'settings.pages.providers.provider.deepseek.title', descriptionKey: 'settings.pages.providers.provider.deepseek.description', + icon: 'i-lobe-icons:deepseek', description: 'deepseek.com', - iconColor: 'i-lobe-icons:deepseek', - defaultOptions: () => ({ - baseUrl: 'https://api.deepseek.com/', - }), - createProvider: async config => createDeepSeek((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createDeepSeek((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'deepseek', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, + defaultBaseUrl: 'https://api.deepseek.com/', + creator: createDeepSeek, + validation: ['health', 'model_list'], + }), 'elevenlabs': { id: 'elevenlabs', category: 'speech', @@ -1782,178 +1311,52 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, - 'together-ai': { + 'together-ai': buildOpenAICompatibleProvider({ id: 'together-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.together.title', name: 'Together.ai', + nameKey: 'settings.pages.providers.provider.together.title', descriptionKey: 'settings.pages.providers.provider.together.description', + icon: 'i-lobe-icons:together', description: 'together.ai', + defaultBaseUrl: 'https://api.together.xyz/v1/', + creator: createTogetherAI, + validation: ['health', 'model_list'], iconColor: 'i-lobe-icons:together', - createProvider: async config => createTogetherAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createTogetherAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'together-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'novita-ai': { + }), + 'novita-ai': buildOpenAICompatibleProvider({ id: 'novita-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.novita.title', name: 'Novita', + nameKey: 'settings.pages.providers.provider.novita.title', descriptionKey: 'settings.pages.providers.provider.novita.description', + icon: 'i-lobe-icons:novita', description: 'novita.ai', + defaultBaseUrl: 'https://api.novita.ai/openai/', + creator: createNovita, + validation: ['health', 'model_list'], iconColor: 'i-lobe-icons:novita', - createProvider: async config => createNovita((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createNovita((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'novita-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'fireworks-ai': { + }), + 'fireworks-ai': buildOpenAICompatibleProvider({ id: 'fireworks-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.fireworks.title', name: 'Fireworks.ai', + nameKey: 'settings.pages.providers.provider.fireworks.title', descriptionKey: 'settings.pages.providers.provider.fireworks.description', - description: 'fireworks.ai', icon: 'i-lobe-icons:fireworks', - createProvider: async config => createFireworks((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createFireworks((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'fireworks-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'featherless-ai': { + description: 'fireworks.ai', + defaultBaseUrl: 'https://api.fireworks.ai/inference/v1/', + creator: createFireworks, + validation: ['health', 'model_list'], + }), + 'featherless-ai': buildOpenAICompatibleProvider({ id: 'featherless-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.featherless.title', name: 'Featherless.ai', + nameKey: 'settings.pages.providers.provider.featherless.title', descriptionKey: 'settings.pages.providers.provider.featherless.description', - description: 'featherless.ai', icon: 'i-lobe-icons:featherless-ai', - defaultOptions: () => ({ - baseUrl: 'https://api.featherless.ai/v1/', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'featherless-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, + description: 'featherless.ai', + defaultBaseUrl: 'https://api.featherless.ai/v1/', + creator: createOpenAI, + validation: ['health', 'model_list'], + }), 'cloudflare-workers-ai': { id: 'cloudflare-workers-ai', category: 'chat', @@ -1984,206 +1387,52 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, - 'perplexity-ai': { + 'perplexity-ai': buildOpenAICompatibleProvider({ id: 'perplexity-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.perplexity.title', name: 'Perplexity', + nameKey: 'settings.pages.providers.provider.perplexity.title', descriptionKey: 'settings.pages.providers.provider.perplexity.description', - description: 'perplexity.ai', icon: 'i-lobe-icons:perplexity', - defaultOptions: () => ({ - baseUrl: 'https://api.perplexity.ai', - }), - createProvider: async config => createPerplexity((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async () => { - return [ - { - id: 'sonar-small-online', - name: 'Sonar Small (Online)', - provider: 'perplexity-ai', - description: 'Efficient model with online search capabilities', - contextLength: 12000, - }, - { - id: 'sonar-medium-online', - name: 'Sonar Medium (Online)', - provider: 'perplexity-ai', - description: 'Balanced model with online search capabilities', - contextLength: 12000, - }, - { - id: 'sonar-large-online', - name: 'Sonar Large (Online)', - provider: 'perplexity-ai', - description: 'Powerful model with online search capabilities', - contextLength: 12000, - }, - { - id: 'codey-small', - name: 'Codey Small', - provider: 'perplexity-ai', - description: 'Specialized for code generation and understanding', - contextLength: 12000, - }, - { - id: 'codey-large', - name: 'Codey Large', - provider: 'perplexity-ai', - description: 'Advanced code generation and understanding', - contextLength: 12000, - }, - ] - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - const res = baseUrlValidator.value(config.baseUrl) - if (res) { - return res - } - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'mistral-ai': { + description: 'perplexity.ai', + defaultBaseUrl: 'https://api.perplexity.ai/', + creator: createPerplexity, + validation: ['health', 'model_list'], + }), + 'mistral-ai': buildOpenAICompatibleProvider({ id: 'mistral-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.mistral.title', name: 'Mistral', + nameKey: 'settings.pages.providers.provider.mistral.title', descriptionKey: 'settings.pages.providers.provider.mistral.description', + icon: 'i-lobe-icons:mistral', description: 'mistral.ai', + defaultBaseUrl: 'https://api.mistral.ai/v1/', + creator: createMistral, + validation: ['health', 'model_list'], iconColor: 'i-lobe-icons:mistral', - createProvider: async config => createMistral((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createMistral((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'mistral-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'moonshot-ai': { + }), + 'moonshot-ai': buildOpenAICompatibleProvider({ id: 'moonshot-ai', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.moonshot.title', name: 'Moonshot AI', + nameKey: 'settings.pages.providers.provider.moonshot.title', descriptionKey: 'settings.pages.providers.provider.moonshot.description', - description: 'moonshot.ai', icon: 'i-lobe-icons:moonshot', - createProvider: async config => createMoonshot((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createMoonshot((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'moonshot-ai', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, - 'modelscope': { + description: 'moonshot.ai', + defaultBaseUrl: 'https://api.moonshot.ai/v1/', + creator: createMoonshot, + validation: ['health', 'model_list'], + }), + 'modelscope': buildOpenAICompatibleProvider({ id: 'modelscope', - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.modelscope.title', name: 'ModelScope', + nameKey: 'settings.pages.providers.provider.modelscope.title', descriptionKey: 'settings.pages.providers.provider.modelscope.description', - description: 'modelscope', icon: 'i-lobe-icons:modelscope', - defaultOptions: () => ({ - baseUrl: 'https://api-inference.modelscope.cn/v1/', - }), - createProvider: async config => createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()), - capabilities: { - listModels: async (config) => { - return (await listModels({ - ...createOpenAI((config.apiKey as string).trim(), (config.baseUrl as string).trim()).model(), - })).map((model) => { - return { - id: model.id, - name: model.id, - provider: 'modelscope', - description: '', - contextLength: 0, - deprecated: false, - } satisfies ModelInfo - }) - }, - }, - validators: { - validateProviderConfig: (config) => { - const errors = [ - !config.apiKey && new Error('API key is required.'), - !config.baseUrl && new Error('Base URL is required.'), - ].filter(Boolean) - - return { - errors, - reason: errors.filter(e => e).map(e => String(e)).join(', ') || '', - valid: !!config.apiKey && !!config.baseUrl, - } - }, - }, - }, + description: 'modelscope', + defaultBaseUrl: 'https://api-inference.modelscope.cn/v1/', + creator: createOpenAI, + validation: ['health', 'model_list', 'chat_completions'], + iconColor: 'i-lobe-icons:modelscope', + }), 'player2': { id: 'player2', category: 'chat', @@ -2344,26 +1593,34 @@ export const useProvidersStore = defineStore('providers', () => { }, } + const configuredProviders = ref>({}) + const validatedCredentials = ref>({}) + // Configuration validation functions async function validateProvider(providerId: string): Promise { const config = providerCredentials.value[providerId] if (!config) return false + const configString = JSON.stringify(config || {}) + if (validatedCredentials.value[providerId] === configString && typeof configuredProviders.value[providerId] === 'boolean') + return configuredProviders.value[providerId] + const metadata = providerMetadata[providerId] if (!metadata) return false + // Always cache the current config string to prevent re-validating the same config + validatedCredentials.value[providerId] = configString + const validationResult = await metadata.validators.validateProviderConfig(config) - if (!validationResult.valid) { - throw new Error(validationResult.reason) - } + + configuredProviders.value[providerId] = validationResult.valid return validationResult.valid } // Create computed properties for each provider's configuration status - const configuredProviders = ref>({}) // Initialize provider configurations function initializeProvider(providerId: string) { diff --git a/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts b/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts new file mode 100644 index 000000000..6e68b635f --- /dev/null +++ b/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts @@ -0,0 +1,159 @@ +import type { ModelInfo, ProviderMetadata } from '../providers' + +import { listModels } from '@xsai/model' + +import { isUrl } from '../../utils/url' + +type ProviderCreator = (apiKey: string, baseUrl: string) => any + +export function buildOpenAICompatibleProvider( + options: Partial & { + id: string + name: string + icon: string + description: string + nameKey: string + descriptionKey: string + category?: 'chat' | 'embed' | 'speech' | 'transcription' + tasks?: string[] + defaultBaseUrl?: string + creator: ProviderCreator + capabilities?: ProviderMetadata['capabilities'] + validators?: ProviderMetadata['validators'] + validation?: ('health' | 'model_list' | 'chat_completions')[] + additionalHeaders?: Record + }, +): ProviderMetadata { + const { id, name, icon, description, nameKey, descriptionKey, category, tasks, defaultBaseUrl, creator, capabilities, validators, validation, additionalHeaders, ...rest } = options + + const finalCapabilities = capabilities || { + listModels: async (config: Record) => { + const provider = creator( + (config.apiKey as string || '').trim(), + (config.baseUrl as string || '').trim(), + ) + if (provider.model) { + return (await listModels({ + ...provider.model(), + })).map((model: any) => { + return { + id: model.id, + name: model.name || model.display_name || model.id, + provider: id, + description: model.description || '', + contextLength: model.context_length || 0, + deprecated: false, + } satisfies ModelInfo + }) + } + return [] + }, + } + + const finalValidators = validators || { + validateProviderConfig: async (config: Record) => { + const errors: Error[] = [] + + if (!config.apiKey) { + errors.push(new Error('API key is required')) + } + if (!config.baseUrl) { + errors.push(new Error('Base URL is required')) + } + + if (errors.length > 0) { + return { errors, reason: errors.map(e => e.message).join(', '), valid: false } + } + + if (!isUrl(config.baseUrl as string) || new URL(config.baseUrl as string).host.length === 0) { + errors.push(new Error('Base URL is not absolute. Check your input.')) + } + + if (!(config.baseUrl as string).endsWith('/')) { + errors.push(new Error('Base URL must end with a trailing slash (/).')) + } + + if (errors.length > 0) { + return { errors, reason: errors.map(e => e.message).join(', '), valid: false } + } + + const validationChecks = validation || [] + let responseModelList = null + let responseChat = null + + if (validationChecks.includes('health')) { + try { + responseChat = await fetch(`${config.baseUrl as string}chat/completions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST' }) + responseModelList = await fetch(`${config.baseUrl as string}models`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders } }) + + if (!([200, 400, 401].includes(responseChat.status) || [200, 400, 401].includes(responseModelList.status))) { + errors.push(new Error(`Invalid Base URL, ${config.baseUrl} is not supported`)) + } + } + catch (e) { + errors.push(new Error(`Invalid Base URL, ${(e as Error).message}`)) + } + } + + if (errors.length > 0) { + return { errors, reason: errors.map(e => e.message).join(', '), valid: false } + } + + if (validationChecks.includes('model_list')) { + try { + let response = responseModelList + if (!response) { + response = await fetch(`${config.baseUrl as string}models`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders } }) + } + + if (!response.ok) { + errors.push(new Error(`Invalid API Key`)) + } + } + catch (e) { + errors.push(new Error(`Model list check failed: ${(e as Error).message}`)) + } + } + + if (validationChecks.includes('chat_completions')) { + try { + let response = responseChat + if (!response) { + response = await fetch(`${config.baseUrl as string}chat/completions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST' }) + } + + if (!response.ok) { + errors.push(new Error(`Invalid API Key`)) + } + } + catch (e) { + errors.push(new Error(`Chat Completions check Failed: ${(e as Error).message}`)) + } + } + + return { + errors, + reason: errors.map(e => e.message).join(', ') || '', + valid: errors.length === 0, + } + }, + } + + return { + id, + category: category || 'chat', + tasks: tasks || ['text-generation'], + nameKey, + name, + descriptionKey, + description, + icon, + defaultOptions: () => ({ + baseUrl: defaultBaseUrl || '', + }), + createProvider: async config => creator((config.apiKey as string || '').trim(), (config.baseUrl as string || '').trim()), + capabilities: finalCapabilities, + validators: finalValidators, + ...rest, + } as ProviderMetadata +}