From 95915923e15adb42a3ace55af9f731206ac63b5c Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 27 May 2026 18:23:25 +0800 Subject: [PATCH] feat(server): enhance TTS configuration with support for default voices --- .../src/routes/admin/config/router/index.ts | 35 +++++++++++---- .../adapters/tts/dashscope-cosyvoice.test.ts | 24 +++++----- .../adapters/tts/dashscope-cosyvoice.ts | 25 +++++------ .../src/services/adapters/tts/index.test.ts | 6 +-- .../domain/admin/router-config/index.ts | 8 ++++ .../tests/admin-router-config.test.ts | 44 ++++++++++++++++++- 6 files changed, 105 insertions(+), 37 deletions(-) diff --git a/apps/server/src/routes/admin/config/router/index.ts b/apps/server/src/routes/admin/config/router/index.ts index 5d1a9d7d1..fa11e0b6c 100644 --- a/apps/server/src/routes/admin/config/router/index.ts +++ b/apps/server/src/routes/admin/config/router/index.ts @@ -7,12 +7,12 @@ import { boolean, literal, maxLength, - minLength, nonEmpty, object, optional, picklist, pipe, + record, regex, safeParse, string, @@ -109,14 +109,23 @@ const SliceSchema = variant('kind', [ const BodySchema = object({ mode: optional(picklist(['merge', 'reset']), 'merge'), dryRun: optional(boolean(), false), - slices: pipe( - array(SliceSchema), - minLength(1, 'slices must not be empty'), - maxLength(MAX_SLICES_PER_REQUEST, `slices must be at most ${MAX_SLICES_PER_REQUEST} entries`), + slices: optional( + pipe( + array(SliceSchema), + maxLength(MAX_SLICES_PER_REQUEST, `slices must be at most ${MAX_SLICES_PER_REQUEST} entries`), + ), + [], ), defaults: optional(object({ chatModel: optional(pipe(string(), nonEmpty('defaults.chatModel must not be empty'), maxLength(200))), ttsModel: optional(pipe(string(), nonEmpty('defaults.ttsModel must not be empty'), maxLength(200))), + ttsVoices: optional(record( + pipe(string(), nonEmpty('defaults.ttsVoices model id must not be empty'), maxLength(200)), + record( + pipe(string(), nonEmpty('defaults.ttsVoices locale must not be empty'), maxLength(50)), + pipe(string(), nonEmpty('defaults.ttsVoices voice id must not be empty'), maxLength(200)), + ), + )), })), }) @@ -132,7 +141,7 @@ const BodySchema = object({ * "mode": "merge" | "reset", // defaults to "merge" * "dryRun": false, // when true, returns redacted preview * // and skips writes + invalidation - * "slices": [ + * "slices": [ // optional when only defaults change * { "kind": "openrouter", "modelName": "chat-default", * "overrideModel": "openai/gpt-4o-mini", "plaintextKey": "..." }, * { "kind": "azure", "modelName": "microsoft/v1", @@ -149,7 +158,12 @@ const BodySchema = object({ * ], * "defaults": { * "chatModel": "chat-default", // writes DEFAULT_CHAT_MODEL - * "ttsModel": "alibaba/cosyvoice-v2" // writes DEFAULT_TTS_MODEL + * "ttsModel": "alibaba/cosyvoice-v2", // writes DEFAULT_TTS_MODEL + * "ttsVoices": { // writes DEFAULT_TTS_VOICES + * "alibaba/cosyvoice-v2": { + * "zh-CN": "longxiaochun_v2" + * } + * } * } * } * @@ -162,7 +176,8 @@ const BodySchema = object({ * "LLM_ROUTER_CONFIG": { ... }, * "UNSPEECH_UPSTREAM": { ... }, * "DEFAULT_CHAT_MODEL": "chat-default", - * "DEFAULT_TTS_MODEL": "alibaba/cosyvoice-v2" + * "DEFAULT_TTS_MODEL": "alibaba/cosyvoice-v2", + * "DEFAULT_TTS_VOICES": { ... } * } * } * @@ -198,6 +213,10 @@ export function createAdminRouterConfigRoutes( } const body = parsed.output + const hasDefaults = body.defaults != null && Object.keys(body.defaults).length > 0 + if (body.slices.length === 0 && !hasDefaults) + throw createBadRequestError('Request body must include at least one slice or defaults entry', 'INVALID_BODY') + const result = await service.apply({ mode: body.mode, dryRun: body.dryRun, diff --git a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts index ba4010290..f125c76d0 100644 --- a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts +++ b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts @@ -57,7 +57,7 @@ describe('dashscopeCosyvoiceAdapter', () => { await expect( dashscopeCosyvoiceAdapter.send( - { text: 'hi' }, + { text: 'hi', voice: 'longxiaochun_v2' }, { keyPlaintext: Buffer.from('sk-test', 'utf8'), baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', @@ -71,10 +71,10 @@ describe('dashscopeCosyvoiceAdapter', () => { expect(fetchImpl).toHaveBeenCalledTimes(1) }) - it('falls back to cosyvoice-v2 + longxiaochun_v2 when caller omits model / voice', async () => { + it('rejects missing voice instead of hardcoding a model-specific default', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0]))) - await dashscopeCosyvoiceAdapter.send( + await expect(dashscopeCosyvoiceAdapter.send( { text: 'hi' }, { keyPlaintext: Buffer.from('sk-test', 'utf8'), @@ -83,15 +83,12 @@ describe('dashscopeCosyvoiceAdapter', () => { adapterParams: {}, fetchImpl: fetchImpl as unknown as typeof fetch, }, - ) + )).rejects.toMatchObject({ statusCode: 400 }) - const body = JSON.parse(fetchImpl.mock.calls[0][1].body as string) - expect(body.model).toBe('alibaba/cosyvoice-v2') - expect(body.voice).toBe('longxiaochun_v2') - expect(body.response_format).toBe('mp3') + expect(fetchImpl).not.toHaveBeenCalled() }) - it('voice catalog is proxied through unspeech (alibaba backend)', async () => { + it('voice catalog is proxied through unspeech with the selected cosyvoice model', async () => { // The catalog itself is unspeech-owned now (embedded JSON in // unspeech/pkg/backend/alibaba/voices.go). This test only verifies the // wire contract — fixture content is intentionally minimal so an @@ -100,10 +97,17 @@ describe('dashscopeCosyvoiceAdapter', () => { voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }], }), { status: 200 })) as unknown as typeof fetch const catalog = await dashscopeCosyvoiceAdapter.getVoiceCatalog({ - adapterParams: {}, + adapterParams: { model: 'cosyvoice-v2' }, unspeechBaseURL: UNSPEECH, fetchImpl, }) expect(catalog).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }]) + expect(fetchImpl).toHaveBeenCalledWith( + `${UNSPEECH}/api/voices?provider=alibaba&model=cosyvoice-v2`, + expect.objectContaining({ + method: 'GET', + headers: { Accept: 'application/json' }, + }), + ) }) }) diff --git a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts index 271e27506..1300499d6 100644 --- a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts +++ b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts @@ -4,17 +4,7 @@ import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalo import { errorMessageFrom } from '@moeru/std' -import { createBadGatewayError, createInternalError } from '../../../utils/error' - -/** - * Default DashScope cosyvoice voice id. v2 voice ids carry an explicit `_v2` - * suffix; `longxiaochun_v2` is the general-purpose Chinese assistant voice - * called out in Alibaba's voice list. - * - * NOTICE: - * Hardcoded near use; promote when ops want per-tenant defaults. - */ -const DEFAULT_COSYVOICE_VOICE = 'longxiaochun_v2' +import { createBadGatewayError, createBadRequestError, createInternalError } from '../../../utils/error' /** * Default cosyvoice audio format. Mirrors the OpenAI `mp3` default expected by @@ -28,8 +18,8 @@ const DEFAULT_COSYVOICE_FORMAT = 'mp3' * v2 is the most conservative current default and shares a request body shape * with v3/v3.5 so ops can retarget via `adapterParams.model` without code. * NOTICE: - * If you bump this past v2, verify the chosen `DEFAULT_COSYVOICE_VOICE` exists - * for that model — voice catalogs differ between v2 (`*_v2`) and v3 (`*_v3`). + * If you bump this past v2, verify the configured default voice exists for + * that model — voice catalogs differ between v2 (`*_v2`) and v3 (`*_v3`). */ const DEFAULT_COSYVOICE_MODEL = 'cosyvoice-v2' @@ -63,7 +53,9 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = { const model = typeof ctx.adapterParams.model === 'string' ? ctx.adapterParams.model : DEFAULT_COSYVOICE_MODEL - const voice = input.voice ?? DEFAULT_COSYVOICE_VOICE + if (!input.voice) + throw createBadRequestError('dashscope-cosyvoice voice is required', 'BAD_REQUEST') + const voice = input.voice const format = input.responseFormat ?? DEFAULT_COSYVOICE_FORMAT const body = JSON.stringify({ @@ -109,7 +101,10 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = { // (unspeech/pkg/backend/alibaba/voices.go `//go:embed voices.json`), // so this call is in-memory on unspeech's side and only crosses a TCP // hop. No upstream credential is required. - const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?provider=alibaba` + const params = new URLSearchParams({ provider: 'alibaba' }) + if (typeof ctx.adapterParams.model === 'string') + params.set('model', ctx.adapterParams.model) + const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?${params.toString()}` let response: Response try { diff --git a/apps/server/src/services/adapters/tts/index.test.ts b/apps/server/src/services/adapters/tts/index.test.ts index 8d327ab7c..1503c215f 100644 --- a/apps/server/src/services/adapters/tts/index.test.ts +++ b/apps/server/src/services/adapters/tts/index.test.ts @@ -61,21 +61,21 @@ describe('getAdapter', () => { }) describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => { - it('calls unspeech with provider=alibaba (no Bearer)', async () => { + it('calls unspeech with provider=alibaba + model (no Bearer)', async () => { const adapter = getAdapter('dashscope-cosyvoice') const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }], }), { status: 200 })) as unknown as typeof fetch const voices = await adapter.getVoiceCatalog({ - adapterParams: {}, + adapterParams: { model: 'cosyvoice-v2' }, unspeechBaseURL: 'http://unspeech.local', fetchImpl, }) expect(voices).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }]) const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0] - expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=alibaba') + expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=alibaba&model=cosyvoice-v2') const headers = (init.headers ?? {}) as Record expect(headers.Authorization).toBeUndefined() }) diff --git a/apps/server/src/services/domain/admin/router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts index ec5e10c8b..2c31b93af 100644 --- a/apps/server/src/services/domain/admin/router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -375,6 +375,7 @@ export interface ApplyInput { defaults?: { chatModel?: string ttsModel?: string + ttsVoices?: Record> } /** Admin user id for audit logging only. Not part of the persisted config. */ actorUserId?: string @@ -396,6 +397,7 @@ export interface ApplyResult { UNSPEECH_UPSTREAM?: unknown DEFAULT_CHAT_MODEL?: string DEFAULT_TTS_MODEL?: string + DEFAULT_TTS_VOICES?: Record> } } @@ -488,6 +490,8 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) { preview.DEFAULT_CHAT_MODEL = input.defaults.chatModel if (input.defaults?.ttsModel) preview.DEFAULT_TTS_MODEL = input.defaults.ttsModel + if (input.defaults?.ttsVoices) + preview.DEFAULT_TTS_VOICES = input.defaults.ttsVoices const applied: AppliedSummary[] = built.map(s => s.target === 'unspeech' ? { kind: s.kind, target: s.target, keyEntryId: s.keyEntryId } @@ -522,6 +526,10 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) { await deps.configKV.set('DEFAULT_TTS_MODEL', input.defaults.ttsModel) invalidatedKeys.push('DEFAULT_TTS_MODEL') } + if (input.defaults?.ttsVoices) { + await deps.configKV.set('DEFAULT_TTS_VOICES', input.defaults.ttsVoices) + invalidatedKeys.push('DEFAULT_TTS_VOICES') + } // Step 5: cross-instance invalidation. audio-speech-ws reads // UNSPEECH_UPSTREAM.streaming fresh on every connection so the publish diff --git a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts index d8d18c8a9..0a6ef6a99 100644 --- a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts +++ b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts @@ -308,7 +308,12 @@ describe('createAdminRouterConfigService', () => { overrideModel: 'openai/gpt-4o-mini', plaintextKey: 'sk-or-secret', }], - defaults: { chatModel: 'chat-default' }, + defaults: { + chatModel: 'chat-default', + ttsVoices: { + 'alibaba/cosyvoice-v2': { 'zh-CN': 'longxiaochun_v2' }, + }, + }, }) expect(kv.store.size).toBe(0) @@ -323,6 +328,9 @@ describe('createAdminRouterConfigService', () => { expect(ct).not.toContain('sk-or-secret') expect(result.preview.DEFAULT_CHAT_MODEL).toBe('chat-default') + expect(result.preview.DEFAULT_TTS_VOICES).toEqual({ + 'alibaba/cosyvoice-v2': { 'zh-CN': 'longxiaochun_v2' }, + }) }) it('writes LLM_ROUTER_CONFIG, DEFAULT_CHAT_MODEL, and publishes invalidation', async () => { @@ -345,6 +353,40 @@ describe('createAdminRouterConfigService', () => { expect(captured.map(p => JSON.parse(p.payload).key).sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG']) }) + it('writes DEFAULT_TTS_VOICES without requiring provider slices', async () => { + const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis }) + const result = await service.apply({ + mode: 'merge', + dryRun: false, + slices: [], + defaults: { + ttsVoices: { + 'alibaba/cosyvoice-v2': { + 'zh-CN': 'longxiaochun_v2', + 'en-US': 'loongava_v2', + }, + 'volcengine/seed-tts-2.0': { + 'zh-CN': 'zh_female_vv_uranus_bigtts', + }, + }, + }, + }) + + expect(kv.store.get('DEFAULT_TTS_VOICES')).toEqual({ + 'alibaba/cosyvoice-v2': { + 'zh-CN': 'longxiaochun_v2', + 'en-US': 'loongava_v2', + }, + 'volcengine/seed-tts-2.0': { + 'zh-CN': 'zh_female_vv_uranus_bigtts', + }, + }) + expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false) + expect(result.preview.DEFAULT_TTS_VOICES).toEqual(kv.store.get('DEFAULT_TTS_VOICES')) + expect(result.invalidatedKeys).toEqual(['DEFAULT_TTS_VOICES']) + expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['DEFAULT_TTS_VOICES']) + }) + it('writes UNSPEECH_UPSTREAM and publishes invalidation when an unspeech slice is included', async () => { const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis }) const result = await service.apply({