refactor(server): merge STREAMING_TTS_UPSTREAM + UNSPEECH_REST_BASE_URL into UNSPEECH_UPSTREAM

One unspeech deployment = one configKV entry. The split into two keys
modelled a hypothetical split deployment (operators pointing REST and
WS at different unspeech instances) that nobody actually runs, and the
explanatory comments justifying the split were exactly the migration-
narrative anti-pattern we just banned from source.

Schema:

  UNSPEECH_UPSTREAM = {
    restBaseURL: string,                  // required, http(s)://host:port
    streaming?: {                         // optional, only when ws TTS is on
      baseURL: string,                    // ws(s)://host:port/...
      keys: [{ id, ciphertext }],
      adapterParams: {},
      models?: [{ id, name?, description? }],
    },
  }

Admin slice surface flattens to one `kind: 'unspeech'`:

  { kind: 'unspeech', restBaseURL,
    streaming?: { upstreamURL, plaintextKey, keyEntryId? } }

Read-site changes:
- routeTts + listTtsVoices read UNSPEECH_UPSTREAM.restBaseURL via
  getOrThrow; absent entry → 503 CONFIG_NOT_SET.
- audio-speech-ws dials UNSPEECH_UPSTREAM.streaming and 1008-closes
  with streaming_tts_not_configured when the subtree is absent.
- handleListStreamingVoices reads .restBaseURL directly (no more
  ws→http scheme swap) and 503s on missing streaming subtree.
- handleListStreamingTTSModels reads .streaming.models.
- config-sync-subscriber listens for UNSPEECH_UPSTREAM invalidations
  instead of UNSPEECH_REST_BASE_URL.

Drive-by: tighten ConfigKVService.getOrThrow return to
`Exclude<ConfigDefinitions[K], undefined>` so call sites stop needing
non-null assertions on optional schema entries — the runtime already
throws, the type now reflects it.

Migration: operators repost a single admin slice to rewrite the merged
entry. Old STREAMING_TTS_UPSTREAM and UNSPEECH_REST_BASE_URL rows
become dead data and can be deleted from configKV; nothing reads them.
This commit is contained in:
RainbowBird
2026-05-19 23:28:42 +08:00
parent 615e0441e8
commit be2c355b0e
13 changed files with 240 additions and 190 deletions
+1 -1
View File
@@ -345,7 +345,7 @@ export async function buildApp(deps: AppDeps) {
/**
* Admin LLM router config seeding/patching. Single entry point for
* writing `LLM_ROUTER_CONFIG`, `STREAMING_TTS_UPSTREAM`, and the
* writing `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and the
* `DEFAULT_{CHAT,TTS}_MODEL` aliases — see
* `routes/admin/config/router/index.ts` for the body shape.
*/
@@ -69,28 +69,36 @@ const DashscopeSliceSchema = object({
})
/**
* `upstreamURL` must be ws:// or wss://. http(s):// here is almost always a
* copy-paste of the unspeech REST endpoint, which would fail at
* `new WebSocket()` inside the audio-speech-ws proxy with no actionable
* error for the admin.
* `restBaseURL` is the unspeech REST root (http(s)://host:port, no path).
* `streaming.upstreamURL` must be ws:// or wss:// — http(s):// here is almost
* always a copy-paste of the REST endpoint and fails at `new WebSocket()`
* inside the audio-speech-ws proxy with no actionable error for the admin.
*/
const StreamingTtsSliceSchema = object({
kind: literal('streaming-tts'),
upstreamURL: pipe(
const UnspeechSliceSchema = object({
kind: literal('unspeech'),
restBaseURL: pipe(
string(),
nonEmpty('upstreamURL is required'),
regex(/^wss?:\/\/\S+$/, 'upstreamURL must start with ws:// or wss://'),
nonEmpty('restBaseURL is required'),
regex(/^https?:\/\/\S+$/, 'restBaseURL must start with http:// or https://'),
maxLength(500),
),
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
streaming: optional(object({
upstreamURL: pipe(
string(),
nonEmpty('streaming.upstreamURL is required'),
regex(/^wss?:\/\/\S+$/, 'streaming.upstreamURL must start with ws:// or wss://'),
maxLength(500),
),
plaintextKey: pipe(string(), nonEmpty('streaming.plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})),
})
const SliceSchema = variant('kind', [
OpenRouterSliceSchema,
AzureSliceSchema,
DashscopeSliceSchema,
StreamingTtsSliceSchema,
UnspeechSliceSchema,
])
const BodySchema = object({
@@ -110,7 +118,7 @@ const BodySchema = object({
/**
* Admin route for seeding / patching the LLM router config tree. Mounted
* at `POST /api/admin/config/router`; the only supported way to write
* `LLM_ROUTER_CONFIG`, `STREAMING_TTS_UPSTREAM`, and the
* `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and the
* `DEFAULT_{CHAT,TTS}_MODEL` aliases.
*
* Body shape (discriminated on `slices[].kind`):
@@ -127,9 +135,12 @@ const BodySchema = object({
* { "kind": "dashscope-cosyvoice", "modelName": "alibaba/cosyvoice-v2",
* "region": "intl", "upstreamModel": "cosyvoice-v2",
* "plaintextKey": "..." },
* { "kind": "streaming-tts",
* "upstreamURL": "ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream",
* "plaintextKey": "..." }
* { "kind": "unspeech",
* "restBaseURL": "http://airi-unspeech.railway.internal:5933",
* "streaming": {
* "upstreamURL": "ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream",
* "plaintextKey": "..."
* } }
* ],
* "defaults": {
* "chatModel": "chat-default", // writes DEFAULT_CHAT_MODEL
@@ -144,7 +155,7 @@ const BodySchema = object({
* "invalidatedKeys": ["LLM_ROUTER_CONFIG", "DEFAULT_CHAT_MODEL", ...],
* "preview": { // ciphertext redacted to "<N chars>"
* "LLM_ROUTER_CONFIG": { ... },
* "STREAMING_TTS_UPSTREAM": { ... },
* "UNSPEECH_UPSTREAM": { ... },
* "DEFAULT_CHAT_MODEL": "chat-default",
* "DEFAULT_TTS_MODEL": "alibaba/cosyvoice-v2"
* }
@@ -56,8 +56,8 @@ export interface AudioSpeechWsHandlersOptions {
* Expects:
* - The route handler has already resolved auth via the `?token=` query
* (see app.ts wiring) and passes a verified `userId` in.
* - `STREAMING_TTS_UPSTREAM` configKV entry is populated with at least one
* key; absent config rejects the upgrade with policy-violation close.
* - `UNSPEECH_UPSTREAM.streaming` configKV subtree is populated with at least
* one key; absent config rejects the upgrade with policy-violation close.
*
* Returns:
* - A function that takes `userId` and returns hono `WSEvents`. Each call
@@ -126,16 +126,17 @@ function createSessionState(userId: string, opts: AudioSpeechWsHandlersOptions)
}
async function dialUpstream() {
let upstreamConfig: Awaited<ReturnType<ConfigKVService['getOptional']>>
let unspeech: Awaited<ReturnType<ConfigKVService['getOptional']>>
try {
upstreamConfig = await opts.configKV.getOptional('STREAMING_TTS_UPSTREAM')
unspeech = await opts.configKV.getOptional('UNSPEECH_UPSTREAM')
}
catch (err) {
log.withError(err).error('STREAMING_TTS_UPSTREAM read failed')
log.withError(err).error('UNSPEECH_UPSTREAM read failed')
closeWithError(1011, 'config_unavailable')
return
}
const upstreamConfig = unspeech?.streaming
if (!upstreamConfig || !upstreamConfig.baseURL || upstreamConfig.keys.length === 0) {
closeWithError(1008, 'streaming_tts_not_configured')
return
@@ -168,11 +168,14 @@ function makeFakeDeps(overrides: {
}
const configKV = {
getOptional: vi.fn(async (key: string) => {
if (key === 'STREAMING_TTS_UPSTREAM') {
if (key === 'UNSPEECH_UPSTREAM') {
return {
baseURL: overrides.upstreamURL,
keys: [{ id: 'test-key-1', ciphertext: 'ENCRYPTED_PLACEHOLDER' }],
adapterParams: {},
restBaseURL: 'http://unspeech.local:5933',
streaming: {
baseURL: overrides.upstreamURL,
keys: [{ id: 'test-key-1', ciphertext: 'ENCRYPTED_PLACEHOLDER' }],
adapterParams: {},
},
}
}
return null
@@ -294,7 +297,7 @@ describe('audio-speech-ws route', () => {
expect(client.closeCode).toBe(1008)
})
it('refuses with streaming_tts_not_configured when STREAMING_TTS_UPSTREAM is empty', async () => {
it('refuses with streaming_tts_not_configured when UNSPEECH_UPSTREAM.streaming is empty', async () => {
const deps = makeFakeDeps({ upstreamURL: 'ws://unused', fluxBalance: 100 })
deps.configKV.getOptional = vi.fn(async () => null) as any
+8 -17
View File
@@ -645,24 +645,15 @@ export function createV1Routes(
/**
* Voice catalog for the streaming TTS provider (`/audio/speech/ws`).
*
* Streaming uses `STREAMING_TTS_UPSTREAM` (a single unspeech instance)
* to actually open the ws session, but the REST voices catalog lives
* at `UNSPEECH_REST_BASE_URL` — kept as a separate configKV entry so
* operators can split the streaming endpoint from the catalog source
* if they want, and so this path doesn't have to derive HTTPS from a
* `wss://` URL (which has bitten us once already).
*
* Errors propagate verbatim: missing config → 503, malformed upstream
* URL → 500, unspeech network failure → 502, unspeech non-2xx → 502.
* URL → 502, unspeech network failure → 502, unspeech non-2xx → 502.
* No empty-array fallback — the UI surfaces a real failure state.
*/
async function handleListStreamingVoices(c: Context<HonoEnv>) {
const streaming = await configKV.getOptional('STREAMING_TTS_UPSTREAM')
if (!streaming || !streaming.baseURL)
const unspeech = await configKV.getOptional('UNSPEECH_UPSTREAM')
if (!unspeech?.streaming?.baseURL)
throw createServiceUnavailableError('streaming tts upstream not configured', 'STREAMING_TTS_NOT_CONFIGURED')
const unspeechBaseURL = await configKV.getOrThrow('UNSPEECH_REST_BASE_URL')
// Pass through the api_resource_id (e.g. `seed-tts-2.0`). unspeech
// filters the embedded Volcengine catalogue server-side; absent model
// means "return everything streaming-safe".
@@ -670,7 +661,7 @@ export function createV1Routes(
let voicesURL: string
try {
const u = new URL(unspeechBaseURL)
const u = new URL(unspeech.restBaseURL)
u.pathname = '/api/voices'
const params = new URLSearchParams({ provider: 'volcengine' })
if (model)
@@ -679,8 +670,8 @@ export function createV1Routes(
voicesURL = u.toString()
}
catch (err) {
logger.withError(err).withFields({ unspeechBaseURL }).warn('streaming-voices: bad UNSPEECH_REST_BASE_URL')
throw createBadGatewayError('UNSPEECH_REST_BASE_URL is malformed')
logger.withError(err).withFields({ restBaseURL: unspeech.restBaseURL }).warn('streaming-voices: bad UNSPEECH_UPSTREAM.restBaseURL')
throw createBadGatewayError('UNSPEECH_UPSTREAM.restBaseURL is malformed')
}
let res: Response
@@ -734,8 +725,8 @@ export function createV1Routes(
}
async function handleListStreamingTTSModels(_c: Context<HonoEnv>) {
const upstream = await configKV.getOptional('STREAMING_TTS_UPSTREAM')
const models = upstream?.models ?? []
const unspeech = await configKV.getOptional('UNSPEECH_UPSTREAM')
const models = unspeech?.streaming?.models ?? []
return Response.json({
models: models.map(m => ({
id: m.id,
+25 -25
View File
@@ -809,13 +809,16 @@ describe('v1CompletionsRoutes', () => {
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' },
],
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: 'Volcengine Seed-TTS 2.0', description: 'TTS 2.0' },
{ id: 'volcengine/seed-tts-1.0' },
],
},
},
}),
)
@@ -833,7 +836,7 @@ describe('v1CompletionsRoutes', () => {
])
})
it('returns an empty list when STREAMING_TTS_UPSTREAM is unset', async () => {
it('returns an empty list when UNSPEECH_UPSTREAM is unset', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
@@ -846,13 +849,16 @@ describe('v1CompletionsRoutes', () => {
expect(data.models).toEqual([])
})
it('returns an empty list when STREAMING_TTS_UPSTREAM has no models', async () => {
it('returns an empty list when streaming subtree has no models', async () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({
STREAMING_TTS_UPSTREAM: {
baseURL: 'wss://unspeech.local',
keys: [{ id: 'k1', ciphertext: 'enc' }],
UNSPEECH_UPSTREAM: {
restBaseURL: 'http://unspeech.local:5933',
streaming: {
baseURL: 'wss://unspeech.local',
keys: [{ id: 'k1', ciphertext: 'enc' }],
},
},
}),
)
@@ -1000,8 +1006,7 @@ describe('v1CompletionsRoutes', () => {
it('returns the streaming-model bucket of DEFAULT_TTS_VOICES when ?model= matches', async () => {
mockUnspeechVoices([{ id: 'zh_female_vv_uranus_bigtts', name: 'Vivi 2.0' }])
const configKV = createMockConfigKV({
STREAMING_TTS_UPSTREAM: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream' },
UNSPEECH_REST_BASE_URL: 'http://unspeech.local:5933',
UNSPEECH_UPSTREAM: { restBaseURL: 'http://unspeech.local:5933', streaming: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream', keys: [{ id: 'k1', ciphertext: 'enc' }] } },
DEFAULT_TTS_VOICES: {
'seed-tts-2.0': { 'zh-cn': 'zh_female_vv_uranus_bigtts' },
'seed-tts-1.0': { 'zh-cn': 'should-not-leak' },
@@ -1023,8 +1028,7 @@ describe('v1CompletionsRoutes', () => {
it('returns empty recommended when ?model= is omitted', async () => {
mockUnspeechVoices([])
const configKV = createMockConfigKV({
STREAMING_TTS_UPSTREAM: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream' },
UNSPEECH_REST_BASE_URL: 'http://unspeech.local:5933',
UNSPEECH_UPSTREAM: { restBaseURL: 'http://unspeech.local:5933', streaming: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream', keys: [{ id: 'k1', ciphertext: 'enc' }] } },
DEFAULT_TTS_VOICES: { 'seed-tts-2.0': { 'zh-cn': 'x' } },
})
@@ -1042,8 +1046,7 @@ describe('v1CompletionsRoutes', () => {
it('returns empty recommended when the requested model has no configKV bucket', async () => {
mockUnspeechVoices([])
const configKV = createMockConfigKV({
STREAMING_TTS_UPSTREAM: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream' },
UNSPEECH_REST_BASE_URL: 'http://unspeech.local:5933',
UNSPEECH_UPSTREAM: { restBaseURL: 'http://unspeech.local:5933', streaming: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream', keys: [{ id: 'k1', ciphertext: 'enc' }] } },
DEFAULT_TTS_VOICES: { 'seed-tts-2.0': { 'zh-cn': 'x' } },
})
@@ -1058,11 +1061,10 @@ describe('v1CompletionsRoutes', () => {
expect(data.recommended).toEqual({})
})
it('returns 503 STREAMING_TTS_NOT_CONFIGURED when STREAMING_TTS_UPSTREAM is absent', async () => {
it('returns 503 STREAMING_TTS_NOT_CONFIGURED when UNSPEECH_UPSTREAM.streaming is absent', async () => {
mockUnspeechVoices([])
const configKV = createMockConfigKV({
STREAMING_TTS_UPSTREAM: undefined,
UNSPEECH_REST_BASE_URL: 'http://unspeech.local:5933',
UNSPEECH_UPSTREAM: { restBaseURL: 'http://unspeech.local:5933' },
})
const app = createTestApp(createMockFluxService(), configKV)
@@ -1080,8 +1082,7 @@ describe('v1CompletionsRoutes', () => {
it('returns 502 BAD_GATEWAY when unspeech responds non-2xx', async () => {
mockUnspeechFailure(503, 'unspeech is sleeping')
const configKV = createMockConfigKV({
STREAMING_TTS_UPSTREAM: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream' },
UNSPEECH_REST_BASE_URL: 'http://unspeech.local:5933',
UNSPEECH_UPSTREAM: { restBaseURL: 'http://unspeech.local:5933', streaming: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream', keys: [{ id: 'k1', ciphertext: 'enc' }] } },
})
const app = createTestApp(createMockFluxService(), configKV)
@@ -1101,8 +1102,7 @@ describe('v1CompletionsRoutes', () => {
throw new Error('ECONNREFUSED')
}) as any
const configKV = createMockConfigKV({
STREAMING_TTS_UPSTREAM: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream' },
UNSPEECH_REST_BASE_URL: 'http://unspeech.local:5933',
UNSPEECH_UPSTREAM: { restBaseURL: 'http://unspeech.local:5933', streaming: { baseURL: 'ws://unspeech.local:5933/v1/audio/speech/stream', keys: [{ id: 'k1', ciphertext: 'enc' }] } },
})
const app = createTestApp(createMockFluxService(), configKV)
+17 -27
View File
@@ -60,12 +60,12 @@ export const ttsUpstreamSchema = object({
})
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')),
baseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.baseURL must not be empty')),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'UNSPEECH_UPSTREAM.streaming.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')),
id: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.models[].id must not be empty')),
name: optional(string()),
description: optional(string()),
})),
@@ -73,6 +73,11 @@ export const streamingTtsUpstreamSchema = object({
),
})
export const unspeechUpstreamSchema = object({
restBaseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.restBaseURL must not be empty')),
streaming: optional(streamingTtsUpstreamSchema),
})
export const ttsModelSchema = object({
provider: ttsProviderSchema,
upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')),
@@ -138,27 +143,12 @@ const ConfigEntrySchemas = {
// No default — the router throws CONFIG_NOT_SET when this entry is absent
// so the admin endpoint (U9) is forced to populate it before traffic flows.
LLM_ROUTER_CONFIG: optional(llmRouterConfigSchema),
// Streaming TTS upstream — a single unspeech instance that the
// /api/v1/audio/speech/ws proxy connects to. Separate from
// 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.). `keys`
// carry the upstream-provider API key (e.g. Volcengine X-Api-Key), not an
// unspeech tenant token.
STREAMING_TTS_UPSTREAM: optional(streamingTtsUpstreamSchema),
// unspeech REST base URL (e.g. `https://airi-unspeech.railway.internal:5933`).
// Used by:
// - HTTP voice catalog lookup for live providers (Azure): the Azure TTS
// adapter calls `<base>/api/voices?backend=microsoft&region=<region>`.
// - Streaming voice catalog lookup: the streaming voices handler calls
// `<base>/api/voices?provider=volcengine&model=<api_resource_id>`.
// Kept explicit (no derivation from STREAMING_TTS_UPSTREAM.baseURL) so
// operators can point REST/voices lookups at a different unspeech instance
// from the streaming ws upstream if they want. Naked schema (no default):
// missing entry surfaces CONFIG_NOT_SET and the request fails fast instead
// of silently returning an empty voices list.
UNSPEECH_REST_BASE_URL: pipe(string(), nonEmpty('UNSPEECH_REST_BASE_URL must not be empty')),
// Single unspeech deployment used for every TTS surface: REST audio/speech,
// REST voices catalog, ws audio/speech/stream. `streaming` is optional —
// operator may run REST-only without the ws upstream. `streaming.keys`
// carry the upstream-provider API key (Volcengine X-Api-Key), not an
// unspeech tenant token (unspeech itself is unauthenticated).
UNSPEECH_UPSTREAM: optional(unspeechUpstreamSchema),
} as const
type ConfigDefinitions = {
@@ -200,16 +190,16 @@ export function createConfigKVService(redis: Redis) {
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K]> {
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await redis.get(configRedisKey(key))
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value
return value as Exclude<ConfigDefinitions[K], undefined>
},
async get<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K]> {
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
@@ -93,7 +93,7 @@ export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'volcengine'
* verbatim. Providers with static, credential-less catalogs (DashScope
* cosyvoice, Volcengine) ignore both fields.
*
* `unspeechBaseURL` is the configKV `UNSPEECH_REST_BASE_URL` resolved by the
* `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the
* router. Passing it through the context keeps adapters free of configKV
* coupling they receive a fully-resolved URL string.
*/
@@ -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, streamingTtsUpstreamSchema, ttsModelSchema } from '../../../adapters/config-kv'
import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, unspeechUpstreamSchema } from '../../../adapters/config-kv'
import { useLogger } from '@guiiai/logg'
@@ -22,13 +22,13 @@ const DEFAULT_KEY_ENTRY_IDS = {
'openrouter': 'openrouter-prod-1',
'azure': 'azure-tts-prod-1',
'dashscope-cosyvoice': 'dashscope-tts-prod-1',
'streaming-tts': 'volcengine-prod-1',
'unspeech': 'volcengine-prod-1',
} as const
type LlmRouterConfig = InferOutput<typeof llmRouterConfigSchema>
type LlmModel = InferOutput<typeof llmModelSchema>
type TtsModel = InferOutput<typeof ttsModelSchema>
type StreamingTtsUpstream = InferOutput<typeof streamingTtsUpstreamSchema>
type UnspeechUpstream = InferOutput<typeof unspeechUpstreamSchema>
/**
* Per-provider input. The admin route validates the shape with Valibot
@@ -42,7 +42,7 @@ export type SliceInput
= | OpenRouterSliceInput
| AzureSliceInput
| DashscopeSliceInput
| StreamingTtsSliceInput
| UnspeechSliceInput
export interface OpenRouterSliceInput {
kind: 'openrouter'
@@ -84,14 +84,19 @@ export interface DashscopeSliceInput {
keyEntryId?: string
}
export interface StreamingTtsSliceInput {
kind: 'streaming-tts'
/** unspeech ws endpoint: `ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream` etc. */
upstreamURL: string
/** Upstream provider key (Volcengine `X-Api-Key`), not an unspeech token. */
plaintextKey: string
/** @default 'volcengine-prod-1' */
keyEntryId?: string
export interface UnspeechSliceInput {
kind: 'unspeech'
/** unspeech REST root: `http(s)://host:port` (no trailing slash, no path). */
restBaseURL: string
/** Streaming subtree — omit when running unspeech REST-only without ws TTS. */
streaming?: {
/** unspeech ws endpoint: `ws(s)://host:port/v1/audio/speech/stream`. */
upstreamURL: string
/** Upstream provider key (Volcengine `X-Api-Key`), not an unspeech token. */
plaintextKey: string
/** @default 'volcengine-prod-1' */
keyEntryId?: string
}
}
interface LlmModelSlice {
@@ -112,14 +117,15 @@ interface TtsModelSlice {
keyEntryId: string
}
interface StreamingTtsSlice {
target: 'streaming-tts'
kind: 'streaming-tts'
value: StreamingTtsUpstream
keyEntryId: string
interface UnspeechSlice {
target: 'unspeech'
kind: 'unspeech'
value: UnspeechUpstream
/** Streaming key entry id when `streaming` is set; `null` otherwise. */
keyEntryId: string | null
}
type BuiltSlice = LlmModelSlice | TtsModelSlice | StreamingTtsSlice
type BuiltSlice = LlmModelSlice | TtsModelSlice | UnspeechSlice
/**
* Encrypts an OpenRouter slice into the LLM_ROUTER_CONFIG.llm shape.
@@ -222,31 +228,42 @@ export function buildDashscopeSlice(input: DashscopeSliceInput, envelope: Envelo
}
/**
* Encrypts a streaming TTS slice into the STREAMING_TTS_UPSTREAM shape.
* Encrypts an unspeech slice into the UNSPEECH_UPSTREAM shape.
*
* Use when:
* - Admin posts a `streaming-tts` slice; called by {@link buildSlice}.
* - Admin posts an `unspeech` slice; called by {@link buildSlice}.
*
* Expects:
* - `upstreamURL` starts with `ws://` or `wss://`. http:// is almost always a
* copy-paste of the unspeech REST endpoint and fails at `new WebSocket()`
* inside the audio-speech-ws proxy.
* - `streaming.upstreamURL` (when provided) starts with `ws://` or `wss://`.
* http:// is almost always a copy-paste of the unspeech REST endpoint and
* fails at `new WebSocket()` inside the audio-speech-ws proxy.
*/
export function buildStreamingTtsSlice(input: StreamingTtsSliceInput, envelope: EnvelopeCrypto): StreamingTtsSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS['streaming-tts']
const ciphertext = envelope.encryptKey(input.plaintextKey, {
export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: EnvelopeCrypto): UnspeechSlice {
if (!input.streaming) {
return {
target: 'unspeech',
kind: 'unspeech',
keyEntryId: null,
value: { restBaseURL: input.restBaseURL },
}
}
const keyEntryId = input.streaming.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.unspeech
const ciphertext = envelope.encryptKey(input.streaming.plaintextKey, {
modelName: STREAMING_TTS_AAD_MODEL_NAME,
keyEntryId,
})
return {
target: 'streaming-tts',
kind: 'streaming-tts',
target: 'unspeech',
kind: 'unspeech',
keyEntryId,
value: {
baseURL: input.upstreamURL,
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: {},
models: [],
restBaseURL: input.restBaseURL,
streaming: {
baseURL: input.streaming.upstreamURL,
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: {},
models: [],
},
},
}
}
@@ -266,8 +283,8 @@ export function buildSlice(input: SliceInput, envelope: EnvelopeCrypto): BuiltSl
return buildAzureSlice(input, envelope)
case 'dashscope-cosyvoice':
return buildDashscopeSlice(input, envelope)
case 'streaming-tts':
return buildStreamingTtsSlice(input, envelope)
case 'unspeech':
return buildUnspeechSlice(input, envelope)
}
}
@@ -360,10 +377,10 @@ export interface ApplyInput {
export interface AppliedSummary {
kind: SliceInput['kind']
target: 'llm-router' | 'streaming-tts'
target: 'llm-router' | 'unspeech'
surface?: 'llm' | 'tts'
modelName?: string
keyEntryId: string
keyEntryId: string | null
}
export interface ApplyResult {
@@ -371,7 +388,7 @@ export interface ApplyResult {
invalidatedKeys: string[]
preview: {
LLM_ROUTER_CONFIG?: unknown
STREAMING_TTS_UPSTREAM?: unknown
UNSPEECH_UPSTREAM?: unknown
DEFAULT_CHAT_MODEL?: string
DEFAULT_TTS_MODEL?: string
}
@@ -408,21 +425,21 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
* Applies an admin request, returning the redacted preview either way.
*
* Expects:
* - At most one `streaming-tts` slice per request. The streaming surface
* is a single unspeech instance per deployment, so multiple entries are
* almost always an admin mistake.
* - At most one `unspeech` slice per request. unspeech is a single
* deployment per environment, so multiple entries are almost always an
* admin mistake.
*/
async function apply(input: ApplyInput): Promise<ApplyResult> {
const streamingCount = input.slices.filter(s => s.kind === 'streaming-tts').length
if (streamingCount > 1)
throw createBadRequestError('At most one streaming-tts slice per request', 'INVALID_BODY')
const unspeechCount = input.slices.filter(s => s.kind === 'unspeech').length
if (unspeechCount > 1)
throw createBadRequestError('At most one unspeech slice per request', 'INVALID_BODY')
// Step 1: encrypt every slice. Throws (via envelope) only on malformed
// master key, which means the deployment is broken; surface as 500.
const built = input.slices.map(s => buildSlice(s, deps.envelope))
const llmTtsSlices = built.filter((s): s is LlmModelSlice | TtsModelSlice => s.target === 'llm-router')
const streamingSlice = built.find((s): s is StreamingTtsSlice => s.target === 'streaming-tts')
const unspeechSlice = built.find((s): s is UnspeechSlice => s.target === 'unspeech')
// Step 2: build the next LLM_ROUTER_CONFIG tree if any LLM/TTS slice
// was supplied. `merge` reads existing first; `reset` skips the read.
@@ -434,17 +451,36 @@ 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
// 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) {
nextUnspeech = {
...newValue,
streaming: { ...newValue.streaming, models: existing.streaming.models },
}
}
else {
nextUnspeech = newValue
}
}
const preview: ApplyResult['preview'] = {}
if (nextRouterConfig)
preview.LLM_ROUTER_CONFIG = redactCiphertext(nextRouterConfig)
if (streamingSlice)
preview.STREAMING_TTS_UPSTREAM = redactCiphertext(streamingSlice.value)
if (nextUnspeech)
preview.UNSPEECH_UPSTREAM = redactCiphertext(nextUnspeech)
if (input.defaults?.chatModel)
preview.DEFAULT_CHAT_MODEL = input.defaults.chatModel
if (input.defaults?.ttsModel)
preview.DEFAULT_TTS_MODEL = input.defaults.ttsModel
const applied: AppliedSummary[] = built.map(s => s.target === 'streaming-tts'
const applied: AppliedSummary[] = built.map(s => s.target === 'unspeech'
? { kind: s.kind, target: s.target, keyEntryId: s.keyEntryId }
: { kind: s.kind, target: s.target, surface: s.surface, modelName: s.modelName, keyEntryId: s.keyEntryId })
@@ -458,18 +494,16 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
return { applied, invalidatedKeys: [], preview }
}
// Step 3: real writes. configKV.set runs the per-key valibot validator,
// Step 4: real writes. configKV.set runs the per-key valibot validator,
// so a malformed shape fails here BEFORE we publish invalidation.
const invalidatedKeys: string[] = []
if (nextRouterConfig) {
await deps.configKV.set('LLM_ROUTER_CONFIG', nextRouterConfig as never)
invalidatedKeys.push('LLM_ROUTER_CONFIG')
}
if (streamingSlice) {
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 (nextUnspeech) {
await deps.configKV.set('UNSPEECH_UPSTREAM', nextUnspeech as never)
invalidatedKeys.push('UNSPEECH_UPSTREAM')
}
if (input.defaults?.chatModel) {
await deps.configKV.set('DEFAULT_CHAT_MODEL', input.defaults.chatModel)
@@ -480,10 +514,10 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
invalidatedKeys.push('DEFAULT_TTS_MODEL')
}
// Step 4: cross-instance invalidation. config-sync-subscriber currently
// only acts on `LLM_ROUTER_CONFIG` (audio-speech-ws reads
// `STREAMING_TTS_UPSTREAM` fresh on every connection), but we publish
// all touched keys for forward compatibility.
// Step 5: cross-instance invalidation. audio-speech-ws reads
// UNSPEECH_UPSTREAM.streaming fresh on every connection so the publish
// is observability-only for that surface; LLM_ROUTER_CONFIG and the
// voice catalog cache rely on it for cross-instance freshness.
for (const key of invalidatedKeys) {
const payload = JSON.stringify({ key, version: Date.now(), publishedAt: Date.now() })
await deps.redis.publish('configkv:invalidate', payload)
@@ -11,7 +11,7 @@ import {
buildDashscopeSlice,
buildNextRouterConfig,
buildOpenRouterSlice,
buildStreamingTtsSlice,
buildUnspeechSlice,
createAdminRouterConfigService,
redactCiphertext,
} from '..'
@@ -185,21 +185,38 @@ describe('buildDashscopeSlice', () => {
})
})
describe('buildStreamingTtsSlice', () => {
it('encrypts under the streaming-tts AAD model label (must match audio-speech-ws decrypt)', () => {
describe('buildUnspeechSlice', () => {
it('writes restBaseURL with no streaming subtree when the slice omits streaming', () => {
const envelope = freshEnvelope()
const built = buildStreamingTtsSlice({
kind: 'streaming-tts',
upstreamURL: 'ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream',
plaintextKey: 'volc-key',
const built = buildUnspeechSlice({
kind: 'unspeech',
restBaseURL: 'http://unspeech.example:5933',
}, envelope)
expect(built.target).toBe('streaming-tts')
expect(built.value.baseURL).toBe('ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream')
expect(built.target).toBe('unspeech')
expect(built.value.restBaseURL).toBe('http://unspeech.example:5933')
expect(built.value.streaming).toBeUndefined()
expect(built.keyEntryId).toBeNull()
})
it('encrypts streaming.plaintextKey under the streaming-tts AAD model label (must match audio-speech-ws decrypt)', () => {
const envelope = freshEnvelope()
const built = buildUnspeechSlice({
kind: 'unspeech',
restBaseURL: 'http://unspeech.example:5933',
streaming: {
upstreamURL: 'ws://unspeech.example:5933/v1/audio/speech/stream',
plaintextKey: 'volc-key',
},
}, envelope)
expect(built.target).toBe('unspeech')
expect(built.value.streaming?.baseURL).toBe('ws://unspeech.example:5933/v1/audio/speech/stream')
// The AAD modelName MUST be the literal 'streaming-tts' — anything else
// surfaces as DECRYPT_FAILED at session start in audio-speech-ws.
const decrypted = envelope.decryptKey(built.value.keys[0].ciphertext, {
const ct = built.value.streaming!.keys[0].ciphertext
const decrypted = envelope.decryptKey(ct, {
modelName: 'streaming-tts',
keyEntryId: 'volcengine-prod-1',
})
@@ -328,34 +345,37 @@ describe('createAdminRouterConfigService', () => {
expect(captured.map(p => JSON.parse(p.payload).key).sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG'])
})
it('writes STREAMING_TTS_UPSTREAM and publishes invalidation when a streaming-tts slice is included', async () => {
it('writes UNSPEECH_UPSTREAM and publishes invalidation when an unspeech slice is included', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: false,
slices: [{
kind: 'streaming-tts',
upstreamURL: 'wss://unspeech.example/v1/audio/speech/stream',
plaintextKey: 'volc',
kind: 'unspeech',
restBaseURL: 'http://unspeech.example:5933',
streaming: {
upstreamURL: 'wss://unspeech.example/v1/audio/speech/stream',
plaintextKey: 'volc',
},
}],
})
expect(kv.store.has('STREAMING_TTS_UPSTREAM')).toBe(true)
expect(kv.store.has('UNSPEECH_UPSTREAM')).toBe(true)
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
expect(result.invalidatedKeys).toEqual(['STREAMING_TTS_UPSTREAM'])
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['STREAMING_TTS_UPSTREAM'])
expect(result.invalidatedKeys).toEqual(['UNSPEECH_UPSTREAM'])
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['UNSPEECH_UPSTREAM'])
})
it('rejects multiple streaming-tts slices', async () => {
it('rejects multiple unspeech slices', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await expect(service.apply({
mode: 'merge',
dryRun: true,
slices: [
{ kind: 'streaming-tts', upstreamURL: 'ws://a/x', plaintextKey: 'a' },
{ kind: 'streaming-tts', upstreamURL: 'ws://b/x', plaintextKey: 'b' },
{ kind: 'unspeech', restBaseURL: 'http://a' },
{ kind: 'unspeech', restBaseURL: 'http://b' },
],
})).rejects.toThrow(/At most one streaming-tts/i)
})).rejects.toThrow(/At most one unspeech/i)
})
it('merge mode reads existing LLM_ROUTER_CONFIG and preserves untouched models', async () => {
@@ -82,8 +82,8 @@ export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): C
const payload = JSON.parse(message) as { key?: unknown }
// LLM_ROUTER_CONFIG drives a model-config cache + voice-catalog cache
// invalidation (key rotation, model add/remove, region swap all need to
// surface immediately). UNSPEECH_REST_BASE_URL only affects the voice
// catalog cache because no other in-process structure references it.
// surface immediately). UNSPEECH_UPSTREAM only affects the voice catalog
// cache because no other in-process structure references it.
if (payload?.key === 'LLM_ROUTER_CONFIG') {
opts.llmRouter.invalidateConfig()
void opts.llmRouter.invalidateTtsVoicesCache().catch((err) => {
@@ -95,9 +95,9 @@ export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): C
})
return
}
if (payload?.key === 'UNSPEECH_REST_BASE_URL') {
if (payload?.key === 'UNSPEECH_UPSTREAM') {
void opts.llmRouter.invalidateTtsVoicesCache().catch((err) => {
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on UNSPEECH_REST_BASE_URL change')
opts.logger.withError(err).warn('Failed to invalidate tts voices cache on UNSPEECH_UPSTREAM change')
})
}
}
@@ -549,7 +549,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
// Adapters POST to unspeech `/v1/audio/speech`; resolve the base URL once
// per request rather than per upstream attempt so a single configKV miss
// surfaces as a clean 503 before any key rotation happens.
const unspeechBaseURL = await options.configKV.getOrThrow('UNSPEECH_REST_BASE_URL')
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', errorMessage?: string }> = []
let triedUpstreams = 0
@@ -652,7 +652,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
}
}
const unspeechBaseURL = await options.configKV.getOrThrow('UNSPEECH_REST_BASE_URL')
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
// Live providers (Azure) need the decrypted Azure subscription key + region;
// static-catalog providers (alibaba, volcengine) ignore both. The router
@@ -694,9 +694,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
/**
* Drops every cached TTS voice catalog. Called by the configkv invalidation
* subscriber when `LLM_ROUTER_CONFIG` or `UNSPEECH_REST_BASE_URL` changes
* a key rotation or unspeech endpoint move must propagate to in-flight
* voice-picker fetches without waiting for the 6h TTL.
* subscriber when `LLM_ROUTER_CONFIG` or `UNSPEECH_UPSTREAM` changes a key
* rotation or unspeech endpoint move must propagate to in-flight voice-
* picker fetches without waiting for the 6h TTL.
*/
async function invalidateTtsVoicesCache(): Promise<void> {
// SCAN avoids blocking redis on a large keyspace; production deployments
@@ -730,7 +730,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
invalidateConfig: configLoader.invalidate,
/**
* Flush the Redis voice catalog cache. The config-sync subscriber calls
* this when LLM_ROUTER_CONFIG or UNSPEECH_REST_BASE_URL is rotated; admin
* this when LLM_ROUTER_CONFIG or UNSPEECH_UPSTREAM is rotated; admin
* writes invalidate it directly so the next voice-picker fetch repopulates.
*/
invalidateTtsVoicesCache,
@@ -56,12 +56,12 @@ function makeMetrics(): GatewayMetrics {
function makeConfigKV(config: RouterConfig | null): ConfigKVService {
return {
getOptional: vi.fn(async (key: string) => (key === 'LLM_ROUTER_CONFIG' ? config : null)),
// routeTts reads UNSPEECH_REST_BASE_URL once per request via getOrThrow.
// routeTts reads UNSPEECH_UPSTREAM once per request via getOrThrow.
// LLM-side tests never invoke routeTts so the value is irrelevant; TTS
// tests need a non-empty string.
// tests need a populated restBaseURL.
getOrThrow: vi.fn(async (key: string) => {
if (key === 'UNSPEECH_REST_BASE_URL')
return 'http://unspeech.local:5933'
if (key === 'UNSPEECH_UPSTREAM')
return { restBaseURL: 'http://unspeech.local:5933' }
return undefined
}),
get: vi.fn(),