diff --git a/apps/server/src/routes/admin/config/router/index.ts b/apps/server/src/routes/admin/config/router/index.ts index a06cc7dda..fb8bf1db2 100644 --- a/apps/server/src/routes/admin/config/router/index.ts +++ b/apps/server/src/routes/admin/config/router/index.ts @@ -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() .use('*', authGuard) .use('*', adminGuard) + .get('/', async (c) => { + return c.json(await service.current()) + }) .post('/', async (c) => { const user = c.get('user')! diff --git a/apps/server/src/services/domain/admin/router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts index 05c6177cb..917c89ff4 100644 --- a/apps/server/src/services/domain/admin/router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -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 type LlmModel = InferOutput type TtsModel = InferOutput type UnspeechUpstream = InferOutput +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 + } + 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 | 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 { + 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 { + 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 = {} + 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 diff --git a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts index 970d3de67..eaad23f29 100644 --- a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts +++ b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts @@ -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('') + 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 }> }> } } + 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' }, ]) }) }) diff --git a/apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue b/apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue new file mode 100644 index 000000000..2022f67b8 --- /dev/null +++ b/apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue b/apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue new file mode 100644 index 000000000..4b6f3af7e --- /dev/null +++ b/apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue @@ -0,0 +1,69 @@ + + + diff --git a/apps/ui-admin/src/components/llm-router/RouterModeControl.vue b/apps/ui-admin/src/components/llm-router/RouterModeControl.vue new file mode 100644 index 000000000..b12b07764 --- /dev/null +++ b/apps/ui-admin/src/components/llm-router/RouterModeControl.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue b/apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue new file mode 100644 index 000000000..22d2a910f --- /dev/null +++ b/apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue b/apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue new file mode 100644 index 000000000..49888dd35 --- /dev/null +++ b/apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue @@ -0,0 +1,150 @@ + + + diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index a787bea22..6c8b640f3 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -47,10 +47,72 @@ export interface AdminUsersPage { total: number } +export interface AdminRouterOpenRouterSlice { + kind: 'openrouter' + modelName: string + overrideModel: string + plaintextKey?: string + baseURL?: string + keyEntryId?: string + existingKeyEntryId?: string + headerTemplate?: string +} + +export interface AdminRouterAzureSlice { + kind: 'azure' + modelName: string + region: string + defaultVoice?: string + plaintextKey?: string + keyEntryId?: string + existingKeyEntryId?: string +} + +export interface AdminRouterDashscopeSlice { + kind: 'dashscope-cosyvoice' + modelName: string + region: 'intl' | 'cn' + upstreamModel: string + plaintextKey?: string + keyEntryId?: string + existingKeyEntryId?: string +} + +export interface AdminRouterStepfunSlice { + kind: 'stepfun' + modelName: string + upstreamModel?: 'stepaudio-2.5-tts' | 'step-tts-2' | 'step-tts-mini' + defaultVoice?: string + instruction?: string + plaintextKey?: string + keyEntryId?: string + existingKeyEntryId?: string +} + +export interface AdminRouterUnspeechSlice { + kind: 'unspeech' + restBaseURL: string + streaming?: { + upstreamURL: string + plaintextKey?: string + keyEntryId?: string + existingKeyEntryId?: string + models?: Array<{ id: string, name?: string, description?: string }> + defaultModel?: string + } +} + +export type AdminRouterConfigSlice + = | AdminRouterOpenRouterSlice + | AdminRouterAzureSlice + | AdminRouterDashscopeSlice + | AdminRouterStepfunSlice + | AdminRouterUnspeechSlice + export interface AdminRouterConfigRequest { mode?: 'merge' | 'reset' dryRun?: boolean - slices?: Array> + slices?: AdminRouterConfigSlice[] defaults?: { chatModel?: string ttsModel?: string @@ -64,6 +126,13 @@ export interface AdminRouterConfigResult { preview: Record } +export interface AdminRouterConfigCurrent { + request: AdminRouterConfigRequest + preview: Record + loadedAt: string + missingKeys: string[] +} + export interface VoicePackParams { [key: string]: string | number | boolean | null } @@ -173,7 +242,12 @@ async function fetchJson(endpoint: URL, init: RequestInit = {}): Promise { payload = await response.json() } catch { - payload = null + const contentType = response.headers.get('Content-Type') + throw new AdminApiError( + `Expected JSON from ${endpoint.pathname}, got ${contentType ?? 'an empty response'}. Check api_server_url.`, + response.status, + null, + ) } if (!response.ok) { @@ -269,6 +343,7 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ ...body, dryRun }), }), + routerConfig: () => adminFetch('/config/router'), speechModels: async () => { const data = await publicFetch<{ models?: SpeechModel[] }>('/audio/models') return Array.isArray(data.models) ? data.models : [] diff --git a/apps/ui-admin/src/modules/router-config-form.test.ts b/apps/ui-admin/src/modules/router-config-form.test.ts new file mode 100644 index 000000000..648ffdea5 --- /dev/null +++ b/apps/ui-admin/src/modules/router-config-form.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' + +import { + buildRouterConfigRequest, + createRouterConfigFormState, + createRouterSliceDraft, + defaultTtsVoicesJson, + formatRouterConfigRequestJson, + formStateFromRequestJson, + validateRouterConfigForm, +} from './router-config-form' + +describe('router config form builder', () => { + it('compiles the default OpenRouter chat setup', () => { + const form = createRouterConfigFormState() + const openrouter = form.slices[0] + if (openrouter.kind !== 'openrouter') + throw new Error('expected default slice to be OpenRouter') + openrouter.plaintextKey = 'sk-openrouter' + + expect(buildRouterConfigRequest(form)).toEqual({ + errors: [], + request: { + mode: 'merge', + slices: [{ + kind: 'openrouter', + modelName: 'chat-default', + overrideModel: 'openai/gpt-4o-mini', + plaintextKey: 'sk-openrouter', + baseURL: 'https://openrouter.ai/api/v1', + }], + defaults: { + chatModel: 'chat-default', + }, + }, + }) + }) + + it('compiles Azure speech defaults without OpenRouter-only fields', () => { + const azure = createRouterSliceDraft('azure', 'azure-test') + azure.plaintextKey = 'azure-key' + azure.defaultVoice = 'zh-CN-XiaoxiaoNeural' + azure.keyEntryId = 'azure-eastasia-1' + const form = { + mode: 'merge' as const, + slices: [azure], + defaults: { + chatModel: '', + ttsModel: 'microsoft/v1', + ttsVoicesJson: '', + }, + } + + expect(buildRouterConfigRequest(form).request).toEqual({ + mode: 'merge', + slices: [{ + kind: 'azure', + modelName: 'microsoft/v1', + region: 'eastasia', + defaultVoice: 'zh-CN-XiaoxiaoNeural', + plaintextKey: 'azure-key', + keyEntryId: 'azure-eastasia-1', + }], + defaults: { + ttsModel: 'microsoft/v1', + }, + }) + }) + + it('preserves DashScope region and upstream model', () => { + const dashscope = createRouterSliceDraft('dashscope-cosyvoice', 'dashscope-test') + dashscope.region = 'cn' + dashscope.upstreamModel = 'cosyvoice-v1' + dashscope.plaintextKey = 'dashscope-key' + + expect(buildRouterConfigRequest({ + mode: 'merge', + slices: [dashscope], + defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' }, + }).request?.slices).toEqual([{ + kind: 'dashscope-cosyvoice', + modelName: 'alibaba/cosyvoice-v2', + region: 'cn', + upstreamModel: 'cosyvoice-v1', + plaintextKey: 'dashscope-key', + }]) + }) + + it('compiles StepFun optional instruction and voice fields', () => { + const stepfun = createRouterSliceDraft('stepfun', 'stepfun-test') + stepfun.plaintextKey = 'stepfun-key' + stepfun.defaultVoice = 'cixingnansheng' + stepfun.instruction = 'Speak warmly' + + expect(buildRouterConfigRequest({ + mode: 'merge', + slices: [stepfun], + defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' }, + }).request?.slices).toEqual([{ + kind: 'stepfun', + modelName: 'stepfun/stepaudio-2.5-tts', + upstreamModel: 'stepaudio-2.5-tts', + defaultVoice: 'cixingnansheng', + instruction: 'Speak warmly', + plaintextKey: 'stepfun-key', + }]) + }) + + it('compiles UnSpeech REST-only and streaming requests', () => { + const restOnly = createRouterSliceDraft('unspeech', 'unspeech-rest') + const streaming = createRouterSliceDraft('unspeech', 'unspeech-stream') + streaming.streamingEnabled = true + streaming.streamingPlaintextKey = 'volcengine-key' + streaming.streamingDefaultModel = 'volcengine/seed-tts-2.0' + + expect(buildRouterConfigRequest({ + mode: 'merge', + slices: [restOnly], + defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' }, + }).request?.slices).toEqual([{ + kind: 'unspeech', + restBaseURL: 'http://airi-unspeech.railway.internal:5933', + }]) + + expect(buildRouterConfigRequest({ + mode: 'merge', + slices: [streaming], + defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' }, + }).request?.slices).toEqual([{ + kind: 'unspeech', + restBaseURL: 'http://airi-unspeech.railway.internal:5933', + streaming: { + upstreamURL: 'ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream', + plaintextKey: 'volcengine-key', + models: [{ + id: 'volcengine/seed-tts-2.0', + name: 'Seed TTS 2.0', + }], + defaultModel: 'volcengine/seed-tts-2.0', + }, + }]) + }) + + it('validates missing keys, URL schemes, empty aliases, and duplicate UnSpeech slices', () => { + const openrouter = createRouterSliceDraft('openrouter', 'bad-openrouter') + openrouter.modelName = '' + openrouter.baseURL = 'ftp://openrouter.local' + const unspeechA = createRouterSliceDraft('unspeech', 'unspeech-a') + const unspeechB = createRouterSliceDraft('unspeech', 'unspeech-b') + unspeechA.streamingEnabled = true + unspeechA.streamingUpstreamURL = 'https://not-websocket.local' + + expect(validateRouterConfigForm({ + mode: 'merge', + slices: [openrouter, unspeechA, unspeechB], + defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' }, + })).toEqual(expect.arrayContaining([ + 'Only one UnSpeech slice can be submitted at a time.', + 'Slice 1 (OpenRouter): model alias is required.', + 'Slice 1 (OpenRouter): provider key is required unless an existing key is loaded.', + 'Slice 1 (OpenRouter): base URL must start with http:// or https://.', + 'Slice 2 (UnSpeech): streaming URL must start with ws:// or wss://.', + 'Slice 2 (UnSpeech): streaming provider key is required unless an existing key is loaded.', + ])) + }) + + it('preserves loaded existing key entries when plaintext fields are blank', () => { + const imported = formStateFromRequestJson(JSON.stringify({ + mode: 'merge', + slices: [{ + kind: 'openrouter', + modelName: 'chat-default', + overrideModel: 'openai/gpt-4o-mini', + baseURL: 'https://openrouter.ai/api/v1', + keyEntryId: 'openrouter-prod-1', + existingKeyEntryId: 'openrouter-prod-1', + }], + defaults: { + chatModel: 'chat-default', + }, + })) + + expect(buildRouterConfigRequest(imported)).toEqual({ + errors: [], + request: { + mode: 'merge', + slices: [{ + kind: 'openrouter', + modelName: 'chat-default', + overrideModel: 'openai/gpt-4o-mini', + baseURL: 'https://openrouter.ai/api/v1', + keyEntryId: 'openrouter-prod-1', + existingKeyEntryId: 'openrouter-prod-1', + }], + defaults: { + chatModel: 'chat-default', + }, + }, + }) + }) + + it('round-trips supported advanced JSON through form import and export', () => { + const form = createRouterConfigFormState() + const openrouter = form.slices[0] + if (openrouter.kind !== 'openrouter') + throw new Error('expected default slice to be OpenRouter') + openrouter.plaintextKey = 'sk-openrouter' + form.defaults.ttsVoicesJson = defaultTtsVoicesJson() + const request = buildRouterConfigRequest(form).request + if (!request) + throw new Error('expected valid request') + + const imported = formStateFromRequestJson(formatRouterConfigRequestJson(request)) + expect(buildRouterConfigRequest(imported).request).toEqual(request) + }) + + it('rejects unsupported advanced JSON slice kinds', () => { + expect(() => formStateFromRequestJson(JSON.stringify({ + mode: 'merge', + slices: [{ kind: 'future-provider' }], + }))).toThrow('Unsupported router slice kind "future-provider".') + }) +}) diff --git a/apps/ui-admin/src/modules/router-config-form.ts b/apps/ui-admin/src/modules/router-config-form.ts new file mode 100644 index 000000000..c29ffb776 --- /dev/null +++ b/apps/ui-admin/src/modules/router-config-form.ts @@ -0,0 +1,640 @@ +import type { + AdminRouterAzureSlice, + AdminRouterConfigRequest, + AdminRouterConfigSlice, + AdminRouterDashscopeSlice, + AdminRouterOpenRouterSlice, + AdminRouterStepfunSlice, + AdminRouterUnspeechSlice, +} from './api' + +import { errorMessageFromUnknown } from '@proj-airi/stage-shared' + +export type RouterConfigMode = 'merge' | 'reset' +export type RouterSliceKind = AdminRouterConfigSlice['kind'] +export type DashscopeRegion = AdminRouterDashscopeSlice['region'] +export type StepfunModel = NonNullable + +export interface RouterDefaultsDraft { + chatModel: string + ttsModel: string + ttsVoicesJson: string +} + +interface SliceDraftBase { + id: string + kind: RouterSliceKind +} + +export interface OpenRouterSliceDraft extends SliceDraftBase { + kind: 'openrouter' + modelName: string + overrideModel: string + plaintextKey: string + baseURL: string + keyEntryId: string + existingKeyEntryId: string + headerTemplate: string +} + +export interface AzureSliceDraft extends SliceDraftBase { + kind: 'azure' + modelName: string + region: string + defaultVoice: string + plaintextKey: string + keyEntryId: string + existingKeyEntryId: string +} + +export interface DashscopeSliceDraft extends SliceDraftBase { + kind: 'dashscope-cosyvoice' + modelName: string + region: DashscopeRegion + upstreamModel: string + plaintextKey: string + keyEntryId: string + existingKeyEntryId: string +} + +export interface StepfunSliceDraft extends SliceDraftBase { + kind: 'stepfun' + modelName: string + upstreamModel: StepfunModel + defaultVoice: string + instruction: string + plaintextKey: string + keyEntryId: string + existingKeyEntryId: string +} + +export interface UnspeechSliceDraft extends SliceDraftBase { + kind: 'unspeech' + restBaseURL: string + streamingEnabled: boolean + streamingUpstreamURL: string + streamingPlaintextKey: string + streamingKeyEntryId: string + streamingExistingKeyEntryId: string + streamingModelsJson: string + streamingDefaultModel: string +} + +export type RouterSliceDraft + = | OpenRouterSliceDraft + | AzureSliceDraft + | DashscopeSliceDraft + | StepfunSliceDraft + | UnspeechSliceDraft + +export interface RouterConfigFormState { + mode: RouterConfigMode + slices: RouterSliceDraft[] + defaults: RouterDefaultsDraft +} + +export interface RouterConfigBuildResult { + request: AdminRouterConfigRequest | null + errors: string[] +} + +const DEFAULT_TTS_VOICES_JSON = '{\n "alibaba/cosyvoice-v2": {\n "zh-CN": "longxiaochun_v2"\n }\n}' +const DEFAULT_STREAMING_MODELS_JSON = '[\n {\n "id": "volcengine/seed-tts-2.0",\n "name": "Seed TTS 2.0"\n }\n]' + +let draftId = 0 + +export const ROUTER_SLICE_KIND_OPTIONS: Array<{ label: string, value: RouterSliceKind, description: string }> = [ + { label: 'OpenRouter', value: 'openrouter', description: 'LLM chat model alias' }, + { label: 'Azure Speech', value: 'azure', description: 'Microsoft TTS model alias' }, + { label: 'DashScope CosyVoice', value: 'dashscope-cosyvoice', description: 'Alibaba TTS model alias' }, + { label: 'StepFun TTS', value: 'stepfun', description: 'StepAudio / Step TTS model alias' }, + { label: 'UnSpeech', value: 'unspeech', description: 'REST and optional streaming TTS upstream' }, +] + +export const DASHSCOPE_REGION_OPTIONS: Array<{ label: string, value: DashscopeRegion, description: string }> = [ + { label: 'International', value: 'intl', description: 'dashscope-intl.aliyuncs.com' }, + { label: 'China', value: 'cn', description: 'dashscope.aliyuncs.com' }, +] + +export const STEPFUN_MODEL_OPTIONS: Array<{ label: string, value: StepfunModel }> = [ + { label: 'StepAudio 2.5 TTS', value: 'stepaudio-2.5-tts' }, + { label: 'Step TTS 2', value: 'step-tts-2' }, + { label: 'Step TTS Mini', value: 'step-tts-mini' }, +] + +/** + * Creates the default LLM Router form state. + * + * Use when: + * - The admin page needs a fresh editable request with the common OpenRouter + * chat-default path prefilled. + * + * Returns: + * - Mutable form state that can be compiled by {@link buildRouterConfigRequest}. + */ +export function createRouterConfigFormState(): RouterConfigFormState { + return { + mode: 'merge', + slices: [createRouterSliceDraft('openrouter')], + defaults: { + chatModel: 'chat-default', + ttsModel: '', + ttsVoicesJson: '', + }, + } +} + +/** + * Creates an editable provider slice draft. + * + * Use when: + * - The admin adds a provider card or imports a supported JSON slice. + * + * Returns: + * - A draft with provider-specific operational defaults. + */ +export function createRouterSliceDraft(kind: 'openrouter', id?: string): OpenRouterSliceDraft +export function createRouterSliceDraft(kind: 'azure', id?: string): AzureSliceDraft +export function createRouterSliceDraft(kind: 'dashscope-cosyvoice', id?: string): DashscopeSliceDraft +export function createRouterSliceDraft(kind: 'stepfun', id?: string): StepfunSliceDraft +export function createRouterSliceDraft(kind: 'unspeech', id?: string): UnspeechSliceDraft +export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): RouterSliceDraft +export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): RouterSliceDraft { + const sliceId = id ?? `${kind}-${++draftId}` + switch (kind) { + case 'openrouter': + return { + id: sliceId, + kind, + modelName: 'chat-default', + overrideModel: 'openai/gpt-4o-mini', + plaintextKey: '', + baseURL: 'https://openrouter.ai/api/v1', + keyEntryId: '', + existingKeyEntryId: '', + headerTemplate: '', + } + case 'azure': + return { + id: sliceId, + kind, + modelName: 'microsoft/v1', + region: 'eastasia', + defaultVoice: '', + plaintextKey: '', + keyEntryId: '', + existingKeyEntryId: '', + } + case 'dashscope-cosyvoice': + return { + id: sliceId, + kind, + modelName: 'alibaba/cosyvoice-v2', + region: 'intl', + upstreamModel: 'cosyvoice-v2', + plaintextKey: '', + keyEntryId: '', + existingKeyEntryId: '', + } + case 'stepfun': + return { + id: sliceId, + kind, + modelName: 'stepfun/stepaudio-2.5-tts', + upstreamModel: 'stepaudio-2.5-tts', + defaultVoice: '', + instruction: '', + plaintextKey: '', + keyEntryId: '', + existingKeyEntryId: '', + } + case 'unspeech': + return { + id: sliceId, + kind, + restBaseURL: 'http://airi-unspeech.railway.internal:5933', + streamingEnabled: false, + streamingUpstreamURL: 'ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream', + streamingPlaintextKey: '', + streamingKeyEntryId: '', + streamingExistingKeyEntryId: '', + streamingModelsJson: DEFAULT_STREAMING_MODELS_JSON, + streamingDefaultModel: '', + } + } +} + +export function defaultTtsVoicesJson(): string { + return DEFAULT_TTS_VOICES_JSON +} + +/** + * Builds the admin router config request from form state. + * + * Use when: + * - Previewing or applying the visible form payload. + * + * Expects: + * - Plaintext keys are present in draft state only; callers should submit the + * returned request directly and avoid rendering it in normal summaries. + * + * Returns: + * - A request when client-side validation passes, otherwise actionable errors. + */ +export function buildRouterConfigRequest(form: RouterConfigFormState): RouterConfigBuildResult { + const errors = validateRouterConfigForm(form) + if (errors.length > 0) + return { request: null, errors } + + return { request: routerConfigRequestFromFormDraft(form), errors: [] } +} + +/** + * Projects form state into request JSON without validating it first. + * + * Use when: + * - The UI needs to export the current draft for inspection before all + * required fields are complete. + * + * Returns: + * - The request shape the validated builder would submit once errors are fixed. + */ +export function routerConfigRequestFromFormDraft(form: RouterConfigFormState): AdminRouterConfigRequest { + const slices = form.slices.map(sliceToRequest) + const defaults = defaultsToRequest(form.defaults) + return { + mode: form.mode, + slices, + ...(Object.keys(defaults).length > 0 ? { defaults } : {}), + } +} + +export function validateRouterConfigForm(form: RouterConfigFormState): string[] { + const errors: string[] = [] + const unspeechCount = form.slices.filter(slice => slice.kind === 'unspeech').length + if (unspeechCount > 1) + errors.push('Only one UnSpeech slice can be submitted at a time.') + + for (const [index, slice] of form.slices.entries()) + errors.push(...validateSlice(slice, index + 1)) + + validateDefaults(form.defaults, errors) + + const hasDefaults = hasText(form.defaults.chatModel) || hasText(form.defaults.ttsModel) || hasText(form.defaults.ttsVoicesJson) + if (form.slices.length === 0 && !hasDefaults) + errors.push('Add at least one provider slice or default alias before previewing.') + + return errors +} + +/** + * Imports supported router config request JSON into editable form state. + * + * Before: + * - `{ "mode": "merge", "slices": [{ "kind": "openrouter", ... }] }` + * + * After: + * - Form state with one OpenRouter draft and matching defaults. + */ +export function formStateFromRequestJson(json: string): RouterConfigFormState { + const parsed = JSON.parse(json) as unknown + if (!isRecord(parsed)) + throw new Error('Advanced JSON must be an object.') + + const mode = parsed.mode === 'reset' ? 'reset' : 'merge' + const slicesValue = parsed.slices + const slices = Array.isArray(slicesValue) + ? slicesValue.map((slice, index) => draftFromRequestSlice(slice, index + 1)) + : [] + + return { + mode, + slices, + defaults: defaultsFromUnknown(parsed.defaults), + } +} + +export function formatRouterConfigRequestJson(request: AdminRouterConfigRequest): string { + return JSON.stringify(request, null, 2) +} + +function validateSlice(slice: RouterSliceDraft, ordinal: number): string[] { + const label = `Slice ${ordinal} (${kindLabel(slice.kind)})` + switch (slice.kind) { + case 'openrouter': + return [ + required(slice.modelName, `${label}: model alias is required.`), + noPipe(slice.modelName, `${label}: model alias must not contain "|".`), + required(slice.overrideModel, `${label}: upstream model is required.`), + requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`), + httpUrl(slice.baseURL, `${label}: base URL must start with http:// or https://.`), + ].filter(isPresent) + case 'azure': + return [ + required(slice.modelName, `${label}: model alias is required.`), + noPipe(slice.modelName, `${label}: model alias must not contain "|".`), + required(slice.region, `${label}: Azure region is required.`), + requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`), + ].filter(isPresent) + case 'dashscope-cosyvoice': + return [ + required(slice.modelName, `${label}: model alias is required.`), + noPipe(slice.modelName, `${label}: model alias must not contain "|".`), + required(slice.upstreamModel, `${label}: upstream model is required.`), + requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`), + ].filter(isPresent) + case 'stepfun': + return [ + required(slice.modelName, `${label}: model alias is required.`), + noPipe(slice.modelName, `${label}: model alias must not contain "|".`), + requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`), + ].filter(isPresent) + case 'unspeech': + return [ + required(slice.restBaseURL, `${label}: REST base URL is required.`), + httpUrl(slice.restBaseURL, `${label}: REST base URL must start with http:// or https://.`), + ...(slice.streamingEnabled + ? [ + required(slice.streamingUpstreamURL, `${label}: streaming WebSocket URL is required.`), + wsUrl(slice.streamingUpstreamURL, `${label}: streaming URL must start with ws:// or wss://.`), + requiredKey(slice.streamingPlaintextKey, slice.streamingExistingKeyEntryId, `${label}: streaming provider key is required unless an existing key is loaded.`), + validateStreamingModels(slice.streamingModelsJson, label), + ].filter(isPresent) + : []), + ].filter(isPresent) + } +} + +function validateDefaults(defaults: RouterDefaultsDraft, errors: string[]) { + if (!hasText(defaults.ttsVoicesJson)) + return + try { + parseTtsVoices(defaults.ttsVoicesJson) + } + catch (error) { + errors.push(errorMessageFromUnknown(error, 'Default TTS voices must be valid JSON.')) + } +} + +function validateStreamingModels(json: string, label: string): string | undefined { + if (!hasText(json)) + return undefined + try { + parseStreamingModels(json) + return undefined + } + catch (error) { + return error instanceof Error ? `${label}: ${error.message}` : `${label}: streaming models must be valid JSON.` + } +} + +function sliceToRequest(slice: RouterSliceDraft): AdminRouterConfigSlice { + switch (slice.kind) { + case 'openrouter': { + const request: AdminRouterOpenRouterSlice = { + kind: slice.kind, + modelName: trim(slice.modelName), + overrideModel: trim(slice.overrideModel), + baseURL: trim(slice.baseURL), + } + assignOptional(request, 'plaintextKey', slice.plaintextKey) + assignOptional(request, 'keyEntryId', slice.keyEntryId) + assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId) + assignOptional(request, 'headerTemplate', slice.headerTemplate) + return request + } + case 'azure': { + const request: AdminRouterAzureSlice = { + kind: slice.kind, + modelName: trim(slice.modelName), + region: trim(slice.region), + } + assignOptional(request, 'plaintextKey', slice.plaintextKey) + assignOptional(request, 'defaultVoice', slice.defaultVoice) + assignOptional(request, 'keyEntryId', slice.keyEntryId) + assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId) + return request + } + case 'dashscope-cosyvoice': { + const request: AdminRouterDashscopeSlice = { + kind: slice.kind, + modelName: trim(slice.modelName), + region: slice.region, + upstreamModel: trim(slice.upstreamModel), + } + assignOptional(request, 'plaintextKey', slice.plaintextKey) + assignOptional(request, 'keyEntryId', slice.keyEntryId) + assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId) + return request + } + case 'stepfun': { + const request: AdminRouterStepfunSlice = { + kind: slice.kind, + modelName: trim(slice.modelName), + upstreamModel: slice.upstreamModel, + } + assignOptional(request, 'plaintextKey', slice.plaintextKey) + assignOptional(request, 'defaultVoice', slice.defaultVoice) + assignOptional(request, 'instruction', slice.instruction) + assignOptional(request, 'keyEntryId', slice.keyEntryId) + assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId) + return request + } + case 'unspeech': { + const request: AdminRouterUnspeechSlice = { + kind: slice.kind, + restBaseURL: trim(slice.restBaseURL), + } + if (slice.streamingEnabled) { + request.streaming = { + upstreamURL: trim(slice.streamingUpstreamURL), + } + assignOptional(request.streaming, 'plaintextKey', slice.streamingPlaintextKey) + assignOptional(request.streaming, 'keyEntryId', slice.streamingKeyEntryId) + assignOptional(request.streaming, 'existingKeyEntryId', slice.streamingExistingKeyEntryId) + assignOptional(request.streaming, 'defaultModel', slice.streamingDefaultModel) + const models = parseStreamingModels(slice.streamingModelsJson) + if (models.length > 0) + request.streaming.models = models + } + return request + } + } +} + +function defaultsToRequest(defaults: RouterDefaultsDraft): NonNullable { + const out: NonNullable = {} + assignOptional(out, 'chatModel', defaults.chatModel) + assignOptional(out, 'ttsModel', defaults.ttsModel) + if (hasText(defaults.ttsVoicesJson)) + out.ttsVoices = parseTtsVoices(defaults.ttsVoicesJson) + return out +} + +function draftFromRequestSlice(value: unknown, ordinal: number): RouterSliceDraft { + if (!isRecord(value) || typeof value.kind !== 'string') + throw new Error(`slices[${ordinal - 1}] must include a supported kind.`) + + switch (value.kind) { + case 'openrouter': { + const draft = createRouterSliceDraft('openrouter', `imported-openrouter-${ordinal}`) as OpenRouterSliceDraft + draft.modelName = stringValue(value.modelName) + draft.overrideModel = stringValue(value.overrideModel) + draft.plaintextKey = stringValue(value.plaintextKey) + draft.baseURL = stringValue(value.baseURL) || draft.baseURL + draft.keyEntryId = stringValue(value.keyEntryId) + draft.existingKeyEntryId = stringValue(value.existingKeyEntryId) + draft.headerTemplate = stringValue(value.headerTemplate) + return draft + } + case 'azure': { + const draft = createRouterSliceDraft('azure', `imported-azure-${ordinal}`) as AzureSliceDraft + draft.modelName = stringValue(value.modelName) + draft.region = stringValue(value.region) + draft.defaultVoice = stringValue(value.defaultVoice) + draft.plaintextKey = stringValue(value.plaintextKey) + draft.keyEntryId = stringValue(value.keyEntryId) + draft.existingKeyEntryId = stringValue(value.existingKeyEntryId) + return draft + } + case 'dashscope-cosyvoice': { + const draft = createRouterSliceDraft('dashscope-cosyvoice', `imported-dashscope-${ordinal}`) as DashscopeSliceDraft + draft.modelName = stringValue(value.modelName) + draft.region = value.region === 'cn' ? 'cn' : 'intl' + draft.upstreamModel = stringValue(value.upstreamModel) + draft.plaintextKey = stringValue(value.plaintextKey) + draft.keyEntryId = stringValue(value.keyEntryId) + draft.existingKeyEntryId = stringValue(value.existingKeyEntryId) + return draft + } + case 'stepfun': { + const draft = createRouterSliceDraft('stepfun', `imported-stepfun-${ordinal}`) as StepfunSliceDraft + draft.modelName = stringValue(value.modelName) + draft.upstreamModel = isStepfunModel(value.upstreamModel) ? value.upstreamModel : draft.upstreamModel + draft.defaultVoice = stringValue(value.defaultVoice) + draft.instruction = stringValue(value.instruction) + draft.plaintextKey = stringValue(value.plaintextKey) + draft.keyEntryId = stringValue(value.keyEntryId) + draft.existingKeyEntryId = stringValue(value.existingKeyEntryId) + return draft + } + case 'unspeech': { + const draft = createRouterSliceDraft('unspeech', `imported-unspeech-${ordinal}`) as UnspeechSliceDraft + draft.restBaseURL = stringValue(value.restBaseURL) + const streaming = isRecord(value.streaming) ? value.streaming : null + draft.streamingEnabled = streaming != null + if (streaming) { + draft.streamingUpstreamURL = stringValue(streaming.upstreamURL) + draft.streamingPlaintextKey = stringValue(streaming.plaintextKey) + draft.streamingKeyEntryId = stringValue(streaming.keyEntryId) + draft.streamingExistingKeyEntryId = stringValue(streaming.existingKeyEntryId) + draft.streamingDefaultModel = stringValue(streaming.defaultModel) + if (Array.isArray(streaming.models)) + draft.streamingModelsJson = JSON.stringify(streaming.models, null, 2) + } + return draft + } + default: + throw new Error(`Unsupported router slice kind "${value.kind}".`) + } +} + +function defaultsFromUnknown(value: unknown): RouterDefaultsDraft { + const defaults = isRecord(value) ? value : {} + return { + chatModel: stringValue(defaults.chatModel), + ttsModel: stringValue(defaults.ttsModel), + ttsVoicesJson: isRecord(defaults.ttsVoices) ? JSON.stringify(defaults.ttsVoices, null, 2) : '', + } +} + +function parseTtsVoices(json: string): Record> { + const parsed = JSON.parse(json) as unknown + if (!isRecord(parsed)) + throw new Error('Default TTS voices must be a JSON object.') + + const out: Record> = {} + for (const [model, locales] of Object.entries(parsed)) { + if (!model.trim() || !isRecord(locales)) + throw new Error('Default TTS voices must map model IDs to locale objects.') + out[model] = {} + for (const [locale, voice] of Object.entries(locales)) { + if (!locale.trim() || typeof voice !== 'string' || !voice.trim()) + throw new Error('Default TTS voices locales must map to voice IDs.') + out[model][locale] = voice + } + } + return out +} + +function parseStreamingModels(json: string): NonNullable['models']> { + if (!hasText(json)) + return [] + const parsed = JSON.parse(json) as unknown + if (!Array.isArray(parsed)) + throw new Error('streaming models must be a JSON array.') + + return parsed.map((item, index) => { + if (!isRecord(item) || typeof item.id !== 'string' || !item.id.trim()) + throw new Error(`streaming models[${index}].id is required.`) + + return { + id: item.id, + ...(typeof item.name === 'string' && item.name.trim() ? { name: item.name } : {}), + ...(typeof item.description === 'string' && item.description.trim() ? { description: item.description } : {}), + } + }) +} + +function kindLabel(kind: RouterSliceKind): string { + return ROUTER_SLICE_KIND_OPTIONS.find(option => option.value === kind)?.label ?? kind +} + +function required(value: string, message: string): string | undefined { + return hasText(value) ? undefined : message +} + +function requiredKey(value: string, existingKeyEntryId: string, message: string): string | undefined { + return hasText(value) || hasText(existingKeyEntryId) ? undefined : message +} + +function noPipe(value: string, message: string): string | undefined { + return value.includes('|') ? message : undefined +} + +function httpUrl(value: string, message: string): string | undefined { + return /^https?:\/\/\S+$/u.test(value.trim()) ? undefined : message +} + +function wsUrl(value: string, message: string): string | undefined { + return /^wss?:\/\/\S+$/u.test(value.trim()) ? undefined : message +} + +function assignOptional(target: T, key: string, value: string) { + if (hasText(value)) + Object.assign(target, { [key]: trim(value) }) +} + +function trim(value: string): string { + return value.trim() +} + +function hasText(value: string): boolean { + return value.trim().length > 0 +} + +function isPresent(value: string | undefined): value is string { + return value != null +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function isStepfunModel(value: unknown): value is StepfunModel { + return value === 'stepaudio-2.5-tts' || value === 'step-tts-2' || value === 'step-tts-mini' +} diff --git a/apps/ui-admin/src/pages/LlmRouterPage.test.ts b/apps/ui-admin/src/pages/LlmRouterPage.test.ts new file mode 100644 index 000000000..54cf12398 --- /dev/null +++ b/apps/ui-admin/src/pages/LlmRouterPage.test.ts @@ -0,0 +1,211 @@ +// @vitest-environment jsdom + +import type { App } from 'vue' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, nextTick } from 'vue' + +import LlmRouterPage from './LlmRouterPage.vue' + +const mocks = vi.hoisted(() => ({ + applyRouterConfig: vi.fn(), + routerConfig: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('../modules/api', () => ({ + adminApi: { + applyRouterConfig: mocks.applyRouterConfig, + routerConfig: mocks.routerConfig, + }, +})) + +vi.mock('vue-sonner', () => ({ + toast: { + success: mocks.toastSuccess, + error: mocks.toastError, + }, +})) + +describe('llm router page', () => { + let app: App + let host: HTMLElement + + beforeEach(() => { + mocks.routerConfig.mockResolvedValue({ + request: { + mode: 'merge', + slices: [], + defaults: {}, + }, + preview: {}, + loadedAt: '2026-06-10T00:00:00.000Z', + missingKeys: ['LLM_ROUTER_CONFIG'], + }) + mocks.applyRouterConfig.mockResolvedValue({ + applied: [{ kind: 'openrouter', target: 'llm-router', surface: 'llm', modelName: 'chat-default', keyEntryId: 'k1' }], + invalidatedKeys: [], + preview: { + LLM_ROUTER_CONFIG: { + llm: { + models: { + 'chat-default': { + upstreams: [{ keys: [{ id: 'k1', ciphertext: '' }] }], + }, + }, + }, + }, + }, + }) + document.body.innerHTML = '
' + host = document.querySelector('#app')! + app = createApp(LlmRouterPage) + app.mount(host) + }) + + afterEach(() => { + app.unmount() + vi.clearAllMocks() + }) + + it('starts as a form-first router config editor', () => { + expect(host.textContent).toContain('Router Config Form') + expect(host.textContent).toContain('Provider configuration') + expect(host.textContent).toContain('LLM') + expect(host.textContent).toContain('TTS') + expect(host.textContent).toContain('OpenRouter') + expect(host.textContent).not.toContain('Router Config Request') + }) + + it('loads current router config into the form and preserves existing keys', async () => { + app.unmount() + mocks.routerConfig.mockResolvedValueOnce({ + request: { + mode: 'merge', + slices: [{ + kind: 'openrouter', + modelName: 'chat-live', + overrideModel: 'openai/gpt-4.1-mini', + baseURL: 'https://openrouter.ai/api/v1', + keyEntryId: 'openrouter-live', + existingKeyEntryId: 'openrouter-live', + }], + defaults: { + chatModel: 'chat-live', + }, + }, + preview: { + LLM_ROUTER_CONFIG: { llm: { models: { 'chat-live': {} } } }, + }, + loadedAt: '2026-06-10T00:00:00.000Z', + missingKeys: [], + }) + document.body.innerHTML = '
' + host = document.querySelector('#app')! + app = createApp(LlmRouterPage) + app.mount(host) + await flushPromises() + + expect(host.textContent).toContain('Loaded current config') + expect(host.textContent).toContain('Loaded key entry openrouter-live') + expect(previewButton().disabled).toBe(false) + }) + + it('shows an actionable error when the current config response is not JSON data', async () => { + app.unmount() + mocks.routerConfig.mockResolvedValueOnce(null) + document.body.innerHTML = '
' + host = document.querySelector('#app')! + app = createApp(LlmRouterPage) + app.mount(host) + await flushPromises() + + expect(host.textContent).toContain('Current router config response is not a valid JSON object.') + }) + + it('separates LLM and TTS provider slices by tab', async () => { + buttonByText('TTS').click() + await nextTick() + + expect(host.textContent).toContain('No TTS provider slices') + expect(sectionHeadings(host)).not.toContain('1. OpenRouter') + + buttonByText('Add').click() + await nextTick() + + expect(host.textContent).toContain('Azure Speech') + }) + + it('keeps preview disabled until required fields are complete', async () => { + expect(previewButton().disabled).toBe(true) + + const keyInput = host.querySelector('input[type="password"]') + expect(keyInput).not.toBeNull() + keyInput!.value = 'sk-openrouter' + keyInput!.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + + expect(previewButton().disabled).toBe(false) + }) + + it('previews the built form payload without hand-written JSON', async () => { + const keyInput = host.querySelector('input[type="password"]') + keyInput!.value = 'sk-openrouter' + keyInput!.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + + previewButton().click() + await nextTick() + await flushPromises() + await nextTick() + + expect(mocks.applyRouterConfig).toHaveBeenCalledWith({ + mode: 'merge', + slices: [{ + kind: 'openrouter', + modelName: 'chat-default', + overrideModel: 'openai/gpt-4o-mini', + plaintextKey: 'sk-openrouter', + baseURL: 'https://openrouter.ai/api/v1', + }], + defaults: { + chatModel: 'chat-default', + }, + }, true) + expect(mocks.toastSuccess).toHaveBeenCalledWith('Router config preview generated') + }) + + it('exports the current form to advanced JSON', async () => { + const exportButton = buttonByText('Export Form') + exportButton.click() + await nextTick() + + const advancedTextarea = Array.from(host.querySelectorAll('textarea')).at(-1) + expect(advancedTextarea?.value).toContain('"kind": "openrouter"') + expect(advancedTextarea?.value).toContain('"chatModel": "chat-default"') + }) +}) + +function previewButton(): HTMLButtonElement { + return buttonByText('Preview') +} + +async function flushPromises() { + await nextTick() + await Promise.resolve() + await nextTick() +} + +function buttonByText(text: string): HTMLButtonElement { + const button = Array.from(document.querySelectorAll('button')) + .find(item => item.textContent?.includes(text)) + if (!(button instanceof HTMLButtonElement)) + throw new Error(`Button "${text}" not found`) + return button +} + +function sectionHeadings(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll('h3')) + .map(heading => heading.textContent?.trim() ?? '') +} diff --git a/apps/ui-admin/src/pages/LlmRouterPage.vue b/apps/ui-admin/src/pages/LlmRouterPage.vue index 75f78fb51..ad7ddc3c6 100644 --- a/apps/ui-admin/src/pages/LlmRouterPage.vue +++ b/apps/ui-admin/src/pages/LlmRouterPage.vue @@ -1,55 +1,205 @@