From 2ca8339a4930510df320d7a0bb4faa14d6f7746b Mon Sep 17 00:00:00 2001 From: leafyy Date: Thu, 3 Sep 2026 19:20:35 +0800 Subject: [PATCH] fix(speech): synchronize streaming catalog state (#2445) --- ...-provider-speech-streaming.browser.test.ts | 23 +++----- packages/provider-inference/src/types.ts | 2 + .../official-provider-speech-streaming.vue | 51 +++++++++--------- .../stage-ui/src/components/scenes/Stage.vue | 6 ++- .../src/libs/providers/providers/index.ts | 1 - .../providers/providers/official/index.ts | 26 +++------ .../src/stores/modules/speech.test.ts | 31 +++++++++++ .../stage-ui/src/stores/modules/speech.ts | 4 +- .../stage-ui/src/stores/providers/config.ts | 40 ++++++++++++++ .../provider-model-catalog.browser.test.ts | 54 +++++++++++++++++++ .../src/stores/providers/provider.test.ts | 2 + .../stage-ui/src/stores/providers/provider.ts | 12 ++++- .../adapters/config-kv/definitions.ts | 5 +- .../services/adapters/config-kv/index.test.ts | 27 ++++++++++ 14 files changed, 219 insertions(+), 65 deletions(-) diff --git a/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts b/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts index 4f59e646c..6a08f8d55 100644 --- a/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts +++ b/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts @@ -164,25 +164,17 @@ describe('official streaming speech provider settings', () => { const fetchMock = vi.fn(async input => responseFor(input)) vi.stubGlobal('fetch', fetchMock) const pinia = createPinia() - const { errors } = await renderPage(pinia) - const providerStore = useProviderStore(pinia) - const forceProviderConfigured = providerStore.forceProviderConfigured - let finishConfiguration: (() => void) | undefined - const configurationPending = new Promise((resolve) => { - finishConfiguration = resolve - }) - vi.spyOn(providerStore, 'forceProviderConfigured').mockImplementation(async (requestedProviderId) => { - await configurationPending - forceProviderConfigured(requestedProviderId) + const actionOrder: string[] = [] + useProviderStore(pinia).$onAction(({ after, name }) => { + if (name === 'forceProviderConfigured') + after(() => actionOrder.push('configured')) + if (name === 'listProviderVoices') + actionOrder.push('voices') }) useAuthStore(pinia).$patch({ user, session }) - await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/models/streaming'))).toBe(true) - expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(false) - - finishConfiguration?.() - await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(true) + await expect.poll(() => actionOrder).toEqual(['configured', 'voices']) expect(errors.map(error => errorMessageFrom(error))).toEqual([]) }) @@ -221,6 +213,7 @@ describe('official streaming speech provider settings', () => { expect(setProviderAvailabilityOverride).not.toHaveBeenCalledWith(providerId, false) expect(setProviderUnconfigured).not.toHaveBeenCalledWith(providerId) expect(providerConfigStore.providers[providerId]?.status).toBe('configured') + await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(true) expect(errors.map(error => errorMessageFrom(error))).toEqual([]) }) }) diff --git a/packages/provider-inference/src/types.ts b/packages/provider-inference/src/types.ts index 9ccda53e8..d0ef292fb 100644 --- a/packages/provider-inference/src/types.ts +++ b/packages/provider-inference/src/types.ts @@ -85,6 +85,8 @@ export interface ProviderModelCatalog { models: ModelInfo[] /** Whether the server exposes this catalog. Absent when discovery did not return an authoritative state. */ available?: boolean + /** Last authoritative availability retained by the action owner after discovery fails. */ + lastKnownAvailable?: boolean /** Server-selected model id, or null when the server has no default. */ defaultModel?: string | null } diff --git a/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue b/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue index 01f6def5e..05f782ba7 100644 --- a/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue +++ b/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue @@ -13,7 +13,7 @@ import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider' import { Callout, ComboboxSelect } from '@proj-airi/ui' import { computedAsync } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { computed, ref, watch } from 'vue' +import { computed, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' @@ -21,7 +21,7 @@ const router = useRouter() const { t } = useI18n() const authStore = useAuthStore() const providersStore = useProviderStore() -const providerStore = useProviderConfigStore() +const providerConfigStore = useProviderConfigStore() const speechStore = useSpeechStore() const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore) @@ -33,7 +33,7 @@ const providerMetadata = computedAsync(() => selectProviderMetadata( )) const fluxPurchaseDisabled = isFluxPurchaseDisabled() -const providerConfig = computed(() => providerStore.getProviderConfig(providerId)) +const providerConfig = computed(() => providerConfigStore.getProviderConfig(providerId)) // Model picker. The catalog and the default model id both come from the // server's `/api/v1/audio/models/streaming` response (operator-controlled @@ -41,22 +41,19 @@ const providerConfig = computed(() => providerStore.getProviderConfig(providerId // adding ICL / other backends doesn't need a UI release. const providerModels = computed(() => providersStore.getModelsForProvider(providerId)) const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false) -const serverDefaultModel = ref(null) -const streamingAvailable = ref(false) -const model = computed({ - get(): string { - return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? '' - }, - set(val: string) { - const config = providerConfig.value - if (config) - config.model = val - }, -}) +const serverDefaultModel = shallowRef(null) +const streamingAvailable = shallowRef(false) +const model = computed(() => (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? '') const modelOptions = computed(() => providerModels.value.map(m => ({ label: m.name, value: m.id }))) const availableVoices = computed(() => speechStore.availableVoices[providerId] || []) -const voicesLoading = ref(false) +const voicesLoading = shallowRef(false) + +async function setModel(value: string | number | undefined) { + if (typeof value !== 'string') + return + await providerConfigStore.setProviderModel(providerId, value) +} async function loadVoices() { voicesLoading.value = true @@ -71,10 +68,11 @@ async function loadVoices() { watch(isAuthenticated, async (authenticated, _, onCleanup) => { let active = true onCleanup(() => active = false) - streamingAvailable.value = false - serverDefaultModel.value = null - if (!authenticated) + if (!authenticated) { + streamingAvailable.value = false + serverDefaultModel.value = null return + } await providersStore.initializeProvider(providerId) if (!active) @@ -87,8 +85,13 @@ watch(isAuthenticated, async (authenticated, _, onCleanup) => { // An absent value means that discovery failed before the server returned an // authoritative state. Keep the last configured state and availability // override so a transient request failure cannot hide the provider. - if (catalog.available === undefined) + if (catalog.available === undefined) { + // Discovery did not produce a new authoritative state. Reuse the state + // returned by the leader so a late follower snapshot cannot disable + // model-scoped voice loading for the rest of this page mount. + streamingAvailable.value = catalog.lastKnownAvailable === true return + } const available = catalog.available await providersStore.setProviderAvailabilityOverride(providerId, available) @@ -110,9 +113,8 @@ watch(isAuthenticated, async (authenticated, _, onCleanup) => { // first model in the same catalog response. Do not read synchronized model // state here because its follower snapshot can arrive after the action. serverDefaultModel.value = catalog.defaultModel ?? catalog.models[0]?.id ?? null - const config = providerConfig.value - if (config && !config.model && serverDefaultModel.value) - config.model = serverDefaultModel.value + if (serverDefaultModel.value) + await providerConfigStore.setProviderModelIfUnset(providerId, serverDefaultModel.value) }, { immediate: true }) // Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL @@ -222,10 +224,11 @@ function handleLogin() {

Pick the streaming TTS model variant. All variants share the same voice catalogue today.

diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index be6477bfa..41c7b0e69 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -36,7 +36,7 @@ import { useIOTraceBridge } from '../../composables/use-io-trace-bridge' import { initIOTracer } from '../../composables/use-io-tracer' import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions' import { live2dMotionMagicProfiles, useLive2DMotionMagic, useLive2DMotionMagicSettings } from '../../features/motions/live2d' -import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers' +import { getDefinedProvider } from '../../libs/providers/providers' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official' import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state' import { createStageTtsSession } from '../../libs/speech/tts-session' @@ -729,7 +729,9 @@ function stopSpeechOutput(reason: string) { */ function resolveStreamingSessionModel(): string | null { const activeModel = activeSpeechModel.value as string | undefined - const sessionModel = activeModel?.includes('/') ? activeModel : getDefaultStreamingModel() + const sessionModel = activeModel?.includes('/') + ? activeModel + : providersStore.getDefaultModelForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) if (!sessionModel?.includes('/')) return null return sessionModel diff --git a/packages/stage-ui/src/libs/providers/providers/index.ts b/packages/stage-ui/src/libs/providers/providers/index.ts index 97546f3e0..ce179f98c 100644 --- a/packages/stage-ui/src/libs/providers/providers/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/index.ts @@ -12,7 +12,6 @@ import './official' registerProviders(portableProviderDefinitions) export { - getDefaultStreamingModel, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, } from './official' diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index d63b514b8..c3791b6c6 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -33,15 +33,6 @@ export function getDefaultSpeechModel(): string | null { return defaultSpeechModelId } -// Server-curated default streaming model id, populated by the streaming -// provider's listModels(). Pages that need to seed an initial model selection -// read this via getDefaultStreamingModel() instead of hardcoding an id. -let defaultStreamingModelId: string | null = null - -export function getDefaultStreamingModel(): string | null { - return defaultStreamingModelId -} - const officialConfigSchema = z.object({}) function authHeaders(): Record { @@ -57,22 +48,19 @@ async function listStreamingModelCatalog(): Promise { // (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `/` // (see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the // server returns whatever the operator put there, no client-side defaults. - // Reset the default up front so a failed probe cannot retain stale data. - defaultStreamingModelId = null - const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models/streaming`, { headers: authHeaders() }) if (!res.ok) throw new Error(`streaming models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256)) - const data = await res.json() as { available?: boolean, models: { id: string, name?: string, description?: string }[], default?: string | null } - if (!Array.isArray(data.models)) - throw new Error('streaming models upstream missing models[]') - - defaultStreamingModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null + const data = await res.json() as { + available: boolean + models: { id: string, name?: string, description?: string }[] + default: string | null + } return { - available: data.available === true, - defaultModel: defaultStreamingModelId, + available: data.available, + defaultModel: data.default ?? null, models: data.models.map(m => ({ id: m.id, name: m.name ?? m.id, diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index 6ad0cbf9d..49f1e3329 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -219,6 +219,37 @@ describe('speech store helpers', () => { expect(listVoices).not.toHaveBeenCalled() }) + // ROOT CAUSE: + // + // Streaming discovery kept its default model in one renderer's module + // variable. Another renderer received the synchronized models but selected + // the first entry instead of the operator default. + // + // Before: read getDefaultStreamingModel() from renderer-local memory. + // + // We fixed this by storing the default beside the synchronized model catalog. + it('selects the synchronized streaming default instead of the first model', async () => { + const providersStore = useProviderStore() + vi.spyOn(providersStore, 'listProviderVoices').mockResolvedValue([]) + const speechStore = useSpeechStore() + await providersStore.initializeProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + providersStore.providerRuntimeState[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID] = { + models: [ + { id: 'volcengine/seed-tts-1.0', name: 'Seed TTS 1.0', provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID }, + { id: 'volcengine/seed-tts-2.0', name: 'Seed TTS 2.0', provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID }, + ], + defaultModel: 'volcengine/seed-tts-2.0', + modelStatus: 'ready', + modelError: null, + } + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + speechStore.activeSpeechModel = '' + + speechStore.ensureActiveSpeechModel() + + expect(speechStore.activeSpeechModel).toBe('volcengine/seed-tts-2.0') + }) + /** * @example * speechStore.ensureActiveSpeechModel() diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index 30282ed4e..23e1e52fb 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -14,7 +14,7 @@ import { useI18n } from 'vue-i18n' import { toXml } from 'xast-util-to-xml' import { x } from 'xastscript' -import { getDefaultSpeechModel, getDefaultStreamingModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official' +import { getDefaultSpeechModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official' import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' @@ -174,7 +174,7 @@ export const useSpeechStore = defineStore('speech', () => { // When no default can be resolved yet (catalog not loaded), clear it to '' // so callers pass `undefined` (server returns the full streaming catalog) // rather than forwarding a stale non-streaming model id as `?model=`. - const nextModel = getDefaultStreamingModel() ?? streamingModels[0]?.id ?? '' + const nextModel = providersStore.getDefaultModelForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) ?? streamingModels[0]?.id ?? '' if (activeSpeechModel.value === nextModel) return activeSpeechModel.value = nextModel diff --git a/packages/stage-ui/src/stores/providers/config.ts b/packages/stage-ui/src/stores/providers/config.ts index 79aa5f640..6fa9f8a41 100644 --- a/packages/stage-ui/src/stores/providers/config.ts +++ b/packages/stage-ui/src/stores/providers/config.ts @@ -153,6 +153,42 @@ export const useProviderConfigStore = defineStore('provider-config', () => { provider.status = status } + /** + * Updates the selected model in the leader-owned provider snapshot. + * + * Follower renderers must await this action instead of mutating replicated + * configuration directly, because `state: true` proposals contain the full + * store and can overwrite newer leader state. + */ + async function setProviderModel(providerId: string, model: string) { + const provider = providers.value[providerId] + if (!provider) + return + + providers.value[providerId] = { + ...provider, + config: { ...provider.config, model }, + } + } + + /** + * Seeds a discovered default without replacing a model selected by the user. + */ + async function setProviderModelIfUnset(providerId: string, model: string) { + const provider = providers.value[providerId] + if (!provider) + return + + const currentModel = provider.config.model + if (typeof currentModel === 'string' && currentModel.length > 0) + return + + providers.value[providerId] = { + ...provider, + config: { ...provider.config, model }, + } + } + function mergeProviderSnapshot(snapshot: Record) { providers.value = { ...providers.value, ...snapshot } for (const providerId of Object.keys(snapshot)) @@ -252,6 +288,8 @@ export const useProviderConfigStore = defineStore('provider-config', () => { markProviderAdded, unmarkProviderAdded, setProviderStatus, + setProviderModel, + setProviderModelIfUnset, fetchProviders, addProvider, removeProvider, @@ -266,6 +304,8 @@ export const useProviderConfigStore = defineStore('provider-config', () => { 'markProviderAdded', 'unmarkProviderAdded', 'setProviderStatus', + 'setProviderModel', + 'setProviderModelIfUnset', 'addProvider', 'removeProvider', 'updateProviderConfig', diff --git a/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts b/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts index d8b21a9b3..0ee26fa64 100644 --- a/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts +++ b/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts @@ -105,5 +105,59 @@ describe('provider model catalog synchronization', () => { }), ], }) + await vi.waitFor(() => expect(followerContext.providerStore.getDefaultModelForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)).toBe('volcengine/seed-tts-2.0')) + + // https://github.com/moeru-ai/airi/pull/2445#discussion_r3913843853 + // ROOT CAUSE: + // + // A function returned from a Pinia setup store becomes an action. The + // default-model lookup was a pure read, but it still ran action hooks. + // + // Before: getDefaultModelForProvider was a returned store function. + // + // We fixed this by exposing the parameterized lookup as a computed getter. + const actionNames: string[] = [] + followerContext.providerStore.$onAction(({ name }) => actionNames.push(name)) + + expect(followerContext.providerStore.getDefaultModelForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)).toBe('volcengine/seed-tts-2.0') + expect(actionNames).not.toContain('getDefaultModelForProvider') + }) + + // https://github.com/moeru-ai/airi/pull/2440#discussion_r3912911639 + // ROOT CAUSE: + // + // The settings renderer wrote the discovered default into its follower + // snapshot. The resulting full-state proposal could overwrite newer leader + // state, and the write was skipped when that snapshot arrived late. + // + // Before: mutate providerConfig.model in the follower page. + // + // We fixed this by routing model updates to awaited leader-owned actions. + it('applies defaults through the leader without replacing a user selection', async () => { + const namespace = `provider-model-default:${crypto.randomUUID()}` + const leaderContext = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leaderContext.runtime.isLeader()).toBe(true)) + + const followerContext = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(followerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId)) + + await followerContext.providerStore.initializeProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + await followerContext.providerConfigStore.setProviderModelIfUnset( + OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + 'volcengine/seed-tts-2.0', + ) + await vi.waitFor(() => expect(followerContext.providerConfigStore.getProviderConfig(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)?.model).toBe('volcengine/seed-tts-2.0')) + + await leaderContext.providerConfigStore.setProviderModel( + OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + 'volcengine/seed-tts-1.0', + ) + await followerContext.providerConfigStore.setProviderModelIfUnset( + OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + 'volcengine/seed-tts-2.0', + ) + + expect(leaderContext.providerConfigStore.getProviderConfig(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)?.model).toBe('volcengine/seed-tts-1.0') + await vi.waitFor(() => expect(followerContext.providerConfigStore.getProviderConfig(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)?.model).toBe('volcengine/seed-tts-1.0')) }) }) diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index 67e9da3f0..a709d0a47 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -34,6 +34,7 @@ describe('provider store synchronization boundary', () => { const store = useProviderStore() const runtimeState = { models: [], + defaultModel: null, modelStatus: 'ready' as const, modelError: null, } @@ -205,6 +206,7 @@ describe('provider store synchronization boundary', () => { store.providerRuntimeState['official-provider'] = { models: [], + defaultModel: null, modelStatus: 'loading', modelError: null, } diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index 75d4a1609..c27b2e245 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -41,6 +41,7 @@ export type { ModelInfo, VoiceInfo } from '../../libs/providers/types' export interface ProviderRuntimeState { validatedCredentialHash?: string models: ModelInfo[] + defaultModel: string | null modelStatus: 'idle' | 'loading' | 'ready' | 'error' modelError: string | null } @@ -365,6 +366,7 @@ export const useProviderStore = defineStore('provider', () => { if (!providerRuntimeState.value[providerId]) { providerRuntimeState.value[providerId] = { models: [], + defaultModel: null, modelStatus: 'idle', modelError: null, } @@ -652,6 +654,7 @@ export const useProviderStore = defineStore('provider', () => { [providerId]: { ...currentRuntimeState, models: normalizedModels, + defaultModel: catalog.defaultModel ?? null, modelStatus: 'ready', modelError: null, }, @@ -679,7 +682,9 @@ export const useProviderStore = defineStore('provider', () => { }, } } - return { models: [] } + const lastKnownAvailable = providerAvailabilityOverrides.value[providerId] + ?? (providerConfigStore.configuredProviders[providerId] ? true : undefined) + return { models: [], lastKnownAvailable } } } @@ -688,6 +693,10 @@ export const useProviderStore = defineStore('provider', () => { return providerRuntimeState.value[providerId]?.models ?? emptyProviderModels } + const getDefaultModelForProvider = computed(() => (providerId: string) => { + return providerRuntimeState.value[providerId]?.defaultModel ?? null + }) + // Load models for all configured providers async function loadModelsForConfiguredProviders() { for (const providerId of availableProviders.value) { @@ -1000,6 +1009,7 @@ export const useProviderStore = defineStore('provider', () => { modelLoadError, fetchModelsForProvider, getModelsForProvider, + getDefaultModelForProvider, listProviderVoices, loadProviderModel, loadModelsForConfiguredProviders, diff --git a/server/apps/api/src/services/adapters/config-kv/definitions.ts b/server/apps/api/src/services/adapters/config-kv/definitions.ts index f5c670f1c..79c96b0e4 100644 --- a/server/apps/api/src/services/adapters/config-kv/definitions.ts +++ b/server/apps/api/src/services/adapters/config-kv/definitions.ts @@ -156,7 +156,10 @@ export const streamingTtsUpstreamSchema = object({ })), [], ), - defaultModel: optional(string()), + defaultModel: optional(pipe( + string(), + nonEmpty('UNSPEECH_UPSTREAM.streaming.defaultModel must not be empty'), + )), }) export const unspeechUpstreamSchema = object({ diff --git a/server/apps/api/src/services/adapters/config-kv/index.test.ts b/server/apps/api/src/services/adapters/config-kv/index.test.ts index 2d341c268..60dc23475 100644 --- a/server/apps/api/src/services/adapters/config-kv/index.test.ts +++ b/server/apps/api/src/services/adapters/config-kv/index.test.ts @@ -92,6 +92,33 @@ describe('configKVService', () => { }) }) + // https://github.com/moeru-ai/airi/pull/2445#discussion_r3913931906 + // ROOT CAUSE: + // + // The streaming TTS config accepted an empty default model. The catalog + // exposed that value as a present default, so clients skipped their fallback. + // + // Before: defaultModel used optional(string()). + // + // We fixed this by rejecting an empty configured default at the ConfigKV boundary. + it('rejects an empty streaming TTS default model', async () => { + store._store.set('UNSPEECH_UPSTREAM', JSON.stringify({ + restBaseURL: 'http://unspeech.local:5933', + streaming: { + baseURL: 'wss://unspeech.local', + keys: [{ id: 'k1', ciphertext: 'enc' }], + defaultModel: '', + }, + })) + + await expect(service.getOptional('UNSPEECH_UPSTREAM')) + .rejects + .toMatchObject({ + statusCode: 503, + errorCode: 'CONFIG_INVALID', + }) + }) + it('wraps database failures as CONFIG_UNAVAILABLE', async () => { store.getRaw.mockRejectedValueOnce(new Error('database offline'))