feat(server): enhance TTS configuration with support for default voices

This commit is contained in:
RainbowBird
2026-05-28 16:59:08 +08:00
parent 92e0f9be24
commit 95915923e1
6 changed files with 105 additions and 37 deletions
@@ -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,
@@ -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' },
}),
)
})
})
@@ -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 {
@@ -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<string, string>
expect(headers.Authorization).toBeUndefined()
})
@@ -375,6 +375,7 @@ export interface ApplyInput {
defaults?: {
chatModel?: string
ttsModel?: string
ttsVoices?: Record<string, Record<string, string>>
}
/** 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<string, Record<string, string>>
}
}
@@ -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
@@ -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({