feat(server): server-controlled default streaming TTS model
The streaming speech provider settings page was seeding its model picker with a hardcoded `volcengine/seed-tts-2.0`, which contradicts the just-landed "streaming model catalog comes from the server" contract. Now the default also comes from configKV. - `UNSPEECH_UPSTREAM.streaming` gains `defaultModel?: string`. - `GET /api/v1/audio/models/streaming` response gains `default: string | null` reading that field. - admin slice apply: `streaming.defaultModel` survives key/URL rotation alongside `streaming.models`. - `providerOfficialSpeechStreaming.listModels` stashes the value in a module-scope ref, exposed via `getDefaultStreamingModel()`. - Streaming speech settings page reads the helper instead of the hardcoded id; falls back to the first server-returned model if the operator hasn't curated a default. `handleGenerateSpeech` no longer has a `seed-tts-2.0` backstop — a missing backend prefix in the selected model id now throws instead of silently defaulting.
This commit is contained in:
@@ -91,6 +91,12 @@ const UnspeechSliceSchema = object({
|
||||
),
|
||||
plaintextKey: pipe(string(), nonEmpty('streaming.plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
models: optional(array(object({
|
||||
id: pipe(string(), nonEmpty('streaming.models[].id is required'), maxLength(200)),
|
||||
name: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
description: optional(pipe(string(), nonEmpty(), maxLength(500))),
|
||||
}))),
|
||||
defaultModel: optional(pipe(string(), nonEmpty('streaming.defaultModel must not be empty'), maxLength(200))),
|
||||
})),
|
||||
})
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ export function createV1Routes(
|
||||
logger.withError(err).warn('Failed to close stream writer')
|
||||
}
|
||||
|
||||
// Extract usage from final SSE data lines
|
||||
let usage: UsageInfo = {}
|
||||
try {
|
||||
const lines = tailBuffer.split('\n').filter(l => l.startsWith('data: ') && !l.includes('[DONE]'))
|
||||
@@ -702,14 +701,9 @@ export function createV1Routes(
|
||||
}
|
||||
|
||||
async function handleListTTSModels(_c: Context<HonoEnv>) {
|
||||
// Surface the concrete TTS models the operator has configured plus the
|
||||
// `auto` alias. Clients need real model ids to pass `?model=<id>` to
|
||||
// `/audio/voices`, otherwise the voice catalog endpoint can never resolve
|
||||
// anything beyond the DEFAULT_TTS_MODEL catalog — which is the bug that
|
||||
// hid the Azure voices from the UI.
|
||||
//
|
||||
// `auto` is kept on top as an explicit "use the operator default" knob
|
||||
// for clients that don't care which concrete model handles them.
|
||||
// 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.
|
||||
const config = await configKV.getOrThrow('LLM_ROUTER_CONFIG')
|
||||
// `LLM_ROUTER_CONFIG` is `optional()` at the schema, so its inferred type
|
||||
// tolerates `undefined`. `getOrThrow` already throws on missing entries,
|
||||
@@ -717,29 +711,32 @@ export function createV1Routes(
|
||||
// a TS narrowing aid.
|
||||
const modelIds = Object.keys(config?.tts?.models ?? {}).sort()
|
||||
return Response.json({
|
||||
models: [
|
||||
{ id: 'auto', name: 'Auto' },
|
||||
...modelIds.map(id => ({ id, name: id })),
|
||||
],
|
||||
models: modelIds.map(id => ({ id, name: id })),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleListStreamingTTSModels(_c: Context<HonoEnv>) {
|
||||
const unspeech = await configKV.getOptional('UNSPEECH_UPSTREAM')
|
||||
const models = unspeech?.streaming?.models ?? []
|
||||
// `available` is the operator-controlled visibility switch the client gates
|
||||
// the streaming provider on. It tracks whether `UNSPEECH_UPSTREAM.streaming`
|
||||
// is configured at all — not whether `models[]` happens to be empty — so an
|
||||
// operator who has wired the upstream but not yet curated models still
|
||||
// surfaces the provider rather than silently hiding it.
|
||||
return Response.json({
|
||||
available: !!unspeech?.streaming?.baseURL,
|
||||
models: models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name ?? m.id,
|
||||
description: m.description,
|
||||
})),
|
||||
default: unspeech?.streaming?.defaultModel ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const chatGuard = configGuard(configKV, ['FLUX_PER_REQUEST'], 'Service is not available yet')
|
||||
const ttsGuard = configGuard(configKV, ['FLUX_PER_1K_CHARS_TTS'], 'TTS service is not available yet')
|
||||
|
||||
// 60 requests per minute per user for LLM completions
|
||||
const completionsRateLimit = rateLimiter({ max: 60, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'openai.completions' })
|
||||
|
||||
// OpenAI-compatible surface (mounted at /api/v1/openai). Only routes that
|
||||
|
||||
@@ -11,8 +11,6 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { createV1Routes } from '.'
|
||||
import { ApiError } from '../../../utils/error'
|
||||
|
||||
// --- Mock helpers ---
|
||||
|
||||
function createMockFluxService(flux = 100): FluxService {
|
||||
return {
|
||||
getFlux: vi.fn(async () => ({ userId: 'user-1', flux })),
|
||||
@@ -165,8 +163,6 @@ function createTestApp(
|
||||
|
||||
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('v1CompletionsRoutes', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
@@ -319,12 +315,10 @@ describe('v1CompletionsRoutes', () => {
|
||||
const data = await res.json() as { id: string }
|
||||
expect(data.id).toBe('chatcmpl-1')
|
||||
|
||||
// Verify flux was debited via billingService
|
||||
expect(billingService.consumeFluxForLLM).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-1', amount: 1 }),
|
||||
)
|
||||
|
||||
// Verify upstream was called with correct URL and resolved model
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://mock-gateway/chat/completions',
|
||||
expect.objectContaining({
|
||||
@@ -406,13 +400,11 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
// Post-billing: no charge on failed requests
|
||||
expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 503 when config keys are missing', async () => {
|
||||
const configKV = createMockConfigKV()
|
||||
// Override getOptional to return null for required keys
|
||||
configKV.getOptional = vi.fn(async () => null)
|
||||
|
||||
const app = createTestApp(createMockFluxService(), configKV)
|
||||
@@ -748,7 +740,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
})
|
||||
|
||||
describe('gET /api/v1/audio/models', () => {
|
||||
it('exposes auto alias plus every configured tts model id', async () => {
|
||||
it('exposes every configured tts model id', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -771,14 +763,13 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: { id: string, name: string }[] }
|
||||
expect(data.models[0]).toEqual({ id: 'auto', name: 'Auto' })
|
||||
expect(data.models.slice(1).map(m => m.id)).toEqual([
|
||||
expect(data.models.map(m => m.id)).toEqual([
|
||||
'alibaba/cosyvoice-v2',
|
||||
'microsoft/v1',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns only the auto alias when no tts models are configured', async () => {
|
||||
it('returns an empty list when no tts models are configured', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -793,7 +784,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: { id: string, name: string }[] }
|
||||
expect(data.models).toEqual([{ id: 'auto', name: 'Auto' }])
|
||||
expect(data.models).toEqual([])
|
||||
})
|
||||
|
||||
it('should return 401 when unauthenticated', async () => {
|
||||
@@ -805,7 +796,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
})
|
||||
|
||||
describe('gET /api/v1/audio/models/streaming', () => {
|
||||
it('returns the operator-configured streaming model catalog', async () => {
|
||||
it('returns the operator-configured streaming model catalog + default', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -818,6 +809,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Volcengine Seed-TTS 2.0', description: 'TTS 2.0' },
|
||||
{ id: 'volcengine/seed-tts-1.0' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -829,11 +821,37 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: { id: string, name: string, description?: string }[] }
|
||||
const data = await res.json() as { available: boolean, models: { id: string, name: string, description?: string }[], default: string | null }
|
||||
expect(data.available).toBe(true)
|
||||
expect(data.models).toEqual([
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Volcengine Seed-TTS 2.0', description: 'TTS 2.0' },
|
||||
{ id: 'volcengine/seed-tts-1.0', name: 'volcengine/seed-tts-1.0' },
|
||||
])
|
||||
expect(data.default).toBe('volcengine/seed-tts-2.0')
|
||||
})
|
||||
|
||||
it('returns default: null when operator has not set a streaming default', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
UNSPEECH_UPSTREAM: {
|
||||
restBaseURL: 'http://unspeech.local:5933',
|
||||
streaming: {
|
||||
baseURL: 'wss://unspeech.local',
|
||||
keys: [{ id: 'k1', ciphertext: 'enc' }],
|
||||
models: [{ id: 'volcengine/seed-tts-2.0', name: 'Vol' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/audio/models/streaming', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
const data = await res.json() as { default: string | null }
|
||||
expect(data.default).toBeNull()
|
||||
})
|
||||
|
||||
it('returns an empty list when UNSPEECH_UPSTREAM is unset', async () => {
|
||||
@@ -845,11 +863,12 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: unknown[] }
|
||||
const data = await res.json() as { available: boolean, models: unknown[] }
|
||||
expect(data.available).toBe(false)
|
||||
expect(data.models).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty list when streaming subtree has no models', async () => {
|
||||
it('reports available: true with empty models when streaming subtree has no models', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -869,7 +888,8 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: unknown[] }
|
||||
const data = await res.json() as { available: boolean, models: unknown[] }
|
||||
expect(data.available).toBe(true)
|
||||
expect(data.models).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ describe('configKVService', () => {
|
||||
service = createConfigKVService(redis as any)
|
||||
})
|
||||
|
||||
// --- get ---
|
||||
|
||||
it('get should throw 503 when key is not set', async () => {
|
||||
await expect(service.getOrThrow('FLUX_PER_1K_CHARS_TTS'))
|
||||
.rejects
|
||||
@@ -43,8 +41,6 @@ describe('configKVService', () => {
|
||||
expect(redis.get).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'))
|
||||
})
|
||||
|
||||
// --- getOptional ---
|
||||
|
||||
it('getOptional should return schema default when key has one', async () => {
|
||||
const value = await service.getOptional('FLUX_PER_REQUEST')
|
||||
expect(value).toBe(5)
|
||||
@@ -62,7 +58,35 @@ describe('configKVService', () => {
|
||||
expect(value).toBe(200)
|
||||
})
|
||||
|
||||
// --- set ---
|
||||
it('getOptional should throw CONFIG_INVALID when Redis contains malformed JSON', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// If an operator edits config:LLM_ROUTER_CONFIG directly with invalid JSON,
|
||||
// JSON.parse used to throw SyntaxError through the request handler and log
|
||||
// it as an unhandled 500.
|
||||
//
|
||||
// We fixed this by translating stored config parse/validation failures into
|
||||
// a stable API error at the configKV boundary.
|
||||
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), '{"llm":{}')
|
||||
|
||||
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('getOptional should throw CONFIG_INVALID when Redis contains schema-invalid JSON', async () => {
|
||||
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), JSON.stringify('5'))
|
||||
|
||||
await expect(service.getOptional('FLUX_PER_REQUEST'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('set should write value to Redis with prefix', async () => {
|
||||
await service.set('FLUX_PER_REQUEST', 10)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Redis from 'ioredis'
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { any, array, boolean, check, nonEmpty, number, object, optional, parse, picklist, pipe, record, regex, string } from 'valibot'
|
||||
|
||||
import { createServiceUnavailableError } from '../../utils/error'
|
||||
@@ -71,6 +72,7 @@ export const streamingTtsUpstreamSchema = object({
|
||||
})),
|
||||
[],
|
||||
),
|
||||
defaultModel: optional(string()),
|
||||
})
|
||||
|
||||
export const unspeechUpstreamSchema = object({
|
||||
@@ -158,7 +160,19 @@ type ConfigDefinitions = {
|
||||
type ConfigKey = keyof ConfigDefinitions
|
||||
|
||||
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
|
||||
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
|
||||
try {
|
||||
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
|
||||
}
|
||||
catch (error) {
|
||||
throw createServiceUnavailableError(
|
||||
'Service configuration is invalid',
|
||||
'CONFIG_INVALID',
|
||||
{
|
||||
key,
|
||||
message: errorMessageFrom(error) ?? 'Unknown config parse error',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function serializeValue<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): string {
|
||||
|
||||
@@ -228,7 +228,7 @@ export const azureAdapter: TtsAdapter = {
|
||||
if (!ctx.keyPlaintext)
|
||||
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
|
||||
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?backend=microsoft®ion=${encodeURIComponent(ctx.region)}`
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?provider=microsoft®ion=${encodeURIComponent(ctx.region)}`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
|
||||
@@ -109,7 +109,7 @@ 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?backend=alibaba`
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?provider=alibaba`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('getAdapter', () => {
|
||||
})
|
||||
|
||||
describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
it('calls unspeech with backend=alibaba (no Bearer)', async () => {
|
||||
it('calls unspeech with provider=alibaba (no Bearer)', async () => {
|
||||
const adapter = getAdapter('dashscope-cosyvoice')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }],
|
||||
@@ -75,7 +75,7 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
|
||||
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?backend=alibaba')
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=alibaba')
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
expect(headers.Authorization).toBeUndefined()
|
||||
})
|
||||
@@ -92,7 +92,7 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
})
|
||||
|
||||
describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
it('calls unspeech with backend=volcengine and forwards adapterParams.model as ?model=', async () => {
|
||||
it('calls unspeech with provider=volcengine and forwards adapterParams.model as ?model=', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'zh_female_x', name: 'X' }],
|
||||
@@ -106,7 +106,7 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
|
||||
expect(voices).toEqual([{ id: 'zh_female_x', name: 'X' }])
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?backend=volcengine&model=seed-tts-2.0')
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine&model=seed-tts-2.0')
|
||||
})
|
||||
|
||||
it('omits ?model= when adapterParams.model is not set', async () => {
|
||||
@@ -118,7 +118,7 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
fetchImpl,
|
||||
})
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?backend=volcengine')
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('azureAdapter.getVoiceCatalog', () => {
|
||||
expect(voices).toEqual([{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }])
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local:5933/api/voices?backend=microsoft®ion=eastasia')
|
||||
expect(calledUrl).toBe('http://unspeech.local:5933/api/voices?provider=microsoft®ion=eastasia')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer subscription-key-XYZ')
|
||||
})
|
||||
|
||||
@@ -120,7 +120,7 @@ export const volcengineAdapter: TtsAdapter = {
|
||||
// further by `compatible_models` — adapterParams.model is the operator-
|
||||
// configured resource id (e.g. `seed-tts-2.0`).
|
||||
const url = new URL(`${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices`)
|
||||
url.searchParams.set('backend', 'volcengine')
|
||||
url.searchParams.set('provider', 'volcengine')
|
||||
const apiResourceId = typeof ctx.adapterParams?.model === 'string'
|
||||
? ctx.adapterParams.model
|
||||
: undefined
|
||||
|
||||
@@ -96,6 +96,10 @@ export interface UnspeechSliceInput {
|
||||
plaintextKey: string
|
||||
/** @default 'volcengine-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Operator-curated streaming models exposed to the frontend picker. */
|
||||
models?: Array<{ id: string, name?: string, description?: string }>
|
||||
/** Server-curated default streaming model id. */
|
||||
defaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +266,8 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
|
||||
baseURL: input.streaming.upstreamURL,
|
||||
keys: [{ id: keyEntryId, ciphertext }],
|
||||
adapterParams: {},
|
||||
models: [],
|
||||
models: input.streaming.models ?? [],
|
||||
defaultModel: input.streaming.defaultModel,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -451,18 +456,22 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
||||
nextRouterConfig = buildNextRouterConfig(input.mode, existing, llmTtsSlices)
|
||||
}
|
||||
|
||||
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` carry the
|
||||
// operator-curated catalog and must survive key/URL rotation, so we read
|
||||
// existing and graft them onto the new value when the slice's streaming
|
||||
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` +
|
||||
// `defaultModel` are operator-curated and must survive key/URL rotation,
|
||||
// so we graft them from the existing entry when the slice's streaming
|
||||
// subtree is set (otherwise there's nothing to merge into).
|
||||
let nextUnspeech: UnspeechUpstream | undefined
|
||||
if (unspeechSlice) {
|
||||
const existing = await deps.configKV.getOptional('UNSPEECH_UPSTREAM')
|
||||
const newValue = unspeechSlice.value
|
||||
if (newValue.streaming && existing?.streaming?.models?.length) {
|
||||
if (newValue.streaming && existing?.streaming) {
|
||||
nextUnspeech = {
|
||||
...newValue,
|
||||
streaming: { ...newValue.streaming, models: existing.streaming.models },
|
||||
streaming: {
|
||||
...newValue.streaming,
|
||||
models: existing.streaming.models?.length ? existing.streaming.models : newValue.streaming.models,
|
||||
defaultModel: existing.streaming.defaultModel ?? newValue.streaming.defaultModel,
|
||||
},
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -356,11 +356,23 @@ describe('createAdminRouterConfigService', () => {
|
||||
streaming: {
|
||||
upstreamURL: 'wss://unspeech.example/v1/audio/speech/stream',
|
||||
plaintextKey: 'volc',
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
expect(kv.store.has('UNSPEECH_UPSTREAM')).toBe(true)
|
||||
expect(kv.store.get('UNSPEECH_UPSTREAM')).toMatchObject({
|
||||
streaming: {
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
})
|
||||
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
|
||||
expect(result.invalidatedKeys).toEqual(['UNSPEECH_UPSTREAM'])
|
||||
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['UNSPEECH_UPSTREAM'])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
|
||||
import type Redis from 'ioredis'
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { GatewayMetrics } from '../../../otel'
|
||||
import type { EnvelopeCrypto } from '../../../utils/envelope-crypto'
|
||||
@@ -176,6 +177,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
|
||||
const logger = useLogger('llm-router').useGlobalConfig()
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
||||
const configLoader = createConfigLoader({ configKV: options.configKV, ttlMs: options.configCacheTtlMs })
|
||||
const ttsVoiceCatalogLoads = new Map<string, Promise<Voice[]>>()
|
||||
|
||||
/**
|
||||
* Run one upstream's key list in order, returning either:
|
||||
@@ -652,44 +654,55 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
|
||||
const existingLoad = ttsVoiceCatalogLoads.get(cacheKey)
|
||||
if (existingLoad != null)
|
||||
return existingLoad
|
||||
|
||||
// Live providers (Azure) need the decrypted Azure subscription key + region;
|
||||
// static-catalog providers (alibaba, volcengine) ignore both. The router
|
||||
// decrypts unconditionally so the adapter doesn't have to know which
|
||||
// category it's in — adapters that don't need creds just won't read them.
|
||||
const region = typeof upstream.adapterParams?.region === 'string'
|
||||
? upstream.adapterParams.region
|
||||
: undefined
|
||||
const load = (async () => {
|
||||
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
|
||||
|
||||
const keyEntry = upstream.keys[0]
|
||||
const plaintext = slice.model.provider === 'azure'
|
||||
? options.envelopeCrypto.decryptKey(keyEntry.ciphertext, { modelName, keyEntryId: keyEntry.id })
|
||||
: undefined
|
||||
// Live providers (Azure) need the decrypted Azure subscription key + region;
|
||||
// static-catalog providers (alibaba, volcengine) ignore both. The router
|
||||
// decrypts unconditionally so the adapter doesn't have to know which
|
||||
// category it's in — adapters that don't need creds just won't read them.
|
||||
const region = typeof upstream.adapterParams?.region === 'string'
|
||||
? upstream.adapterParams.region
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
keyPlaintext: plaintext,
|
||||
region,
|
||||
adapterParams: upstream.adapterParams ?? {},
|
||||
unspeechBaseURL,
|
||||
fetchImpl,
|
||||
})
|
||||
const keyEntry = upstream.keys[0]
|
||||
const plaintext = slice.model.provider === 'azure'
|
||||
? options.envelopeCrypto.decryptKey(keyEntry.ciphertext, { modelName, keyEntryId: keyEntry.id })
|
||||
: undefined
|
||||
|
||||
// Cache only on success — failure responses must NOT be persisted or
|
||||
// the next admin reconfigure would have to wait out the TTL even after
|
||||
// fixing credentials.
|
||||
const ttl = options.ttsVoiceCacheTtlSeconds ?? ttsVoicesCacheTtl(slice.model.provider)
|
||||
await options.redis.set(cacheKey, JSON.stringify(voices), 'EX', ttl)
|
||||
.catch((err) => {
|
||||
logger.withError(err).withFields({ cacheKey }).warn('failed to write tts voices cache')
|
||||
try {
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
keyPlaintext: plaintext,
|
||||
region,
|
||||
adapterParams: upstream.adapterParams ?? {},
|
||||
unspeechBaseURL,
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
return voices
|
||||
}
|
||||
finally {
|
||||
plaintext?.fill(0)
|
||||
}
|
||||
// Cache only on success — failure responses must NOT be persisted or
|
||||
// the next admin reconfigure would have to wait out the TTL even after
|
||||
// fixing credentials.
|
||||
const ttl = options.ttsVoiceCacheTtlSeconds ?? ttsVoicesCacheTtl(slice.model.provider)
|
||||
await options.redis.set(cacheKey, JSON.stringify(voices), 'EX', ttl)
|
||||
.catch((err) => {
|
||||
logger.withError(err).withFields({ cacheKey }).warn('failed to write tts voices cache')
|
||||
})
|
||||
|
||||
return voices
|
||||
}
|
||||
finally {
|
||||
plaintext?.fill(0)
|
||||
}
|
||||
})().finally(() => {
|
||||
ttsVoiceCatalogLoads.delete(cacheKey)
|
||||
})
|
||||
|
||||
ttsVoiceCatalogLoads.set(cacheKey, load)
|
||||
return load
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -754,5 +754,48 @@ describe('createLlmRouterService', () => {
|
||||
// adapter's `Error & { status }` was read as undefined.
|
||||
expect(fallbackCalls[0][1]).toMatchObject({ reason: '401' })
|
||||
})
|
||||
|
||||
it('listTtsVoices deduplicates concurrent cold-cache upstream fetches per model', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Azure voice catalogs are cached after a successful fetch, but concurrent
|
||||
// cold-cache requests used to miss Redis together and each hit unspeech's
|
||||
// microsoft voices endpoint. That can amplify one settings-page open into
|
||||
// several Azure voices/list calls and trigger upstream 429.
|
||||
//
|
||||
// We fixed this by sharing the in-flight catalog load for the same
|
||||
// provider/model cache key. Failures are still returned to every caller and
|
||||
// are not cached.
|
||||
const { config, crypto } = makeTtsConfig({
|
||||
upstreams: [{ baseURL: 'https://az.example', keyIds: ['kA1'], adapterParams: { region: 'eastasia' } }],
|
||||
})
|
||||
|
||||
let resolveFetch!: () => void
|
||||
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => {
|
||||
resolveFetch = () => resolve(happyResponse({
|
||||
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
|
||||
}))
|
||||
}))
|
||||
|
||||
const router = createLlmRouterService({
|
||||
configKV: makeConfigKV(config),
|
||||
envelopeCrypto: crypto,
|
||||
gatewayMetrics: null,
|
||||
fetchImpl,
|
||||
redis: makeRedisStub(),
|
||||
})
|
||||
|
||||
const first = router.listTtsVoices('tts-test')
|
||||
const second = router.listTtsVoices('tts-test')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
resolveFetch()
|
||||
|
||||
const [firstVoices, secondVoices] = await Promise.all([first, second])
|
||||
expect(firstVoices.map(voice => voice.id)).toEqual(['en-US-AvaMultilingualNeural'])
|
||||
expect(secondVoices.map(voice => voice.id)).toEqual(['en-US-AvaMultilingualNeural'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user