diff --git a/apps/server/src/routes/openai/v1/index.ts b/apps/server/src/routes/openai/v1/index.ts index 0081a5f78..054c20d5e 100644 --- a/apps/server/src/routes/openai/v1/index.ts +++ b/apps/server/src/routes/openai/v1/index.ts @@ -712,6 +712,18 @@ export function createV1Routes( }) } + async function handleListStreamingTTSModels(_c: Context) { + 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 } } diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 8eb57e414..a19bb5d2a 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -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 = [ diff --git a/apps/server/src/services/adapters/config-kv.ts b/apps/server/src/services/adapters/config-kv.ts index 758dab4be..62d7a489f 100644 --- a/apps/server/src/services/adapters/config-kv.ts +++ b/apps/server/src/services/adapters/config-kv.ts @@ -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 = { diff --git a/apps/server/src/services/domain/admin/router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts index 7d8efc96d..3296334be 100644 --- a/apps/server/src/services/domain/admin/router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -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 type LlmModel = InferOutput type TtsModel = InferOutput -type TtsUpstream = InferOutput +type StreamingTtsUpstream = InferOutput /** * 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) { diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index b3a78096a..93bbec7b7 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -205,23 +205,20 @@ export const providerOfficialSpeechStreaming = defineProvider({ validationRequiredWhen: () => false, extraMethods: { listModels: async (): Promise => { - // Streaming-capable models. The wire `model` field uses the - // `/` 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 => { // Streaming voices live behind a dedicated endpoint