diff --git a/apps/server/docs/ai-context/verifications/llm-router.md b/apps/server/docs/ai-context/verifications/llm-router.md index 24a88da99..611fed42b 100644 --- a/apps/server/docs/ai-context/verifications/llm-router.md +++ b/apps/server/docs/ai-context/verifications/llm-router.md @@ -28,14 +28,28 @@ stops asserting completion ahead of measurement. upstream, then invokes the router directly to call OpenRouter for a chat completion. Validates envelope decrypt → configKV load → key rotation → upstream fetch → response parse on the real wire path. -- **Command**: +- **Command** (admin endpoint replaced the seed script on 2026-05-18; the + 2026-05-15 evidence below was captured with the now-removed + `scripts/seed-router-config.ts`): ```bash + # 1. seed via the admin endpoint — requires an account whose email is in + # ADMIN_EMAILS and is verified. + curl -sS -X POST http://localhost:3000/api/admin/config/router \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "mode": "merge", + "slices": [{ + "kind": "openrouter", + "modelName": "chat-default", + "overrideModel": "openai/gpt-4o-mini", + "plaintextKey": "" + }], + "defaults": { "chatModel": "chat-default" } + }' | jq + + # 2. exercise the router via the in-process e2e harness. cd apps/server - pnpm exec dotenvx run --env-file=.env.local -- \ - tsx scripts/seed-router-config.ts \ - --openrouter-key '' \ - --openrouter-model openai/gpt-4o-mini \ - --default-chat-model chat-default pnpm exec dotenvx run --env-file=.env.local -- \ tsx scripts/e2e-llm-router.ts ``` @@ -106,9 +120,14 @@ stops asserting completion ahead of measurement. ## Known limitations / follow-up -- **U9 admin HTTP endpoint**: bootstrap currently goes through the - `scripts/seed-router-config.ts` CLI. The plan's full HTTP admin endpoint - with ETag + audit log + HMAC publish is deferred; tracked in plan U9. +- **U9 admin HTTP endpoint**: partially shipped 2026-05-18 as + `POST /api/admin/config/router` (see `routes/admin/config/router/index.ts`). + Covers the write path with audit-log fields on the structured logger, + envelope encryption in-process, and cross-instance invalidation publish. + The plan's ETag-based optimistic concurrency control and HMAC-signed + invalidate payload are still deferred; the `config_write` and + `config_invalid_hmac` counters described below remain producerless until + those land. - ~~**GATEWAY_BASE_URL**: still required in env schema~~. Resolved 2026-05-15: env entry removed, all routes go through `llmRouter.route` / `routeTts` / `listTtsVoices`. The `LLM_ROUTER_MASTER_KEY` env var is @@ -123,8 +142,9 @@ stops asserting completion ahead of measurement. PR — `app.ts` now emits `connected` / `error` / `reconnecting` from the `configkv:invalidate` subscriber). The remaining two counters (`config_write`, `config_invalid_hmac`) intentionally have no panels - because their producer is the Plan U9 admin HTTP endpoint that has - not shipped; they will rejoin Rows 6.5 / 6.7 alongside the U9 PR. + because their producer is the ETag + HMAC slice of the U9 admin + endpoint that has not shipped (see the U9 entry above); they will + rejoin Rows 6.5 / 6.7 when that slice lands. Alert rules (key.exhausted > 0, fallback ratio > 30%, single-key error ratio > 80%) are still configured through Grafana UI, not build.ts — IaC-ifying them is a separate follow-up. diff --git a/apps/server/docs/ai-context/verifications/streaming-tts.md b/apps/server/docs/ai-context/verifications/streaming-tts.md index 570d8a3d1..948d5abb9 100644 --- a/apps/server/docs/ai-context/verifications/streaming-tts.md +++ b/apps/server/docs/ai-context/verifications/streaming-tts.md @@ -82,23 +82,30 @@ once it lands. ### Prerequisite: seed `STREAMING_TTS_UPSTREAM` ```bash -cd apps/server -STREAMING_TTS_UPSTREAM_URL="ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream" \ -VOLCENGINE_TTS_API_KEY="$VOLCENGINE_TTS_API_KEY" \ -pnpm exec dotenvx run --env-file=.env.local -- \ - tsx scripts/seed-streaming-tts.ts +curl -sS -X POST http://localhost:3000/api/admin/config/router \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "slices": [{ + "kind": "streaming-tts", + "upstreamURL": "ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream", + "plaintextKey": "" + }] + }' | jq ``` -The script reads `LLM_ROUTER_MASTER_KEY` and `REDIS_URL` from -`.env.local`, envelope-encrypts the Volcengine key under AAD -`{ modelName: 'streaming-tts', keyEntryId: 'volcengine-prod-1' }`, and -writes the `STREAMING_TTS_UPSTREAM` configKV entry. Use `--dry-run` to -preview the ciphertext length without committing. +The server envelope-encrypts the plaintext key under AAD +`{ modelName: 'streaming-tts', keyEntryId: 'volcengine-prod-1' }` and +writes `STREAMING_TTS_UPSTREAM`. Add `"dryRun": true` to preview the +ciphertext length without committing. -To point at a different unspeech instance later, just re-run the script -with a different `STREAMING_TTS_UPSTREAM_URL`. To rotate the upstream -key, re-run with `--key-id volcengine-prod-N` (the audio-speech-ws -route always reads `keys[0]`, so a write replaces the active key). +To point at a different unspeech instance later, repeat the call with a +different `upstreamURL`. To rotate the upstream key, pass +`"keyEntryId": "volcengine-prod-N"` (the audio-speech-ws route always +reads `keys[0]`, so a write replaces the active key). + +`$ADMIN_TOKEN` is a Bearer token for an account whose email is in +`ADMIN_EMAILS` and is verified. ### Scenario L1: streaming session happy path diff --git a/apps/server/scripts/e2e-llm-router.ts b/apps/server/scripts/e2e-llm-router.ts index 7d0317768..2c7f0dbe4 100644 --- a/apps/server/scripts/e2e-llm-router.ts +++ b/apps/server/scripts/e2e-llm-router.ts @@ -10,7 +10,9 @@ * * Expects: * - `.env.local` provides REDIS_URL, LLM_ROUTER_MASTER_KEY. - * - `LLM_ROUTER_CONFIG` already seeded (run scripts/seed-router-config.ts first). + * - `LLM_ROUTER_CONFIG` already seeded via + * `POST /api/admin/config/router` (see + * `docs/ai-context/verifications/llm-router.md` for the curl invocation). * * Returns: exit 0 with the assistant response printed; exit 1 on failure. */ diff --git a/apps/server/scripts/seed-router-config.ts b/apps/server/scripts/seed-router-config.ts deleted file mode 100644 index 09ecd2d11..000000000 --- a/apps/server/scripts/seed-router-config.ts +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env tsx -/* eslint-disable no-console */ -/** - * Seed / patch LLM_ROUTER_CONFIG in configKV (Postgres truth + Redis cache). - * - * Use when: - * - Bootstrapping a new deployment before U9's full admin endpoint ships. - * - Adding one TTS provider on top of an existing config without touching - * the others (the default `--merge` mode). - * - Previewing a write before committing it (`--dry-run`). - * - * Expects: - * - `.env.local` (or the deployment env) provides `REDIS_URL` and - * `LLM_ROUTER_MASTER_KEY`. - * - Provider plaintext keys come from env vars — never CLI flags — so they - * stay out of shell history and `ps`: - * OPENROUTER_KEY="sk-or-..." - * AZURE_KEY="..." - * DASHSCOPE_KEY="..." - * - * Modes: - * - default: merge. Reads existing `LLM_ROUTER_CONFIG`, upserts entries for - * providers whose key env var is set, leaves the rest untouched. - * - `--reset`: full overwrite. The new config contains only the providers - * you supplied keys for; everything else is dropped. - * - `--dry-run`: compute the final config and print it (ciphertext redacted) - * without writing or publishing. - * - * On a real write (non dry-run) the script publishes `configkv:invalidate` - * so any running instance picks the new config up within Pub/Sub propagation - * time (R16 / KTD-4, ≤5s under healthy Redis). - */ -import type { ConfigKVService } from '../src/services/config-kv' -import type { EnvelopeCrypto } from '../src/utils/envelope-crypto' - -import { env, exit } from 'node:process' - -import Redis from 'ioredis' - -import { parseEnv } from '../src/libs/env' -import { createConfigKVService } from '../src/services/config-kv' -import { createEnvelopeCrypto } from '../src/utils/envelope-crypto' - -interface Args { - mode: 'merge' | 'reset' - dryRun: boolean - openrouterModel: string - defaultChatModel: string - azureRegion: string - azureTtsModel: string - dashscopeTtsModel: string - /** `intl` → dashscope-intl.aliyuncs.com (Singapore); `cn` → dashscope.aliyuncs.com (Beijing). */ - dashscopeRegion: string - /** Concrete cosyvoice variant the adapter calls upstream. Independent from `dashscopeTtsModel` (the gateway-facing alias). */ - dashscopeUpstreamModel: string - defaultTtsModel: string | undefined -} - -function parseArgs(argv: string[]): Args { - const flags = new Set() - const values: Record = {} - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (!arg.startsWith('--')) - continue - const key = arg.slice(2) - const next = argv[i + 1] - if (next == null || next.startsWith('--')) { - flags.add(key) - } - else { - values[key] = next - i++ - } - } - return { - mode: flags.has('reset') ? 'reset' : 'merge', - dryRun: flags.has('dry-run'), - openrouterModel: values['openrouter-model'] ?? 'openai/gpt-4o-mini', - defaultChatModel: values['default-chat-model'] ?? 'chat-default', - azureRegion: values['azure-region'] ?? 'eastasia', - azureTtsModel: values['azure-tts-model'] ?? 'microsoft/v1', - dashscopeTtsModel: values['dashscope-tts-model'] ?? 'alibaba/cosyvoice-v2', - dashscopeRegion: values['dashscope-region'] ?? 'intl', - dashscopeUpstreamModel: values['dashscope-upstream-model'] ?? 'cosyvoice-v2', - defaultTtsModel: values['default-tts-model'], - } -} - -interface ProviderSlice { - llmModelName?: string - llmModel?: Record - ttsModelName?: string - ttsModel?: Record -} - -function buildOpenRouter(args: Args, plaintext: string, envelope: EnvelopeCrypto): ProviderSlice { - const keyEntryId = 'openrouter-prod-1' - const ciphertext = envelope.encryptKey(plaintext, { - modelName: args.defaultChatModel, - keyEntryId, - }) - return { - llmModelName: args.defaultChatModel, - llmModel: { - upstreams: [{ - baseURL: 'https://openrouter.ai/api/v1', - overrideModel: args.openrouterModel, - keys: [{ id: keyEntryId, ciphertext }], - headerTemplate: 'Bearer {KEY}', - }], - }, - } -} - -function buildAzure(args: Args, plaintext: string, envelope: EnvelopeCrypto): ProviderSlice { - const keyEntryId = 'azure-tts-prod-1' - const ciphertext = envelope.encryptKey(plaintext, { - modelName: args.azureTtsModel, - keyEntryId, - }) - return { - ttsModelName: args.azureTtsModel, - ttsModel: { - provider: 'azure', - upstreams: [{ - baseURL: `https://${args.azureRegion}.tts.speech.microsoft.com/cognitiveservices/v1`, - keys: [{ id: keyEntryId, ciphertext }], - adapterParams: { region: args.azureRegion }, - }], - }, - } -} - -function buildDashscope(args: Args, plaintext: string, envelope: EnvelopeCrypto): ProviderSlice { - const keyEntryId = 'dashscope-tts-prod-1' - const ciphertext = envelope.encryptKey(plaintext, { - modelName: args.dashscopeTtsModel, - keyEntryId, - }) - // dashscope-cosyvoice adapter expects the FULL non-streaming endpoint path — - // it does not append `/services/audio/tts/SpeechSynthesizer` itself. A bare - // `/api/v1` baseURL was the root cause of the 404 storm during the v1→v2 - // migration; do not regress here. - const host = args.dashscopeRegion === 'cn' - ? 'dashscope.aliyuncs.com' - : 'dashscope-intl.aliyuncs.com' - return { - ttsModelName: args.dashscopeTtsModel, - ttsModel: { - provider: 'dashscope-cosyvoice', - upstreams: [{ - baseURL: `https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`, - keys: [{ id: keyEntryId, ciphertext }], - adapterParams: { model: args.dashscopeUpstreamModel }, - }], - }, - } -} - -interface BuiltConfig { - config: { llm: { models: Record }, tts: { models: Record } } - appliedSlices: ProviderSlice[] - defaultChatModel: string | undefined - defaultTtsModel: string | undefined -} - -/** - * Decides the next `LLM_ROUTER_CONFIG` shape from current args + (optionally) - * the existing config. - * - * Use when: - * - About to write a new config tree, before serializing it. - * - * Returns: - * - `config` — the next tree to write. - * - `appliedSlices` — which providers were upserted this run (for log output). - * - `defaultChatModel` / `defaultTtsModel` — the alias values to write into - * their dedicated configKV entries (undefined means "don't change"). - */ -function buildNextConfig(args: Args, existing: any, slices: ProviderSlice[]): BuiltConfig { - // `merge`: start from existing (or empty if none yet); `reset`: start fresh. - // `existing` is the parsed `LLM_ROUTER_CONFIG` value (or null when absent). - const llmModels: Record - = args.mode === 'merge' && existing?.llm?.models ? { ...existing.llm.models } : {} - const ttsModels: Record - = args.mode === 'merge' && existing?.tts?.models ? { ...existing.tts.models } : {} - - for (const slice of slices) { - if (slice.llmModelName && slice.llmModel) - llmModels[slice.llmModelName] = slice.llmModel - if (slice.ttsModelName && slice.ttsModel) - ttsModels[slice.ttsModelName] = slice.ttsModel - } - - // Default chat alias: explicit flag wins; otherwise only set on first-time - // bootstrap (no existing alias) and only if we just added the chat slice. - const llmSlice = slices.find(s => s.llmModelName) - let defaultChatModel: string | undefined - if (args.defaultChatModel && llmSlice && llmSlice.llmModelName === args.defaultChatModel) - defaultChatModel = args.defaultChatModel - - // Default TTS alias: explicit `--default-tts-model` wins; otherwise pick the - // first TTS slice we added this run, but never silently override an alias - // the operator already chose in `merge` mode. - let defaultTtsModel: string | undefined = args.defaultTtsModel - if (!defaultTtsModel && args.mode === 'reset') { - const ttsSlice = slices.find(s => s.ttsModelName) - defaultTtsModel = ttsSlice?.ttsModelName - } - - return { - config: { llm: { models: llmModels }, tts: { models: ttsModels } }, - appliedSlices: slices, - defaultChatModel, - defaultTtsModel, - } -} - -/** - * Redacts every `ciphertext` field down to its length for safe printing. - * - * Before: - * - `{ "keys": [{ "id": "k1", "ciphertext": "aGVsbG8=...long..." }] }` - * - * After: - * - `{ "keys": [{ "id": "k1", "ciphertext": "" }] }` - */ -function redactCiphertext(value: unknown): unknown { - if (Array.isArray(value)) - return value.map(redactCiphertext) - if (value && typeof value === 'object') { - const out: Record = {} - for (const [k, v] of Object.entries(value as Record)) { - if (k === 'ciphertext' && typeof v === 'string') - out[k] = `` - else - out[k] = redactCiphertext(v) - } - return out - } - return value -} - -async function main() { - const parsedEnv = parseEnv(env) - if (!parsedEnv.LLM_ROUTER_MASTER_KEY) { - console.error('error: LLM_ROUTER_MASTER_KEY env var is required (32 random bytes, base64)') - exit(1) - } - - const args = parseArgs(process.argv.slice(2)) - - const openrouterKey = process.env.OPENROUTER_KEY - const azureKey = process.env.AZURE_KEY - const dashscopeKey = process.env.DASHSCOPE_KEY - - if (!openrouterKey && !azureKey && !dashscopeKey) { - console.error('error: at least one provider key env var must be set:') - console.error(' OPENROUTER_KEY=... pnpm ... seed-router-config.ts # chat') - console.error(' AZURE_KEY=... pnpm ... seed-router-config.ts # tts (azure)') - console.error(' DASHSCOPE_KEY=... pnpm ... seed-router-config.ts # tts (cosyvoice)') - console.error('keys are read from env so they never appear in shell history or `ps`.') - exit(1) - } - - const envelope = createEnvelopeCrypto({ - masterKey: parsedEnv.LLM_ROUTER_MASTER_KEY, - previousMasterKey: parsedEnv.LLM_ROUTER_MASTER_KEY_PREVIOUS, - }) - - const slices: ProviderSlice[] = [] - if (openrouterKey) - slices.push(buildOpenRouter(args, openrouterKey, envelope)) - if (azureKey) - slices.push(buildAzure(args, azureKey, envelope)) - if (dashscopeKey) - slices.push(buildDashscope(args, dashscopeKey, envelope)) - - // Connect to Redis before reading existing config (merge mode) and before - // writing. In dry-run we still connect because `merge` needs to read. - const redis = new Redis(parsedEnv.REDIS_URL) - const configKV: ConfigKVService = createConfigKVService(redis) - - const existing = args.mode === 'merge' - ? await configKV.getOptional('LLM_ROUTER_CONFIG') - : null - - const built = buildNextConfig(args, existing, slices) - - // Summary header — same in both real and dry-run paths so output is easy to - // diff between modes. - console.log(`mode: ${args.mode}${args.dryRun ? ' (dry-run)' : ''}`) - console.log(`providers: ${slices.map((s) => { - if (s.llmModelName) - return `openrouter→${s.llmModelName}` - if (s.ttsModel && (s.ttsModel as any).provider === 'azure') - return `azure→${s.ttsModelName}` - if (s.ttsModel && (s.ttsModel as any).provider === 'dashscope-cosyvoice') - return `dashscope→${s.ttsModelName}` - return s.ttsModelName ?? '?' - }).join(', ') || '(none)'}`) - console.log(`llm.models: [${Object.keys(built.config.llm.models).join(', ')}]`) - console.log(`tts.models: [${Object.keys(built.config.tts.models).join(', ')}]`) - if (built.defaultChatModel) - console.log(`DEFAULT_CHAT_MODEL → ${built.defaultChatModel}`) - if (built.defaultTtsModel) - console.log(`DEFAULT_TTS_MODEL → ${built.defaultTtsModel}`) - - if (args.dryRun) { - console.log('') - console.log('--- LLM_ROUTER_CONFIG (ciphertext redacted) ---') - console.log(JSON.stringify(redactCiphertext(built.config), null, 2)) - console.log('--- end ---') - console.log('dry-run: no writes, no publish.') - await redis.quit() - return - } - - // Real write path. configKV.set runs the valibot validator on the way in, - // so a malformed slice fails here before we publish invalidation. - await configKV.set('LLM_ROUTER_CONFIG', built.config as never) - if (built.defaultChatModel) - await configKV.set('DEFAULT_CHAT_MODEL', built.defaultChatModel) - if (built.defaultTtsModel) - await configKV.set('DEFAULT_TTS_MODEL', built.defaultTtsModel) - - const keysToInvalidate: string[] = ['LLM_ROUTER_CONFIG'] - if (built.defaultChatModel) - keysToInvalidate.push('DEFAULT_CHAT_MODEL') - if (built.defaultTtsModel) - keysToInvalidate.push('DEFAULT_TTS_MODEL') - for (const key of keysToInvalidate) { - const payload = JSON.stringify({ key, version: Date.now(), publishedAt: Date.now() }) - await redis.publish('configkv:invalidate', payload) - } - - console.log(`Published configkv:invalidate for ${keysToInvalidate.length} keys.`) - await redis.quit() -} - -main().catch((err) => { - console.error('seed-router-config failed:', err) - exit(1) -}) diff --git a/apps/server/scripts/seed-streaming-tts.ts b/apps/server/scripts/seed-streaming-tts.ts deleted file mode 100644 index bd9ea6d6c..000000000 --- a/apps/server/scripts/seed-streaming-tts.ts +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env tsx -/* eslint-disable no-console */ -/** - * Seed the `STREAMING_TTS_UPSTREAM` configKV entry that powers - * `/api/v1/audio/speech/ws`. - * - * Why a separate script from `seed-router-config.ts`: - * - STREAMING_TTS_UPSTREAM is a sibling top-level configKV entry, not part - * of `LLM_ROUTER_CONFIG.tts.models`. Mixing the two write paths would - * make the merge semantics in `seed-router-config.ts` harder to follow. - * - The streaming surface has only one upstream (one unspeech instance - * per deployment), so a focused script avoids the multi-provider - * merge logic entirely. - * - * What the script writes: - * - `STREAMING_TTS_UPSTREAM.baseURL` — the unspeech ws endpoint, e.g. - * `ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream` - * or `wss://unspeech.example.com/v1/audio/speech/stream`. - * - `STREAMING_TTS_UPSTREAM.keys[0]` — the **upstream provider** key - * (Volcengine `X-Api-Key`) wrapped in an envelope ciphertext, not the - * unspeech key — unspeech itself has no auth concept, it just - * forwards the `Authorization` header verbatim to the upstream. - * - * Envelope AAD: - * - `modelName: 'streaming-tts'` and `keyEntryId: 'volcengine-prod-1'`. - * These must match the values `audio-speech-ws/index.ts` uses when - * decrypting (`STREAM_MODEL_LABEL_FALLBACK = 'streaming-tts'`). - * - * Cross-instance invalidation: - * - Published on `configkv:invalidate`. The audio-speech-ws route reads - * STREAMING_TTS_UPSTREAM fresh on every connection (no in-memory - * cache), so the publish is currently informational — it stays here - * for forward compatibility if we add caching later. - * - * Usage: - * - * STREAMING_TTS_UPSTREAM_URL="ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream" \ - * VOLCENGINE_TTS_API_KEY="sk-..." \ - * pnpm exec dotenvx run --env-file=.env.local -- \ - * tsx scripts/seed-streaming-tts.ts - * - * # preview the ciphertext shape without writing: - * pnpm exec dotenvx run --env-file=.env.local -- \ - * tsx scripts/seed-streaming-tts.ts --dry-run - * - * # rotate the key id (default 'volcengine-prod-1'): - * tsx scripts/seed-streaming-tts.ts --key-id volcengine-prod-2 - */ -import type { ConfigKVService } from '../src/services/config-kv' - -import process, { env, exit } from 'node:process' - -import Redis from 'ioredis' - -import { parseEnv } from '../src/libs/env' -import { createConfigKVService } from '../src/services/config-kv' -import { createEnvelopeCrypto } from '../src/utils/envelope-crypto' - -// Must match `STREAM_MODEL_LABEL_FALLBACK` in -// apps/server/src/routes/audio-speech-ws/index.ts — the route decrypts -// with this AAD, so seeding under a different label would surface as -// `DECRYPT_FAILED` at session start. -const STREAM_AAD_MODEL_NAME = 'streaming-tts' - -interface Args { - dryRun: boolean - keyId: string -} - -function parseArgs(argv: string[]): Args { - const flags = new Set() - const values: Record = {} - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (!arg.startsWith('--')) - continue - const key = arg.slice(2) - const next = argv[i + 1] - if (next == null || next.startsWith('--')) { - flags.add(key) - } - else { - values[key] = next - i++ - } - } - return { - dryRun: flags.has('dry-run'), - keyId: values['key-id'] ?? 'volcengine-prod-1', - } -} - -async function main() { - const parsedEnv = parseEnv(env) - if (!parsedEnv.LLM_ROUTER_MASTER_KEY) { - console.error('error: LLM_ROUTER_MASTER_KEY env var is required (32 random bytes, base64)') - exit(1) - } - - const args = parseArgs(process.argv.slice(2)) - - const upstreamURL = process.env.STREAMING_TTS_UPSTREAM_URL - const volcKey = process.env.VOLCENGINE_TTS_API_KEY - - if (!upstreamURL || !volcKey) { - console.error('error: STREAMING_TTS_UPSTREAM_URL and VOLCENGINE_TTS_API_KEY env vars are required.') - console.error(' STREAMING_TTS_UPSTREAM_URL=ws://:5933/v1/audio/speech/stream') - console.error(' VOLCENGINE_TTS_API_KEY=') - console.error('keys are read from env so they never appear in shell history or `ps`.') - exit(1) - } - - // Soft-validate the scheme. unspeech is reachable over either ws:// (Railway - // internal networking) or wss:// (public TLS-terminated). Anything else - // (http://, https://) is almost certainly a copy-paste error from the - // unspeech REST endpoint URL. - if (!upstreamURL.startsWith('ws://') && !upstreamURL.startsWith('wss://')) { - console.error(`error: STREAMING_TTS_UPSTREAM_URL must start with ws:// or wss://, got: ${upstreamURL}`) - console.error('hint: the apps/server proxy opens a WebSocket — http:// will fail at the new WebSocket() call.') - exit(1) - } - - const envelope = createEnvelopeCrypto({ - masterKey: parsedEnv.LLM_ROUTER_MASTER_KEY, - previousMasterKey: parsedEnv.LLM_ROUTER_MASTER_KEY_PREVIOUS, - }) - - const ciphertext = envelope.encryptKey(volcKey, { - modelName: STREAM_AAD_MODEL_NAME, - keyEntryId: args.keyId, - }) - - const value = { - baseURL: upstreamURL, - keys: [{ id: args.keyId, ciphertext }], - adapterParams: {}, - } - - console.log(`mode: seed${args.dryRun ? ' (dry-run)' : ''}`) - console.log(`baseURL: ${upstreamURL}`) - console.log(`keys[0].id: ${args.keyId}`) - console.log(`ciphertext: <${ciphertext.length} chars>`) - - if (args.dryRun) { - console.log('dry-run: no writes, no publish.') - return - } - - const redis = new Redis(parsedEnv.REDIS_URL) - const configKV: ConfigKVService = createConfigKVService(redis) - - // configKV.set runs the valibot validator (ttsUpstreamSchema) before - // committing; a malformed shape fails here instead of at first request. - await configKV.set('STREAMING_TTS_UPSTREAM', value as never) - - const payload = JSON.stringify({ - key: 'STREAMING_TTS_UPSTREAM', - version: Date.now(), - publishedAt: Date.now(), - }) - await redis.publish('configkv:invalidate', payload) - - console.log('STREAMING_TTS_UPSTREAM written.') - console.log('Published configkv:invalidate.') - - await redis.quit() -} - -main().catch((err) => { - console.error('seed-streaming-tts failed:', err) - exit(1) -}) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 1cab72fd3..c602b3ad9 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -344,10 +344,10 @@ export async function buildApp(deps: AppDeps) { .route('/api/admin/flux-grants', createAdminFluxGrantsRoutes(deps.adminFluxGrantsService, deps.env)) /** - * Admin LLM router config seeding/patching. Replaces the - * `scripts/seed-router-config.ts` and `scripts/seed-streaming-tts.ts` - * one-off scripts for in-cluster use; the scripts stay as break-glass - * tools. See `routes/admin/config/router/index.ts` for the body shape. + * Admin LLM router config seeding/patching. Single entry point for + * writing `LLM_ROUTER_CONFIG`, `STREAMING_TTS_UPSTREAM`, and the + * `DEFAULT_{CHAT,TTS}_MODEL` aliases — see + * `routes/admin/config/router/index.ts` for the body shape. */ .route('/api/admin/config/router', createAdminRouterConfigRoutes(deps.adminRouterConfigService, deps.env)) diff --git a/apps/server/src/routes/admin/config/router/index.ts b/apps/server/src/routes/admin/config/router/index.ts index fed8b3b83..64e56f24e 100644 --- a/apps/server/src/routes/admin/config/router/index.ts +++ b/apps/server/src/routes/admin/config/router/index.ts @@ -69,10 +69,10 @@ const DashscopeSliceSchema = object({ }) /** - * `upstreamURL` must be ws:// or wss://. Mirrors the soft-validate in the - * seed-streaming-tts script — 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. + * `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. */ const StreamingTtsSliceSchema = object({ kind: literal('streaming-tts'), @@ -108,12 +108,10 @@ const BodySchema = object({ }) /** - * Admin route for seeding / patching the LLM router config tree. - * - * Mounted at `POST /api/admin/config/router`. Replaces the - * `scripts/seed-router-config.ts` and `scripts/seed-streaming-tts.ts` - * one-off scripts for routine in-cluster operation; the scripts are kept - * as break-glass tools for cold-boot / disaster recovery. + * 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 + * `DEFAULT_{CHAT,TTS}_MODEL` aliases. * * Body shape (discriminated on `slices[].kind`): *