feat(server): add default TTS model handling and update related tests

This commit is contained in:
RainbowBird
2026-06-28 16:55:09 +08:00
parent 1065ed565b
commit 8036501fb0
5 changed files with 63 additions and 15 deletions
@@ -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,
})
}
@@ -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 () => {
@@ -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<string, Record<string, string>> = {}
// 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<ModelInfo[]> => {
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,
@@ -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()
}
})
})
@@ -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()
}