From be2c355b0e41b8956f8c1729afd47796f99fb3db Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 19 May 2026 23:28:42 +0800 Subject: [PATCH] refactor(server): merge STREAMING_TTS_UPSTREAM + UNSPEECH_REST_BASE_URL into UNSPEECH_UPSTREAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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. --- apps/server/src/app.ts | 2 +- .../src/routes/admin/config/router/index.ts | 45 ++++-- .../src/routes/audio-speech-ws/index.ts | 11 +- .../src/routes/audio-speech-ws/route.test.ts | 13 +- apps/server/src/routes/openai/v1/index.ts | 25 +-- .../server/src/routes/openai/v1/route.test.ts | 50 +++--- .../server/src/services/adapters/config-kv.ts | 44 ++---- .../server/src/services/adapters/tts/types.ts | 2 +- .../domain/admin/router-config/index.ts | 148 +++++++++++------- .../tests/admin-router-config.test.ts | 62 +++++--- .../llm-router/config-sync-subscriber.ts | 8 +- .../src/services/domain/llm-router/router.ts | 12 +- .../domain/llm-router/tests/router.test.ts | 8 +- 13 files changed, 240 insertions(+), 190 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d1378bfc6..45409c05b 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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. */ diff --git a/apps/server/src/routes/admin/config/router/index.ts b/apps/server/src/routes/admin/config/router/index.ts index c608a8e7a..66ef6926a 100644 --- a/apps/server/src/routes/admin/config/router/index.ts +++ b/apps/server/src/routes/admin/config/router/index.ts @@ -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 "" * "LLM_ROUTER_CONFIG": { ... }, - * "STREAMING_TTS_UPSTREAM": { ... }, + * "UNSPEECH_UPSTREAM": { ... }, * "DEFAULT_CHAT_MODEL": "chat-default", * "DEFAULT_TTS_MODEL": "alibaba/cosyvoice-v2" * } diff --git a/apps/server/src/routes/audio-speech-ws/index.ts b/apps/server/src/routes/audio-speech-ws/index.ts index fe8c56992..09bff6718 100644 --- a/apps/server/src/routes/audio-speech-ws/index.ts +++ b/apps/server/src/routes/audio-speech-ws/index.ts @@ -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> + let unspeech: Awaited> 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 diff --git a/apps/server/src/routes/audio-speech-ws/route.test.ts b/apps/server/src/routes/audio-speech-ws/route.test.ts index 29a3a7414..86b625fc6 100644 --- a/apps/server/src/routes/audio-speech-ws/route.test.ts +++ b/apps/server/src/routes/audio-speech-ws/route.test.ts @@ -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 diff --git a/apps/server/src/routes/openai/v1/index.ts b/apps/server/src/routes/openai/v1/index.ts index f066cad76..97f94befa 100644 --- a/apps/server/src/routes/openai/v1/index.ts +++ b/apps/server/src/routes/openai/v1/index.ts @@ -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) { - 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) { - 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, diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 383040a8d..d4d52fd18 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -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) diff --git a/apps/server/src/services/adapters/config-kv.ts b/apps/server/src/services/adapters/config-kv.ts index 64521148e..7c992881d 100644 --- a/apps/server/src/services/adapters/config-kv.ts +++ b/apps/server/src/services/adapters/config-kv.ts @@ -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 `/api/voices?backend=microsoft®ion=`. - // - Streaming voice catalog lookup: the streaming voices handler calls - // `/api/voices?provider=volcengine&model=`. - // 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(key: K): Promise { + async getOrThrow(key: K): Promise> { 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 }, - async get(key: K): Promise { + async get(key: K): Promise> { return this.getOrThrow(key) }, diff --git a/apps/server/src/services/adapters/tts/types.ts b/apps/server/src/services/adapters/tts/types.ts index 0e3468d1b..a5fd8de91 100644 --- a/apps/server/src/services/adapters/tts/types.ts +++ b/apps/server/src/services/adapters/tts/types.ts @@ -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. */ diff --git a/apps/server/src/services/domain/admin/router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts index 3296334be..ea78e4785 100644 --- a/apps/server/src/services/domain/admin/router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -2,7 +2,7 @@ import type Redis from 'ioredis' import type { InferOutput } from 'valibot' import type { EnvelopeCrypto } from '../../../../utils/envelope-crypto' -import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, 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 type LlmModel = InferOutput type TtsModel = InferOutput -type StreamingTtsUpstream = InferOutput +type UnspeechUpstream = InferOutput /** * 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 { - 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) diff --git a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts index 58810fb06..45234973d 100644 --- a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts +++ b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts @@ -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 () => { diff --git a/apps/server/src/services/domain/llm-router/config-sync-subscriber.ts b/apps/server/src/services/domain/llm-router/config-sync-subscriber.ts index c65d24b4c..6c45df17f 100644 --- a/apps/server/src/services/domain/llm-router/config-sync-subscriber.ts +++ b/apps/server/src/services/domain/llm-router/config-sync-subscriber.ts @@ -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') }) } } diff --git a/apps/server/src/services/domain/llm-router/router.ts b/apps/server/src/services/domain/llm-router/router.ts index 8f22dd326..72fd166e7 100644 --- a/apps/server/src/services/domain/llm-router/router.ts +++ b/apps/server/src/services/domain/llm-router/router.ts @@ -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 { // 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, diff --git a/apps/server/src/services/domain/llm-router/tests/router.test.ts b/apps/server/src/services/domain/llm-router/tests/router.test.ts index db0f479f6..dcc75e3b2 100644 --- a/apps/server/src/services/domain/llm-router/tests/router.test.ts +++ b/apps/server/src/services/domain/llm-router/tests/router.test.ts @@ -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(),