feat(server): enhance TTS configuration with support for default voices
This commit is contained in:
@@ -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
|
||||
|
||||
+43
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user