feat(admin-ui): load and edit router config from forms

Add a form-first LLM/TTS router config editor and a redacted admin config snapshot so operators can inspect configKV state before applying changes.

Preserve existing encrypted key entries when loaded slices are submitted without new plaintext keys.

Signed-off-by: RainbowBird <git@luoling.moe>
Commit-Message-Assisted-by: Claude (via Claude Code)
This commit is contained in:
RainbowBird
2026-06-11 00:14:34 +08:00
parent 52e0476154
commit 5bf6deab89
17 changed files with 2782 additions and 121 deletions
@@ -44,9 +44,10 @@ 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)),
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
baseURL: optional(pipe(string(), url('baseURL must be a valid URL'))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
})
@@ -55,8 +56,9 @@ const AzureSliceSchema = object({
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
region: pipe(string(), nonEmpty('region is required'), maxLength(64)),
defaultVoice: optional(pipe(string(), nonEmpty('defaultVoice must not be empty'), maxLength(200))),
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
const DashscopeSliceSchema = object({
@@ -64,8 +66,20 @@ const DashscopeSliceSchema = object({
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)),
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
const StepfunSliceSchema = object({
kind: literal('stepfun'),
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
upstreamModel: optional(picklist(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'], 'upstreamModel must be a supported StepFun TTS model')),
defaultVoice: optional(pipe(string(), nonEmpty('defaultVoice must not be empty'), maxLength(200))),
instruction: optional(pipe(string(), nonEmpty('instruction must not be empty'), maxLength(200))),
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
/**
@@ -89,8 +103,9 @@ const UnspeechSliceSchema = object({
regex(/^wss?:\/\/\S+$/, 'streaming.upstreamURL must start with ws:// or wss://'),
maxLength(500),
),
plaintextKey: pipe(string(), nonEmpty('streaming.plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
plaintextKey: optional(pipe(string(), nonEmpty('streaming.plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
models: optional(array(object({
id: pipe(string(), nonEmpty('streaming.models[].id is required'), maxLength(200)),
name: optional(pipe(string(), nonEmpty(), maxLength(200))),
@@ -104,6 +119,7 @@ const SliceSchema = variant('kind', [
OpenRouterSliceSchema,
AzureSliceSchema,
DashscopeSliceSchema,
StepfunSliceSchema,
UnspeechSliceSchema,
])
@@ -150,6 +166,9 @@ const BodySchema = object({
* { "kind": "dashscope-cosyvoice", "modelName": "alibaba/cosyvoice-v2",
* "region": "intl", "upstreamModel": "cosyvoice-v2",
* "plaintextKey": "..." },
* { "kind": "stepfun", "modelName": "stepfun/stepaudio-2.5-tts",
* "upstreamModel": "stepaudio-2.5-tts",
* "defaultVoice": "cixingnansheng", "plaintextKey": "..." },
* { "kind": "unspeech",
* "restBaseURL": "http://airi-unspeech.railway.internal:5933",
* "streaming": {
@@ -194,6 +213,9 @@ export function createAdminRouterConfigRoutes(
return new Hono<HonoEnv>()
.use('*', authGuard)
.use('*', adminGuard)
.get('/', async (c) => {
return c.json(await service.current())
})
.post('/', async (c) => {
const user = c.get('user')!
@@ -22,6 +22,7 @@ const DEFAULT_KEY_ENTRY_IDS = {
'openrouter': 'openrouter-prod-1',
'azure': 'azure-tts-prod-1',
'dashscope-cosyvoice': 'dashscope-tts-prod-1',
'stepfun': 'stepfun-tts-prod-1',
'unspeech': 'volcengine-prod-1',
} as const
@@ -34,6 +35,7 @@ type LlmRouterConfig = InferOutput<typeof llmRouterConfigSchema>
type LlmModel = InferOutput<typeof llmModelSchema>
type TtsModel = InferOutput<typeof ttsModelSchema>
type UnspeechUpstream = InferOutput<typeof unspeechUpstreamSchema>
type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
/**
* Per-provider input. The admin route validates the shape with Valibot
@@ -47,6 +49,7 @@ export type SliceInput
= | OpenRouterSliceInput
| AzureSliceInput
| DashscopeSliceInput
| StepfunSliceInput
| UnspeechSliceInput
export interface OpenRouterSliceInput {
@@ -56,11 +59,13 @@ export interface OpenRouterSliceInput {
/** 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
plaintextKey?: string
/** @default 'https://openrouter.ai/api/v1' */
baseURL?: string
/** @default 'openrouter-prod-1' */
keyEntryId?: string
/** Existing key entry to preserve when `plaintextKey` is omitted. */
existingKeyEntryId?: string
/** @default 'Bearer {KEY}' */
headerTemplate?: string
}
@@ -73,9 +78,11 @@ export interface AzureSliceInput {
region: string
/** Default Microsoft voice used when `/audio/speech` omits `voice`. */
defaultVoice?: string
plaintextKey: string
plaintextKey?: string
/** @default 'azure-tts-prod-1' */
keyEntryId?: string
/** Existing key entry to preserve when `plaintextKey` is omitted. */
existingKeyEntryId?: string
}
export interface DashscopeSliceInput {
@@ -86,9 +93,28 @@ export interface DashscopeSliceInput {
region: 'intl' | 'cn'
/** Concrete cosyvoice variant the adapter calls upstream. Independent from `modelName`. */
upstreamModel: string
plaintextKey: string
plaintextKey?: string
/** @default 'dashscope-tts-prod-1' */
keyEntryId?: string
/** Existing key entry to preserve when `plaintextKey` is omitted. */
existingKeyEntryId?: string
}
export interface StepfunSliceInput {
kind: 'stepfun'
/** Key under `LLM_ROUTER_CONFIG.tts.models` (e.g. `stepfun/stepaudio-2.5-tts`). */
modelName: string
/** Concrete StepFun TTS model sent upstream. */
upstreamModel?: 'stepaudio-2.5-tts' | 'step-tts-2' | 'step-tts-mini'
/** Default official voice used when `/audio/speech` omits `voice`. */
defaultVoice?: string
/** Default global instruction for `stepaudio-2.5-tts`; per-request `instruction` overrides it. */
instruction?: string
plaintextKey?: string
/** @default 'stepfun-tts-prod-1' */
keyEntryId?: string
/** Existing key entry to preserve when `plaintextKey` is omitted. */
existingKeyEntryId?: string
}
export interface UnspeechSliceInput {
@@ -100,9 +126,11 @@ export interface UnspeechSliceInput {
/** unspeech ws endpoint: `ws(s)://host:port/v1/audio/speech/stream`. */
upstreamURL: string
/** Upstream provider key (Volcengine `X-Api-Key`), not an unspeech token. */
plaintextKey: string
plaintextKey?: string
/** @default 'volcengine-prod-1' */
keyEntryId?: string
/** Existing key entry to preserve when `plaintextKey` is omitted. */
existingKeyEntryId?: string
/** Operator-curated streaming models exposed to the frontend picker. */
models?: Array<{ id: string, name?: string, description?: string }>
/** Server-curated default streaming model id. */
@@ -122,7 +150,7 @@ interface LlmModelSlice {
interface TtsModelSlice {
target: 'llm-router'
surface: 'tts'
kind: 'azure' | 'dashscope-cosyvoice'
kind: 'azure' | 'dashscope-cosyvoice' | 'stepfun'
modelName: string
model: TtsModel
keyEntryId: string
@@ -150,7 +178,7 @@ type BuiltSlice = LlmModelSlice | TtsModelSlice | UnspeechSlice
*/
export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.openrouter
const ciphertext = envelope.encryptKey(input.plaintextKey, {
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
modelName: input.modelName,
keyEntryId,
})
@@ -180,7 +208,7 @@ export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: Enve
*/
export function buildAzureSlice(input: AzureSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.azure
const ciphertext = envelope.encryptKey(input.plaintextKey, {
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
modelName: input.modelName,
keyEntryId,
})
@@ -219,7 +247,7 @@ export function buildAzureSlice(input: AzureSliceInput, envelope: EnvelopeCrypto
*/
export function buildDashscopeSlice(input: DashscopeSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS['dashscope-cosyvoice']
const ciphertext = envelope.encryptKey(input.plaintextKey, {
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
modelName: input.modelName,
keyEntryId,
})
@@ -244,6 +272,44 @@ export function buildDashscopeSlice(input: DashscopeSliceInput, envelope: Envelo
}
}
/**
* Encrypts a StepFun TTS slice into the LLM_ROUTER_CONFIG.tts shape.
*
* Use when:
* - Admin posts a `stepfun` slice for StepAudio 2.5 TTS or Step TTS 2/Mini.
*
* Expects:
* - `upstreamModel` is the concrete StepFun model sent to
* `POST /v1/audio/speech`; defaults to `stepaudio-2.5-tts`.
*/
export function buildStepfunSlice(input: StepfunSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.stepfun
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
modelName: input.modelName,
keyEntryId,
})
return {
target: 'llm-router',
surface: 'tts',
kind: 'stepfun',
modelName: input.modelName,
keyEntryId,
model: {
provider: 'stepfun',
upstreams: [{
baseURL: 'https://api.stepfun.com/v1/audio/speech',
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: {
model: input.upstreamModel ?? 'stepaudio-2.5-tts',
...(input.defaultVoice ? { defaultVoice: input.defaultVoice } : {}),
...(input.instruction ? { instruction: input.instruction } : {}),
},
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
}
}
/**
* Encrypts an unspeech slice into the UNSPEECH_UPSTREAM shape.
*
@@ -265,7 +331,7 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
}
}
const keyEntryId = input.streaming.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.unspeech
const ciphertext = envelope.encryptKey(input.streaming.plaintextKey, {
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.streaming.plaintextKey, input.kind), {
modelName: STREAMING_TTS_AAD_MODEL_NAME,
keyEntryId,
})
@@ -286,6 +352,162 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
}
}
function requiredPlaintextKey(value: string | undefined, kind: SliceInput['kind']): string {
if (value?.trim())
return value
throw createBadRequestError(`${kind} plaintext key is required when no existing key can be preserved`, 'INVALID_BODY')
}
function firstKey(upstream: { keys: KeyEntry[] } | undefined, preferredId: string | undefined): KeyEntry | null {
if (!upstream?.keys.length)
return null
if (preferredId) {
const selected = upstream.keys.find(key => key.id === preferredId)
if (selected)
return selected
}
return upstream.keys[0] ?? null
}
function preservedKeyOrThrow(upstream: { keys: KeyEntry[] } | undefined, preferredId: string | undefined, kind: SliceInput['kind']): KeyEntry {
const key = firstKey(upstream, preferredId)
if (!key)
throw createBadRequestError(`${kind} existing key entry was not found; paste a new provider key to rotate it`, 'INVALID_BODY')
return key
}
function buildOpenRouterSlicePreservingKey(input: OpenRouterSliceInput, envelope: EnvelopeCrypto, existing: LlmModel | undefined): LlmModelSlice {
if (input.plaintextKey?.trim())
return buildOpenRouterSlice(input, envelope)
const existingUpstream = existing?.upstreams[0]
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
return {
target: 'llm-router',
surface: 'llm',
kind: 'openrouter',
modelName: input.modelName,
keyEntryId: key.id,
model: {
upstreams: [{
baseURL: input.baseURL ?? existingUpstream?.baseURL ?? 'https://openrouter.ai/api/v1',
overrideModel: input.overrideModel,
keys: [key],
headerTemplate: input.headerTemplate ?? existingUpstream?.headerTemplate ?? 'Bearer {KEY}',
}],
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
},
}
}
function buildAzureSlicePreservingKey(input: AzureSliceInput, envelope: EnvelopeCrypto, existing: TtsModel | undefined): TtsModelSlice {
if (input.plaintextKey?.trim())
return buildAzureSlice(input, envelope)
const existingUpstream = existing?.upstreams[0]
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
return {
target: 'llm-router',
surface: 'tts',
kind: 'azure',
modelName: input.modelName,
keyEntryId: key.id,
model: {
provider: 'azure',
upstreams: [{
baseURL: `https://${input.region}.tts.speech.microsoft.com/cognitiveservices/v1`,
keys: [key],
adapterParams: {
region: input.region,
...(input.defaultVoice ? { defaultVoice: input.defaultVoice } : {}),
},
}],
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
},
}
}
function buildDashscopeSlicePreservingKey(input: DashscopeSliceInput, envelope: EnvelopeCrypto, existing: TtsModel | undefined): TtsModelSlice {
if (input.plaintextKey?.trim())
return buildDashscopeSlice(input, envelope)
const existingUpstream = existing?.upstreams[0]
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
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: key.id,
model: {
provider: 'dashscope-cosyvoice',
upstreams: [{
baseURL: `https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`,
keys: [key],
adapterParams: { model: input.upstreamModel },
}],
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
},
}
}
function buildStepfunSlicePreservingKey(input: StepfunSliceInput, envelope: EnvelopeCrypto, existing: TtsModel | undefined): TtsModelSlice {
if (input.plaintextKey?.trim())
return buildStepfunSlice(input, envelope)
const existingUpstream = existing?.upstreams[0]
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
return {
target: 'llm-router',
surface: 'tts',
kind: 'stepfun',
modelName: input.modelName,
keyEntryId: key.id,
model: {
provider: 'stepfun',
upstreams: [{
baseURL: 'https://api.stepfun.com/v1/audio/speech',
keys: [key],
adapterParams: {
model: input.upstreamModel ?? 'stepaudio-2.5-tts',
...(input.defaultVoice ? { defaultVoice: input.defaultVoice } : {}),
...(input.instruction ? { instruction: input.instruction } : {}),
},
}],
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
},
}
}
function buildUnspeechSlicePreservingKey(input: UnspeechSliceInput, envelope: EnvelopeCrypto, existing: UnspeechUpstream | undefined | null): UnspeechSlice {
if (!input.streaming || input.streaming.plaintextKey?.trim())
return buildUnspeechSlice(input, envelope)
const key = preservedKeyOrThrow(existing?.streaming, input.streaming.existingKeyEntryId ?? input.streaming.keyEntryId, input.kind)
return {
target: 'unspeech',
kind: 'unspeech',
keyEntryId: key.id,
value: {
restBaseURL: input.restBaseURL,
streaming: {
baseURL: input.streaming.upstreamURL,
keys: [key],
adapterParams: existing?.streaming?.adapterParams ?? {},
models: input.streaming.models ?? existing?.streaming?.models ?? [],
defaultModel: input.streaming.defaultModel ?? existing?.streaming?.defaultModel,
},
},
}
}
/**
* Encrypts a slice input. Routes to the per-kind builder.
*
@@ -293,16 +515,25 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
* - 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 {
export function buildSlice(
input: SliceInput,
envelope: EnvelopeCrypto,
existing?: {
routerConfig?: LlmRouterConfig | null
unspeech?: UnspeechUpstream | null
},
): BuiltSlice {
switch (input.kind) {
case 'openrouter':
return buildOpenRouterSlice(input, envelope)
return buildOpenRouterSlicePreservingKey(input, envelope, existing?.routerConfig?.llm.models[input.modelName])
case 'azure':
return buildAzureSlice(input, envelope)
return buildAzureSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
case 'dashscope-cosyvoice':
return buildDashscopeSlice(input, envelope)
return buildDashscopeSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
case 'stepfun':
return buildStepfunSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
case 'unspeech':
return buildUnspeechSlice(input, envelope)
return buildUnspeechSlicePreservingKey(input, envelope, existing?.unspeech)
}
}
@@ -414,6 +645,140 @@ export interface ApplyResult {
}
}
export interface CurrentRouterConfigResult {
request: {
mode: 'merge'
slices: SliceInput[]
defaults: NonNullable<ApplyInput['defaults']>
}
preview: ApplyResult['preview']
loadedAt: string
missingKeys: string[]
}
function sliceNeedsExistingKey(slice: SliceInput): boolean {
if (slice.kind === 'unspeech')
return slice.streaming != null && !slice.streaming.plaintextKey?.trim()
return !slice.plaintextKey?.trim()
}
function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
if (!config)
return []
const slices: SliceInput[] = []
for (const [modelName, model] of Object.entries(config.llm.models)) {
const slice = openRouterSliceFromModel(modelName, model)
if (slice)
slices.push(slice)
}
for (const [modelName, model] of Object.entries(config.tts.models)) {
const slice = ttsSliceFromModel(modelName, model)
if (slice)
slices.push(slice)
}
return slices
}
function openRouterSliceFromModel(modelName: string, model: LlmModel): OpenRouterSliceInput | null {
const upstream = model.upstreams[0]
const key = upstream?.keys[0]
if (!upstream || !key)
return null
return {
kind: 'openrouter',
modelName,
overrideModel: upstream.overrideModel ?? modelName,
baseURL: upstream.baseURL,
headerTemplate: upstream.headerTemplate,
keyEntryId: key.id,
existingKeyEntryId: key.id,
}
}
function ttsSliceFromModel(modelName: string, model: TtsModel): AzureSliceInput | DashscopeSliceInput | StepfunSliceInput | null {
const upstream = model.upstreams[0]
const key = upstream?.keys[0]
if (!upstream || !key)
return null
if (model.provider === 'azure') {
const region = stringFromRecord(upstream.adapterParams, 'region') ?? azureRegionFromBaseURL(upstream.baseURL) ?? ''
return {
kind: 'azure',
modelName,
region,
defaultVoice: stringFromRecord(upstream.adapterParams, 'defaultVoice'),
keyEntryId: key.id,
existingKeyEntryId: key.id,
}
}
if (model.provider === 'dashscope-cosyvoice') {
return {
kind: 'dashscope-cosyvoice',
modelName,
region: upstream.baseURL.includes('dashscope.aliyuncs.com') ? 'cn' : 'intl',
upstreamModel: stringFromRecord(upstream.adapterParams, 'model') ?? modelName,
keyEntryId: key.id,
existingKeyEntryId: key.id,
}
}
if (model.provider === 'stepfun') {
const upstreamModel = stringFromRecord(upstream.adapterParams, 'model')
return {
kind: 'stepfun',
modelName,
upstreamModel: isStepfunInputModel(upstreamModel) ? upstreamModel : undefined,
defaultVoice: stringFromRecord(upstream.adapterParams, 'defaultVoice'),
instruction: stringFromRecord(upstream.adapterParams, 'instruction'),
keyEntryId: key.id,
existingKeyEntryId: key.id,
}
}
return null
}
function slicesFromUnspeech(unspeech: UnspeechUpstream | null): UnspeechSliceInput[] {
if (!unspeech)
return []
const key = unspeech.streaming?.keys[0]
return [{
kind: 'unspeech',
restBaseURL: unspeech.restBaseURL,
...(unspeech.streaming
? {
streaming: {
upstreamURL: unspeech.streaming.baseURL,
keyEntryId: key?.id,
existingKeyEntryId: key?.id,
models: unspeech.streaming.models,
defaultModel: unspeech.streaming.defaultModel,
},
}
: {}),
}]
}
function azureRegionFromBaseURL(baseURL: string): string | null {
const match = /^https:\/\/([^.]+)\.tts\.speech\.microsoft\.com\//u.exec(baseURL)
return match?.[1] ?? null
}
function stringFromRecord(recordValue: Record<string, unknown> | undefined, key: string): string | undefined {
const value = recordValue?.[key]
return typeof value === 'string' && value.trim() ? value : undefined
}
function isStepfunInputModel(value: string | undefined): value is NonNullable<StepfunSliceInput['upstreamModel']> {
return value === 'stepaudio-2.5-tts' || value === 'step-tts-2' || value === 'step-tts-mini'
}
interface AdminRouterConfigDeps {
configKV: ConfigKVService
envelope: EnvelopeCrypto
@@ -441,6 +806,62 @@ interface AdminRouterConfigDeps {
export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
const logger = useLogger('admin-router-config').useGlobalConfig()
async function current(): Promise<CurrentRouterConfigResult> {
const [
routerConfig,
unspeech,
chatModel,
ttsModel,
ttsVoices,
] = await Promise.all([
deps.configKV.getOptional('LLM_ROUTER_CONFIG'),
deps.configKV.getOptional('UNSPEECH_UPSTREAM'),
deps.configKV.getOptional('DEFAULT_CHAT_MODEL'),
deps.configKV.getOptional('DEFAULT_TTS_MODEL'),
deps.configKV.getOptional('DEFAULT_TTS_VOICES'),
])
const slices: SliceInput[] = [
...slicesFromRouterConfig(routerConfig ?? null),
...slicesFromUnspeech(unspeech ?? null),
]
const defaults: NonNullable<ApplyInput['defaults']> = {}
if (chatModel)
defaults.chatModel = chatModel
if (ttsModel)
defaults.ttsModel = ttsModel
if (ttsVoices && Object.keys(ttsVoices).length > 0)
defaults.ttsVoices = ttsVoices
const preview: ApplyResult['preview'] = {}
if (routerConfig)
preview.LLM_ROUTER_CONFIG = redactCiphertext(routerConfig)
if (unspeech)
preview.UNSPEECH_UPSTREAM = redactCiphertext(unspeech)
if (chatModel)
preview.DEFAULT_CHAT_MODEL = chatModel
if (ttsModel)
preview.DEFAULT_TTS_MODEL = ttsModel
if (ttsVoices && Object.keys(ttsVoices).length > 0)
preview.DEFAULT_TTS_VOICES = ttsVoices
return {
request: {
mode: 'merge',
slices,
defaults,
},
preview,
loadedAt: new Date().toISOString(),
missingKeys: [
...(routerConfig ? [] : ['LLM_ROUTER_CONFIG']),
...(unspeech ? [] : ['UNSPEECH_UPSTREAM']),
...(chatModel ? [] : ['DEFAULT_CHAT_MODEL']),
...(ttsModel ? [] : ['DEFAULT_TTS_MODEL']),
],
}
}
/**
* Applies an admin request, returning the redacted preview either way.
*
@@ -454,9 +875,22 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
if (unspeechCount > 1)
throw createBadRequestError('At most one unspeech slice per request', 'INVALID_BODY')
// Step 1: encrypt every slice. Throws (via envelope) only on malformed
// master key, which means the deployment is broken; surface as 500.
const built = input.slices.map(s => buildSlice(s, deps.envelope))
const hasLlmTtsInput = input.slices.some(s => s.kind !== 'unspeech')
const hasUnspeechInput = input.slices.some(s => s.kind === 'unspeech')
const shouldReadRouterConfig = hasLlmTtsInput
&& (input.mode === 'merge' || input.slices.some(sliceNeedsExistingKey))
const shouldReadUnspeech = hasUnspeechInput
const [existingRouterConfig, existingUnspeech] = await Promise.all([
shouldReadRouterConfig ? deps.configKV.getOptional('LLM_ROUTER_CONFIG') : Promise.resolve(null),
shouldReadUnspeech ? deps.configKV.getOptional('UNSPEECH_UPSTREAM') : Promise.resolve(null),
])
// Step 1: encrypt new keys and preserve existing ciphertexts when the
// admin loaded current config and left a key field blank.
const built = input.slices.map(s => buildSlice(s, deps.envelope, {
routerConfig: existingRouterConfig,
unspeech: existingUnspeech,
}))
const llmTtsSlices = built.filter((s): s is LlmModelSlice | TtsModelSlice => s.target === 'llm-router')
const unspeechSlice = built.find((s): s is UnspeechSlice => s.target === 'unspeech')
@@ -465,10 +899,7 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
// 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)
nextRouterConfig = buildNextRouterConfig(input.mode, existingRouterConfig, llmTtsSlices)
}
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` +
@@ -477,15 +908,14 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
// subtree is set (otherwise there's nothing to merge into).
let nextUnspeech: UnspeechUpstream | undefined
if (unspeechSlice) {
const existing = await deps.configKV.getOptional('UNSPEECH_UPSTREAM')
const newValue = unspeechSlice.value
if (newValue.streaming && existing?.streaming) {
if (newValue.streaming && existingUnspeech?.streaming) {
nextUnspeech = {
...newValue,
streaming: {
...newValue.streaming,
models: existing.streaming.models?.length ? existing.streaming.models : newValue.streaming.models,
defaultModel: existing.streaming.defaultModel ?? newValue.streaming.defaultModel,
models: existingUnspeech.streaming.models?.length ? existingUnspeech.streaming.models : newValue.streaming.models,
defaultModel: existingUnspeech.streaming.defaultModel ?? newValue.streaming.defaultModel,
},
}
}
@@ -563,7 +993,7 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
return { applied, invalidatedKeys, preview }
}
return { apply }
return { apply, current }
}
export type AdminRouterConfigService = ReturnType<typeof createAdminRouterConfigService>
@@ -11,6 +11,7 @@ import {
buildDashscopeSlice,
buildNextRouterConfig,
buildOpenRouterSlice,
buildStepfunSlice,
buildUnspeechSlice,
createAdminRouterConfigService,
redactCiphertext,
@@ -197,6 +198,36 @@ describe('buildDashscopeSlice', () => {
})
})
describe('buildStepfunSlice', () => {
it('builds the StepFun TTS endpoint and surfaces model defaults in adapterParams', () => {
const envelope = freshEnvelope()
const built = buildStepfunSlice({
kind: 'stepfun',
modelName: 'stepfun/stepaudio-2.5-tts',
upstreamModel: 'stepaudio-2.5-tts',
defaultVoice: 'cixingnansheng',
instruction: '温柔、克制、有一点笑意',
plaintextKey: 'step-key',
}, envelope)
expect(built.kind).toBe('stepfun')
expect(built.model.provider).toBe('stepfun')
expect(built.model.upstreams[0].baseURL).toBe('https://api.stepfun.com/v1/audio/speech')
expect(built.model.upstreams[0].adapterParams).toEqual({
model: 'stepaudio-2.5-tts',
defaultVoice: 'cixingnansheng',
instruction: '温柔、克制、有一点笑意',
})
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'stepfun/stepaudio-2.5-tts',
keyEntryId: 'stepfun-tts-prod-1',
})
expect(decrypted.toString('utf8')).toBe('step-key')
})
})
describe('buildUnspeechSlice', () => {
it('writes restBaseURL with no streaming subtree when the slice omits streaming', () => {
const envelope = freshEnvelope()
@@ -492,6 +523,82 @@ describe('createAdminRouterConfigService', () => {
expect(Object.keys(written.tts.models)).toEqual(['microsoft/v1'])
})
it('current returns editable slices from configKV without exposing raw ciphertext', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: {
models: {
'chat-live': {
upstreams: [{
baseURL: 'https://openrouter.ai/api/v1',
overrideModel: 'openai/gpt-4.1-mini',
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
tts: { models: {} },
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
})
kv.store.set('DEFAULT_CHAT_MODEL', 'chat-live')
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
const current = await service.current()
expect(current.request.slices).toEqual([{
kind: 'openrouter',
modelName: 'chat-live',
overrideModel: 'openai/gpt-4.1-mini',
baseURL: 'https://openrouter.ai/api/v1',
headerTemplate: 'Bearer {KEY}',
keyEntryId: 'openrouter-live',
existingKeyEntryId: 'openrouter-live',
}])
expect(current.request.defaults.chatModel).toBe('chat-live')
expect(JSON.stringify(current.preview)).toContain('<ciphertext: 17 chars>')
expect(JSON.stringify(current.preview)).not.toContain('secret-ciphertext')
})
it('preserves an existing key entry when an applied slice omits plaintextKey', async () => {
kv.store.set('LLM_ROUTER_CONFIG', {
llm: {
models: {
'chat-live': {
upstreams: [{
baseURL: 'https://openrouter.ai/api/v1',
overrideModel: 'openai/gpt-4.1-mini',
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
headerTemplate: 'Bearer {KEY}',
}],
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
},
},
},
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: 'openrouter',
modelName: 'chat-live',
overrideModel: 'openai/gpt-4.1-mini',
baseURL: 'https://proxy.example/api/v1',
keyEntryId: 'openrouter-live',
existingKeyEntryId: 'openrouter-live',
}],
})
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, { upstreams: Array<{ baseURL: string, keys: Array<{ id: string, ciphertext: string }> }> }> } }
const upstream = written.llm.models['chat-live'].upstreams[0]
expect(upstream.baseURL).toBe('https://proxy.example/api/v1')
expect(upstream.keys).toEqual([{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }])
})
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}' }] } } },
@@ -526,12 +633,14 @@ describe('createAdminRouterConfigService', () => {
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' },
{ kind: 'stepfun', modelName: 'stepfun/stepaudio-2.5-tts', upstreamModel: 'stepaudio-2.5-tts', 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' },
{ kind: 'stepfun', target: 'llm-router', surface: 'tts', modelName: 'stepfun/stepaudio-2.5-tts', keyEntryId: 'stepfun-tts-prod-1' },
])
})
})