fix(speech): synchronize streaming catalog state (#2445)
This commit is contained in:
@@ -164,25 +164,17 @@ describe('official streaming speech provider settings', () => {
|
|||||||
const fetchMock = vi.fn<typeof fetch>(async input => responseFor(input))
|
const fetchMock = vi.fn<typeof fetch>(async input => responseFor(input))
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
const { errors } = await renderPage(pinia)
|
const { errors } = await renderPage(pinia)
|
||||||
const providerStore = useProviderStore(pinia)
|
const actionOrder: string[] = []
|
||||||
const forceProviderConfigured = providerStore.forceProviderConfigured
|
useProviderStore(pinia).$onAction(({ after, name }) => {
|
||||||
let finishConfiguration: (() => void) | undefined
|
if (name === 'forceProviderConfigured')
|
||||||
const configurationPending = new Promise<void>((resolve) => {
|
after(() => actionOrder.push('configured'))
|
||||||
finishConfiguration = resolve
|
if (name === 'listProviderVoices')
|
||||||
})
|
actionOrder.push('voices')
|
||||||
vi.spyOn(providerStore, 'forceProviderConfigured').mockImplementation(async (requestedProviderId) => {
|
|
||||||
await configurationPending
|
|
||||||
forceProviderConfigured(requestedProviderId)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useAuthStore(pinia).$patch({ user, session })
|
useAuthStore(pinia).$patch({ user, session })
|
||||||
await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/models/streaming'))).toBe(true)
|
await expect.poll(() => actionOrder).toEqual(['configured', 'voices'])
|
||||||
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)
|
|
||||||
expect(errors.map(error => errorMessageFrom(error))).toEqual([])
|
expect(errors.map(error => errorMessageFrom(error))).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -221,6 +213,7 @@ describe('official streaming speech provider settings', () => {
|
|||||||
expect(setProviderAvailabilityOverride).not.toHaveBeenCalledWith(providerId, false)
|
expect(setProviderAvailabilityOverride).not.toHaveBeenCalledWith(providerId, false)
|
||||||
expect(setProviderUnconfigured).not.toHaveBeenCalledWith(providerId)
|
expect(setProviderUnconfigured).not.toHaveBeenCalledWith(providerId)
|
||||||
expect(providerConfigStore.providers[providerId]?.status).toBe('configured')
|
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([])
|
expect(errors.map(error => errorMessageFrom(error))).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface ProviderModelCatalog {
|
|||||||
models: ModelInfo[]
|
models: ModelInfo[]
|
||||||
/** Whether the server exposes this catalog. Absent when discovery did not return an authoritative state. */
|
/** Whether the server exposes this catalog. Absent when discovery did not return an authoritative state. */
|
||||||
available?: boolean
|
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. */
|
/** Server-selected model id, or null when the server has no default. */
|
||||||
defaultModel?: string | null
|
defaultModel?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-24
@@ -13,7 +13,7 @@ import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
|||||||
import { Callout, ComboboxSelect } from '@proj-airi/ui'
|
import { Callout, ComboboxSelect } from '@proj-airi/ui'
|
||||||
import { computedAsync } from '@vueuse/core'
|
import { computedAsync } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, shallowRef, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ const router = useRouter()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const providersStore = useProviderStore()
|
const providersStore = useProviderStore()
|
||||||
const providerStore = useProviderConfigStore()
|
const providerConfigStore = useProviderConfigStore()
|
||||||
const speechStore = useSpeechStore()
|
const speechStore = useSpeechStore()
|
||||||
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ const providerMetadata = computedAsync(() => selectProviderMetadata(
|
|||||||
))
|
))
|
||||||
const fluxPurchaseDisabled = isFluxPurchaseDisabled()
|
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
|
// Model picker. The catalog and the default model id both come from the
|
||||||
// server's `/api/v1/audio/models/streaming` response (operator-controlled
|
// 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.
|
// adding ICL / other backends doesn't need a UI release.
|
||||||
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
|
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
|
||||||
const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false)
|
const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false)
|
||||||
const serverDefaultModel = ref<string | null>(null)
|
const serverDefaultModel = shallowRef<string | null>(null)
|
||||||
const streamingAvailable = ref(false)
|
const streamingAvailable = shallowRef(false)
|
||||||
const model = computed({
|
const model = computed(() => (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? '')
|
||||||
get(): string {
|
|
||||||
return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? ''
|
|
||||||
},
|
|
||||||
set(val: string) {
|
|
||||||
const config = providerConfig.value
|
|
||||||
if (config)
|
|
||||||
config.model = val
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const modelOptions = computed(() => providerModels.value.map(m => ({ label: m.name, value: m.id })))
|
const modelOptions = computed(() => providerModels.value.map(m => ({ label: m.name, value: m.id })))
|
||||||
|
|
||||||
const availableVoices = computed(() => speechStore.availableVoices[providerId] || [])
|
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() {
|
async function loadVoices() {
|
||||||
voicesLoading.value = true
|
voicesLoading.value = true
|
||||||
@@ -71,10 +68,11 @@ async function loadVoices() {
|
|||||||
watch(isAuthenticated, async (authenticated, _, onCleanup) => {
|
watch(isAuthenticated, async (authenticated, _, onCleanup) => {
|
||||||
let active = true
|
let active = true
|
||||||
onCleanup(() => active = false)
|
onCleanup(() => active = false)
|
||||||
streamingAvailable.value = false
|
if (!authenticated) {
|
||||||
serverDefaultModel.value = null
|
streamingAvailable.value = false
|
||||||
if (!authenticated)
|
serverDefaultModel.value = null
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await providersStore.initializeProvider(providerId)
|
await providersStore.initializeProvider(providerId)
|
||||||
if (!active)
|
if (!active)
|
||||||
@@ -87,8 +85,13 @@ watch(isAuthenticated, async (authenticated, _, onCleanup) => {
|
|||||||
// An absent value means that discovery failed before the server returned an
|
// An absent value means that discovery failed before the server returned an
|
||||||
// authoritative state. Keep the last configured state and availability
|
// authoritative state. Keep the last configured state and availability
|
||||||
// override so a transient request failure cannot hide the provider.
|
// 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
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const available = catalog.available
|
const available = catalog.available
|
||||||
await providersStore.setProviderAvailabilityOverride(providerId, 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
|
// first model in the same catalog response. Do not read synchronized model
|
||||||
// state here because its follower snapshot can arrive after the action.
|
// state here because its follower snapshot can arrive after the action.
|
||||||
serverDefaultModel.value = catalog.defaultModel ?? catalog.models[0]?.id ?? null
|
serverDefaultModel.value = catalog.defaultModel ?? catalog.models[0]?.id ?? null
|
||||||
const config = providerConfig.value
|
if (serverDefaultModel.value)
|
||||||
if (config && !config.model && serverDefaultModel.value)
|
await providerConfigStore.setProviderModelIfUnset(providerId, serverDefaultModel.value)
|
||||||
config.model = serverDefaultModel.value
|
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL
|
// Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL
|
||||||
@@ -222,10 +224,11 @@ function handleLogin() {
|
|||||||
<p>Pick the streaming TTS model variant. All variants share the same voice catalogue today.</p>
|
<p>Pick the streaming TTS model variant. All variants share the same voice catalogue today.</p>
|
||||||
</Callout>
|
</Callout>
|
||||||
<ComboboxSelect
|
<ComboboxSelect
|
||||||
v-model="model"
|
:model-value="model"
|
||||||
:options="modelOptions"
|
:options="modelOptions"
|
||||||
:disabled="modelsLoading || !providerConfig"
|
:disabled="modelsLoading || !providerConfig"
|
||||||
placeholder="Choose a model..."
|
placeholder="Choose a model..."
|
||||||
|
@update:model-value="setModel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { useIOTraceBridge } from '../../composables/use-io-trace-bridge'
|
|||||||
import { initIOTracer } from '../../composables/use-io-tracer'
|
import { initIOTracer } from '../../composables/use-io-tracer'
|
||||||
import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
|
import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
|
||||||
import { live2dMotionMagicProfiles, useLive2DMotionMagic, useLive2DMotionMagicSettings } from '../../features/motions/live2d'
|
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 { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
|
||||||
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
|
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
|
||||||
import { createStageTtsSession } from '../../libs/speech/tts-session'
|
import { createStageTtsSession } from '../../libs/speech/tts-session'
|
||||||
@@ -729,7 +729,9 @@ function stopSpeechOutput(reason: string) {
|
|||||||
*/
|
*/
|
||||||
function resolveStreamingSessionModel(): string | null {
|
function resolveStreamingSessionModel(): string | null {
|
||||||
const activeModel = activeSpeechModel.value as string | undefined
|
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('/'))
|
if (!sessionModel?.includes('/'))
|
||||||
return null
|
return null
|
||||||
return sessionModel
|
return sessionModel
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import './official'
|
|||||||
registerProviders(portableProviderDefinitions)
|
registerProviders(portableProviderDefinitions)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getDefaultStreamingModel,
|
|
||||||
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
|
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
|
||||||
} from './official'
|
} from './official'
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ export function getDefaultSpeechModel(): string | null {
|
|||||||
return defaultSpeechModelId
|
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({})
|
const officialConfigSchema = z.object({})
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
function authHeaders(): Record<string, string> {
|
||||||
@@ -57,22 +48,19 @@ async function listStreamingModelCatalog(): Promise<ProviderModelCatalog> {
|
|||||||
// (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `<backend>/<api_resource_id>`
|
// (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `<backend>/<api_resource_id>`
|
||||||
// (see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the
|
// (see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the
|
||||||
// server returns whatever the operator put there, no client-side defaults.
|
// 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() })
|
const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models/streaming`, { headers: authHeaders() })
|
||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
throw new Error(`streaming models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))
|
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 }
|
const data = await res.json() as {
|
||||||
if (!Array.isArray(data.models))
|
available: boolean
|
||||||
throw new Error('streaming models upstream missing models[]')
|
models: { id: string, name?: string, description?: string }[]
|
||||||
|
default: string | null
|
||||||
defaultStreamingModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: data.available === true,
|
available: data.available,
|
||||||
defaultModel: defaultStreamingModelId,
|
defaultModel: data.default ?? null,
|
||||||
models: data.models.map(m => ({
|
models: data.models.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name ?? m.id,
|
name: m.name ?? m.id,
|
||||||
|
|||||||
@@ -219,6 +219,37 @@ describe('speech store helpers', () => {
|
|||||||
expect(listVoices).not.toHaveBeenCalled()
|
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
|
* @example
|
||||||
* speechStore.ensureActiveSpeechModel()
|
* speechStore.ensureActiveSpeechModel()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { toXml } from 'xast-util-to-xml'
|
import { toXml } from 'xast-util-to-xml'
|
||||||
import { x } from 'xastscript'
|
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 { useProviderConfigStore } from '../providers/config'
|
||||||
import { useProviderStore } from '../providers/provider'
|
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 ''
|
// When no default can be resolved yet (catalog not loaded), clear it to ''
|
||||||
// so callers pass `undefined` (server returns the full streaming catalog)
|
// so callers pass `undefined` (server returns the full streaming catalog)
|
||||||
// rather than forwarding a stale non-streaming model id as `?model=`.
|
// 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)
|
if (activeSpeechModel.value === nextModel)
|
||||||
return
|
return
|
||||||
activeSpeechModel.value = nextModel
|
activeSpeechModel.value = nextModel
|
||||||
|
|||||||
@@ -153,6 +153,42 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
|
|||||||
provider.status = status
|
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<string, InferenceServiceProvider>) {
|
function mergeProviderSnapshot(snapshot: Record<string, InferenceServiceProvider>) {
|
||||||
providers.value = { ...providers.value, ...snapshot }
|
providers.value = { ...providers.value, ...snapshot }
|
||||||
for (const providerId of Object.keys(snapshot))
|
for (const providerId of Object.keys(snapshot))
|
||||||
@@ -252,6 +288,8 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
|
|||||||
markProviderAdded,
|
markProviderAdded,
|
||||||
unmarkProviderAdded,
|
unmarkProviderAdded,
|
||||||
setProviderStatus,
|
setProviderStatus,
|
||||||
|
setProviderModel,
|
||||||
|
setProviderModelIfUnset,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
addProvider,
|
addProvider,
|
||||||
removeProvider,
|
removeProvider,
|
||||||
@@ -266,6 +304,8 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
|
|||||||
'markProviderAdded',
|
'markProviderAdded',
|
||||||
'unmarkProviderAdded',
|
'unmarkProviderAdded',
|
||||||
'setProviderStatus',
|
'setProviderStatus',
|
||||||
|
'setProviderModel',
|
||||||
|
'setProviderModelIfUnset',
|
||||||
'addProvider',
|
'addProvider',
|
||||||
'removeProvider',
|
'removeProvider',
|
||||||
'updateProviderConfig',
|
'updateProviderConfig',
|
||||||
|
|||||||
@@ -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'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe('provider store synchronization boundary', () => {
|
|||||||
const store = useProviderStore()
|
const store = useProviderStore()
|
||||||
const runtimeState = {
|
const runtimeState = {
|
||||||
models: [],
|
models: [],
|
||||||
|
defaultModel: null,
|
||||||
modelStatus: 'ready' as const,
|
modelStatus: 'ready' as const,
|
||||||
modelError: null,
|
modelError: null,
|
||||||
}
|
}
|
||||||
@@ -205,6 +206,7 @@ describe('provider store synchronization boundary', () => {
|
|||||||
|
|
||||||
store.providerRuntimeState['official-provider'] = {
|
store.providerRuntimeState['official-provider'] = {
|
||||||
models: [],
|
models: [],
|
||||||
|
defaultModel: null,
|
||||||
modelStatus: 'loading',
|
modelStatus: 'loading',
|
||||||
modelError: null,
|
modelError: null,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type { ModelInfo, VoiceInfo } from '../../libs/providers/types'
|
|||||||
export interface ProviderRuntimeState {
|
export interface ProviderRuntimeState {
|
||||||
validatedCredentialHash?: string
|
validatedCredentialHash?: string
|
||||||
models: ModelInfo[]
|
models: ModelInfo[]
|
||||||
|
defaultModel: string | null
|
||||||
modelStatus: 'idle' | 'loading' | 'ready' | 'error'
|
modelStatus: 'idle' | 'loading' | 'ready' | 'error'
|
||||||
modelError: string | null
|
modelError: string | null
|
||||||
}
|
}
|
||||||
@@ -365,6 +366,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
if (!providerRuntimeState.value[providerId]) {
|
if (!providerRuntimeState.value[providerId]) {
|
||||||
providerRuntimeState.value[providerId] = {
|
providerRuntimeState.value[providerId] = {
|
||||||
models: [],
|
models: [],
|
||||||
|
defaultModel: null,
|
||||||
modelStatus: 'idle',
|
modelStatus: 'idle',
|
||||||
modelError: null,
|
modelError: null,
|
||||||
}
|
}
|
||||||
@@ -652,6 +654,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
[providerId]: {
|
[providerId]: {
|
||||||
...currentRuntimeState,
|
...currentRuntimeState,
|
||||||
models: normalizedModels,
|
models: normalizedModels,
|
||||||
|
defaultModel: catalog.defaultModel ?? null,
|
||||||
modelStatus: 'ready',
|
modelStatus: 'ready',
|
||||||
modelError: null,
|
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
|
return providerRuntimeState.value[providerId]?.models ?? emptyProviderModels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDefaultModelForProvider = computed(() => (providerId: string) => {
|
||||||
|
return providerRuntimeState.value[providerId]?.defaultModel ?? null
|
||||||
|
})
|
||||||
|
|
||||||
// Load models for all configured providers
|
// Load models for all configured providers
|
||||||
async function loadModelsForConfiguredProviders() {
|
async function loadModelsForConfiguredProviders() {
|
||||||
for (const providerId of availableProviders.value) {
|
for (const providerId of availableProviders.value) {
|
||||||
@@ -1000,6 +1009,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
modelLoadError,
|
modelLoadError,
|
||||||
fetchModelsForProvider,
|
fetchModelsForProvider,
|
||||||
getModelsForProvider,
|
getModelsForProvider,
|
||||||
|
getDefaultModelForProvider,
|
||||||
listProviderVoices,
|
listProviderVoices,
|
||||||
loadProviderModel,
|
loadProviderModel,
|
||||||
loadModelsForConfiguredProviders,
|
loadModelsForConfiguredProviders,
|
||||||
|
|||||||
@@ -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({
|
export const unspeechUpstreamSchema = object({
|
||||||
|
|||||||
@@ -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 () => {
|
it('wraps database failures as CONFIG_UNAVAILABLE', async () => {
|
||||||
store.getRaw.mockRejectedValueOnce(new Error('database offline'))
|
store.getRaw.mockRejectedValueOnce(new Error('database offline'))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user