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:
@@ -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>
|
||||
|
||||
+109
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user