refactor(server): drop seed-router-config / seed-streaming-tts scripts
The two seed scripts are fully superseded by the new admin endpoint `POST /api/admin/config/router` — same encryption, same configKV writes, same `configkv:invalidate` publish, plus auth + audit + body limits. Keeping both code paths created a drift risk on the AAD label and the merge semantics. Doc + test fallout: - `e2e-llm-router.ts` now points readers to the admin endpoint for the prerequisite seed step. - `docs/ai-context/verifications/llm-router.md` and `streaming-tts.md` get curl-based seed instructions; the 2026-05-15 llm-router evidence stays intact with a note that the script it used has since been removed. - The U9 follow-up entry in `llm-router.md` flips from "not shipped" to "partially shipped" — ETag + HMAC publish are still deferred, so the `config_write` / `config_invalid_hmac` Grafana panels stay parked. - Self-edit on the admin route + `app.ts` docstrings to drop the earlier "scripts stay as break-glass" wording.
This commit is contained in:
@@ -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": "<OPENROUTER_KEY>"
|
||||
}],
|
||||
"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_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.
|
||||
|
||||
@@ -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": "<VOLCENGINE_TTS_API_KEY>"
|
||||
}]
|
||||
}' | 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<string>()
|
||||
const values: Record<string, string> = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (!arg.startsWith('--'))
|
||||
continue
|
||||
const key = arg.slice(2)
|
||||
const next = argv[i + 1]
|
||||
if (next == null || next.startsWith('--')) {
|
||||
flags.add(key)
|
||||
}
|
||||
else {
|
||||
values[key] = next
|
||||
i++
|
||||
}
|
||||
}
|
||||
return {
|
||||
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<string, unknown>
|
||||
ttsModelName?: string
|
||||
ttsModel?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown> }, tts: { models: Record<string, unknown> } }
|
||||
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<string, unknown>
|
||||
= args.mode === 'merge' && existing?.llm?.models ? { ...existing.llm.models } : {}
|
||||
const ttsModels: Record<string, unknown>
|
||||
= 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": "<ciphertext: 1024 chars>" }] }`
|
||||
*/
|
||||
function redactCiphertext(value: unknown): unknown {
|
||||
if (Array.isArray(value))
|
||||
return value.map(redactCiphertext)
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (k === 'ciphertext' && typeof v === 'string')
|
||||
out[k] = `<ciphertext: ${v.length} chars>`
|
||||
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)
|
||||
})
|
||||
@@ -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<string>()
|
||||
const values: Record<string, string> = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (!arg.startsWith('--'))
|
||||
continue
|
||||
const key = arg.slice(2)
|
||||
const next = argv[i + 1]
|
||||
if (next == null || next.startsWith('--')) {
|
||||
flags.add(key)
|
||||
}
|
||||
else {
|
||||
values[key] = next
|
||||
i++
|
||||
}
|
||||
}
|
||||
return {
|
||||
dryRun: flags.has('dry-run'),
|
||||
keyId: values['key-id'] ?? 'volcengine-prod-1',
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const parsedEnv = parseEnv(env)
|
||||
if (!parsedEnv.LLM_ROUTER_MASTER_KEY) {
|
||||
console.error('error: LLM_ROUTER_MASTER_KEY env var is required (32 random bytes, base64)')
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
const upstreamURL = process.env.STREAMING_TTS_UPSTREAM_URL
|
||||
const volcKey = process.env.VOLCENGINE_TTS_API_KEY
|
||||
|
||||
if (!upstreamURL || !volcKey) {
|
||||
console.error('error: STREAMING_TTS_UPSTREAM_URL and VOLCENGINE_TTS_API_KEY env vars are required.')
|
||||
console.error(' STREAMING_TTS_UPSTREAM_URL=ws://<unspeech-host>:5933/v1/audio/speech/stream')
|
||||
console.error(' VOLCENGINE_TTS_API_KEY=<volcengine X-Api-Key>')
|
||||
console.error('keys are read from env so they never appear in shell history or `ps`.')
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Soft-validate the scheme. unspeech is reachable over either ws:// (Railway
|
||||
// internal networking) or wss:// (public TLS-terminated). Anything else
|
||||
// (http://, https://) is almost certainly a copy-paste error from the
|
||||
// unspeech REST endpoint URL.
|
||||
if (!upstreamURL.startsWith('ws://') && !upstreamURL.startsWith('wss://')) {
|
||||
console.error(`error: STREAMING_TTS_UPSTREAM_URL must start with ws:// or wss://, got: ${upstreamURL}`)
|
||||
console.error('hint: the apps/server proxy opens a WebSocket — http:// will fail at the new WebSocket() call.')
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const envelope = createEnvelopeCrypto({
|
||||
masterKey: parsedEnv.LLM_ROUTER_MASTER_KEY,
|
||||
previousMasterKey: parsedEnv.LLM_ROUTER_MASTER_KEY_PREVIOUS,
|
||||
})
|
||||
|
||||
const ciphertext = envelope.encryptKey(volcKey, {
|
||||
modelName: STREAM_AAD_MODEL_NAME,
|
||||
keyEntryId: args.keyId,
|
||||
})
|
||||
|
||||
const value = {
|
||||
baseURL: upstreamURL,
|
||||
keys: [{ id: args.keyId, ciphertext }],
|
||||
adapterParams: {},
|
||||
}
|
||||
|
||||
console.log(`mode: seed${args.dryRun ? ' (dry-run)' : ''}`)
|
||||
console.log(`baseURL: ${upstreamURL}`)
|
||||
console.log(`keys[0].id: ${args.keyId}`)
|
||||
console.log(`ciphertext: <${ciphertext.length} chars>`)
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('dry-run: no writes, no publish.')
|
||||
return
|
||||
}
|
||||
|
||||
const redis = new Redis(parsedEnv.REDIS_URL)
|
||||
const configKV: ConfigKVService = createConfigKVService(redis)
|
||||
|
||||
// configKV.set runs the valibot validator (ttsUpstreamSchema) before
|
||||
// committing; a malformed shape fails here instead of at first request.
|
||||
await configKV.set('STREAMING_TTS_UPSTREAM', value as never)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
key: 'STREAMING_TTS_UPSTREAM',
|
||||
version: Date.now(),
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
await redis.publish('configkv:invalidate', payload)
|
||||
|
||||
console.log('STREAMING_TTS_UPSTREAM written.')
|
||||
console.log('Published configkv:invalidate.')
|
||||
|
||||
await redis.quit()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('seed-streaming-tts failed:', err)
|
||||
exit(1)
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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`):
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user