feat: Add generic OpenAI-compatible provider for chat and audio (#415)

This commit introduces a new "OpenAI Compatible" provider, allowing users to connect to any service that implements the OpenAI API specification.

Previously, users were limited to the pre-defined list of providers. With the proliferation of many new AI services and local models (like LM Studio, vLLM, etc.) that expose an OpenAI-compatible endpoint, it has become impractical to add and maintain a separate integration for each one.

This new generic provider solves that problem by offering a single, flexible solution. It clarifies the user's choice by separating the official OpenAI service from other compatible alternatives, improving the overall user experience.

Closes #401, #388

Co-authored-by: Neko <neko@ayaka.moe>

---------

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Ilya Bogdanov
2025-08-25 02:49:31 +08:00
committed by GitHub
co-authored by Neko
parent cbc959a3f3
commit bde909fd64
10 changed files with 785 additions and 2 deletions
@@ -18,6 +18,7 @@ import {
FieldRange,
Textarea,
} from '@proj-airi/ui'
import { watchDebounced } from '@vueuse/core'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref, watch } from 'vue'
@@ -59,9 +60,19 @@ onMounted(async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
})
watch(activeSpeechProvider, async () => {
watchDebounced(activeSpeechProvider, async () => {
await providersStore.loadModelsForConfiguredProviders()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
}, { debounce: 100 })
watch(activeSpeechVoiceId, (newId) => {
if (newId) {
const voices = availableVoices.value[activeSpeechProvider.value] || []
const existingVoice = voices.find(voice => voice.id === newId)
if (!existingVoice) {
updateCustomVoiceName(newId)
}
}
})
// Function to generate speech
@@ -0,0 +1,177 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { SpeechProvider } from '@xsai-ext/shared-providers'
import OpenAICompatibleSpeechPlayground from '@proj-airi/stage-ui/components/Scenarios/Providers/OpenAICompatibleSpeechPlayground.vue'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
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'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const { t } = useI18n()
const router = useRouter()
const defaultVoiceSettings = {
speed: 1.0,
}
// Get provider metadata
const providerId = 'openai-compatible-audio-speech'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Settings refs
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].apiKey = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].baseUrl = value
},
})
const model = computed({
get: () => providers.value[providerId]?.model || 'tts-1',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].model = value
},
})
const voice = computed({
get: () => providers.value[providerId]?.voice || 'alloy',
set: (value) => {
if (providers.value[providerId])
providers.value[providerId].voice = value
},
})
const speed = ref<number>(1.0)
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate speech with specific parameters
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean, modelId?: string) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize speech provider')
const providerConfig = providersStore.getProviderConfig(providerId)
return await speechStore.speech(
provider,
modelId || model.value,
input,
voiceId || voice.value,
{
...providerConfig,
...defaultVoiceSettings,
speed: speed.value,
},
)
}
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
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName || 'OpenAI Compatible'"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.example.com/v1/"
/>
<FieldRange
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
<OpenAICompatibleSpeechPlayground
v-model:model-value="model"
v-model:voice="voice"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of the OpenAI Compatible Speech."
/>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import type { TranscriptionProvider } from '@xsai-ext/shared-providers'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
TranscriptionPlayground,
} from '@proj-airi/stage-ui/components'
import { useHearingStore, useProvidersStore } from '@proj-airi/stage-ui/stores'
import { FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const { t } = useI18n()
const router = useRouter()
// Get provider metadata
const providerId = 'openai-compatible-audio-transcription'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
const pageTitle = computed(() => providerMetadata.value?.localizedName || t('settings.pages.providers.provider.openai-compatible-audio-transcription.title'))
// Settings refs
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: value => (providers.value[providerId] = { ...providers.value[providerId], apiKey: value }),
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl || '',
set: value => (providers.value[providerId] = { ...providers.value[providerId], baseUrl: value }),
})
const model = computed({
get: () => providers.value[providerId]?.model || 'whisper-1',
set: value => (providers.value[providerId] = { ...providers.value[providerId], model: value }),
})
// Check if API key is configured
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
// Generate transcription
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize transcription provider')
return await hearingStore.transcription(
provider,
model.value,
file,
'json',
)
}
onMounted(() => {
providersStore.initializeProvider(providerId)
const config = providers.value[providerId] || {}
apiKey.value = config.apiKey || ''
baseUrl.value = config.baseUrl || ''
model.value = config.model || 'whisper-1'
})
function handleResetSettings() {
const defaults = providerMetadata.value?.defaultOptions?.() || {}
providers.value[providerId] = {
apiKey: '',
baseUrl: defaults.baseUrl || '',
model: 'whisper-1',
}
// Force update refs
apiKey.value = ''
baseUrl.value = defaults.baseUrl || ''
model.value = 'whisper-1'
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="pageTitle"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
<FieldInput
v-model="model"
:label="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_name')"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
placeholder="https://api.example.com/v1/"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
<template #playground>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="apiKeyConfigured"
/>
</template>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
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<Record<string, any>> }
// Get provider metadata
const providerId = 'openai-compatible'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
// Use computed properties for settings
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 || '',
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 || ''
})
// Watch settings and update the provider configuration
watch([apiKey, baseUrl], () => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.().baseUrl || '',
}
})
function handleResetSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName || 'OpenAI Compatible'"
:provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:provider-name="providerMetadata?.localizedName"
placeholder="sk-..."
/>
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
:placeholder="providerMetadata?.defaultOptions?.().baseUrl as string || 'https://api.example.com/v1/'"
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -423,6 +423,9 @@ pages:
openai:
description: openai.com
title: OpenAI
openai-compatible:
description: OpenAI Compatible
title: OpenAI Compatible
openrouter:
description: openrouter.ai
title: OpenRouter
@@ -422,6 +422,9 @@ pages:
openai:
description: openai.com
title: OpenAI
openai-compatible:
description: OpenAI Compatible
title: OpenAI Compatible
openrouter:
description: openrouter.ai
title: OpenRouter
@@ -392,6 +392,9 @@ pages:
openai:
description: OpenAi.com
title: OpenAI
openai-compatible:
description: OpenAI Compatible
title: OpenAI Compatible
openrouter:
description: OpenRouter.ai
title: OpenRouter
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { FieldCheckbox, FieldInput } from '@proj-airi/ui'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { TestDummyMarker } from '../../Gadgets'
const props = defineProps<{
// Input fields
defaultText?: string
modelValue: string
voice: string
// Provider-specific handlers (provided from parent)
generateSpeech: (input: string, voice: string, useSSML: boolean, model?: string) => Promise<ArrayBuffer>
// Current state
apiKeyConfigured?: boolean
}>()
const emit = defineEmits(['update:modelValue', 'update:voice'])
const { t } = useI18n()
const model = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const voice = computed({
get: () => props.voice,
set: value => emit('update:voice', value),
})
// Playground state
const testText = ref(props.defaultText || 'Hello! This is a test of the voice synthesis.')
const isGenerating = ref(false)
const audioUrl = ref('')
const errorMessage = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const useSSML = ref(false)
const ssmlText = ref('')
// Function to generate speech
async function handleGenerateTestSpeech() {
if ((!testText.value.trim() && !useSSML.value) || (useSSML.value && !ssmlText.value.trim()))
return
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const input = useSSML.value ? ssmlText.value : testText.value
const response = await props.generateSpeech(input, voice.value, useSSML.value, model.value)
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
// Expose public methods and state
defineExpose({
testText,
ssmlText,
useSSML,
isGenerating,
audioUrl,
errorMessage,
audioPlayer,
generateTestSpeech: handleGenerateTestSpeech,
stopTestAudio,
})
</script>
<template>
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldInput
v-model="model"
label="Model ID"
placeholder="tts-1"
/>
<FieldInput
v-model="voice"
label="Voice"
placeholder="alloy"
/>
<FieldCheckbox
v-model="useSSML"
:label="t('settings.pages.modules.speech.sections.section.voice-settings.use-ssml.label')"
:description="t('settings.pages.modules.speech.sections.section.voice-settings.use-ssml.description')"
/>
<template v-if="!useSSML">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
:placeholder="t('settings.pages.modules.speech.sections.section.voice-settings.input-ssml.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<!-- Playground actions -->
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKeyConfigured"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKeyConfigured }"
bg="neutral-700 dark:neutral-300" @click="handleGenerateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>{{ t('settings.pages.modules.speech.sections.section.playground.buttons.stop.label') }}</span>
</div>
</button>
</div>
<!-- Error messages -->
<div v-if="!apiKeyConfigured" class="mt-2 text-sm text-red-500">
{{ t('settings.pages.providers.provider.elevenlabs.playground.validation.error-missing-api-key') }}
</div>
<div v-if="errorMessage" class="mt-2 text-sm text-red-500">
{{ errorMessage }}
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
<!-- Slot for additional provider-specific UI in the playground -->
<slot />
</div>
</template>
+1 -1
View File
@@ -16,7 +16,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
// Check if any essential provider is configured
const hasEssentialProviderConfigured = computed(() => {
const essentialProviders = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek']
const essentialProviders = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'openai-compatible']
return essentialProviders.some(providerId => providersStore.configuredProviders[providerId])
})
+147
View File
@@ -812,6 +812,54 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
},
'openai-compatible': {
id: 'openai-compatible',
category: 'chat',
tasks: ['text-generation'],
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',
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)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
},
'openai-audio-speech': {
id: 'openai-audio-speech',
category: 'speech',
@@ -929,6 +977,57 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
},
'openai-compatible-audio-speech': {
id: 'openai-compatible-audio-speech',
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)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
},
'openai-audio-transcription': {
id: 'openai-audio-transcription',
category: 'transcription',
@@ -976,6 +1075,54 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
},
'openai-compatible-audio-transcription': {
id: 'openai-compatible-audio-transcription',
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)
if (!!config.baseUrl && !isAbsoluteUrl(config.baseUrl as string)) {
return notBaseUrlError.value
}
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
},
'azure-ai-foundry': {
id: 'azure-ai-foundry',
category: 'chat',