feat(server): expose streaming TTS model catalog from config

Streaming TTS model list was hardcoded in the official provider with two
Volcengine ids. This moves it server-side so operators control the
catalog without a client release.

Catalog lives on `STREAMING_TTS_UPSTREAM.models` (same configKV entry as
the ws upstream + key envelope) rather than a separate kv key — connection
target, keys, and surfaced ids are one deployment decision, splitting
them risks drift on rotation. `LLM_ROUTER_CONFIG.tts.models` is the
router/fallback domain for HTTP TTS; streaming is single-ws and has no
router semantics, so it gets its own `streamingTtsUpstreamSchema`
instead of reusing `ttsUpstreamSchema`.

New `GET /api/v1/audio/models/streaming` returns the configured list;
empty when upstream or models is unset (UI renders "no models" instead
of 5xx). Admin slice apply preserves existing `models` across key/connection
rotation so admin POSTs that only carry upstream+keys do not wipe the
catalog. Frontend `providerOfficialSpeechStreaming.listModels` fetches
the endpoint and throws on upstream errors (no silent empty array).
This commit is contained in:
RainbowBird
2026-05-19 23:14:16 +08:00
parent 65751f598d
commit 6b0788dd60
5 changed files with 123 additions and 26 deletions
+13
View File
@@ -712,6 +712,18 @@ export function createV1Routes(
})
}
async function handleListStreamingTTSModels(_c: Context<HonoEnv>) {
const upstream = await configKV.getOptional('STREAMING_TTS_UPSTREAM')
const models = upstream?.models ?? []
return Response.json({
models: models.map(m => ({
id: m.id,
name: m.name ?? m.id,
description: m.description,
})),
})
}
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')
@@ -739,6 +751,7 @@ export function createV1Routes(
.get('/voices', handleListVoices)
.get('/voices/streaming', handleListStreamingVoices)
.get('/models', handleListTTSModels)
.get('/models/streaming', handleListStreamingTTSModels)
return { openaiRoutes, audioRoutes }
}
@@ -771,6 +771,77 @@ describe('v1CompletionsRoutes', () => {
})
})
describe('gET /api/v1/audio/models/streaming', () => {
it('returns the operator-configured streaming model catalog', async () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({
STREAMING_TTS_UPSTREAM: {
baseURL: 'wss://unspeech.local',
keys: [{ id: 'k1', ciphertext: 'enc' }],
models: [
{ id: 'volcengine/seed-tts-2.0', name: 'Volcengine Seed-TTS 2.0', description: 'TTS 2.0' },
{ id: 'volcengine/seed-tts-1.0' },
],
},
}),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/models/streaming', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json() as { models: { id: string, name: string, description?: string }[] }
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' },
])
})
it('returns an empty list when STREAMING_TTS_UPSTREAM is unset', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/models/streaming', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json() as { models: unknown[] }
expect(data.models).toEqual([])
})
it('returns an empty list when STREAMING_TTS_UPSTREAM has no models', async () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({
STREAMING_TTS_UPSTREAM: {
baseURL: 'wss://unspeech.local',
keys: [{ id: 'k1', ciphertext: 'enc' }],
},
}),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/models/streaming', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json() as { models: unknown[] }
expect(data.models).toEqual([])
})
it('should return 401 when unauthenticated', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.request('/api/v1/audio/models/streaming', { method: 'GET' })
expect(res.status).toBe(401)
})
})
describe('gET /api/v1/audio/voices', () => {
it('returns the recommended bucket scoped to the resolved model', async () => {
const voices = [
+18 -5
View File
@@ -59,6 +59,20 @@ export const ttsUpstreamSchema = object({
adapterParams: optional(record(string(), any()), {}),
})
export const streamingTtsUpstreamSchema = object({
baseURL: pipe(string(), nonEmpty('STREAMING_TTS_UPSTREAM.baseURL must not be empty')),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'STREAMING_TTS_UPSTREAM.keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
models: optional(
array(object({
id: pipe(string(), nonEmpty('STREAMING_TTS_UPSTREAM.models[].id must not be empty')),
name: optional(string()),
description: optional(string()),
})),
[],
),
})
export const ttsModelSchema = object({
provider: ttsProviderSchema,
upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')),
@@ -129,11 +143,10 @@ const ConfigEntrySchemas = {
// LLM_ROUTER_CONFIG.tts.models because the streaming surface has different
// semantics from one-shot HTTP TTS: ws-to-ws bridging, no per-attempt retry
// (a live ws cannot transparently switch upstream mid-session), upstream
// does the protocol translation to providers (Volcengine v3 etc.). Reuses
// ttsUpstreamSchema only for the key envelope shape — `keys` carry the
// upstream-provider API key (e.g. Volcengine X-Api-Key), not an unspeech
// tenant token.
STREAMING_TTS_UPSTREAM: optional(ttsUpstreamSchema),
// does the protocol translation to providers (Volcengine v3 etc.). `keys`
// carry the upstream-provider API key (e.g. Volcengine X-Api-Key), not an
// unspeech tenant token.
STREAMING_TTS_UPSTREAM: optional(streamingTtsUpstreamSchema),
} as const
type ConfigDefinitions = {
@@ -2,7 +2,7 @@ import type Redis from 'ioredis'
import type { InferOutput } from 'valibot'
import type { EnvelopeCrypto } from '../../../../utils/envelope-crypto'
import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, ttsUpstreamSchema } from '../../../adapters/config-kv'
import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, streamingTtsUpstreamSchema, ttsModelSchema } from '../../../adapters/config-kv'
import { useLogger } from '@guiiai/logg'
@@ -28,7 +28,7 @@ const DEFAULT_KEY_ENTRY_IDS = {
type LlmRouterConfig = InferOutput<typeof llmRouterConfigSchema>
type LlmModel = InferOutput<typeof llmModelSchema>
type TtsModel = InferOutput<typeof ttsModelSchema>
type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
type StreamingTtsUpstream = InferOutput<typeof streamingTtsUpstreamSchema>
/**
* Per-provider input. The admin route validates the shape with Valibot
@@ -115,7 +115,7 @@ interface TtsModelSlice {
interface StreamingTtsSlice {
target: 'streaming-tts'
kind: 'streaming-tts'
value: TtsUpstream
value: StreamingTtsUpstream
keyEntryId: string
}
@@ -246,6 +246,7 @@ export function buildStreamingTtsSlice(input: StreamingTtsSliceInput, envelope:
baseURL: input.upstreamURL,
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: {},
models: [],
},
}
}
@@ -465,7 +466,9 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
invalidatedKeys.push('LLM_ROUTER_CONFIG')
}
if (streamingSlice) {
await deps.configKV.set('STREAMING_TTS_UPSTREAM', streamingSlice.value as never)
const existing = await deps.configKV.getOptional('STREAMING_TTS_UPSTREAM')
const merged = { ...streamingSlice.value, models: existing?.models ?? streamingSlice.value.models }
await deps.configKV.set('STREAMING_TTS_UPSTREAM', merged as never)
invalidatedKeys.push('STREAMING_TTS_UPSTREAM')
}
if (input.defaults?.chatModel) {
@@ -205,23 +205,20 @@ export const providerOfficialSpeechStreaming = defineProvider({
validationRequiredWhen: () => false,
extraMethods: {
listModels: async (): Promise<ModelInfo[]> => {
// Streaming-capable models. The wire `model` field uses the
// `<backend>/<id>` shape unspeech expects (see
// `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`).
return [
{
id: 'volcengine/seed-tts-2.0',
name: 'Volcengine Seed-TTS 2.0',
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
description: 'Volcengine bidirectional streaming TTS (TTS 2.0)',
},
{
id: 'volcengine/seed-tts-1.0',
name: 'Volcengine Seed-TTS 1.0',
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
description: 'Volcengine bidirectional streaming TTS (TTS 1.0)',
},
]
const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models/streaming`, { headers: authHeaders() })
if (!res.ok)
throw new Error(`streaming models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))
const data = await res.json() as { models?: { id: string, name?: string, description?: string }[] }
if (!Array.isArray(data.models))
throw new Error('streaming models upstream returned malformed body')
return data.models.map(m => ({
id: m.id,
name: m.name ?? m.id,
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
description: m.description,
}))
},
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
// Streaming voices live behind a dedicated endpoint