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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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®ion=<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)
|
||||
|
||||
+41
-21
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user