feat(server,stage-ui): bidirectional streaming TTS + audio path refactor
Why:
- Add a real bidirectional streaming TTS path: raw LLM tokens are
forwarded to the upstream model (Volcengine v3 via the unspeech ws
bridge) without client-side segmentation, so the model owns sentence
splitting and audio chunks play as they arrive.
- Move audio endpoints out of /api/v1/openai/. `/audio/voices`,
`/audio/models`, `/audio/voices/streaming` are not real OpenAI public
APIs, and the streaming TTS surface has nothing to do with OpenAI —
keeping them under /openai/ mislabelled the contract.
- Introduce `capabilities.speech.transport` on ProviderDefinition so
future streaming providers (ElevenLabs / Cartesia / OpenAI Realtime)
opt in without touching Stage.vue or the session factory.
- Unify Stage.vue's TTS path through a single StageTtsSession so the
chat-orchestrator hooks no longer branch on provider id.
What:
- apps/server: new ws proxy /api/v1/audio/speech/ws bridges client ↔
unspeech with auth, pre-flight flux check, billing from upstream
session.finished.usage, OTel spans.
- apps/server: audio routes moved from /api/v1/openai/audio/* to
/api/v1/audio/* (hard cutover; 404 sentinel tests added).
- apps/server: new /api/v1/audio/voices/streaming proxy reads voices
from unspeech /api/voices?provider=volcengine.
- apps/server: new STREAMING_TTS_UPSTREAM configKV entry +
scripts/seed-streaming-tts.ts.
- stage-ui: new libs/speech/streaming-pipeline.ts opens one ws per LLM
intent (appendText / finish / cancel + onSentence / onError / onDone).
- stage-ui: new libs/speech/tts-session.ts — StageTtsSession interface
with segmenter and streaming adapters; factory dispatches by
capabilities.speech.transport instead of hard-coded provider id.
- stage-ui: providerOfficialSpeechStreaming with capabilities.speech =
{ transport: 'bidirectional-ws' }; settings page with model/voice
picker + ws-based preview.
- stage-ui: Stage.vue chat hooks collapsed to a single currentSession;
hot-swap watcher cancels mid-session on provider/voice/model change;
unmount cancels and drains playback.
Tests:
- 9 streaming-pipeline tests (happy path / buffered / error / cancel /
truncation)
- 11 tts-session tests (factory branch coverage + adapter contracts)
- 4 audio-speech-ws route tests (forwarding / billing / pre-flight /
config-missing)
- 3 legacy-path 404 sentinels in v1 route tests
- Verification doc updated to reflect automated coverage.
This commit is contained in:
@@ -83,19 +83,22 @@ once it lands.
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
LLM_ROUTER_MASTER_KEY="$LLM_ROUTER_MASTER_KEY" \
|
||||
STREAMING_TTS_UPSTREAM_URL="ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream" \
|
||||
VOLCENGINE_TTS_API_KEY="$VOLCENGINE_TTS_API_KEY" \
|
||||
pnpm exec dotenvx run --env-file=.env.local -- \
|
||||
tsx scripts/seed-router-config.ts \
|
||||
--streaming-tts-key "$VOLCENGINE_TTS_API_KEY"
|
||||
tsx scripts/seed-streaming-tts.ts
|
||||
```
|
||||
|
||||
> Note: `seed-router-config.ts` does not yet implement
|
||||
> `--streaming-tts-key`. Until then, write the configKV entry directly
|
||||
> from a one-off node REPL using `configKV.set('STREAMING_TTS_UPSTREAM',
|
||||
> { baseURL: 'ws://localhost:5933/v1/audio/speech/stream', keys: [{ id:
|
||||
> 'volcengine-prod-1', ciphertext: envelopeCrypto.encryptKey(<plaintext>,
|
||||
> { modelName: 'streaming-tts', keyEntryId: 'volcengine-prod-1' }) }] })`.
|
||||
The script reads `LLM_ROUTER_MASTER_KEY` and `REDIS_URL` from
|
||||
`.env.local`, envelope-encrypts the Volcengine key under AAD
|
||||
`{ modelName: 'streaming-tts', keyEntryId: 'volcengine-prod-1' }`, and
|
||||
writes the `STREAMING_TTS_UPSTREAM` configKV entry. Use `--dry-run` to
|
||||
preview the ciphertext length without committing.
|
||||
|
||||
To point at a different unspeech instance later, just re-run the script
|
||||
with a different `STREAMING_TTS_UPSTREAM_URL`. To rotate the upstream
|
||||
key, re-run with `--key-id volcengine-prod-N` (the audio-speech-ws
|
||||
route always reads `keys[0]`, so a write replaces the active key).
|
||||
|
||||
### Scenario L1: streaming session happy path
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env tsx
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Seed the `STREAMING_TTS_UPSTREAM` configKV entry that powers
|
||||
* `/api/v1/audio/speech/ws`.
|
||||
*
|
||||
* Why a separate script from `seed-router-config.ts`:
|
||||
* - STREAMING_TTS_UPSTREAM is a sibling top-level configKV entry, not part
|
||||
* of `LLM_ROUTER_CONFIG.tts.models`. Mixing the two write paths would
|
||||
* make the merge semantics in `seed-router-config.ts` harder to follow.
|
||||
* - The streaming surface has only one upstream (one unspeech instance
|
||||
* per deployment), so a focused script avoids the multi-provider
|
||||
* merge logic entirely.
|
||||
*
|
||||
* What the script writes:
|
||||
* - `STREAMING_TTS_UPSTREAM.baseURL` — the unspeech ws endpoint, e.g.
|
||||
* `ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream`
|
||||
* or `wss://unspeech.example.com/v1/audio/speech/stream`.
|
||||
* - `STREAMING_TTS_UPSTREAM.keys[0]` — the **upstream provider** key
|
||||
* (Volcengine `X-Api-Key`) wrapped in an envelope ciphertext, not the
|
||||
* unspeech key — unspeech itself has no auth concept, it just
|
||||
* forwards the `Authorization` header verbatim to the upstream.
|
||||
*
|
||||
* Envelope AAD:
|
||||
* - `modelName: 'streaming-tts'` and `keyEntryId: 'volcengine-prod-1'`.
|
||||
* These must match the values `audio-speech-ws/index.ts` uses when
|
||||
* decrypting (`STREAM_MODEL_LABEL_FALLBACK = 'streaming-tts'`).
|
||||
*
|
||||
* Cross-instance invalidation:
|
||||
* - Published on `configkv:invalidate`. The audio-speech-ws route reads
|
||||
* STREAMING_TTS_UPSTREAM fresh on every connection (no in-memory
|
||||
* cache), so the publish is currently informational — it stays here
|
||||
* for forward compatibility if we add caching later.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* STREAMING_TTS_UPSTREAM_URL="ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream" \
|
||||
* VOLCENGINE_TTS_API_KEY="sk-..." \
|
||||
* pnpm exec dotenvx run --env-file=.env.local -- \
|
||||
* tsx scripts/seed-streaming-tts.ts
|
||||
*
|
||||
* # preview the ciphertext shape without writing:
|
||||
* pnpm exec dotenvx run --env-file=.env.local -- \
|
||||
* tsx scripts/seed-streaming-tts.ts --dry-run
|
||||
*
|
||||
* # rotate the key id (default 'volcengine-prod-1'):
|
||||
* tsx scripts/seed-streaming-tts.ts --key-id volcengine-prod-2
|
||||
*/
|
||||
import type { ConfigKVService } from '../src/services/config-kv'
|
||||
|
||||
import process, { env, exit } from 'node:process'
|
||||
|
||||
import Redis from 'ioredis'
|
||||
|
||||
import { parseEnv } from '../src/libs/env'
|
||||
import { createConfigKVService } from '../src/services/config-kv'
|
||||
import { createEnvelopeCrypto } from '../src/utils/envelope-crypto'
|
||||
|
||||
// Must match `STREAM_MODEL_LABEL_FALLBACK` in
|
||||
// apps/server/src/routes/audio-speech-ws/index.ts — the route decrypts
|
||||
// with this AAD, so seeding under a different label would surface as
|
||||
// `DECRYPT_FAILED` at session start.
|
||||
const STREAM_AAD_MODEL_NAME = 'streaming-tts'
|
||||
|
||||
interface Args {
|
||||
dryRun: boolean
|
||||
keyId: string
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const flags = new Set<string>()
|
||||
const values: Record<string, string> = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (!arg.startsWith('--'))
|
||||
continue
|
||||
const key = arg.slice(2)
|
||||
const next = argv[i + 1]
|
||||
if (next == null || next.startsWith('--')) {
|
||||
flags.add(key)
|
||||
}
|
||||
else {
|
||||
values[key] = next
|
||||
i++
|
||||
}
|
||||
}
|
||||
return {
|
||||
dryRun: flags.has('dry-run'),
|
||||
keyId: values['key-id'] ?? 'volcengine-prod-1',
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const parsedEnv = parseEnv(env)
|
||||
if (!parsedEnv.LLM_ROUTER_MASTER_KEY) {
|
||||
console.error('error: LLM_ROUTER_MASTER_KEY env var is required (32 random bytes, base64)')
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
const upstreamURL = process.env.STREAMING_TTS_UPSTREAM_URL
|
||||
const volcKey = process.env.VOLCENGINE_TTS_API_KEY
|
||||
|
||||
if (!upstreamURL || !volcKey) {
|
||||
console.error('error: STREAMING_TTS_UPSTREAM_URL and VOLCENGINE_TTS_API_KEY env vars are required.')
|
||||
console.error(' STREAMING_TTS_UPSTREAM_URL=ws://<unspeech-host>:5933/v1/audio/speech/stream')
|
||||
console.error(' VOLCENGINE_TTS_API_KEY=<volcengine X-Api-Key>')
|
||||
console.error('keys are read from env so they never appear in shell history or `ps`.')
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Soft-validate the scheme. unspeech is reachable over either ws:// (Railway
|
||||
// internal networking) or wss:// (public TLS-terminated). Anything else
|
||||
// (http://, https://) is almost certainly a copy-paste error from the
|
||||
// unspeech REST endpoint URL.
|
||||
if (!upstreamURL.startsWith('ws://') && !upstreamURL.startsWith('wss://')) {
|
||||
console.error(`error: STREAMING_TTS_UPSTREAM_URL must start with ws:// or wss://, got: ${upstreamURL}`)
|
||||
console.error('hint: the apps/server proxy opens a WebSocket — http:// will fail at the new WebSocket() call.')
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const envelope = createEnvelopeCrypto({
|
||||
masterKey: parsedEnv.LLM_ROUTER_MASTER_KEY,
|
||||
previousMasterKey: parsedEnv.LLM_ROUTER_MASTER_KEY_PREVIOUS,
|
||||
})
|
||||
|
||||
const ciphertext = envelope.encryptKey(volcKey, {
|
||||
modelName: STREAM_AAD_MODEL_NAME,
|
||||
keyEntryId: args.keyId,
|
||||
})
|
||||
|
||||
const value = {
|
||||
baseURL: upstreamURL,
|
||||
keys: [{ id: args.keyId, ciphertext }],
|
||||
adapterParams: {},
|
||||
}
|
||||
|
||||
console.log(`mode: seed${args.dryRun ? ' (dry-run)' : ''}`)
|
||||
console.log(`baseURL: ${upstreamURL}`)
|
||||
console.log(`keys[0].id: ${args.keyId}`)
|
||||
console.log(`ciphertext: <${ciphertext.length} chars>`)
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('dry-run: no writes, no publish.')
|
||||
return
|
||||
}
|
||||
|
||||
const redis = new Redis(parsedEnv.REDIS_URL)
|
||||
const configKV: ConfigKVService = createConfigKVService(redis)
|
||||
|
||||
// configKV.set runs the valibot validator (ttsUpstreamSchema) before
|
||||
// committing; a malformed shape fails here instead of at first request.
|
||||
await configKV.set('STREAMING_TTS_UPSTREAM', value as never)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
key: 'STREAMING_TTS_UPSTREAM',
|
||||
version: Date.now(),
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
await redis.publish('configkv:invalidate', payload)
|
||||
|
||||
console.log('STREAMING_TTS_UPSTREAM written.')
|
||||
console.log('Published configkv:invalidate.')
|
||||
|
||||
await redis.quit()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('seed-streaming-tts failed:', err)
|
||||
exit(1)
|
||||
})
|
||||
+12
-3
@@ -53,7 +53,7 @@ import { createCharacterRoutes } from './routes/characters'
|
||||
import { createChatWsHandlers } from './routes/chat-ws'
|
||||
import { createChatRoutes } from './routes/chats'
|
||||
import { createFluxRoutes } from './routes/flux'
|
||||
import { createV1CompletionsRoutes } from './routes/openai/v1'
|
||||
import { createV1Routes } from './routes/openai/v1'
|
||||
import { createProviderRoutes } from './routes/providers'
|
||||
import { createStripeRoutes } from './routes/stripe'
|
||||
import { createAdminFluxGrantsService } from './services/admin-flux-grants'
|
||||
@@ -204,6 +204,11 @@ export async function buildApp(deps: AppDeps) {
|
||||
logger: useLogger('config-sync').useGlobalConfig(),
|
||||
})
|
||||
|
||||
// Built once so the OpenAI-compat and audio routers share the same closure
|
||||
// (helpers like recordMetrics / recordRequestLog cross both surfaces) but
|
||||
// mount at different prefixes — see the `.route` calls below.
|
||||
const v1Routes = createV1Routes(deps.fluxService, deps.billingService, deps.configKV, deps.requestLogService, deps.ttsMeter, deps.llmRouter, deps.otel?.genAi, deps.otel?.revenue, deps.otel?.rateLimit)
|
||||
|
||||
const builtApp = app
|
||||
.use('*', sessionMiddleware(deps.auth, deps.env))
|
||||
.use('*', bodyLimit({ maxSize: 1024 * 1024 }))
|
||||
@@ -311,9 +316,13 @@ export async function buildApp(deps: AppDeps) {
|
||||
.route('/api/v1/chats', createChatRoutes(deps.chatService))
|
||||
|
||||
/**
|
||||
* V1 routes for official provider.
|
||||
* V1 OpenAI-compatible and audio routes. The factory returns two
|
||||
* sibling routers because the audio surface deliberately lives outside
|
||||
* `/openai/` — its `/voices`, `/voices/streaming`, and `/models`
|
||||
* extensions aren't OpenAI public APIs.
|
||||
*/
|
||||
.route('/api/v1/openai', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.requestLogService, deps.ttsMeter, deps.llmRouter, deps.otel?.genAi, deps.otel?.revenue, deps.otel?.rateLimit))
|
||||
.route('/api/v1/openai', v1Routes.openaiRoutes)
|
||||
.route('/api/v1/audio', v1Routes.audioRoutes)
|
||||
|
||||
/**
|
||||
* Flux routes.
|
||||
|
||||
@@ -67,7 +67,7 @@ function getLlmMetricAttributes(opts: { model: string, type: string, status: num
|
||||
}
|
||||
}
|
||||
|
||||
export function createV1CompletionsRoutes(
|
||||
export function createV1Routes(
|
||||
fluxService: FluxService,
|
||||
billingService: BillingService,
|
||||
configKV: ConfigKVService,
|
||||
@@ -635,6 +635,65 @@ export function createV1CompletionsRoutes(
|
||||
return Response.json({ voices, recommended })
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice catalog for the streaming TTS provider (`/audio/speech/ws`).
|
||||
*
|
||||
* The HTTP `/audio/voices?model=…` endpoint above queries
|
||||
* `LLM_ROUTER_CONFIG.tts.models` and is unaware of the streaming
|
||||
* surface. Streaming uses `STREAMING_TTS_UPSTREAM` (a single unspeech
|
||||
* instance) instead, and unspeech ships an embed-time voice catalog
|
||||
* for Volcengine that doesn't require credentials — so we proxy
|
||||
* straight to it. Falls back to an empty list if streaming isn't
|
||||
* configured yet so the client can render "no voices" instead of
|
||||
* exploding.
|
||||
*/
|
||||
async function handleListStreamingVoices(_c: Context<HonoEnv>) {
|
||||
const upstream = await configKV.getOptional('STREAMING_TTS_UPSTREAM')
|
||||
if (!upstream || !upstream.baseURL)
|
||||
return Response.json({ voices: [], recommended: {} })
|
||||
|
||||
let voicesURL: string
|
||||
try {
|
||||
const u = new URL(upstream.baseURL)
|
||||
// ws:// → http://, wss:// → https://. unspeech serves both the WS
|
||||
// stream and the REST voices endpoint on the same listener.
|
||||
u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:'
|
||||
u.pathname = '/api/voices'
|
||||
u.search = '?provider=volcengine'
|
||||
voicesURL = u.toString()
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ baseURL: upstream.baseURL }).warn('streaming-voices: bad upstream URL')
|
||||
return Response.json({ voices: [], recommended: {} })
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await globalThis.fetch(voicesURL, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ voicesURL }).warn('streaming-voices: unspeech fetch failed')
|
||||
return Response.json({ voices: [], recommended: {} })
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
logger.withFields({ voicesURL, status: res.status }).warn('streaming-voices: unspeech non-2xx')
|
||||
return Response.json({ voices: [], recommended: {} })
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({})) as { voices?: unknown[] }
|
||||
return Response.json({
|
||||
voices: Array.isArray(data.voices) ? data.voices : [],
|
||||
// STREAMING_TTS_UPSTREAM is not part of DEFAULT_TTS_VOICES (which
|
||||
// keys on LLM_ROUTER_CONFIG model ids). Future enhancement: add a
|
||||
// separate streaming-recommended map if locale-based auto-pick
|
||||
// matters for streaming too.
|
||||
recommended: {},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleListTTSModels(_c: Context<HonoEnv>) {
|
||||
// Mirror the chat provider: expose a single 'auto' routing alias instead
|
||||
// of the concrete DEFAULT_TTS_MODEL id. Keeps clients insulated from
|
||||
@@ -652,11 +711,27 @@ export function createV1CompletionsRoutes(
|
||||
// 60 requests per minute per user for LLM completions
|
||||
const completionsRateLimit = rateLimiter({ max: 60, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'openai.completions' })
|
||||
|
||||
return new Hono<HonoEnv>()
|
||||
// OpenAI-compatible surface (mounted at /api/v1/openai). Only routes that
|
||||
// mirror an actual OpenAI public endpoint belong here. Audio used to live
|
||||
// under this prefix too, but the `/audio/voices` listing endpoint isn't a
|
||||
// real OpenAI route and the streaming TTS protocol has nothing to do with
|
||||
// OpenAI — keeping them here mislabelled the surface, so audio now mounts
|
||||
// at /api/v1/audio (see `audioRoutes` below).
|
||||
const openaiRoutes = new Hono<HonoEnv>()
|
||||
.use('*', authGuard)
|
||||
.post('/chat/completions', completionsRateLimit, chatGuard, handleCompletion)
|
||||
.post('/chat/completion', completionsRateLimit, chatGuard, handleCompletion)
|
||||
.post('/audio/speech', ttsGuard, handleTTS)
|
||||
.get('/audio/voices', handleListVoices)
|
||||
.get('/audio/models', handleListTTSModels)
|
||||
|
||||
// AIRI audio surface (mounted at /api/v1/audio). Lives outside /openai/ so
|
||||
// the `/voices`, `/voices/streaming`, and `/models` extensions aren't
|
||||
// misread as OpenAI-compatible. `/audio/speech/ws` is registered
|
||||
// separately in app.ts because it needs the WebSocket upgrade middleware.
|
||||
const audioRoutes = new Hono<HonoEnv>()
|
||||
.use('*', authGuard)
|
||||
.post('/speech', ttsGuard, handleTTS)
|
||||
.get('/voices', handleListVoices)
|
||||
.get('/voices/streaming', handleListStreamingVoices)
|
||||
.get('/models', handleListTTSModels)
|
||||
|
||||
return { openaiRoutes, audioRoutes }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { HonoEnv } from '../../../types/hono'
|
||||
import { Hono } from 'hono'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createV1CompletionsRoutes } from '.'
|
||||
import { createV1Routes } from '.'
|
||||
import { ApiError } from '../../../utils/error'
|
||||
|
||||
// --- Mock helpers ---
|
||||
@@ -122,7 +122,7 @@ function createTestApp(
|
||||
ttsMeter?: ReturnType<typeof createMockTtsMeter>,
|
||||
llmRouter?: LlmRouterService,
|
||||
) {
|
||||
const routes = createV1CompletionsRoutes(
|
||||
const { openaiRoutes, audioRoutes } = createV1Routes(
|
||||
fluxService,
|
||||
billingService ?? createMockBillingService(),
|
||||
configKV,
|
||||
@@ -153,7 +153,12 @@ function createTestApp(
|
||||
await next()
|
||||
})
|
||||
|
||||
app.route('/api/v1/openai', routes)
|
||||
// Mounting mirrors production (see app.ts): chat completions under
|
||||
// `/api/v1/openai`, audio under `/api/v1/audio`. Test request URLs were
|
||||
// batch-migrated from the legacy `/api/v1/openai/audio/*` prefix when the
|
||||
// audio surface was split out of the OpenAI-compat namespace.
|
||||
app.route('/api/v1/openai', openaiRoutes)
|
||||
app.route('/api/v1/audio', audioRoutes)
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -492,7 +497,38 @@ describe('v1CompletionsRoutes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pOST /api/v1/openai/audio/speech', () => {
|
||||
describe('legacy audio paths under /openai/', () => {
|
||||
// Audio used to live at /api/v1/openai/audio/*. After the refactor it
|
||||
// moved to /api/v1/audio/*; these are kept as 404 sentinels so a
|
||||
// future accidental re-mount under the old prefix is caught by tests.
|
||||
// Codex review LOW #6.
|
||||
it('returns 404 for /api/v1/openai/audio/speech (moved to /api/v1/audio/speech)', async () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV())
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', { method: 'POST' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
it('returns 404 for /api/v1/openai/audio/voices', async () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV())
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/voices', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
it('returns 404 for /api/v1/openai/audio/models', async () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV())
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/models', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pOST /api/v1/audio/speech', () => {
|
||||
it('should proxy TTS request to upstream with resolved model', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), {
|
||||
status: 200,
|
||||
@@ -505,7 +541,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'test', voice: 'alloy' }),
|
||||
@@ -532,7 +568,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV(), billingService)
|
||||
|
||||
await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'hello', voice: 'alloy' }),
|
||||
@@ -554,7 +590,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV(), billingService, undefined, undefined, llmRouter)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'hello', voice: 'alloy' }),
|
||||
@@ -573,7 +609,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'hello', voice: 'alloy' }),
|
||||
@@ -593,7 +629,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV(), billingService)
|
||||
|
||||
await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: '', voice: 'alloy' }),
|
||||
@@ -618,7 +654,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV(), billingService, undefined, ttsMeter)
|
||||
|
||||
await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: longInput, voice: 'alloy' }),
|
||||
@@ -634,7 +670,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
it('should return 401 when unauthenticated', async () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV())
|
||||
|
||||
const res = await app.request('/api/v1/openai/audio/voices', { method: 'GET' })
|
||||
const res = await app.request('/api/v1/audio/voices', { method: 'GET' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
@@ -671,7 +707,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'hi', voice: 'en-US-AvaMultilingualNeural' }),
|
||||
@@ -699,7 +735,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV(), undefined, undefined, undefined, llmRouter)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/speech', {
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'hi', voice: 'alloy' }),
|
||||
@@ -710,7 +746,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('gET /api/v1/openai/audio/models', () => {
|
||||
describe('gET /api/v1/audio/models', () => {
|
||||
it('exposes only the auto routing alias regardless of DEFAULT_TTS_MODEL', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
@@ -718,7 +754,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/models', { method: 'GET' }),
|
||||
new Request('http://localhost/api/v1/audio/models', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
@@ -730,12 +766,12 @@ describe('v1CompletionsRoutes', () => {
|
||||
it('should return 401 when unauthenticated', async () => {
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV())
|
||||
|
||||
const res = await app.request('/api/v1/openai/audio/models', { method: 'GET' })
|
||||
const res = await app.request('/api/v1/audio/models', { method: 'GET' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gET /api/v1/openai/audio/voices', () => {
|
||||
describe('gET /api/v1/audio/voices', () => {
|
||||
it('returns the adapter voice catalog plus recommended map for the resolved model', async () => {
|
||||
const voices = [
|
||||
{ id: 'en-US-JennyNeural', name: 'Jenny', provider: 'azure', locale: 'en-US', gender: 'Female' },
|
||||
@@ -751,7 +787,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
const app = createTestApp(createMockFluxService(), configKV, undefined, undefined, undefined, llmRouter)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/audio/voices', { method: 'GET' }),
|
||||
new Request('http://localhost/api/v1/audio/voices', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
@@ -769,7 +805,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
const app = createTestApp(createMockFluxService(), createMockConfigKV(), undefined, undefined, undefined, llmRouter)
|
||||
|
||||
await app.fetch(new Request('http://localhost/api/v1/openai/audio/voices?model=alibaba/cosyvoice-v1'), { user: testUser } as any)
|
||||
await app.fetch(new Request('http://localhost/api/v1/audio/voices?model=alibaba/cosyvoice-v1'), { user: testUser } as any)
|
||||
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('alibaba/cosyvoice-v1')
|
||||
})
|
||||
|
||||
@@ -781,7 +817,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
const app = createTestApp(createMockFluxService(), configKV, undefined, undefined, undefined, llmRouter)
|
||||
|
||||
await app.fetch(new Request('http://localhost/api/v1/openai/audio/voices?model=auto'), { user: testUser } as any)
|
||||
await app.fetch(new Request('http://localhost/api/v1/audio/voices?model=auto'), { user: testUser } as any)
|
||||
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user