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:
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user