fix(stage-ui): align module provider visibility with auth (#2353)

This commit is contained in:
Neko
2026-08-24 21:27:58 +08:00
committed by GitHub
parent f14a7ac9a1
commit e2376736af
18 changed files with 435 additions and 47 deletions
+15 -1
View File
@@ -10,7 +10,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { configureAsDefaultsIfEmpty } from '@proj-airi/stage-ui/stores/modules/default' import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
@@ -51,6 +51,16 @@ useSettingsStageModel()
useVisionStore() useVisionStore()
let stopAuthenticatedSetup: (() => void) | undefined let stopAuthenticatedSetup: (() => void) | undefined
let stopLoggedOutSetup: (() => void) | undefined
async function removeAuthenticationProviderConfiguration() {
if (!syncedPinia.isLeader())
return
if (await unconfigureAuthenticationProviders())
await cardStore.persistActiveCardModuleSelections()
}
function registerAuthenticatedSetup() { function registerAuthenticatedSetup() {
stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => {
if (!syncedPinia.isLeader()) if (!syncedPinia.isLeader())
@@ -60,6 +70,7 @@ function registerAuthenticatedSetup() {
await cardStore.persistActiveCardModuleSelections() await cardStore.persistActiveCardModuleSelections()
await onboardingStore.closeAfterAuthentication() await onboardingStore.closeAfterAuthentication()
}) })
stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration)
} }
const primaryColor = computed(() => { const primaryColor = computed(() => {
@@ -103,6 +114,8 @@ onMounted(async () => {
await displayModelsStore.initialize() await displayModelsStore.initialize()
await cardStore.initialize() await cardStore.initialize()
registerAuthenticatedSetup() registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
if (onboardingStore.needsOnboarding) { if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true onboardingStore.showingSetup = true
@@ -122,6 +135,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
stopAuthenticatedSetup?.() stopAuthenticatedSetup?.()
stopLoggedOutSetup?.()
contextBridgeStore.dispose() contextBridgeStore.dispose()
}) })
+15 -1
View File
@@ -20,7 +20,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { configureAsDefaultsIfEmpty } from '@proj-airi/stage-ui/stores/modules/default' import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
@@ -140,6 +140,16 @@ function createFullStageRuntime() {
useVisionStore() useVisionStore()
let stopAuthenticatedSetup: (() => void) | undefined let stopAuthenticatedSetup: (() => void) | undefined
let stopLoggedOutSetup: (() => void) | undefined
async function removeAuthenticationProviderConfiguration() {
if (!syncedPinia.isLeader())
return
if (await unconfigureAuthenticationProviders())
await cardStore.persistActiveCardModuleSelections()
}
function registerAuthenticatedSetup() { function registerAuthenticatedSetup() {
stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => {
if (!syncedPinia.isLeader()) if (!syncedPinia.isLeader())
@@ -149,6 +159,7 @@ function createFullStageRuntime() {
await cardStore.persistActiveCardModuleSelections() await cardStore.persistActiveCardModuleSelections()
await onboardingStore.closeAfterAuthentication() await onboardingStore.closeAfterAuthentication()
}) })
stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration)
} }
const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore) const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore)
@@ -254,6 +265,8 @@ function createFullStageRuntime() {
await displayModelsStore.initialize() await displayModelsStore.initialize()
await cardStore.initialize() await cardStore.initialize()
registerAuthenticatedSetup() registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
await displayModelsStore.loadDisplayModelsFromIndexedDB() await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel() await settingsStore.initializeStageModel()
@@ -299,6 +312,7 @@ function createFullStageRuntime() {
}, },
dispose() { dispose() {
stopAuthenticatedSetup?.() stopAuthenticatedSetup?.()
stopLoggedOutSetup?.()
contextBridgeStore.dispose() contextBridgeStore.dispose()
}, },
} }
+15 -1
View File
@@ -12,7 +12,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { configureAsDefaultsIfEmpty } from '@proj-airi/stage-ui/stores/modules/default' import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
@@ -58,6 +58,16 @@ useSettingsStageModel()
useVisionStore() useVisionStore()
let stopAuthenticatedSetup: (() => void) | undefined let stopAuthenticatedSetup: (() => void) | undefined
let stopLoggedOutSetup: (() => void) | undefined
async function removeAuthenticationProviderConfiguration() {
if (!syncedPinia.isLeader())
return
if (await unconfigureAuthenticationProviders())
await cardStore.persistActiveCardModuleSelections()
}
function registerAuthenticatedSetup() { function registerAuthenticatedSetup() {
stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => {
if (!syncedPinia.isLeader()) if (!syncedPinia.isLeader())
@@ -67,6 +77,7 @@ function registerAuthenticatedSetup() {
await cardStore.persistActiveCardModuleSelections() await cardStore.persistActiveCardModuleSelections()
await onboardingStore.closeAfterAuthentication() await onboardingStore.closeAfterAuthentication()
}) })
stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration)
} }
const inferencePreload = useInferencePreload() const inferencePreload = useInferencePreload()
@@ -118,6 +129,8 @@ onMounted(async () => {
await displayModelsStore.initialize() await displayModelsStore.initialize()
await cardStore.initialize() await cardStore.initialize()
registerAuthenticatedSetup() registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
if (onboardingStore.needsOnboarding) { if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true onboardingStore.showingSetup = true
@@ -138,6 +151,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
stopAuthenticatedSetup?.() stopAuthenticatedSetup?.()
stopLoggedOutSetup?.()
stopLeadershipListener() stopLeadershipListener()
contextBridgeStore.dispose() contextBridgeStore.dispose()
}) })
@@ -18,7 +18,7 @@ const airiCardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore() const consciousnessStore = useConsciousnessStore()
const consciousnessSettingsStore = useConsciousnessSettingsStore() const consciousnessSettingsStore = useConsciousnessSettingsStore()
const { configuredProviders } = storeToRefs(providerStore) const { configuredProviders } = storeToRefs(providerStore)
const { persistedChatProvidersMetadata } = storeToRefs(providersStore) const { moduleChatProvidersMetadata } = storeToRefs(providersStore)
const { reasoning } = storeToRefs(consciousnessSettingsStore) const { reasoning } = storeToRefs(consciousnessSettingsStore)
const { const {
activeProvider, activeProvider,
@@ -88,13 +88,13 @@ async function updateReasoning(value: boolean) {
See also: https://stackoverflow.com/a/33737340 See also: https://stackoverflow.com/a/33737340
--> -->
<fieldset <fieldset
v-if="persistedChatProvidersMetadata.length > 0" v-if="moduleChatProvidersMetadata.length > 0"
flex="~ row gap-4" flex="~ row gap-4"
min-w-0 overflow-x-auto scroll-smooth min-w-0 overflow-x-auto scroll-smooth
role="radiogroup" role="radiogroup"
> >
<RadioCardSimple <RadioCardSimple
v-for="metadata in persistedChatProvidersMetadata" v-for="metadata in moduleChatProvidersMetadata"
:id="metadata.id" :id="metadata.id"
:key="metadata.id" :key="metadata.id"
v-model="activeProvider" v-model="activeProvider"
@@ -33,7 +33,7 @@ const {
} = storeToRefs(hearingStore) } = storeToRefs(hearingStore)
const providersStore = useProviderStore() const providersStore = useProviderStore()
const providerStore = useProviderConfigStore() const providerStore = useProviderConfigStore()
const { configuredTranscriptionProvidersMetadata } = storeToRefs(providersStore) const { moduleTranscriptionProvidersMetadata } = storeToRefs(providersStore)
const { trackProviderClick } = useAnalytics() const { trackProviderClick } = useAnalytics()
const settingsAudioDeviceStore = useSettingsAudioDevice() const settingsAudioDeviceStore = useSettingsAudioDevice()
@@ -364,13 +364,13 @@ onUnmounted(() => {
See also: https://stackoverflow.com/a/33737340 See also: https://stackoverflow.com/a/33737340
--> -->
<fieldset <fieldset
v-if="configuredTranscriptionProvidersMetadata.length > 0" v-if="moduleTranscriptionProvidersMetadata.length > 0"
flex="~ row gap-4" flex="~ row gap-4"
min-w-0 overflow-x-auto scroll-smooth min-w-0 overflow-x-auto scroll-smooth
role="radiogroup" role="radiogroup"
> >
<RadioCardSimple <RadioCardSimple
v-for="metadata in configuredTranscriptionProvidersMetadata" v-for="metadata in moduleTranscriptionProvidersMetadata"
:id="metadata.id" :id="metadata.id"
:key="metadata.id" :key="metadata.id"
v-model="activeTranscriptionProvider" v-model="activeTranscriptionProvider"
@@ -35,7 +35,7 @@ const providersStore = useProviderStore()
const providerStore = useProviderConfigStore() const providerStore = useProviderConfigStore()
const speechStore = useSpeechStore() const speechStore = useSpeechStore()
const airiCardStore = useAiriCardStore() const airiCardStore = useAiriCardStore()
const { allAudioSpeechProvidersMetadata, configuredSpeechProvidersMetadata } = storeToRefs(providersStore) const { allAudioSpeechProvidersMetadata, moduleSpeechProvidersMetadata } = storeToRefs(providersStore)
const { const {
activeSpeechProvider, activeSpeechProvider,
activeSpeechModel, activeSpeechModel,
@@ -76,7 +76,7 @@ let lastOfficialTtsExposureKey = ''
const STREAMING_MODEL_OPTION_PREFIX = 'streaming:' const STREAMING_MODEL_OPTION_PREFIX = 'streaming:'
const selectableSpeechSources = computed(() => { const selectableSpeechSources = computed(() => {
const configuredSources = configuredSpeechProvidersMetadata.value const configuredSources = moduleSpeechProvidersMetadata.value
.filter(metadata => .filter(metadata =>
metadata.id !== 'speech-noop' metadata.id !== 'speech-noop'
&& metadata.id !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, && metadata.id !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
@@ -17,7 +17,7 @@ const airiCardStore = useAiriCardStore()
const visionStore = useVisionStore() const visionStore = useVisionStore()
const visionProcessingStore = useVisionProcessingStore() const visionProcessingStore = useVisionProcessingStore()
const { configuredProviders } = storeToRefs(providerStore) const { configuredProviders } = storeToRefs(providerStore)
const { persistedVisionProvidersMetadata } = storeToRefs(providersStore) const { moduleVisionProvidersMetadata } = storeToRefs(providersStore)
const { const {
activeProvider, activeProvider,
activeModel, activeModel,
@@ -106,12 +106,12 @@ function formatRelativeTime(timestamp: number | null) {
</div> </div>
<div :class="['max-w-full']"> <div :class="['max-w-full']">
<fieldset <fieldset
v-if="persistedVisionProvidersMetadata.length > 0" v-if="moduleVisionProvidersMetadata.length > 0"
:class="['flex', 'min-w-0', 'flex-row', 'gap-4', 'overflow-x-auto', 'scroll-smooth']" :class="['flex', 'min-w-0', 'flex-row', 'gap-4', 'overflow-x-auto', 'scroll-smooth']"
role="radiogroup" role="radiogroup"
> >
<RadioCardSimple <RadioCardSimple
v-for="metadata in persistedVisionProvidersMetadata" v-for="metadata in moduleVisionProvidersMetadata"
:id="metadata.id" :id="metadata.id"
:key="metadata.id" :key="metadata.id"
v-model="activeProvider" v-model="activeProvider"
@@ -1,4 +1,3 @@
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { z } from 'zod' import { z } from 'zod'
import { defineProvider } from '../registry' import { defineProvider } from '../registry'
@@ -39,9 +38,7 @@ export const providerBrowserWebSpeechApi = defineProvider({
}, },
}, },
// Electron uses Chromium, but it does not include the Google API keys that isAvailableBy: isWebSpeechApiAvailable,
// the Web Speech API needs. The provider only works in browser contexts.
isAvailableBy: () => !isStageTamagotchi() && isWebSpeechApiAvailable(),
createProviderConfig: () => webSpeechApiConfigSchema, createProviderConfig: () => webSpeechApiConfigSchema,
createProvider: createWebSpeechAPIProvider, createProvider: createWebSpeechAPIProvider,
validationRequiredWhen: () => false, validationRequiredWhen: () => false,
@@ -10,9 +10,11 @@ import { SERVER_URL } from '../../../../libs/server'
import { defineProvider } from '../registry' import { defineProvider } from '../registry'
import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICON, withCredentials } from './shared' import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICON, withCredentials } from './shared'
export const OFFICIAL_CHAT_PROVIDER_ID = 'official-provider'
export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech' export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech'
export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming' export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming'
export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcription' export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcription'
export const OFFICIAL_VISION_PROVIDER_ID = 'vision-official-provider'
// Locale → voice id map recommended by the server, keyed by provider id. // Locale → voice id map recommended by the server, keyed by provider id.
// Populated by each speech provider's listVoices() from the response's // Populated by each speech provider's listVoices() from the response's
@@ -63,7 +65,7 @@ function authHeaders(): Record<string, string> {
} }
export const providerOfficialChat = defineProvider({ export const providerOfficialChat = defineProvider({
id: 'official-provider', id: OFFICIAL_CHAT_PROVIDER_ID,
order: -1, order: -1,
name: 'Official Provider', name: 'Official Provider',
nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.title'), nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.title'),
@@ -72,6 +74,7 @@ export const providerOfficialChat = defineProvider({
tasks: ['text-generation'], tasks: ['text-generation'],
icon: OFFICIAL_ICON, icon: OFFICIAL_ICON,
requiresCredentials: false, requiresCredentials: false,
configuredBy: 'authentication',
createProviderConfig: () => officialConfigSchema, createProviderConfig: () => officialConfigSchema,
createProvider(_config) { createProvider(_config) {
@@ -92,7 +95,7 @@ export const providerOfficialChat = defineProvider({
{ {
id: 'auto', id: 'auto',
name: 'Auto', name: 'Auto',
provider: 'official-provider', provider: OFFICIAL_CHAT_PROVIDER_ID,
description: 'Automatically routed by AI Gateway', description: 'Automatically routed by AI Gateway',
}, },
], ],
@@ -109,6 +112,7 @@ export const providerOfficialSpeech = defineProvider({
tasks: ['text-to-speech'], tasks: ['text-to-speech'],
icon: OFFICIAL_ICON, icon: OFFICIAL_ICON,
requiresCredentials: false, requiresCredentials: false,
configuredBy: 'authentication',
createProviderConfig: () => officialConfigSchema, createProviderConfig: () => officialConfigSchema,
createProvider(_config) { createProvider(_config) {
const provider = createOfficialAudioProvider() const provider = createOfficialAudioProvider()
@@ -226,6 +230,7 @@ export const providerOfficialSpeechStreaming = defineProvider({
tasks: ['text-to-speech'], tasks: ['text-to-speech'],
icon: OFFICIAL_ICON, icon: OFFICIAL_ICON,
requiresCredentials: false, requiresCredentials: false,
configuredBy: 'authentication',
// Mark this provider as speaking the bidirectional ws TTS protocol so the // Mark this provider as speaking the bidirectional ws TTS protocol so the
// session adapter (`tts-session.ts`) picks the streaming path without // session adapter (`tts-session.ts`) picks the streaming path without
// hard-coding provider id. Default for every other provider is `'rest'`. // hard-coding provider id. Default for every other provider is `'rest'`.
@@ -350,6 +355,7 @@ export const providerOfficialTranscription = defineProvider({
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'], tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'],
icon: OFFICIAL_ICON, icon: OFFICIAL_ICON,
requiresCredentials: false, requiresCredentials: false,
configuredBy: 'authentication',
capabilities: { capabilities: {
transcription: { transcription: {
protocol: 'http', protocol: 'http',
@@ -29,6 +29,7 @@ export type ProviderInstance
/** Validation lifecycle for one serializable provider configuration. */ /** Validation lifecycle for one serializable provider configuration. */
export type ProviderValidationStatus = 'unconfigured' | 'validating' | 'configured' | 'invalid' | 'bypassed' export type ProviderValidationStatus = 'unconfigured' | 'validating' | 'configured' | 'invalid' | 'bypassed'
export type ProviderConfiguredBy = 'user' | 'authentication'
/** Serializable configuration for one provider instance. */ /** Serializable configuration for one provider instance. */
export interface InferenceServiceProvider { export interface InferenceServiceProvider {
@@ -40,6 +41,8 @@ export interface InferenceServiceProvider {
config: Record<string, unknown> config: Record<string, unknown>
/** Current validation state for this provider configuration. */ /** Current validation state for this provider configuration. */
status: ProviderValidationStatus status: ProviderValidationStatus
/** Lifecycle owner that creates and revokes this provider configuration. */
configuredBy: ProviderConfiguredBy
} }
export function isModelProvider(providerInstance: ProviderInstance): providerInstance is ModelProvider | ModelProviderWithExtraOptions { export function isModelProvider(providerInstance: ProviderInstance): providerInstance is ModelProvider | ModelProviderWithExtraOptions {
@@ -194,6 +197,13 @@ export interface ProviderDefinition<TConfig extends any = any> {
*/ */
requiresCredentials?: boolean requiresCredentials?: boolean
/**
* Lifecycle owner for provider configurations created from this definition.
*
* @default 'user'
*/
configuredBy?: ProviderConfiguredBy
createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType<TConfig> createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType<TConfig>
onboardingFields?: (ctx: { t: ComposerTranslation }) => ProviderOnboardingField[] onboardingFields?: (ctx: { t: ComposerTranslation }) => ProviderOnboardingField[]
createProvider: (config: TConfig) => ProviderInstance createProvider: (config: TConfig) => ProviderInstance
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { parse as parseSchema } from 'zod/v4/core' import { parse as parseSchema } from 'zod/v4/core'
import { ATLASCLOUD_DEFAULT_BASE_URL, providerAtlasCloud } from '../libs/providers/providers/atlascloud' import { ATLASCLOUD_DEFAULT_BASE_URL, providerAtlasCloud } from '../libs/providers/providers/atlascloud'
import { OFFICIAL_CHAT_PROVIDER_ID } from '../libs/providers/providers/official'
import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible' import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible'
import { inferenceServiceProvidersService } from './inference-service-providers' import { inferenceServiceProvidersService } from './inference-service-providers'
@@ -21,6 +22,13 @@ describe('services inference-service-providers', () => {
expect(provider.definitionId).toBe(providerOpenAICompatible.id) expect(provider.definitionId).toBe(providerOpenAICompatible.id)
expect(provider.config).toEqual({}) expect(provider.config).toEqual({})
expect(provider.status).toBe('unconfigured') expect(provider.status).toBe('unconfigured')
expect(provider.configuredBy).toBe('user')
})
it('preserves definition-owned authentication configuration', () => {
const provider = inferenceServiceProvidersService.buildLocal(OFFICIAL_CHAT_PROVIDER_ID, {})
expect(provider.configuredBy).toBe('authentication')
}) })
/** /**
@@ -104,6 +112,7 @@ describe('services inference-service-providers', () => {
config: { baseUrl: 'https://example.com/v1/' }, config: { baseUrl: 'https://example.com/v1/' },
id: 'provider-1', id: 'provider-1',
status: 'configured', status: 'configured',
configuredBy: 'user',
}), }),
}) })
}) })
@@ -107,6 +107,7 @@ export function createInferenceServiceProvidersService(): InferenceServiceProvid
definitionId, definitionId,
config: initialConfig, config: initialConfig,
status: 'unconfigured', status: 'unconfigured',
configuredBy: definition.configuredBy ?? 'user',
} }
} }
@@ -126,6 +127,7 @@ export function createInferenceServiceProvidersService(): InferenceServiceProvid
definitionId: item.definitionId, definitionId: item.definitionId,
config: item.config, config: item.config,
status, status,
configuredBy: getDefinedProvider(item.definitionId)?.configuredBy ?? 'user',
} }
} }
@@ -1,11 +1,11 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useProviderConfigStore } from '../providers/config' import { useProviderConfigStore } from '../providers/config'
import { useProviderStore } from '../providers/provider' import { useProviderStore } from '../providers/provider'
import { useConsciousnessStore } from './consciousness' import { useConsciousnessStore } from './consciousness'
import { configureAsDefaultsIfEmpty } from './default' import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from './default'
import { useHearingStore } from './hearing' import { useHearingStore } from './hearing'
import { useSpeechStore } from './speech' import { useSpeechStore } from './speech'
import { useVisionStore } from './vision' import { useVisionStore } from './vision'
@@ -72,9 +72,13 @@ describe('official provider module defaults', () => {
expect(visionStore.activeModel).toBe('auto') expect(visionStore.activeModel).toBe('auto')
expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') expect(providerConfigStore.providers['official-provider']?.status).toBe('configured')
expect(providerConfigStore.providers['official-provider']?.configuredBy).toBe('authentication')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.configuredBy).toBe('authentication')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.configuredBy).toBe('authentication')
expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured') expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured')
expect(providerConfigStore.providers['vision-official-provider']?.configuredBy).toBe('authentication')
expect(providerConfigStore.addedProviders['official-provider']).toBe(true) expect(providerConfigStore.addedProviders['official-provider']).toBe(true)
expect(providerConfigStore.addedProviders[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBe(true) expect(providerConfigStore.addedProviders[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBe(true)
expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBe(true) expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBe(true)
@@ -193,11 +197,11 @@ describe('official provider module defaults', () => {
expect(speechStore.activeSpeechVoiceId).toBe('') expect(speechStore.activeSpeechVoiceId).toBe('')
expect(visionStore.activeProvider).toBe('custom-vision') expect(visionStore.activeProvider).toBe('custom-vision')
expect(visionStore.activeModel).toBe('custom-vision-model') expect(visionStore.activeModel).toBe('custom-vision-model')
expect(providerConfigStore.providers['official-provider']?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBe(true) expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBe(true)
expect(providerConfigStore.providers['official-provider']).toBeUndefined() expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBeUndefined()
expect(providerConfigStore.providers['vision-official-provider']).toBeUndefined()
}) })
it('adds a configured official provider to the visible provider list', async () => { it('adds a configured official provider to the visible provider list', async () => {
@@ -224,10 +228,10 @@ describe('official provider module defaults', () => {
expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') expect(providerConfigStore.providers['official-provider']?.status).toBe('configured')
expect(providerConfigStore.addedProviders['official-provider']).toBe(true) expect(providerConfigStore.addedProviders['official-provider']).toBe(true)
expect(forceProviderConfigured).toHaveBeenCalledOnce() expect(forceProviderConfigured).toHaveBeenCalledTimes(4)
await expect(configureAsDefaultsIfEmpty()).resolves.toBe(false) await expect(configureAsDefaultsIfEmpty()).resolves.toBe(false)
expect(forceProviderConfigured).toHaveBeenCalledOnce() expect(forceProviderConfigured).toHaveBeenCalledTimes(4)
}) })
it('keeps a custom module and configures the other empty modules', async () => { it('keeps a custom module and configures the other empty modules', async () => {
@@ -249,9 +253,126 @@ describe('official provider module defaults', () => {
expect(speechStore.activeSpeechModel).toBe('auto') expect(speechStore.activeSpeechModel).toBe('auto')
expect(visionStore.activeProvider).toBe('vision-official-provider') expect(visionStore.activeProvider).toBe('vision-official-provider')
expect(visionStore.activeModel).toBe('auto') expect(visionStore.activeModel).toBe('auto')
expect(providerConfigStore.providers['official-provider']).toBeUndefined() expect(providerConfigStore.providers['official-provider']?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured') expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured')
}) })
// ROOT CAUSE:
//
// Authenticated setup only created an official provider record when its
// module was empty or already used that provider. A custom module selection
// therefore hid the official choice even while the user was signed in.
//
// We fixed this by configuring every standard official provider after login
// while applying official module selections only to empty modules.
it('configures official choices without replacing custom module selections', async () => {
const consciousnessStore = useConsciousnessStore()
const hearingStore = useHearingStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const providerConfigStore = useProviderConfigStore()
consciousnessStore.activeProvider = 'custom-chat'
consciousnessStore.activeModel = 'custom-chat-model'
hearingStore.activeTranscriptionProvider = 'custom-transcription'
hearingStore.activeTranscriptionModel = 'custom-transcription-model'
speechStore.activeSpeechProvider = 'custom-speech'
speechStore.activeSpeechModel = 'custom-speech-model'
visionStore.activeProvider = 'custom-vision'
visionStore.activeModel = 'custom-vision-model'
await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true)
expect(consciousnessStore.activeProvider).toBe('custom-chat')
expect(consciousnessStore.activeModel).toBe('custom-chat-model')
expect(hearingStore.activeTranscriptionProvider).toBe('custom-transcription')
expect(hearingStore.activeTranscriptionModel).toBe('custom-transcription-model')
expect(speechStore.activeSpeechProvider).toBe('custom-speech')
expect(speechStore.activeSpeechModel).toBe('custom-speech-model')
expect(visionStore.activeProvider).toBe('custom-vision')
expect(visionStore.activeModel).toBe('custom-vision-model')
expect(providerConfigStore.providers['official-provider']?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured')
expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured')
})
// ROOT CAUSE:
//
// Official provider records and module selections survived logout. Module
// pages therefore still treated official services as configured even though
// the authenticated session that grants access no longer existed.
//
// We fixed this by unconfiguring official records on logout and clearing only
// module selections that belong to those records. Custom selections remain.
it('removes authenticated official configuration on logout', async () => {
const consciousnessStore = useConsciousnessStore()
const hearingStore = useHearingStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const providerStore = useProviderStore()
const providerConfigStore = useProviderConfigStore()
await configureAsDefaultsIfEmpty()
await providerStore.initializeProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
await providerStore.forceProviderConfigured(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
speechStore.activeSpeechProvider = OFFICIAL_SPEECH_STREAMING_PROVIDER_ID
speechStore.activeSpeechModel = 'streaming-model'
await expect(unconfigureAuthenticationProviders()).resolves.toBe(true)
expect(consciousnessStore.activeProvider).toBe('')
expect(consciousnessStore.activeModel).toBe('')
expect(hearingStore.activeTranscriptionProvider).toBe('')
expect(hearingStore.activeTranscriptionModel).toBe('')
expect(speechStore.activeSpeechProvider).toBe('speech-noop')
expect(speechStore.activeSpeechModel).toBe('')
expect(speechStore.activeSpeechVoiceId).toBe('')
expect(visionStore.activeProvider).toBe('')
expect(visionStore.activeModel).toBe('')
expect(providerConfigStore.providers['official-provider']?.status).toBe('unconfigured')
expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('unconfigured')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('unconfigured')
expect(providerConfigStore.providers[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID]?.status).toBe('unconfigured')
expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('unconfigured')
expect(providerConfigStore.addedProviders['official-provider']).toBeUndefined()
expect(providerConfigStore.addedProviders[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBeUndefined()
expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBeUndefined()
expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID]).toBeUndefined()
expect(providerConfigStore.addedProviders['vision-official-provider']).toBeUndefined()
await expect(unconfigureAuthenticationProviders()).resolves.toBe(false)
await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true)
expect(providerConfigStore.providers['official-provider']?.status).toBe('configured')
})
it('keeps custom module selections when official providers are unconfigured', async () => {
const consciousnessStore = useConsciousnessStore()
const hearingStore = useHearingStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
consciousnessStore.activeProvider = 'custom-chat'
consciousnessStore.activeModel = 'custom-chat-model'
hearingStore.activeTranscriptionProvider = 'custom-transcription'
hearingStore.activeTranscriptionModel = 'custom-transcription-model'
speechStore.activeSpeechProvider = 'speech-noop'
speechStore.activeSpeechModel = ''
visionStore.activeProvider = 'custom-vision'
visionStore.activeModel = 'custom-vision-model'
await expect(unconfigureAuthenticationProviders()).resolves.toBe(false)
expect(consciousnessStore.activeProvider).toBe('custom-chat')
expect(consciousnessStore.activeModel).toBe('custom-chat-model')
expect(hearingStore.activeTranscriptionProvider).toBe('custom-transcription')
expect(hearingStore.activeTranscriptionModel).toBe('custom-transcription-model')
expect(speechStore.activeSpeechProvider).toBe('speech-noop')
expect(speechStore.activeSpeechModel).toBe('')
expect(visionStore.activeProvider).toBe('custom-vision')
expect(visionStore.activeModel).toBe('custom-vision-model')
})
}) })
+70 -16
View File
@@ -1,4 +1,4 @@
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official' import { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useProviderConfigStore } from '../providers/config' import { useProviderConfigStore } from '../providers/config'
import { useProviderStore } from '../providers/provider' import { useProviderStore } from '../providers/provider'
import { useConsciousnessStore } from './consciousness' import { useConsciousnessStore } from './consciousness'
@@ -8,20 +8,20 @@ import { useVisionStore } from './vision'
// Vision provider instances use a category prefix but share the official chat definition. // Vision provider instances use a category prefix but share the official chat definition.
const officialModuleDefaults = { const officialModuleDefaults = {
consciousness: { provider: 'official-provider', model: 'auto' }, consciousness: { provider: OFFICIAL_CHAT_PROVIDER_ID, model: 'auto' },
hearing: { provider: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, model: 'auto' }, hearing: { provider: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, model: 'auto' },
speech: { provider: OFFICIAL_SPEECH_PROVIDER_ID, model: 'auto' }, speech: { provider: OFFICIAL_SPEECH_PROVIDER_ID, model: 'auto' },
vision: { provider: 'vision-official-provider', model: 'auto' }, vision: { provider: OFFICIAL_VISION_PROVIDER_ID, model: 'auto' },
} as const } as const
/** /**
* Applies official defaults to empty inference modules. * Applies official defaults to empty inference modules.
* *
* The caller must own synchronized Pinia leadership. This command keeps the * The caller must own synchronized Pinia leadership. This command keeps the
* configuration of each custom provider unchanged. It also repairs missing * configuration of each custom provider unchanged. It configures the official
* provider records for modules that already use an official provider. * choice for every module, then applies it only to empty or incomplete
* Provider setup completes before module selections become visible to other * official selections. Provider setup completes before module selections
* windows. * become visible to other windows.
* *
* @returns `true` when the command changes module or provider state. * @returns `true` when the command changes module or provider state.
*/ */
@@ -42,15 +42,7 @@ export async function configureAsDefaultsIfEmpty(): Promise<boolean> {
const providerStore = useProviderStore() const providerStore = useProviderStore()
const providerConfigStore = useProviderConfigStore() const providerConfigStore = useProviderConfigStore()
const managedOfficialProviders = new Set<string>() const managedOfficialProviders = new Set<string>(Object.values(officialModuleDefaults).map(module => module.provider))
if (needsConsciousnessDefault || usesOfficialConsciousness)
managedOfficialProviders.add(officialModuleDefaults.consciousness.provider)
if (needsHearingDefault || usesOfficialHearing)
managedOfficialProviders.add(officialModuleDefaults.hearing.provider)
if (needsSpeechDefault || usesOfficialSpeech)
managedOfficialProviders.add(officialModuleDefaults.speech.provider)
if (needsVisionDefault || usesOfficialVision)
managedOfficialProviders.add(officialModuleDefaults.vision.provider)
let changed = false let changed = false
for (const provider of managedOfficialProviders) { for (const provider of managedOfficialProviders) {
@@ -112,3 +104,65 @@ export async function configureAsDefaultsIfEmpty(): Promise<boolean> {
return changed return changed
} }
/**
* Removes provider state that is valid only during an authenticated session.
*
* The caller must own synchronized Pinia leadership. Custom module selections
* remain unchanged. An official speech selection falls back to No Speech so
* the speech pipeline keeps its account-free disabled state after logout.
*
* @returns `true` when the command changes module or provider state.
*/
export async function unconfigureAuthenticationProviders(): Promise<boolean> {
const consciousnessStore = useConsciousnessStore()
const hearingStore = useHearingStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const providerStore = useProviderStore()
const providerConfigStore = useProviderConfigStore()
let changed = false
if (providerConfigStore.providers[consciousnessStore.activeProvider]?.configuredBy === 'authentication') {
consciousnessStore.activeProvider = ''
consciousnessStore.activeModel = ''
consciousnessStore.customModelName = ''
changed = true
}
if (providerConfigStore.providers[hearingStore.activeTranscriptionProvider]?.configuredBy === 'authentication') {
hearingStore.activeTranscriptionProvider = ''
hearingStore.activeTranscriptionModel = ''
hearingStore.activeCustomModelName = ''
changed = true
}
if (providerConfigStore.providers[speechStore.activeSpeechProvider]?.configuredBy === 'authentication') {
speechStore.activeSpeechProvider = 'speech-noop'
speechStore.activeSpeechModel = ''
speechStore.activeSpeechVoiceId = ''
speechStore.activeSpeechVoice = undefined
changed = true
}
if (providerConfigStore.providers[visionStore.activeProvider]?.configuredBy === 'authentication') {
visionStore.activeProvider = ''
visionStore.activeModel = ''
visionStore.customModelName = ''
changed = true
}
for (const [providerId, provider] of Object.entries(providerConfigStore.providers)) {
if (provider.configuredBy !== 'authentication')
continue
const isPersistedAsAdded = providerConfigStore.addedProviders[providerId]
if (provider.status === 'unconfigured' && !isPersistedAsAdded)
continue
await providerStore.setProviderUnconfigured(providerId)
changed = true
}
return changed
}
@@ -29,6 +29,7 @@ const localProvider = {
definitionId: 'openai-compatible', definitionId: 'openai-compatible',
config: {}, config: {},
status: 'unconfigured', status: 'unconfigured',
configuredBy: 'user',
} satisfies InferenceServiceProvider } satisfies InferenceServiceProvider
const remoteProvider = { const remoteProvider = {
@@ -65,6 +65,20 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
definitionId, definitionId,
config, config,
status: 'unconfigured', status: 'unconfigured',
configuredBy: definition.configuredBy ?? 'user',
}
}
// Provider definitions own configuration lifecycle policy. Apply that
// policy to persisted snapshots before module pages consume them. Providers
// without an owner declaration remain user-configured.
for (const provider of Object.values(providers.value)) {
const configuredByDefinition = getDefinedProvider(provider.definitionId)?.configuredBy
if (configuredByDefinition) {
provider.configuredBy = configuredByDefinition
}
else if (!provider.configuredBy) {
provider.configuredBy = 'user'
} }
} }
@@ -119,6 +133,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
definitionId, definitionId,
config, config,
status: 'unconfigured' as const, status: 'unconfigured' as const,
configuredBy: definition.configuredBy ?? 'user',
} }
providers.value[providerId] = provider providers.value[providerId] = provider
return provider return provider
@@ -1,10 +1,12 @@
import type { ChatProvider } from '@xsai-ext/providers/utils' import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Session, User } from 'better-auth'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useAuthStore } from '../auth'
import { useProviderConfigStore } from './config' import { useProviderConfigStore } from './config'
import { useProviderStore } from './provider' import { useProviderStore } from './provider'
@@ -89,6 +91,71 @@ describe('provider store synchronization boundary', () => {
expect(getProviderCalls).toBe(0) expect(getProviderCalls).toBe(0)
}) })
// ROOT CAUSE:
//
// Module pages treated every credential-free provider as available before
// configuration. The official providers also have credential-free local
// definitions, but their availability belongs to the authenticated session.
//
// We fixed this by requiring a configured record for official providers
// while keeping account-free browser and local providers available.
it('lists official providers only after authenticated setup configures them', async () => {
const store = useProviderStore()
const configStore = useProviderConfigStore()
await vi.waitFor(() => {
expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).toContain('speech-noop')
expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).not.toContain('official-provider')
expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_PROVIDER_ID)
expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).not.toContain('vision-official-provider')
})
expect(configStore.providers['official-provider']).toBeUndefined()
expect(configStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]).toBeUndefined()
expect(configStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBeUndefined()
expect(configStore.providers['vision-official-provider']).toBeUndefined()
await store.initializeProvider('official-provider')
await store.forceProviderConfigured('official-provider')
await store.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID)
await store.forceProviderConfigured(OFFICIAL_SPEECH_PROVIDER_ID)
await store.initializeProvider(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
await store.forceProviderConfigured(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
await store.initializeProvider('vision-official-provider')
await store.forceProviderConfigured('vision-official-provider')
expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).not.toContain('official-provider')
expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_PROVIDER_ID)
expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).not.toContain('vision-official-provider')
const user: User = {
id: 'user-1',
name: 'AIRI User',
email: 'user@example.com',
emailVerified: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
}
const session: Session = {
id: 'session-1',
token: 'server-session-token',
userId: user.id,
expiresAt: new Date('2026-12-01T00:00:00.000Z'),
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
}
useAuthStore().$patch({ user, session })
expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).toContain('official-provider')
expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).toContain(OFFICIAL_SPEECH_PROVIDER_ID)
expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).toContain('vision-official-provider')
})
// ROOT CAUSE: // ROOT CAUSE:
// //
// getModelsForProvider created a new empty array for every cache miss. // getModelsForProvider created a new empty array for every cache miss.
@@ -31,6 +31,7 @@ import {
validateProvider as runProviderValidation, validateProvider as runProviderValidation,
} from '../../libs/providers' } from '../../libs/providers'
import { selectProviderMetadata, selectProvidersMetadata } from '../../libs/providers/metadata' import { selectProviderMetadata, selectProvidersMetadata } from '../../libs/providers/metadata'
import { useAuthStore } from '../auth'
import { useProviderConfigStore } from './config' import { useProviderConfigStore } from './config'
import { normalizeProviderConfigDefaults } from './config-defaults' import { normalizeProviderConfigDefaults } from './config-defaults'
@@ -86,6 +87,7 @@ const useProviderStateStore = defineStore('provider-state', () => {
* configuration remains in {@link useProviderConfigStore}. * configuration remains in {@link useProviderConfigStore}.
*/ */
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const authStore = useAuthStore()
const providerConfigStore = useProviderConfigStore() const providerConfigStore = useProviderConfigStore()
const providerStateStore = useProviderStateStore() const providerStateStore = useProviderStateStore()
const providerCredentials = computed(() => providerConfigStore.configs) const providerCredentials = computed(() => providerConfigStore.configs)
@@ -714,10 +716,17 @@ export const useProviderStore = defineStore('provider', () => {
.filter(d => providerMetadata[d.id]) .filter(d => providerMetadata[d.id])
.map(d => projectProvider(d.id)) .map(d => projectProvider(d.id))
.filter(metadata => metadata !== undefined) .filter(metadata => metadata !== undefined)
// Vision providers reuse chat definitions under separate instance ids.
// Include these generated definitions before configured custom instances.
const visionDefinitions = definedProviders
.filter(definition => providerMetadata[definition.id]?.category === 'chat')
.map(definition => projectProvider(`${VISION_PROVIDER_ID_PREFIX}${definition.id}`))
.filter(metadata => metadata !== undefined)
const definitionIds = new Set([...definitions, ...visionDefinitions].map(metadata => metadata.id))
const configuredInstances: ProviderMetadata[] = [] const configuredInstances: ProviderMetadata[] = []
for (const providerId of Object.keys(providerConfigStore.providers)) { for (const providerId of Object.keys(providerConfigStore.providers)) {
if (providerMetadata[providerId]) if (definitionIds.has(providerId))
continue continue
const metadata = projectProvider(providerId) const metadata = projectProvider(providerId)
@@ -725,7 +734,7 @@ export const useProviderStore = defineStore('provider', () => {
configuredInstances.push(metadata) configuredInstances.push(metadata)
} }
return [...definitions, ...configuredInstances] return [...definitions, ...visionDefinitions, ...configuredInstances]
}) })
function getTranscriptionFeatures(providerId: string) { function getTranscriptionFeatures(providerId: string) {
@@ -875,6 +884,57 @@ export const useProviderStore = defineStore('provider', () => {
return !!addedProviders.value[providerId] || isProviderConfigDirty(providerId) return !!addedProviders.value[providerId] || isProviderConfigDirty(providerId)
} }
function isProviderAvailableWithoutConfiguration(providerId: string) {
return providerConfiguredBy(providerId) !== 'authentication'
&& getProviderDefinition(providerId).requiresCredentials === false
}
function providerConfiguredBy(providerId: string) {
const configuredProvider = providerConfigStore.providers[providerId]
if (configuredProvider)
return configuredProvider.configuredBy
return getProviderDefinition(providerId).configuredBy ?? 'user'
}
function isProviderConfiguredForModule(providerId: string) {
return providerConfigStore.configuredProviders[providerId]
&& (providerConfiguredBy(providerId) !== 'authentication' || authStore.isAuthenticated)
}
// Authentication-owned providers do not require a user-supplied API key,
// but they do require an authenticated session. Browser and local providers
// remain available without a persisted configuration record.
const moduleChatProvidersMetadata = computed(() => {
return allChatProvidersMetadata.value.filter(metadata =>
isProviderConfiguredForModule(metadata.id)
|| (providerConfiguredBy(metadata.id) !== 'authentication' && shouldListProvider(metadata.id))
|| isProviderAvailableWithoutConfiguration(metadata.id),
)
})
const moduleSpeechProvidersMetadata = computed(() => {
return allAudioSpeechProvidersMetadata.value.filter(metadata =>
isProviderConfiguredForModule(metadata.id)
|| isProviderAvailableWithoutConfiguration(metadata.id),
)
})
const moduleTranscriptionProvidersMetadata = computed(() => {
return allAudioTranscriptionProvidersMetadata.value.filter(metadata =>
isProviderConfiguredForModule(metadata.id)
|| isProviderAvailableWithoutConfiguration(metadata.id),
)
})
const moduleVisionProvidersMetadata = computed(() => {
return allVisionProvidersMetadata.value.filter(metadata =>
isProviderConfiguredForModule(metadata.id)
|| (providerConfiguredBy(metadata.id) !== 'authentication' && shouldListProvider(metadata.id))
|| isProviderAvailableWithoutConfiguration(metadata.id),
)
})
const persistedProvidersMetadata = computed(() => { const persistedProvidersMetadata = computed(() => {
return availableProvidersMetadata.value.filter(metadata => shouldListProvider(metadata.id)) return availableProvidersMetadata.value.filter(metadata => shouldListProvider(metadata.id))
}) })
@@ -925,6 +985,10 @@ export const useProviderStore = defineStore('provider', () => {
configuredSpeechProvidersMetadata, configuredSpeechProvidersMetadata,
configuredTranscriptionProvidersMetadata, configuredTranscriptionProvidersMetadata,
configuredVisionProvidersMetadata, configuredVisionProvidersMetadata,
moduleChatProvidersMetadata,
moduleSpeechProvidersMetadata,
moduleTranscriptionProvidersMetadata,
moduleVisionProvidersMetadata,
persistedChatProvidersMetadata, persistedChatProvidersMetadata,
persistedVisionProvidersMetadata, persistedVisionProvidersMetadata,
} }