diff --git a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts index 6db7ccabb..d886fa117 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts @@ -125,8 +125,11 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp async function listSpeechModels() { // Surface the concrete TTS models the operator has configured. The UI // should select an explicit model id so voice catalog requests stay - // model-scoped instead of hiding behind DEFAULT_TTS_MODEL. + // model-scoped instead of hiding behind DEFAULT_TTS_MODEL. The `default` + // field lets the initial client selection mirror the same server-side + // alias that `/audio/speech` uses for `model: "auto"`. const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') + const defaultModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL') // `LLM_ROUTER_CONFIG` is `optional()` at the schema, so its inferred type // tolerates `undefined`. `getOrThrow` already throws on missing entries, // so by this line we know `config` is present — the `?.` here is purely @@ -134,6 +137,7 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp const modelIds = Object.keys(config?.tts?.models ?? {}).sort() return Response.json({ models: modelIds.map(id => ({ id, name: id })), + default: defaultModel, }) } diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 4e050c234..ff115c2e3 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -1134,6 +1134,7 @@ describe('v1CompletionsRoutes', () => { const app = createTestApp( createMockFluxService(), createMockConfigKV({ + DEFAULT_TTS_MODEL: 'microsoft/v1', LLM_ROUTER_CONFIG: { llm: { models: {} }, tts: { @@ -1152,11 +1153,12 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(200) - const data = await res.json() as { models: { id: string, name: string }[] } + const data = await res.json() as { models: { id: string, name: string }[], default: string } expect(data.models.map(m => m.id)).toEqual([ 'alibaba/cosyvoice-v2', 'microsoft/v1', ]) + expect(data.default).toBe('microsoft/v1') }) it('returns an empty list when no tts models are configured', async () => { 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 2494b685b..f4bc0d2c4 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -22,6 +22,15 @@ export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcripti // first-voice matching when the server returns no recommendations. const recommendedVoicesByProvider: Record> = {} +// Server-curated default HTTP speech model id, populated by the HTTP speech +// provider's listModels(). The speech store uses this when it needs to seed an +// empty/stale model selection, so the UI mirrors `/audio/speech` `model: auto`. +let defaultSpeechModelId: string | null = null + +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. @@ -117,14 +126,17 @@ export const providerOfficialSpeech = defineProvider({ validationRequiredWhen: () => false, extraMethods: { listModels: async (): Promise => { + defaultSpeechModelId = null const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models`, { headers: authHeaders() }) if (!res.ok) throw new Error(`audio models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256)) - const data = await res.json() as { models?: { id: string, name: string }[] } + const data = await res.json() as { models?: { id: string, name: string }[], default?: string | null } if (!Array.isArray(data.models)) throw new Error('audio models upstream returned malformed body') + defaultSpeechModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null + return data.models.map(m => ({ id: m.id, name: m.name, diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index efdcc0a7a..07ac9e311 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -1,7 +1,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -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, providerOfficialSpeech } from '../../libs/providers/providers/official' import { useProvidersStore } from '../providers' import { toSignedPercent, useSpeechStore, voicePackForSpeechProvider } from './speech' @@ -289,7 +289,29 @@ describe('speech store helpers', () => { * @example * speechStore.ensureActiveSpeechModel() */ - it('resets stale streaming model when the regular official speech provider is active', () => { + it('resets stale streaming model to the server default when the regular official speech provider is active', async () => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }) + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString() + if (url.includes('/api/v1/audio/models')) { + return new Response(JSON.stringify({ + models: [ + { id: 'alibaba/cosyvoice-v2', name: 'alibaba/cosyvoice-v2' }, + { id: 'microsoft/v1', name: 'microsoft/v1' }, + ], + default: 'microsoft/v1', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ voices: [], recommended: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch) + const providersStore = useProvidersStore() const speechStore = useSpeechStore() speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID @@ -301,15 +323,20 @@ describe('speech store helpers', () => { provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, languages: [], } - providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = [ - { id: 'microsoft/v1', name: 'microsoft/v1', provider: OFFICIAL_SPEECH_PROVIDER_ID }, - { id: 'alibaba/cosyvoice-v2', name: 'alibaba/cosyvoice-v2', provider: OFFICIAL_SPEECH_PROVIDER_ID }, - ] + try { + providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!( + {}, + providerOfficialSpeech.createProvider({}), + ) - speechStore.ensureActiveSpeechModel() + speechStore.ensureActiveSpeechModel() - expect(speechStore.activeSpeechModel).toBe('microsoft/v1') - expect(speechStore.activeSpeechVoiceId).toBe('') - expect(speechStore.activeSpeechVoice).toBeUndefined() + expect(speechStore.activeSpeechModel).toBe('microsoft/v1') + expect(speechStore.activeSpeechVoiceId).toBe('') + expect(speechStore.activeSpeechVoice).toBeUndefined() + } + finally { + vi.unstubAllGlobals() + } }) }) diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index e71b1b13c..9af4e0c5e 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -13,7 +13,7 @@ import { useI18n } from 'vue-i18n' import { toXml } from 'xast-util-to-xml' import { x } from 'xastscript' -import { getDefaultStreamingModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official' +import { getDefaultSpeechModel, getDefaultStreamingModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official' import { useProvidersStore } from '../providers' export function toSignedPercent(value: number): string { @@ -286,7 +286,10 @@ export const useSpeechStore = defineStore('speech', () => { if (hasValidSelection) return - activeSpeechModel.value = models[0]?.id ?? '' + const defaultModel = getDefaultSpeechModel() + activeSpeechModel.value = defaultModel && models.some(m => m.id === defaultModel) + ? defaultModel + : models[0]?.id ?? '' clearVoiceSelection() }