feat(server): admin endpoint for seeding/patching LLM router config

Replaces routine use of `scripts/seed-router-config.ts` and
`scripts/seed-streaming-tts.ts` with `POST /api/admin/config/router`.
Operators can now patch one provider at a time without shelling into
the Railway runner; the seed scripts stay as break-glass tools for
cold-boot and disaster recovery.

The endpoint accepts a discriminated-union slice list (openrouter /
azure / dashscope-cosyvoice / streaming-tts), envelope-encrypts
plaintext keys in-process (never echoed back), and supports
merge/reset modes plus dryRun. Writes go through the existing
configKV + Redis `configkv:invalidate` channel so multi-instance
deployments pick up changes within the pub/sub propagation window.

Guarded by the existing `authGuard + adminGuard` pair
(`ADMIN_EMAILS` allowlist + verified email).
This commit is contained in:
RainbowBird
2026-05-18 23:36:07 +08:00
parent efe0f8ac19
commit c1b88701db
5 changed files with 1156 additions and 1 deletions
+1
View File
@@ -47,6 +47,7 @@ function createTestDeps() {
stripeService: {} as any,
billingService: {} as any,
adminFluxGrantsService: {} as any,
adminRouterConfigService: {} as any,
ttsMeter: {} as any,
requestLogService: {} as any,
configKV: {
+26 -1
View File
@@ -6,6 +6,7 @@ import type { Database } from './libs/db'
import type { Env } from './libs/env'
import type { OtelInstance } from './otel'
import type { AdminFluxGrantsService } from './services/admin-flux-grants'
import type { AdminRouterConfigService } from './services/admin-router-config'
import type { BillingService } from './services/billing/billing-service'
import type { FluxMeter } from './services/billing/flux-meter'
import type { CharacterService } from './services/characters'
@@ -46,6 +47,7 @@ import { sessionMiddleware } from './middlewares/auth'
import { emitOtelLog, initOtel } from './otel'
import { registerActiveSessionsGauge } from './otel/gauges/active-sessions'
import { registerDistinctActiveUsersGauge } from './otel/gauges/distinct-active-users'
import { createAdminRouterConfigRoutes } from './routes/admin/config/router'
import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants'
import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws'
import { createAuthRoutes } from './routes/auth'
@@ -57,6 +59,7 @@ import { createV1Routes } from './routes/openai/v1'
import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createAdminFluxGrantsService } from './services/admin-flux-grants'
import { createAdminRouterConfigService } from './services/admin-router-config'
import { createBillingService } from './services/billing/billing-service'
import { createFluxMeter } from './services/billing/flux-meter'
import { createCharacterService } from './services/characters'
@@ -71,7 +74,6 @@ import { createProviderService } from './services/providers'
import { createRequestLogService } from './services/request-log'
import { createStripeService } from './services/stripe'
import { createUserDeletionService } from './services/user-deletion'
import { ApiError, createInternalError } from './utils/error'
import { createEnvelopeCrypto } from './utils/envelope-crypto'
import { ApiError, createInternalError, createUnauthorizedError } from './utils/error'
import { nanoid } from './utils/id'
@@ -88,6 +90,7 @@ interface AppDeps {
stripeService: StripeService
billingService: BillingService
adminFluxGrantsService: AdminFluxGrantsService
adminRouterConfigService: AdminRouterConfigService
ttsMeter: FluxMeter
requestLogService: RequestLogService
configKV: ConfigKVService
@@ -340,6 +343,14 @@ 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.
*/
.route('/api/admin/config/router', createAdminRouterConfigRoutes(deps.adminRouterConfigService, deps.env))
/**
* Catch-all 404 in JSON. Replaces hono's default `text/html` "404 Not
* Found" so unmatched routes (typos, stale email links, scanners) get a
@@ -608,6 +619,18 @@ export async function createApp() {
}),
})
// Admin router-config seeding service. Reuses the shared envelope crypto
// so written ciphertexts decrypt cleanly under the same master key the
// gateway already uses. Mounted at POST /api/admin/config/router.
const adminRouterConfigService = injeca.provide('services:adminRouterConfig', {
dependsOn: { configKV, envelopeCrypto, redis },
build: ({ dependsOn }) => createAdminRouterConfigService({
configKV: dependsOn.configKV,
envelope: dependsOn.envelopeCrypto,
redis: dependsOn.redis,
}),
})
// LLM router (KTD-5 in-process replacement for the knoway sidecar).
// LLM_ROUTER_MASTER_KEY is required at env-parse time, so this provider
// always builds a real router — the legacy `null` fallback path is gone.
@@ -633,6 +656,7 @@ export async function createApp() {
stripeService,
billingService,
adminFluxGrantsService,
adminRouterConfigService,
ttsMeter,
configKV,
envelopeCrypto,
@@ -668,6 +692,7 @@ export async function createApp() {
stripeService: resolved.stripeService,
billingService: resolved.billingService,
adminFluxGrantsService: resolved.adminFluxGrantsService,
adminRouterConfigService: resolved.adminRouterConfigService,
ttsMeter: resolved.ttsMeter,
requestLogService: resolved.requestLogService,
configKV: resolved.configKV,
@@ -0,0 +1,198 @@
import type { Env } from '../../../../libs/env'
import type { AdminRouterConfigService, SliceInput } from '../../../../services/admin-router-config'
import type { HonoEnv } from '../../../../types/hono'
import { Hono } from 'hono'
import {
array,
boolean,
literal,
maxLength,
minLength,
nonEmpty,
object,
optional,
picklist,
pipe,
regex,
safeParse,
string,
url,
variant,
} from 'valibot'
import { adminGuard } from '../../../../middlewares/admin-guard'
import { authGuard } from '../../../../middlewares/auth'
import { createBadRequestError } from '../../../../utils/error'
/**
* Hard cap on slices per request. The envelope crypto is cheap (~1ms each),
* so the cap exists to bound request body size and audit log noise, not CPU.
* Realistic admin calls touch 13 providers at a time.
*/
const MAX_SLICES_PER_REQUEST = 20
/**
* Hard cap on plaintext key length. Real provider keys are 30200 chars;
* 1KB leaves headroom for unusual formats while keeping the body lean.
*/
const MAX_KEY_LENGTH = 1024
/** AAD separator constraint mirrored from `keyEntrySchema` in config-kv. */
const NO_PIPE = regex(/^[^|]+$/, 'must not contain "|" (reserved AAD separator)')
const OpenRouterSliceSchema = object({
kind: literal('openrouter'),
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
overrideModel: pipe(string(), nonEmpty('overrideModel is required'), maxLength(200)),
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
baseURL: optional(pipe(string(), url('baseURL must be a valid URL'))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
})
const AzureSliceSchema = object({
kind: literal('azure'),
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
region: pipe(string(), nonEmpty('region is required'), maxLength(64)),
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
const DashscopeSliceSchema = object({
kind: literal('dashscope-cosyvoice'),
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
region: picklist(['intl', 'cn'], 'region must be "intl" or "cn"'),
upstreamModel: pipe(string(), nonEmpty('upstreamModel is required'), maxLength(200)),
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
/**
* `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.
*/
const StreamingTtsSliceSchema = object({
kind: literal('streaming-tts'),
upstreamURL: pipe(
string(),
nonEmpty('upstreamURL is required'),
regex(/^wss?:\/\/\S+$/, 'upstreamURL must start with ws:// or wss://'),
maxLength(500),
),
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
const SliceSchema = variant('kind', [
OpenRouterSliceSchema,
AzureSliceSchema,
DashscopeSliceSchema,
StreamingTtsSliceSchema,
])
const BodySchema = object({
mode: optional(picklist(['merge', 'reset']), 'merge'),
dryRun: optional(boolean(), false),
slices: pipe(
array(SliceSchema),
minLength(1, 'slices must not be empty'),
maxLength(MAX_SLICES_PER_REQUEST, `slices must be at most ${MAX_SLICES_PER_REQUEST} entries`),
),
defaults: optional(object({
chatModel: optional(pipe(string(), nonEmpty('defaults.chatModel must not be empty'), maxLength(200))),
ttsModel: optional(pipe(string(), nonEmpty('defaults.ttsModel must not be empty'), maxLength(200))),
})),
})
/**
* 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.
*
* Body shape (discriminated on `slices[].kind`):
*
* {
* "mode": "merge" | "reset", // defaults to "merge"
* "dryRun": false, // when true, returns redacted preview
* // and skips writes + invalidation
* "slices": [
* { "kind": "openrouter", "modelName": "chat-default",
* "overrideModel": "openai/gpt-4o-mini", "plaintextKey": "..." },
* { "kind": "azure", "modelName": "microsoft/v1",
* "region": "eastasia", "plaintextKey": "..." },
* { "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": "..." }
* ],
* "defaults": {
* "chatModel": "chat-default", // writes DEFAULT_CHAT_MODEL
* "ttsModel": "alibaba/cosyvoice-v2" // writes DEFAULT_TTS_MODEL
* }
* }
*
* Response:
*
* {
* "applied": [{ kind, target, modelName?, keyEntryId, surface? }, ...],
* "invalidatedKeys": ["LLM_ROUTER_CONFIG", "DEFAULT_CHAT_MODEL", ...],
* "preview": { // ciphertext redacted to "<N chars>"
* "LLM_ROUTER_CONFIG": { ... },
* "STREAMING_TTS_UPSTREAM": { ... },
* "DEFAULT_CHAT_MODEL": "chat-default",
* "DEFAULT_TTS_MODEL": "alibaba/cosyvoice-v2"
* }
* }
*
* Security notes:
* - `plaintextKey` is consumed in-process and never returned. The preview
* only ever contains length-redacted ciphertext.
* - The route relies on `bodyLimit(1MB)` from the global middleware chain;
* no per-route bumps.
*/
export function createAdminRouterConfigRoutes(
service: AdminRouterConfigService,
env: Env,
) {
return new Hono<HonoEnv>()
.use('*', authGuard)
.use('*', adminGuard(env))
.post('/', async (c) => {
const user = c.get('user')!
const raw = await c.req.json().catch(() => null)
if (raw == null)
throw createBadRequestError('Request body must be JSON', 'INVALID_BODY')
const parsed = safeParse(BodySchema, raw)
if (!parsed.success) {
throw createBadRequestError(
'Invalid request body',
'INVALID_BODY',
parsed.issues.map(i => ({
path: i.path?.map(p => p.key).join('.'),
message: i.message,
})),
)
}
const body = parsed.output
const result = await service.apply({
mode: body.mode,
dryRun: body.dryRun,
slices: body.slices as SliceInput[],
defaults: body.defaults,
actorUserId: user.id,
})
return c.json(result)
})
}
@@ -0,0 +1,502 @@
import type Redis from 'ioredis'
import type { InferOutput } from 'valibot'
import type { EnvelopeCrypto } from '../../utils/envelope-crypto'
import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, ttsUpstreamSchema } from '../config-kv'
import { useLogger } from '@guiiai/logg'
import { createBadRequestError } from '../../utils/error'
/**
* AAD label used when encrypting/decrypting the streaming TTS upstream key.
* Must match `STREAM_MODEL_LABEL_FALLBACK` in
* apps/server/src/routes/audio-speech-ws/index.ts — the ws proxy decrypts
* with this label, so writing under a different one surfaces as
* `DECRYPT_FAILED` at session start.
*/
const STREAMING_TTS_AAD_MODEL_NAME = 'streaming-tts'
/** Default key entry id per provider. Operator can override per request. */
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',
} as const
type LlmRouterConfig = InferOutput<typeof llmRouterConfigSchema>
type LlmModel = InferOutput<typeof llmModelSchema>
type TtsModel = InferOutput<typeof ttsModelSchema>
type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
/**
* Per-provider input. The admin route validates the shape with Valibot
* (discriminated on `kind`) before handing the slice to the service.
*
* `plaintextKey` enters the process here, gets envelope-encrypted in
* {@link buildSlice}, and is dropped from memory before the response is
* built — it never reaches logs, ciphertext previews, or audit records.
*/
export type SliceInput
= | OpenRouterSliceInput
| AzureSliceInput
| DashscopeSliceInput
| StreamingTtsSliceInput
export interface OpenRouterSliceInput {
kind: 'openrouter'
/** Key under `LLM_ROUTER_CONFIG.llm.models`. */
modelName: string
/** Upstream model id sent to OpenRouter (e.g. `openai/gpt-4o-mini`). */
overrideModel: string
/** Plaintext provider key. Encrypted in-place; never echoed back. */
plaintextKey: string
/** @default 'https://openrouter.ai/api/v1' */
baseURL?: string
/** @default 'openrouter-prod-1' */
keyEntryId?: string
/** @default 'Bearer {KEY}' */
headerTemplate?: string
}
export interface AzureSliceInput {
kind: 'azure'
/** Key under `LLM_ROUTER_CONFIG.tts.models` (e.g. `microsoft/v1`). */
modelName: string
/** Azure Speech region, used in baseURL and `adapterParams.region`. */
region: string
plaintextKey: string
/** @default 'azure-tts-prod-1' */
keyEntryId?: string
}
export interface DashscopeSliceInput {
kind: 'dashscope-cosyvoice'
/** Key under `LLM_ROUTER_CONFIG.tts.models` (e.g. `alibaba/cosyvoice-v2`). */
modelName: string
/** `intl` → dashscope-intl.aliyuncs.com (Singapore); `cn` → dashscope.aliyuncs.com (Beijing). */
region: 'intl' | 'cn'
/** Concrete cosyvoice variant the adapter calls upstream. Independent from `modelName`. */
upstreamModel: string
plaintextKey: string
/** @default 'dashscope-tts-prod-1' */
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
}
interface LlmModelSlice {
target: 'llm-router'
surface: 'llm'
kind: 'openrouter'
modelName: string
model: LlmModel
keyEntryId: string
}
interface TtsModelSlice {
target: 'llm-router'
surface: 'tts'
kind: 'azure' | 'dashscope-cosyvoice'
modelName: string
model: TtsModel
keyEntryId: string
}
interface StreamingTtsSlice {
target: 'streaming-tts'
kind: 'streaming-tts'
value: TtsUpstream
keyEntryId: string
}
type BuiltSlice = LlmModelSlice | TtsModelSlice | StreamingTtsSlice
/**
* Encrypts an OpenRouter slice into the LLM_ROUTER_CONFIG.llm shape.
*
* Use when:
* - Admin posts an `openrouter` slice; called by {@link buildSlice}.
*
* Returns:
* - A `BuiltSlice` whose `model.upstreams[0].keys[0].ciphertext` is the
* envelope-encrypted plaintext key with AAD `{modelName, keyEntryId}`.
*/
export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.openrouter
const ciphertext = envelope.encryptKey(input.plaintextKey, {
modelName: input.modelName,
keyEntryId,
})
return {
target: 'llm-router',
surface: 'llm',
kind: 'openrouter',
modelName: input.modelName,
keyEntryId,
model: {
upstreams: [{
baseURL: input.baseURL ?? 'https://openrouter.ai/api/v1',
overrideModel: input.overrideModel,
keys: [{ id: keyEntryId, ciphertext }],
headerTemplate: input.headerTemplate ?? 'Bearer {KEY}',
}],
} as LlmModel,
}
}
/**
* Encrypts an Azure TTS slice into the LLM_ROUTER_CONFIG.tts shape.
*
* Use when:
* - Admin posts an `azure` slice; called by {@link buildSlice}.
*/
export function buildAzureSlice(input: AzureSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.azure
const ciphertext = envelope.encryptKey(input.plaintextKey, {
modelName: input.modelName,
keyEntryId,
})
return {
target: 'llm-router',
surface: 'tts',
kind: 'azure',
modelName: input.modelName,
keyEntryId,
model: {
provider: 'azure',
upstreams: [{
baseURL: `https://${input.region}.tts.speech.microsoft.com/cognitiveservices/v1`,
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: { region: input.region },
}],
} as TtsModel,
}
}
/**
* Encrypts a DashScope cosyvoice slice into the LLM_ROUTER_CONFIG.tts shape.
*
* Use when:
* - Admin posts a `dashscope-cosyvoice` slice; called by {@link buildSlice}.
*
* Expects:
* - The dashscope-cosyvoice adapter does NOT append
* `/services/audio/tts/SpeechSynthesizer`; the full non-streaming endpoint
* path must be baked into `baseURL` here. A bare `/api/v1` baseURL was the
* root cause of the 404 storm during the v1→v2 migration.
*/
export function buildDashscopeSlice(input: DashscopeSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS['dashscope-cosyvoice']
const ciphertext = envelope.encryptKey(input.plaintextKey, {
modelName: input.modelName,
keyEntryId,
})
const host = input.region === 'cn'
? 'dashscope.aliyuncs.com'
: 'dashscope-intl.aliyuncs.com'
return {
target: 'llm-router',
surface: 'tts',
kind: 'dashscope-cosyvoice',
modelName: input.modelName,
keyEntryId,
model: {
provider: 'dashscope-cosyvoice',
upstreams: [{
baseURL: `https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`,
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: { model: input.upstreamModel },
}],
} as TtsModel,
}
}
/**
* Encrypts a streaming TTS slice into the STREAMING_TTS_UPSTREAM shape.
*
* Use when:
* - Admin posts a `streaming-tts` 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.
*/
export function buildStreamingTtsSlice(input: StreamingTtsSliceInput, envelope: EnvelopeCrypto): StreamingTtsSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS['streaming-tts']
const ciphertext = envelope.encryptKey(input.plaintextKey, {
modelName: STREAMING_TTS_AAD_MODEL_NAME,
keyEntryId,
})
return {
target: 'streaming-tts',
kind: 'streaming-tts',
keyEntryId,
value: {
baseURL: input.upstreamURL,
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: {},
},
}
}
/**
* Encrypts a slice input. Routes to the per-kind builder.
*
* Use when:
* - The service main path needs to turn an admin-supplied slice into a
* ready-to-write configKV fragment. Tests dispatch the same way.
*/
export function buildSlice(input: SliceInput, envelope: EnvelopeCrypto): BuiltSlice {
switch (input.kind) {
case 'openrouter':
return buildOpenRouterSlice(input, envelope)
case 'azure':
return buildAzureSlice(input, envelope)
case 'dashscope-cosyvoice':
return buildDashscopeSlice(input, envelope)
case 'streaming-tts':
return buildStreamingTtsSlice(input, envelope)
}
}
/**
* Computes the next `LLM_ROUTER_CONFIG` tree.
*
* Use when:
* - One or more LLM/TTS slices need to be merged into (or reset on top of)
* the existing configKV entry.
*
* Expects:
* - `existing` is the current parsed `LLM_ROUTER_CONFIG` (or `null` if the
* entry is absent). `mode: 'merge'` preserves models not touched this run;
* `mode: 'reset'` drops every prior entry and keeps only what is in
* `slices`.
*
* Returns:
* - The next config tree, ready to feed `configKV.set('LLM_ROUTER_CONFIG', ...)`.
* `defaults` is preserved verbatim when merging — the admin endpoint does
* not currently re-tune timeouts via this path.
*/
export function buildNextRouterConfig(
mode: 'merge' | 'reset',
existing: LlmRouterConfig | null | undefined,
slices: (LlmModelSlice | TtsModelSlice)[],
): LlmRouterConfig {
const llmModels: Record<string, LlmModel>
= mode === 'merge' && existing?.llm?.models ? { ...existing.llm.models } : {}
const ttsModels: Record<string, TtsModel>
= mode === 'merge' && existing?.tts?.models ? { ...existing.tts.models } : {}
for (const slice of slices) {
if (slice.surface === 'llm')
llmModels[slice.modelName] = slice.model
else
ttsModels[slice.modelName] = slice.model
}
// Defaults live alongside the models but aren't editable through this
// endpoint yet; keep the existing tree in merge mode so we don't blow them
// away. In reset mode, fall back to the schema default object.
const defaults = mode === 'merge' && existing?.defaults
? existing.defaults
: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] }
return {
llm: { models: llmModels },
tts: { models: ttsModels },
defaults,
}
}
/**
* Redacts every `ciphertext` field down to its byte length for safe response
* preview.
*
* Before:
* - `{ "keys": [{ "id": "k1", "ciphertext": "aGVsbG8=...long..." }] }`
*
* After:
* - `{ "keys": [{ "id": "k1", "ciphertext": "<ciphertext: 1024 chars>" }] }`
*/
export 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
}
export interface ApplyInput {
mode: 'merge' | 'reset'
dryRun: boolean
slices: SliceInput[]
defaults?: {
chatModel?: string
ttsModel?: string
}
/** Admin user id for audit logging only. Not part of the persisted config. */
actorUserId?: string
}
export interface AppliedSummary {
kind: SliceInput['kind']
target: 'llm-router' | 'streaming-tts'
surface?: 'llm' | 'tts'
modelName?: string
keyEntryId: string
}
export interface ApplyResult {
applied: AppliedSummary[]
invalidatedKeys: string[]
preview: {
LLM_ROUTER_CONFIG?: unknown
STREAMING_TTS_UPSTREAM?: unknown
DEFAULT_CHAT_MODEL?: string
DEFAULT_TTS_MODEL?: string
}
}
interface AdminRouterConfigDeps {
configKV: ConfigKVService
envelope: EnvelopeCrypto
redis: Redis
}
/**
* Admin service for seeding / patching the LLM router configKV tree.
*
* Use when:
* - Mounting `POST /api/admin/config/router`. The HTTP layer parses the body
* and forwards it here; this layer owns encryption, merge semantics,
* validation, and cross-instance invalidation.
*
* Expects:
* - `envelope` is wired with the same master key the LLM router decrypts
* under, otherwise written ciphertexts will surface as `DECRYPT_FAILED` at
* the first /chat/completions or /audio/speech request.
*
* Returns:
* - `apply()` resolves to the redacted preview, the list of touched configKV
* keys, and a per-slice summary suitable for an audit row. Plaintext keys
* are dropped from memory before this resolves.
*/
export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
const logger = useLogger('admin-router-config').useGlobalConfig()
/**
* 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.
*/
async function apply(input: ApplyInput): Promise<ApplyResult> {
const streamingCount = input.slices.filter(s => s.kind === 'streaming-tts').length
if (streamingCount > 1)
throw createBadRequestError('At most one streaming-tts slice per request', 'INVALID_BODY')
// 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')
// Step 2: build the next LLM_ROUTER_CONFIG tree if any LLM/TTS slice
// was supplied. `merge` reads existing first; `reset` skips the read.
let nextRouterConfig: LlmRouterConfig | undefined
if (llmTtsSlices.length > 0) {
const existing = input.mode === 'merge'
? await deps.configKV.getOptional('LLM_ROUTER_CONFIG')
: null
nextRouterConfig = buildNextRouterConfig(input.mode, existing, llmTtsSlices)
}
const preview: ApplyResult['preview'] = {}
if (nextRouterConfig)
preview.LLM_ROUTER_CONFIG = redactCiphertext(nextRouterConfig)
if (streamingSlice)
preview.STREAMING_TTS_UPSTREAM = redactCiphertext(streamingSlice.value)
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'
? { kind: s.kind, target: s.target, keyEntryId: s.keyEntryId }
: { kind: s.kind, target: s.target, surface: s.surface, modelName: s.modelName, keyEntryId: s.keyEntryId })
if (input.dryRun) {
logger.withFields({
actorUserId: input.actorUserId,
mode: input.mode,
applied,
dryRun: true,
}).log('admin-router-config dry-run')
return { applied, invalidatedKeys: [], preview }
}
// Step 3: 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) {
await deps.configKV.set('STREAMING_TTS_UPSTREAM', streamingSlice.value as never)
invalidatedKeys.push('STREAMING_TTS_UPSTREAM')
}
if (input.defaults?.chatModel) {
await deps.configKV.set('DEFAULT_CHAT_MODEL', input.defaults.chatModel)
invalidatedKeys.push('DEFAULT_CHAT_MODEL')
}
if (input.defaults?.ttsModel) {
await deps.configKV.set('DEFAULT_TTS_MODEL', input.defaults.ttsModel)
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.
for (const key of invalidatedKeys) {
const payload = JSON.stringify({ key, version: Date.now(), publishedAt: Date.now() })
await deps.redis.publish('configkv:invalidate', payload)
}
logger.withFields({
actorUserId: input.actorUserId,
mode: input.mode,
applied,
invalidatedKeys,
}).log('admin-router-config applied')
return { applied, invalidatedKeys, preview }
}
return { apply }
}
export type AdminRouterConfigService = ReturnType<typeof createAdminRouterConfigService>
@@ -0,0 +1,429 @@
import type Redis from 'ioredis'
import type { ConfigKVService } from '../../config-kv'
import { randomBytes } from 'node:crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildAzureSlice,
buildDashscopeSlice,
buildNextRouterConfig,
buildOpenRouterSlice,
buildStreamingTtsSlice,
createAdminRouterConfigService,
redactCiphertext,
} from '..'
import { createEnvelopeCrypto } from '../../../utils/envelope-crypto'
function freshEnvelope() {
return createEnvelopeCrypto({ masterKey: randomBytes(32) })
}
interface FakeConfigKV {
store: Map<string, unknown>
service: ConfigKVService
publishedChannels: { channel: string, payload: string }[]
}
/**
* In-memory fake for ConfigKVService + a thin Redis publish stub.
*
* Use when:
* - Service tests need to assert which configKV entries got written and
* which channel publishes fired, without touching real Redis.
*/
function fakeConfigKV(): FakeConfigKV {
const store = new Map<string, unknown>()
const service: Partial<ConfigKVService> = {
async getOptional(key: string) {
return (store.get(key) ?? null) as never
},
async getOrThrow(key: string) {
const v = store.get(key)
if (v === undefined)
throw new Error(`fake getOrThrow missing ${key}`)
return v as never
},
async get(key: string) {
return this.getOrThrow!(key as never)
},
async set(key: string, value: unknown) {
store.set(key, value)
},
}
return { store, service: service as ConfigKVService, publishedChannels: [] }
}
function fakeRedis(captured: { channel: string, payload: string }[]): Redis {
return {
publish: vi.fn(async (channel: string, payload: string) => {
captured.push({ channel, payload })
return 1
}),
} as unknown as Redis
}
describe('redactCiphertext', () => {
it('replaces ciphertext strings with a length tag', () => {
const input = {
keys: [{ id: 'k1', ciphertext: 'a'.repeat(100) }],
adapterParams: { nested: { ciphertext: 'b'.repeat(50) } },
}
expect(redactCiphertext(input)).toEqual({
keys: [{ id: 'k1', ciphertext: '<ciphertext: 100 chars>' }],
adapterParams: { nested: { ciphertext: '<ciphertext: 50 chars>' } },
})
})
it('leaves non-ciphertext fields unchanged', () => {
expect(redactCiphertext({ baseURL: 'https://x', count: 3, flag: true })).toEqual({
baseURL: 'https://x',
count: 3,
flag: true,
})
})
it('walks arrays', () => {
expect(redactCiphertext([{ ciphertext: 'xx' }, { ciphertext: 'yyy' }])).toEqual([
{ ciphertext: '<ciphertext: 2 chars>' },
{ ciphertext: '<ciphertext: 3 chars>' },
])
})
})
describe('buildOpenRouterSlice', () => {
it('encrypts the plaintext key under {modelName, keyEntryId} AAD', () => {
const envelope = freshEnvelope()
const built = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk-or-secret',
}, envelope)
expect(built.target).toBe('llm-router')
expect(built.surface).toBe('llm')
expect(built.modelName).toBe('chat-default')
expect(built.keyEntryId).toBe('openrouter-prod-1')
const upstream = built.model.upstreams[0]
expect(upstream.baseURL).toBe('https://openrouter.ai/api/v1')
expect(upstream.overrideModel).toBe('openai/gpt-4o-mini')
expect(upstream.headerTemplate).toBe('Bearer {KEY}')
// Round-trip the ciphertext under the same AAD — guards against the
// AAD getting silently changed (which would surface as DECRYPT_FAILED
// at gateway runtime, but never in tests like this if we only checked
// the ciphertext length).
const decrypted = envelope.decryptKey(upstream.keys[0].ciphertext, {
modelName: 'chat-default',
keyEntryId: 'openrouter-prod-1',
})
expect(decrypted.toString('utf8')).toBe('sk-or-secret')
})
it('respects custom baseURL, keyEntryId, and headerTemplate', () => {
const envelope = freshEnvelope()
const built = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
baseURL: 'https://proxy.example/api/v1',
keyEntryId: 'openrouter-prod-2',
headerTemplate: 'X-Custom-Token {KEY}',
}, envelope)
expect(built.keyEntryId).toBe('openrouter-prod-2')
expect(built.model.upstreams[0].baseURL).toBe('https://proxy.example/api/v1')
expect(built.model.upstreams[0].headerTemplate).toBe('X-Custom-Token {KEY}')
expect(built.model.upstreams[0].keys[0].id).toBe('openrouter-prod-2')
})
})
describe('buildAzureSlice', () => {
it('builds the cognitiveservices baseURL from region and surfaces region in adapterParams', () => {
const envelope = freshEnvelope()
const built = buildAzureSlice({
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
plaintextKey: 'azure-key',
}, envelope)
expect(built.kind).toBe('azure')
expect(built.model.provider).toBe('azure')
expect(built.model.upstreams[0].baseURL).toBe('https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1')
expect(built.model.upstreams[0].adapterParams).toEqual({ region: 'eastasia' })
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'microsoft/v1',
keyEntryId: 'azure-tts-prod-1',
})
expect(decrypted.toString('utf8')).toBe('azure-key')
})
})
describe('buildDashscopeSlice', () => {
it.each([
['intl', 'dashscope-intl.aliyuncs.com'],
['cn', 'dashscope.aliyuncs.com'],
] as const)('uses the %s region host', (region, host) => {
const envelope = freshEnvelope()
const built = buildDashscopeSlice({
kind: 'dashscope-cosyvoice',
modelName: 'alibaba/cosyvoice-v2',
region,
upstreamModel: 'cosyvoice-v2',
plaintextKey: 'sk-dash',
}, envelope)
expect(built.model.upstreams[0].baseURL).toBe(`https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`)
expect(built.model.upstreams[0].adapterParams).toEqual({ model: 'cosyvoice-v2' })
})
})
describe('buildStreamingTtsSlice', () => {
it('encrypts under the streaming-tts AAD model label (must match audio-speech-ws decrypt)', () => {
const envelope = freshEnvelope()
const built = buildStreamingTtsSlice({
kind: 'streaming-tts',
upstreamURL: 'ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream',
plaintextKey: 'volc-key',
}, envelope)
expect(built.target).toBe('streaming-tts')
expect(built.value.baseURL).toBe('ws://airi-unspeech.railway.internal: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, {
modelName: 'streaming-tts',
keyEntryId: 'volcengine-prod-1',
})
expect(decrypted.toString('utf8')).toBe('volc-key')
})
})
describe('buildNextRouterConfig', () => {
it('merge mode preserves models not touched this run', () => {
const envelope = freshEnvelope()
const existing = {
llm: { models: { 'untouched-chat': { upstreams: [{ baseURL: 'https://old', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
tts: { models: { 'untouched-tts': { provider: 'azure' as const, upstreams: [{ baseURL: 'https://old-tts', keys: [{ id: 'k', ciphertext: 'c' }], adapterParams: {} }] } } },
defaults: { perAttemptTimeoutMs: 12345, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
}
const newSlice = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}, envelope)
const next = buildNextRouterConfig('merge', existing, [newSlice])
expect(Object.keys(next.llm.models).sort()).toEqual(['chat-default', 'untouched-chat'])
expect(Object.keys(next.tts.models)).toEqual(['untouched-tts'])
// Defaults preserved verbatim in merge mode — the admin endpoint does
// not currently re-tune timeouts, and zeroing them would silently break
// gateway timeouts.
expect(next.defaults?.perAttemptTimeoutMs).toBe(12345)
})
it('reset mode drops every prior entry and uses default timeouts', () => {
const envelope = freshEnvelope()
const existing = {
llm: { models: { old: { upstreams: [{ baseURL: 'https://old', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 12345, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
}
const newSlice = buildOpenRouterSlice({
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}, envelope)
const next = buildNextRouterConfig('reset', existing, [newSlice])
expect(Object.keys(next.llm.models)).toEqual(['chat-default'])
expect(next.defaults?.perAttemptTimeoutMs).toBe(30000)
})
it('starts from empty when existing is null (first-time bootstrap)', () => {
const envelope = freshEnvelope()
const newSlice = buildAzureSlice({
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
plaintextKey: 'azure-key',
}, envelope)
const next = buildNextRouterConfig('merge', null, [newSlice])
expect(Object.keys(next.llm.models)).toEqual([])
expect(Object.keys(next.tts.models)).toEqual(['microsoft/v1'])
})
})
describe('createAdminRouterConfigService', () => {
let kv: FakeConfigKV
let captured: { channel: string, payload: string }[]
let redis: Redis
let envelope: ReturnType<typeof createEnvelopeCrypto>
beforeEach(() => {
kv = fakeConfigKV()
captured = []
redis = fakeRedis(captured)
envelope = freshEnvelope()
})
it('dry-run returns redacted preview without touching the store or pub/sub', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: true,
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk-or-secret',
}],
defaults: { chatModel: 'chat-default' },
})
expect(kv.store.size).toBe(0)
expect(captured).toEqual([])
expect(result.invalidatedKeys).toEqual([])
// Preview must redact ciphertext — leaking plaintext OR raw ciphertext
// back to admin response would be a regression.
const preview = result.preview.LLM_ROUTER_CONFIG as { llm: { models: Record<string, { upstreams: { keys: { ciphertext: string }[] }[] }> } }
const ct = preview.llm.models['chat-default'].upstreams[0].keys[0].ciphertext
expect(ct).toMatch(/^<ciphertext: \d+ chars>$/)
expect(ct).not.toContain('sk-or-secret')
expect(result.preview.DEFAULT_CHAT_MODEL).toBe('chat-default')
})
it('writes LLM_ROUTER_CONFIG, DEFAULT_CHAT_MODEL, and publishes invalidation', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'reset',
dryRun: false,
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}],
defaults: { chatModel: 'chat-default' },
})
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(true)
expect(kv.store.get('DEFAULT_CHAT_MODEL')).toBe('chat-default')
expect(result.invalidatedKeys.sort()).toEqual(['DEFAULT_CHAT_MODEL', 'LLM_ROUTER_CONFIG'])
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 () => {
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',
}],
})
expect(kv.store.has('STREAMING_TTS_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'])
})
it('rejects multiple streaming-tts 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' },
],
})).rejects.toThrow(/At most one streaming-tts/i)
})
it('merge mode reads existing LLM_ROUTER_CONFIG and preserves untouched models', async () => {
// Seed an existing entry directly into the fake store, matching the
// shape configKV.getOptional would have returned after a prior admin call.
kv.store.set('LLM_ROUTER_CONFIG', {
llm: { models: { 'preexisting-chat': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await service.apply({
mode: 'merge',
dryRun: false,
slices: [{
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
plaintextKey: 'azure-key',
}],
})
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, unknown> }, tts: { models: Record<string, unknown> } }
expect(Object.keys(written.llm.models)).toEqual(['preexisting-chat'])
expect(Object.keys(written.tts.models)).toEqual(['microsoft/v1'])
})
it('reset mode skips the existing read and drops prior entries', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: { models: { 'should-be-dropped': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
const getOptionalSpy = vi.spyOn(kv.service, 'getOptional')
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
await service.apply({
mode: 'reset',
dryRun: false,
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk',
}],
})
expect(getOptionalSpy).not.toHaveBeenCalled()
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, unknown> } }
expect(Object.keys(written.llm.models)).toEqual(['chat-default'])
})
it('returns per-slice applied summaries that the audit log can use', async () => {
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const result = await service.apply({
mode: 'merge',
dryRun: true,
slices: [
{ kind: 'openrouter', modelName: 'chat-default', overrideModel: 'openai/gpt-4o-mini', plaintextKey: 'sk' },
{ kind: 'dashscope-cosyvoice', modelName: 'alibaba/cosyvoice-v2', region: 'intl', upstreamModel: 'cosyvoice-v2', plaintextKey: 'sk' },
],
})
expect(result.applied).toEqual([
{ kind: 'openrouter', target: 'llm-router', surface: 'llm', modelName: 'chat-default', keyEntryId: 'openrouter-prod-1' },
{ kind: 'dashscope-cosyvoice', target: 'llm-router', surface: 'tts', modelName: 'alibaba/cosyvoice-v2', keyEntryId: 'dashscope-tts-prod-1' },
])
})
})