mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-09 15:58:27 +00:00
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:
parent
52e0476154
commit
5bf6deab89
17 changed files with 2782 additions and 121 deletions
|
|
@ -44,9 +44,10 @@ const OpenRouterSliceSchema = object({
|
|||
kind: literal('openrouter'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
overrideModel: pipe(string(), nonEmpty('overrideModel is required'), maxLength(200)),
|
||||
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
baseURL: optional(pipe(string(), url('baseURL must be a valid URL'))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
})
|
||||
|
||||
|
|
@ -55,8 +56,9 @@ const AzureSliceSchema = object({
|
|||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
region: pipe(string(), nonEmpty('region is required'), maxLength(64)),
|
||||
defaultVoice: optional(pipe(string(), nonEmpty('defaultVoice must not be empty'), maxLength(200))),
|
||||
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
})
|
||||
|
||||
const DashscopeSliceSchema = object({
|
||||
|
|
@ -64,8 +66,20 @@ const DashscopeSliceSchema = object({
|
|||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
region: picklist(['intl', 'cn'], 'region must be "intl" or "cn"'),
|
||||
upstreamModel: pipe(string(), nonEmpty('upstreamModel is required'), maxLength(200)),
|
||||
plaintextKey: pipe(string(), nonEmpty('plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
})
|
||||
|
||||
const StepfunSliceSchema = object({
|
||||
kind: literal('stepfun'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
upstreamModel: optional(picklist(['stepaudio-2.5-tts', 'step-tts-2', 'step-tts-mini'], 'upstreamModel must be a supported StepFun TTS model')),
|
||||
defaultVoice: optional(pipe(string(), nonEmpty('defaultVoice must not be empty'), maxLength(200))),
|
||||
instruction: optional(pipe(string(), nonEmpty('instruction must not be empty'), maxLength(200))),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -89,8 +103,9 @@ const UnspeechSliceSchema = object({
|
|||
regex(/^wss?:\/\/\S+$/, 'streaming.upstreamURL must start with ws:// or wss://'),
|
||||
maxLength(500),
|
||||
),
|
||||
plaintextKey: pipe(string(), nonEmpty('streaming.plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('streaming.plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
models: optional(array(object({
|
||||
id: pipe(string(), nonEmpty('streaming.models[].id is required'), maxLength(200)),
|
||||
name: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
|
|
@ -104,6 +119,7 @@ const SliceSchema = variant('kind', [
|
|||
OpenRouterSliceSchema,
|
||||
AzureSliceSchema,
|
||||
DashscopeSliceSchema,
|
||||
StepfunSliceSchema,
|
||||
UnspeechSliceSchema,
|
||||
])
|
||||
|
||||
|
|
@ -150,6 +166,9 @@ const BodySchema = object({
|
|||
* { "kind": "dashscope-cosyvoice", "modelName": "alibaba/cosyvoice-v2",
|
||||
* "region": "intl", "upstreamModel": "cosyvoice-v2",
|
||||
* "plaintextKey": "..." },
|
||||
* { "kind": "stepfun", "modelName": "stepfun/stepaudio-2.5-tts",
|
||||
* "upstreamModel": "stepaudio-2.5-tts",
|
||||
* "defaultVoice": "cixingnansheng", "plaintextKey": "..." },
|
||||
* { "kind": "unspeech",
|
||||
* "restBaseURL": "http://airi-unspeech.railway.internal:5933",
|
||||
* "streaming": {
|
||||
|
|
@ -194,6 +213,9 @@ export function createAdminRouterConfigRoutes(
|
|||
return new Hono<HonoEnv>()
|
||||
.use('*', authGuard)
|
||||
.use('*', adminGuard)
|
||||
.get('/', async (c) => {
|
||||
return c.json(await service.current())
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user')!
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const DEFAULT_KEY_ENTRY_IDS = {
|
|||
'openrouter': 'openrouter-prod-1',
|
||||
'azure': 'azure-tts-prod-1',
|
||||
'dashscope-cosyvoice': 'dashscope-tts-prod-1',
|
||||
'stepfun': 'stepfun-tts-prod-1',
|
||||
'unspeech': 'volcengine-prod-1',
|
||||
} as const
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ type LlmRouterConfig = InferOutput<typeof llmRouterConfigSchema>
|
|||
type LlmModel = InferOutput<typeof llmModelSchema>
|
||||
type TtsModel = InferOutput<typeof ttsModelSchema>
|
||||
type UnspeechUpstream = InferOutput<typeof unspeechUpstreamSchema>
|
||||
type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
|
||||
|
||||
/**
|
||||
* Per-provider input. The admin route validates the shape with Valibot
|
||||
|
|
@ -47,6 +49,7 @@ export type SliceInput
|
|||
= | OpenRouterSliceInput
|
||||
| AzureSliceInput
|
||||
| DashscopeSliceInput
|
||||
| StepfunSliceInput
|
||||
| UnspeechSliceInput
|
||||
|
||||
export interface OpenRouterSliceInput {
|
||||
|
|
@ -56,11 +59,13 @@ export interface OpenRouterSliceInput {
|
|||
/** Upstream model id sent to OpenRouter (e.g. `openai/gpt-4o-mini`). */
|
||||
overrideModel: string
|
||||
/** Plaintext provider key. Encrypted in-place; never echoed back. */
|
||||
plaintextKey: string
|
||||
plaintextKey?: string
|
||||
/** @default 'https://openrouter.ai/api/v1' */
|
||||
baseURL?: string
|
||||
/** @default 'openrouter-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
/** @default 'Bearer {KEY}' */
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
|
@ -73,9 +78,11 @@ export interface AzureSliceInput {
|
|||
region: string
|
||||
/** Default Microsoft voice used when `/audio/speech` omits `voice`. */
|
||||
defaultVoice?: string
|
||||
plaintextKey: string
|
||||
plaintextKey?: string
|
||||
/** @default 'azure-tts-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
}
|
||||
|
||||
export interface DashscopeSliceInput {
|
||||
|
|
@ -86,9 +93,28 @@ export interface DashscopeSliceInput {
|
|||
region: 'intl' | 'cn'
|
||||
/** Concrete cosyvoice variant the adapter calls upstream. Independent from `modelName`. */
|
||||
upstreamModel: string
|
||||
plaintextKey: string
|
||||
plaintextKey?: string
|
||||
/** @default 'dashscope-tts-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
}
|
||||
|
||||
export interface StepfunSliceInput {
|
||||
kind: 'stepfun'
|
||||
/** Key under `LLM_ROUTER_CONFIG.tts.models` (e.g. `stepfun/stepaudio-2.5-tts`). */
|
||||
modelName: string
|
||||
/** Concrete StepFun TTS model sent upstream. */
|
||||
upstreamModel?: 'stepaudio-2.5-tts' | 'step-tts-2' | 'step-tts-mini'
|
||||
/** Default official voice used when `/audio/speech` omits `voice`. */
|
||||
defaultVoice?: string
|
||||
/** Default global instruction for `stepaudio-2.5-tts`; per-request `instruction` overrides it. */
|
||||
instruction?: string
|
||||
plaintextKey?: string
|
||||
/** @default 'stepfun-tts-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
}
|
||||
|
||||
export interface UnspeechSliceInput {
|
||||
|
|
@ -100,9 +126,11 @@ export interface UnspeechSliceInput {
|
|||
/** unspeech ws endpoint: `ws(s)://host:port/v1/audio/speech/stream`. */
|
||||
upstreamURL: string
|
||||
/** Upstream provider key (Volcengine `X-Api-Key`), not an unspeech token. */
|
||||
plaintextKey: string
|
||||
plaintextKey?: string
|
||||
/** @default 'volcengine-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
/** Operator-curated streaming models exposed to the frontend picker. */
|
||||
models?: Array<{ id: string, name?: string, description?: string }>
|
||||
/** Server-curated default streaming model id. */
|
||||
|
|
@ -122,7 +150,7 @@ interface LlmModelSlice {
|
|||
interface TtsModelSlice {
|
||||
target: 'llm-router'
|
||||
surface: 'tts'
|
||||
kind: 'azure' | 'dashscope-cosyvoice'
|
||||
kind: 'azure' | 'dashscope-cosyvoice' | 'stepfun'
|
||||
modelName: string
|
||||
model: TtsModel
|
||||
keyEntryId: string
|
||||
|
|
@ -150,7 +178,7 @@ type BuiltSlice = LlmModelSlice | TtsModelSlice | UnspeechSlice
|
|||
*/
|
||||
export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.openrouter
|
||||
const ciphertext = envelope.encryptKey(input.plaintextKey, {
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
})
|
||||
|
|
@ -180,7 +208,7 @@ export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: Enve
|
|||
*/
|
||||
export function buildAzureSlice(input: AzureSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.azure
|
||||
const ciphertext = envelope.encryptKey(input.plaintextKey, {
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
})
|
||||
|
|
@ -219,7 +247,7 @@ export function buildAzureSlice(input: AzureSliceInput, envelope: EnvelopeCrypto
|
|||
*/
|
||||
export function buildDashscopeSlice(input: DashscopeSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS['dashscope-cosyvoice']
|
||||
const ciphertext = envelope.encryptKey(input.plaintextKey, {
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
})
|
||||
|
|
@ -244,6 +272,44 @@ export function buildDashscopeSlice(input: DashscopeSliceInput, envelope: Envelo
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a StepFun TTS slice into the LLM_ROUTER_CONFIG.tts shape.
|
||||
*
|
||||
* Use when:
|
||||
* - Admin posts a `stepfun` slice for StepAudio 2.5 TTS or Step TTS 2/Mini.
|
||||
*
|
||||
* Expects:
|
||||
* - `upstreamModel` is the concrete StepFun model sent to
|
||||
* `POST /v1/audio/speech`; defaults to `stepaudio-2.5-tts`.
|
||||
*/
|
||||
export function buildStepfunSlice(input: StepfunSliceInput, envelope: EnvelopeCrypto): TtsModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.stepfun
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
})
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'tts',
|
||||
kind: 'stepfun',
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
model: {
|
||||
provider: 'stepfun',
|
||||
upstreams: [{
|
||||
baseURL: 'https://api.stepfun.com/v1/audio/speech',
|
||||
keys: [{ id: keyEntryId, ciphertext }],
|
||||
adapterParams: {
|
||||
model: input.upstreamModel ?? 'stepaudio-2.5-tts',
|
||||
...(input.defaultVoice ? { defaultVoice: input.defaultVoice } : {}),
|
||||
...(input.instruction ? { instruction: input.instruction } : {}),
|
||||
},
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an unspeech slice into the UNSPEECH_UPSTREAM shape.
|
||||
*
|
||||
|
|
@ -265,7 +331,7 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
|
|||
}
|
||||
}
|
||||
const keyEntryId = input.streaming.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.unspeech
|
||||
const ciphertext = envelope.encryptKey(input.streaming.plaintextKey, {
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.streaming.plaintextKey, input.kind), {
|
||||
modelName: STREAMING_TTS_AAD_MODEL_NAME,
|
||||
keyEntryId,
|
||||
})
|
||||
|
|
@ -286,6 +352,162 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
|
|||
}
|
||||
}
|
||||
|
||||
function requiredPlaintextKey(value: string | undefined, kind: SliceInput['kind']): string {
|
||||
if (value?.trim())
|
||||
return value
|
||||
|
||||
throw createBadRequestError(`${kind} plaintext key is required when no existing key can be preserved`, 'INVALID_BODY')
|
||||
}
|
||||
|
||||
function firstKey(upstream: { keys: KeyEntry[] } | undefined, preferredId: string | undefined): KeyEntry | null {
|
||||
if (!upstream?.keys.length)
|
||||
return null
|
||||
|
||||
if (preferredId) {
|
||||
const selected = upstream.keys.find(key => key.id === preferredId)
|
||||
if (selected)
|
||||
return selected
|
||||
}
|
||||
|
||||
return upstream.keys[0] ?? null
|
||||
}
|
||||
|
||||
function preservedKeyOrThrow(upstream: { keys: KeyEntry[] } | undefined, preferredId: string | undefined, kind: SliceInput['kind']): KeyEntry {
|
||||
const key = firstKey(upstream, preferredId)
|
||||
if (!key)
|
||||
throw createBadRequestError(`${kind} existing key entry was not found; paste a new provider key to rotate it`, 'INVALID_BODY')
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
function buildOpenRouterSlicePreservingKey(input: OpenRouterSliceInput, envelope: EnvelopeCrypto, existing: LlmModel | undefined): LlmModelSlice {
|
||||
if (input.plaintextKey?.trim())
|
||||
return buildOpenRouterSlice(input, envelope)
|
||||
|
||||
const existingUpstream = existing?.upstreams[0]
|
||||
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'llm',
|
||||
kind: 'openrouter',
|
||||
modelName: input.modelName,
|
||||
keyEntryId: key.id,
|
||||
model: {
|
||||
upstreams: [{
|
||||
baseURL: input.baseURL ?? existingUpstream?.baseURL ?? 'https://openrouter.ai/api/v1',
|
||||
overrideModel: input.overrideModel,
|
||||
keys: [key],
|
||||
headerTemplate: input.headerTemplate ?? existingUpstream?.headerTemplate ?? 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildAzureSlicePreservingKey(input: AzureSliceInput, envelope: EnvelopeCrypto, existing: TtsModel | undefined): TtsModelSlice {
|
||||
if (input.plaintextKey?.trim())
|
||||
return buildAzureSlice(input, envelope)
|
||||
|
||||
const existingUpstream = existing?.upstreams[0]
|
||||
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'tts',
|
||||
kind: 'azure',
|
||||
modelName: input.modelName,
|
||||
keyEntryId: key.id,
|
||||
model: {
|
||||
provider: 'azure',
|
||||
upstreams: [{
|
||||
baseURL: `https://${input.region}.tts.speech.microsoft.com/cognitiveservices/v1`,
|
||||
keys: [key],
|
||||
adapterParams: {
|
||||
region: input.region,
|
||||
...(input.defaultVoice ? { defaultVoice: input.defaultVoice } : {}),
|
||||
},
|
||||
}],
|
||||
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildDashscopeSlicePreservingKey(input: DashscopeSliceInput, envelope: EnvelopeCrypto, existing: TtsModel | undefined): TtsModelSlice {
|
||||
if (input.plaintextKey?.trim())
|
||||
return buildDashscopeSlice(input, envelope)
|
||||
|
||||
const existingUpstream = existing?.upstreams[0]
|
||||
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
|
||||
const host = input.region === 'cn'
|
||||
? 'dashscope.aliyuncs.com'
|
||||
: 'dashscope-intl.aliyuncs.com'
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'tts',
|
||||
kind: 'dashscope-cosyvoice',
|
||||
modelName: input.modelName,
|
||||
keyEntryId: key.id,
|
||||
model: {
|
||||
provider: 'dashscope-cosyvoice',
|
||||
upstreams: [{
|
||||
baseURL: `https://${host}/api/v1/services/audio/tts/SpeechSynthesizer`,
|
||||
keys: [key],
|
||||
adapterParams: { model: input.upstreamModel },
|
||||
}],
|
||||
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildStepfunSlicePreservingKey(input: StepfunSliceInput, envelope: EnvelopeCrypto, existing: TtsModel | undefined): TtsModelSlice {
|
||||
if (input.plaintextKey?.trim())
|
||||
return buildStepfunSlice(input, envelope)
|
||||
|
||||
const existingUpstream = existing?.upstreams[0]
|
||||
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'tts',
|
||||
kind: 'stepfun',
|
||||
modelName: input.modelName,
|
||||
keyEntryId: key.id,
|
||||
model: {
|
||||
provider: 'stepfun',
|
||||
upstreams: [{
|
||||
baseURL: 'https://api.stepfun.com/v1/audio/speech',
|
||||
keys: [key],
|
||||
adapterParams: {
|
||||
model: input.upstreamModel ?? 'stepaudio-2.5-tts',
|
||||
...(input.defaultVoice ? { defaultVoice: input.defaultVoice } : {}),
|
||||
...(input.instruction ? { instruction: input.instruction } : {}),
|
||||
},
|
||||
}],
|
||||
fallbackTriggers: existing?.fallbackTriggers ?? DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnspeechSlicePreservingKey(input: UnspeechSliceInput, envelope: EnvelopeCrypto, existing: UnspeechUpstream | undefined | null): UnspeechSlice {
|
||||
if (!input.streaming || input.streaming.plaintextKey?.trim())
|
||||
return buildUnspeechSlice(input, envelope)
|
||||
|
||||
const key = preservedKeyOrThrow(existing?.streaming, input.streaming.existingKeyEntryId ?? input.streaming.keyEntryId, input.kind)
|
||||
return {
|
||||
target: 'unspeech',
|
||||
kind: 'unspeech',
|
||||
keyEntryId: key.id,
|
||||
value: {
|
||||
restBaseURL: input.restBaseURL,
|
||||
streaming: {
|
||||
baseURL: input.streaming.upstreamURL,
|
||||
keys: [key],
|
||||
adapterParams: existing?.streaming?.adapterParams ?? {},
|
||||
models: input.streaming.models ?? existing?.streaming?.models ?? [],
|
||||
defaultModel: input.streaming.defaultModel ?? existing?.streaming?.defaultModel,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a slice input. Routes to the per-kind builder.
|
||||
*
|
||||
|
|
@ -293,16 +515,25 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
|
|||
* - The service main path needs to turn an admin-supplied slice into a
|
||||
* ready-to-write configKV fragment. Tests dispatch the same way.
|
||||
*/
|
||||
export function buildSlice(input: SliceInput, envelope: EnvelopeCrypto): BuiltSlice {
|
||||
export function buildSlice(
|
||||
input: SliceInput,
|
||||
envelope: EnvelopeCrypto,
|
||||
existing?: {
|
||||
routerConfig?: LlmRouterConfig | null
|
||||
unspeech?: UnspeechUpstream | null
|
||||
},
|
||||
): BuiltSlice {
|
||||
switch (input.kind) {
|
||||
case 'openrouter':
|
||||
return buildOpenRouterSlice(input, envelope)
|
||||
return buildOpenRouterSlicePreservingKey(input, envelope, existing?.routerConfig?.llm.models[input.modelName])
|
||||
case 'azure':
|
||||
return buildAzureSlice(input, envelope)
|
||||
return buildAzureSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
|
||||
case 'dashscope-cosyvoice':
|
||||
return buildDashscopeSlice(input, envelope)
|
||||
return buildDashscopeSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
|
||||
case 'stepfun':
|
||||
return buildStepfunSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
|
||||
case 'unspeech':
|
||||
return buildUnspeechSlice(input, envelope)
|
||||
return buildUnspeechSlicePreservingKey(input, envelope, existing?.unspeech)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +645,140 @@ export interface ApplyResult {
|
|||
}
|
||||
}
|
||||
|
||||
export interface CurrentRouterConfigResult {
|
||||
request: {
|
||||
mode: 'merge'
|
||||
slices: SliceInput[]
|
||||
defaults: NonNullable<ApplyInput['defaults']>
|
||||
}
|
||||
preview: ApplyResult['preview']
|
||||
loadedAt: string
|
||||
missingKeys: string[]
|
||||
}
|
||||
|
||||
function sliceNeedsExistingKey(slice: SliceInput): boolean {
|
||||
if (slice.kind === 'unspeech')
|
||||
return slice.streaming != null && !slice.streaming.plaintextKey?.trim()
|
||||
|
||||
return !slice.plaintextKey?.trim()
|
||||
}
|
||||
|
||||
function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
|
||||
if (!config)
|
||||
return []
|
||||
|
||||
const slices: SliceInput[] = []
|
||||
for (const [modelName, model] of Object.entries(config.llm.models)) {
|
||||
const slice = openRouterSliceFromModel(modelName, model)
|
||||
if (slice)
|
||||
slices.push(slice)
|
||||
}
|
||||
for (const [modelName, model] of Object.entries(config.tts.models)) {
|
||||
const slice = ttsSliceFromModel(modelName, model)
|
||||
if (slice)
|
||||
slices.push(slice)
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
function openRouterSliceFromModel(modelName: string, model: LlmModel): OpenRouterSliceInput | null {
|
||||
const upstream = model.upstreams[0]
|
||||
const key = upstream?.keys[0]
|
||||
if (!upstream || !key)
|
||||
return null
|
||||
|
||||
return {
|
||||
kind: 'openrouter',
|
||||
modelName,
|
||||
overrideModel: upstream.overrideModel ?? modelName,
|
||||
baseURL: upstream.baseURL,
|
||||
headerTemplate: upstream.headerTemplate,
|
||||
keyEntryId: key.id,
|
||||
existingKeyEntryId: key.id,
|
||||
}
|
||||
}
|
||||
|
||||
function ttsSliceFromModel(modelName: string, model: TtsModel): AzureSliceInput | DashscopeSliceInput | StepfunSliceInput | null {
|
||||
const upstream = model.upstreams[0]
|
||||
const key = upstream?.keys[0]
|
||||
if (!upstream || !key)
|
||||
return null
|
||||
|
||||
if (model.provider === 'azure') {
|
||||
const region = stringFromRecord(upstream.adapterParams, 'region') ?? azureRegionFromBaseURL(upstream.baseURL) ?? ''
|
||||
return {
|
||||
kind: 'azure',
|
||||
modelName,
|
||||
region,
|
||||
defaultVoice: stringFromRecord(upstream.adapterParams, 'defaultVoice'),
|
||||
keyEntryId: key.id,
|
||||
existingKeyEntryId: key.id,
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider === 'dashscope-cosyvoice') {
|
||||
return {
|
||||
kind: 'dashscope-cosyvoice',
|
||||
modelName,
|
||||
region: upstream.baseURL.includes('dashscope.aliyuncs.com') ? 'cn' : 'intl',
|
||||
upstreamModel: stringFromRecord(upstream.adapterParams, 'model') ?? modelName,
|
||||
keyEntryId: key.id,
|
||||
existingKeyEntryId: key.id,
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider === 'stepfun') {
|
||||
const upstreamModel = stringFromRecord(upstream.adapterParams, 'model')
|
||||
return {
|
||||
kind: 'stepfun',
|
||||
modelName,
|
||||
upstreamModel: isStepfunInputModel(upstreamModel) ? upstreamModel : undefined,
|
||||
defaultVoice: stringFromRecord(upstream.adapterParams, 'defaultVoice'),
|
||||
instruction: stringFromRecord(upstream.adapterParams, 'instruction'),
|
||||
keyEntryId: key.id,
|
||||
existingKeyEntryId: key.id,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function slicesFromUnspeech(unspeech: UnspeechUpstream | null): UnspeechSliceInput[] {
|
||||
if (!unspeech)
|
||||
return []
|
||||
|
||||
const key = unspeech.streaming?.keys[0]
|
||||
return [{
|
||||
kind: 'unspeech',
|
||||
restBaseURL: unspeech.restBaseURL,
|
||||
...(unspeech.streaming
|
||||
? {
|
||||
streaming: {
|
||||
upstreamURL: unspeech.streaming.baseURL,
|
||||
keyEntryId: key?.id,
|
||||
existingKeyEntryId: key?.id,
|
||||
models: unspeech.streaming.models,
|
||||
defaultModel: unspeech.streaming.defaultModel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}]
|
||||
}
|
||||
|
||||
function azureRegionFromBaseURL(baseURL: string): string | null {
|
||||
const match = /^https:\/\/([^.]+)\.tts\.speech\.microsoft\.com\//u.exec(baseURL)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function stringFromRecord(recordValue: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = recordValue?.[key]
|
||||
return typeof value === 'string' && value.trim() ? value : undefined
|
||||
}
|
||||
|
||||
function isStepfunInputModel(value: string | undefined): value is NonNullable<StepfunSliceInput['upstreamModel']> {
|
||||
return value === 'stepaudio-2.5-tts' || value === 'step-tts-2' || value === 'step-tts-mini'
|
||||
}
|
||||
|
||||
interface AdminRouterConfigDeps {
|
||||
configKV: ConfigKVService
|
||||
envelope: EnvelopeCrypto
|
||||
|
|
@ -441,6 +806,62 @@ interface AdminRouterConfigDeps {
|
|||
export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
||||
const logger = useLogger('admin-router-config').useGlobalConfig()
|
||||
|
||||
async function current(): Promise<CurrentRouterConfigResult> {
|
||||
const [
|
||||
routerConfig,
|
||||
unspeech,
|
||||
chatModel,
|
||||
ttsModel,
|
||||
ttsVoices,
|
||||
] = await Promise.all([
|
||||
deps.configKV.getOptional('LLM_ROUTER_CONFIG'),
|
||||
deps.configKV.getOptional('UNSPEECH_UPSTREAM'),
|
||||
deps.configKV.getOptional('DEFAULT_CHAT_MODEL'),
|
||||
deps.configKV.getOptional('DEFAULT_TTS_MODEL'),
|
||||
deps.configKV.getOptional('DEFAULT_TTS_VOICES'),
|
||||
])
|
||||
|
||||
const slices: SliceInput[] = [
|
||||
...slicesFromRouterConfig(routerConfig ?? null),
|
||||
...slicesFromUnspeech(unspeech ?? null),
|
||||
]
|
||||
const defaults: NonNullable<ApplyInput['defaults']> = {}
|
||||
if (chatModel)
|
||||
defaults.chatModel = chatModel
|
||||
if (ttsModel)
|
||||
defaults.ttsModel = ttsModel
|
||||
if (ttsVoices && Object.keys(ttsVoices).length > 0)
|
||||
defaults.ttsVoices = ttsVoices
|
||||
|
||||
const preview: ApplyResult['preview'] = {}
|
||||
if (routerConfig)
|
||||
preview.LLM_ROUTER_CONFIG = redactCiphertext(routerConfig)
|
||||
if (unspeech)
|
||||
preview.UNSPEECH_UPSTREAM = redactCiphertext(unspeech)
|
||||
if (chatModel)
|
||||
preview.DEFAULT_CHAT_MODEL = chatModel
|
||||
if (ttsModel)
|
||||
preview.DEFAULT_TTS_MODEL = ttsModel
|
||||
if (ttsVoices && Object.keys(ttsVoices).length > 0)
|
||||
preview.DEFAULT_TTS_VOICES = ttsVoices
|
||||
|
||||
return {
|
||||
request: {
|
||||
mode: 'merge',
|
||||
slices,
|
||||
defaults,
|
||||
},
|
||||
preview,
|
||||
loadedAt: new Date().toISOString(),
|
||||
missingKeys: [
|
||||
...(routerConfig ? [] : ['LLM_ROUTER_CONFIG']),
|
||||
...(unspeech ? [] : ['UNSPEECH_UPSTREAM']),
|
||||
...(chatModel ? [] : ['DEFAULT_CHAT_MODEL']),
|
||||
...(ttsModel ? [] : ['DEFAULT_TTS_MODEL']),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an admin request, returning the redacted preview either way.
|
||||
*
|
||||
|
|
@ -454,9 +875,22 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
|||
if (unspeechCount > 1)
|
||||
throw createBadRequestError('At most one unspeech slice per request', 'INVALID_BODY')
|
||||
|
||||
// Step 1: encrypt every slice. Throws (via envelope) only on malformed
|
||||
// master key, which means the deployment is broken; surface as 500.
|
||||
const built = input.slices.map(s => buildSlice(s, deps.envelope))
|
||||
const hasLlmTtsInput = input.slices.some(s => s.kind !== 'unspeech')
|
||||
const hasUnspeechInput = input.slices.some(s => s.kind === 'unspeech')
|
||||
const shouldReadRouterConfig = hasLlmTtsInput
|
||||
&& (input.mode === 'merge' || input.slices.some(sliceNeedsExistingKey))
|
||||
const shouldReadUnspeech = hasUnspeechInput
|
||||
const [existingRouterConfig, existingUnspeech] = await Promise.all([
|
||||
shouldReadRouterConfig ? deps.configKV.getOptional('LLM_ROUTER_CONFIG') : Promise.resolve(null),
|
||||
shouldReadUnspeech ? deps.configKV.getOptional('UNSPEECH_UPSTREAM') : Promise.resolve(null),
|
||||
])
|
||||
|
||||
// Step 1: encrypt new keys and preserve existing ciphertexts when the
|
||||
// admin loaded current config and left a key field blank.
|
||||
const built = input.slices.map(s => buildSlice(s, deps.envelope, {
|
||||
routerConfig: existingRouterConfig,
|
||||
unspeech: existingUnspeech,
|
||||
}))
|
||||
|
||||
const llmTtsSlices = built.filter((s): s is LlmModelSlice | TtsModelSlice => s.target === 'llm-router')
|
||||
const unspeechSlice = built.find((s): s is UnspeechSlice => s.target === 'unspeech')
|
||||
|
|
@ -465,10 +899,7 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
|||
// was supplied. `merge` reads existing first; `reset` skips the read.
|
||||
let nextRouterConfig: LlmRouterConfig | undefined
|
||||
if (llmTtsSlices.length > 0) {
|
||||
const existing = input.mode === 'merge'
|
||||
? await deps.configKV.getOptional('LLM_ROUTER_CONFIG')
|
||||
: null
|
||||
nextRouterConfig = buildNextRouterConfig(input.mode, existing, llmTtsSlices)
|
||||
nextRouterConfig = buildNextRouterConfig(input.mode, existingRouterConfig, llmTtsSlices)
|
||||
}
|
||||
|
||||
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` +
|
||||
|
|
@ -477,15 +908,14 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
|||
// subtree is set (otherwise there's nothing to merge into).
|
||||
let nextUnspeech: UnspeechUpstream | undefined
|
||||
if (unspeechSlice) {
|
||||
const existing = await deps.configKV.getOptional('UNSPEECH_UPSTREAM')
|
||||
const newValue = unspeechSlice.value
|
||||
if (newValue.streaming && existing?.streaming) {
|
||||
if (newValue.streaming && existingUnspeech?.streaming) {
|
||||
nextUnspeech = {
|
||||
...newValue,
|
||||
streaming: {
|
||||
...newValue.streaming,
|
||||
models: existing.streaming.models?.length ? existing.streaming.models : newValue.streaming.models,
|
||||
defaultModel: existing.streaming.defaultModel ?? newValue.streaming.defaultModel,
|
||||
models: existingUnspeech.streaming.models?.length ? existingUnspeech.streaming.models : newValue.streaming.models,
|
||||
defaultModel: existingUnspeech.streaming.defaultModel ?? newValue.streaming.defaultModel,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -563,7 +993,7 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
|||
return { applied, invalidatedKeys, preview }
|
||||
}
|
||||
|
||||
return { apply }
|
||||
return { apply, current }
|
||||
}
|
||||
|
||||
export type AdminRouterConfigService = ReturnType<typeof createAdminRouterConfigService>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
buildDashscopeSlice,
|
||||
buildNextRouterConfig,
|
||||
buildOpenRouterSlice,
|
||||
buildStepfunSlice,
|
||||
buildUnspeechSlice,
|
||||
createAdminRouterConfigService,
|
||||
redactCiphertext,
|
||||
|
|
@ -197,6 +198,36 @@ describe('buildDashscopeSlice', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('buildStepfunSlice', () => {
|
||||
it('builds the StepFun TTS endpoint and surfaces model defaults in adapterParams', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const built = buildStepfunSlice({
|
||||
kind: 'stepfun',
|
||||
modelName: 'stepfun/stepaudio-2.5-tts',
|
||||
upstreamModel: 'stepaudio-2.5-tts',
|
||||
defaultVoice: 'cixingnansheng',
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
plaintextKey: 'step-key',
|
||||
}, envelope)
|
||||
|
||||
expect(built.kind).toBe('stepfun')
|
||||
expect(built.model.provider).toBe('stepfun')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://api.stepfun.com/v1/audio/speech')
|
||||
expect(built.model.upstreams[0].adapterParams).toEqual({
|
||||
model: 'stepaudio-2.5-tts',
|
||||
defaultVoice: 'cixingnansheng',
|
||||
instruction: '温柔、克制、有一点笑意',
|
||||
})
|
||||
expect(built.model.fallbackTriggers).toEqual(DEFAULT_FALLBACK_TRIGGERS)
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'stepfun/stepaudio-2.5-tts',
|
||||
keyEntryId: 'stepfun-tts-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe('step-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUnspeechSlice', () => {
|
||||
it('writes restBaseURL with no streaming subtree when the slice omits streaming', () => {
|
||||
const envelope = freshEnvelope()
|
||||
|
|
@ -492,6 +523,82 @@ describe('createAdminRouterConfigService', () => {
|
|||
expect(Object.keys(written.tts.models)).toEqual(['microsoft/v1'])
|
||||
})
|
||||
|
||||
it('current returns editable slices from configKV without exposing raw ciphertext', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-live': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
kv.store.set('DEFAULT_CHAT_MODEL', 'chat-live')
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const current = await service.current()
|
||||
|
||||
expect(current.request.slices).toEqual([{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-live',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'openrouter-live',
|
||||
existingKeyEntryId: 'openrouter-live',
|
||||
}])
|
||||
expect(current.request.defaults.chatModel).toBe('chat-live')
|
||||
expect(JSON.stringify(current.preview)).toContain('<ciphertext: 17 chars>')
|
||||
expect(JSON.stringify(current.preview)).not.toContain('secret-ciphertext')
|
||||
})
|
||||
|
||||
it('preserves an existing key entry when an applied slice omits plaintextKey', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-live': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
keys: [{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
await service.apply({
|
||||
mode: 'merge',
|
||||
dryRun: false,
|
||||
slices: [{
|
||||
kind: 'openrouter',
|
||||
modelName: 'chat-live',
|
||||
overrideModel: 'openai/gpt-4.1-mini',
|
||||
baseURL: 'https://proxy.example/api/v1',
|
||||
keyEntryId: 'openrouter-live',
|
||||
existingKeyEntryId: 'openrouter-live',
|
||||
}],
|
||||
})
|
||||
|
||||
const written = kv.store.get('LLM_ROUTER_CONFIG') as { llm: { models: Record<string, { upstreams: Array<{ baseURL: string, keys: Array<{ id: string, ciphertext: string }> }> }> } }
|
||||
const upstream = written.llm.models['chat-live'].upstreams[0]
|
||||
expect(upstream.baseURL).toBe('https://proxy.example/api/v1')
|
||||
expect(upstream.keys).toEqual([{ id: 'openrouter-live', ciphertext: 'secret-ciphertext' }])
|
||||
})
|
||||
|
||||
it('reset mode skips the existing read and drops prior entries', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: { models: { 'should-be-dropped': { upstreams: [{ baseURL: 'https://x', keys: [{ id: 'k', ciphertext: 'c' }], headerTemplate: 'Bearer {KEY}' }] } } },
|
||||
|
|
@ -526,12 +633,14 @@ describe('createAdminRouterConfigService', () => {
|
|||
slices: [
|
||||
{ kind: 'openrouter', modelName: 'chat-default', overrideModel: 'openai/gpt-4o-mini', plaintextKey: 'sk' },
|
||||
{ kind: 'dashscope-cosyvoice', modelName: 'alibaba/cosyvoice-v2', region: 'intl', upstreamModel: 'cosyvoice-v2', plaintextKey: 'sk' },
|
||||
{ kind: 'stepfun', modelName: 'stepfun/stepaudio-2.5-tts', upstreamModel: 'stepaudio-2.5-tts', plaintextKey: 'sk' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.applied).toEqual([
|
||||
{ kind: 'openrouter', target: 'llm-router', surface: 'llm', modelName: 'chat-default', keyEntryId: 'openrouter-prod-1' },
|
||||
{ kind: 'dashscope-cosyvoice', target: 'llm-router', surface: 'tts', modelName: 'alibaba/cosyvoice-v2', keyEntryId: 'dashscope-tts-prod-1' },
|
||||
{ kind: 'stepfun', target: 'llm-router', surface: 'tts', modelName: 'stepfun/stepaudio-2.5-tts', keyEntryId: 'stepfun-tts-prod-1' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
<script setup lang="ts">
|
||||
import { Button, Callout, FieldTextArea } from '@proj-airi/ui'
|
||||
|
||||
defineProps<{
|
||||
error: string | null
|
||||
disabled: boolean
|
||||
busy: 'preview' | 'apply' | 'advanced-preview' | 'advanced-apply' | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
exportForm: []
|
||||
importForm: []
|
||||
preview: []
|
||||
apply: []
|
||||
}>()
|
||||
|
||||
const json = defineModel<string>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="['panel', 'overflow-hidden']">
|
||||
<div :class="['flex', 'items-start', 'justify-between', 'gap-3', 'border-b', 'border-neutral-200', 'px-4', 'py-3']">
|
||||
<div>
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Advanced JSON
|
||||
</h3>
|
||||
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
|
||||
Escape hatch for auditing or unsupported future fields.
|
||||
</p>
|
||||
</div>
|
||||
<span :class="['i-lucide-code-2', 'text-neutral-500']" />
|
||||
</div>
|
||||
|
||||
<div :class="['space-y-4', 'p-4']">
|
||||
<div :class="['flex', 'flex-wrap', 'gap-2']">
|
||||
<Button icon="i-lucide-file-output" label="Export Form" size="sm" type="button" variant="secondary" @click="emit('exportForm')" />
|
||||
<Button icon="i-lucide-file-input" label="Import JSON" size="sm" type="button" variant="secondary" @click="emit('importForm')" />
|
||||
</div>
|
||||
|
||||
<FieldTextArea
|
||||
v-model="json"
|
||||
:required="false"
|
||||
:rows="12"
|
||||
textarea-class="font-mono text-xs leading-5"
|
||||
/>
|
||||
|
||||
<Callout v-if="error" label="Advanced JSON error" theme="orange">
|
||||
{{ error }}
|
||||
</Callout>
|
||||
|
||||
<div :class="['flex', 'flex-wrap', 'justify-end', 'gap-2']">
|
||||
<Button
|
||||
icon="i-lucide-eye"
|
||||
label="Preview JSON"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="disabled || error != null"
|
||||
:loading="busy === 'advanced-preview'"
|
||||
@click="emit('preview')"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-save"
|
||||
label="Apply JSON"
|
||||
size="sm"
|
||||
type="button"
|
||||
:disabled="disabled || error != null"
|
||||
:loading="busy === 'advanced-apply'"
|
||||
@click="emit('apply')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<script setup lang="ts">
|
||||
import type { RouterDefaultsDraft } from '../../modules/router-config-form'
|
||||
|
||||
import { Button, FieldInput, FieldTextArea } from '@proj-airi/ui'
|
||||
|
||||
import { defaultTtsVoicesJson } from '../../modules/router-config-form'
|
||||
|
||||
const defaults = defineModel<RouterDefaultsDraft>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
|
||||
<div :class="['mb-4', 'flex', 'items-start', 'justify-between', 'gap-3']">
|
||||
<div>
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Defaults
|
||||
</h3>
|
||||
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
|
||||
Writes default model aliases alongside provider slices when filled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput
|
||||
v-model="defaults.chatModel"
|
||||
description="Writes DEFAULT_CHAT_MODEL."
|
||||
input-class="font-mono text-xs"
|
||||
label="Chat model"
|
||||
placeholder="chat-default"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="defaults.ttsModel"
|
||||
description="Writes DEFAULT_TTS_MODEL."
|
||||
input-class="font-mono text-xs"
|
||||
label="TTS model"
|
||||
placeholder="alibaba/cosyvoice-v2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="['mt-4']">
|
||||
<div :class="['mb-2', 'flex', 'items-center', 'justify-between', 'gap-3']">
|
||||
<div>
|
||||
<div :class="['text-sm', 'font-medium']">
|
||||
Recommended voices
|
||||
</div>
|
||||
<div :class="['text-xs', 'text-neutral-500']">
|
||||
JSON object written to DEFAULT_TTS_VOICES.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="whitespace-nowrap"
|
||||
label="Example"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="defaults.ttsVoicesJson = defaultTtsVoicesJson()"
|
||||
/>
|
||||
</div>
|
||||
<FieldTextArea
|
||||
v-model="defaults.ttsVoicesJson"
|
||||
:required="false"
|
||||
:rows="6"
|
||||
textarea-class="font-mono text-xs leading-5"
|
||||
placeholder="{ "alibaba/cosyvoice-v2": { "zh-CN": "longxiaochun_v2" } }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import type { RouterConfigMode } from '../../modules/router-config-form'
|
||||
|
||||
import { Button, Callout } from '@proj-airi/ui'
|
||||
|
||||
const mode = defineModel<RouterConfigMode>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
|
||||
<div :class="['flex', 'items-start', 'justify-between', 'gap-3']">
|
||||
<div>
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Write Mode
|
||||
</h3>
|
||||
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
|
||||
Merge keeps untouched router models. Reset replaces the router model tree with this request.
|
||||
</p>
|
||||
</div>
|
||||
<span :class="['badge', mode === 'reset' ? 'badge-amber' : 'badge-green']">
|
||||
<span :class="[mode === 'reset' ? 'i-lucide-alert-triangle' : 'i-lucide-git-merge']" />
|
||||
{{ mode === 'reset' ? 'Reset' : 'Merge' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :class="['mt-4', 'grid', 'gap-2', 'sm:grid-cols-2']">
|
||||
<Button
|
||||
icon="i-lucide-git-merge"
|
||||
label="Merge"
|
||||
size="sm"
|
||||
type="button"
|
||||
:toggled="mode === 'merge'"
|
||||
variant="secondary-muted"
|
||||
@click="mode = 'merge'"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-rotate-ccw"
|
||||
label="Reset"
|
||||
size="sm"
|
||||
type="button"
|
||||
:toggled="mode === 'reset'"
|
||||
:variant="mode === 'reset' ? 'caution' : 'secondary-muted'"
|
||||
@click="mode = 'reset'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout v-if="mode === 'reset'" :class="['mt-4']" label="Reset mode" theme="orange">
|
||||
Existing LLM/TTS router models not included in this request will be dropped by the server.
|
||||
</Callout>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
import type { AdminRouterConfigResult } from '../../modules/api'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
result: AdminRouterConfigResult | null
|
||||
}>()
|
||||
|
||||
const applied = computed(() => props.result?.applied ?? [])
|
||||
const invalidatedKeys = computed(() => props.result?.invalidatedKeys ?? [])
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="['panel', 'overflow-hidden']">
|
||||
<div :class="['border-b', 'border-neutral-200', 'px-4', 'py-3', 'text-sm', 'font-semibold']">
|
||||
{{ title }}
|
||||
</div>
|
||||
|
||||
<div v-if="result" :class="['space-y-4', 'p-4']">
|
||||
<div :class="['grid', 'gap-3', 'text-xs', 'sm:grid-cols-2']">
|
||||
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
|
||||
<div :class="['text-neutral-500']">
|
||||
Slices
|
||||
</div>
|
||||
<div :class="['mt-1', 'text-lg', 'font-semibold']">
|
||||
{{ applied.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
|
||||
<div :class="['text-neutral-500']">
|
||||
Invalidated keys
|
||||
</div>
|
||||
<div :class="['mt-1', 'text-lg', 'font-semibold']">
|
||||
{{ invalidatedKeys.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="applied.length" :class="['space-y-2']">
|
||||
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
|
||||
Applied
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'gap-2']">
|
||||
<span
|
||||
v-for="(item, index) in applied"
|
||||
:key="index"
|
||||
:class="['badge']"
|
||||
>
|
||||
{{ item.kind }}
|
||||
<span v-if="item.modelName" :class="['font-mono']">{{ item.modelName }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidatedKeys.length" :class="['space-y-2']">
|
||||
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
|
||||
Keys
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'gap-2']">
|
||||
<span v-for="key in invalidatedKeys" :key="key" :class="['badge', 'badge-green', 'font-mono']">
|
||||
{{ key }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre :class="['max-h-[420px]', 'overflow-auto', 'rounded-lg', 'bg-neutral-950', 'p-4', 'text-xs', 'leading-5', 'text-neutral-50']">{{ formatJson(result.preview) }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-else :class="['empty-state', 'min-h-40']">
|
||||
<span :class="['i-lucide-clipboard-list', 'text-2xl']" />
|
||||
No data yet
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
150
apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue
Normal file
150
apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<script setup lang="ts">
|
||||
import type { RouterSliceDraft } from '../../modules/router-config-form'
|
||||
|
||||
import { Button, FieldInput, FieldSelect, FieldTextArea } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
DASHSCOPE_REGION_OPTIONS,
|
||||
STEPFUN_MODEL_OPTIONS,
|
||||
} from '../../modules/router-config-form'
|
||||
|
||||
defineProps<{
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
duplicate: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const slice = defineModel<RouterSliceDraft>('slice', { required: true })
|
||||
|
||||
const title = computed(() => {
|
||||
switch (slice.value.kind) {
|
||||
case 'openrouter':
|
||||
return 'OpenRouter'
|
||||
case 'azure':
|
||||
return 'Azure Speech'
|
||||
case 'dashscope-cosyvoice':
|
||||
return 'DashScope CosyVoice'
|
||||
case 'stepfun':
|
||||
return 'StepFun TTS'
|
||||
case 'unspeech':
|
||||
return 'UnSpeech'
|
||||
default:
|
||||
return 'Router Slice'
|
||||
}
|
||||
})
|
||||
|
||||
const providerKeyDescription = computed(() => {
|
||||
if (slice.value.kind === 'unspeech')
|
||||
return ''
|
||||
|
||||
return slice.value.existingKeyEntryId
|
||||
? `Loaded key entry ${slice.value.existingKeyEntryId}. Leave blank to keep it.`
|
||||
: undefined
|
||||
})
|
||||
|
||||
const providerKeyPlaceholder = computed(() => {
|
||||
if (slice.value.kind === 'unspeech')
|
||||
return ''
|
||||
|
||||
return slice.value.existingKeyEntryId ? 'Leave blank to keep existing key' : 'Paste provider key'
|
||||
})
|
||||
|
||||
const streamingKeyDescription = computed(() => {
|
||||
if (slice.value.kind !== 'unspeech' || !slice.value.streamingExistingKeyEntryId)
|
||||
return undefined
|
||||
|
||||
return `Loaded key entry ${slice.value.streamingExistingKeyEntryId}. Leave blank to keep it.`
|
||||
})
|
||||
|
||||
const streamingKeyPlaceholder = computed(() => {
|
||||
if (slice.value.kind !== 'unspeech' || !slice.value.streamingExistingKeyEntryId)
|
||||
return 'Paste streaming provider key'
|
||||
|
||||
return 'Leave blank to keep existing key'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
|
||||
<div :class="['mb-4', 'flex', 'flex-col', 'gap-3', 'md:flex-row', 'md:items-start', 'md:justify-between']">
|
||||
<div :class="['min-w-0']">
|
||||
<h3 :class="['truncate', 'text-sm', 'font-semibold']">
|
||||
{{ index }}. {{ title }}
|
||||
</h3>
|
||||
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
|
||||
{{ slice.kind }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex', 'shrink-0', 'flex-wrap', 'gap-2', 'md:justify-end']">
|
||||
<Button class="whitespace-nowrap" icon="i-lucide-copy" label="Duplicate" size="sm" type="button" variant="secondary" @click="emit('duplicate')" />
|
||||
<Button class="whitespace-nowrap" icon="i-lucide-trash-2" label="Remove" size="sm" type="button" variant="danger" @click="emit('remove')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="slice.kind === 'openrouter'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="chat-default" required />
|
||||
<FieldInput v-model="slice.overrideModel" input-class="font-mono text-xs" label="Upstream model" placeholder="openai/gpt-4o-mini" required />
|
||||
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
|
||||
<FieldInput v-model="slice.baseURL" input-class="font-mono text-xs" label="Base URL" placeholder="https://openrouter.ai/api/v1" required />
|
||||
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="openrouter-prod-1" />
|
||||
<FieldInput v-model="slice.headerTemplate" input-class="font-mono text-xs" label="Header template" placeholder="Bearer {KEY}" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="slice.kind === 'azure'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="microsoft/v1" required />
|
||||
<FieldInput v-model="slice.region" input-class="font-mono text-xs" label="Azure region" placeholder="eastasia" required />
|
||||
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
|
||||
<FieldInput v-model="slice.defaultVoice" input-class="font-mono text-xs" label="Default voice" placeholder="zh-CN-XiaoxiaoNeural" />
|
||||
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="azure-tts-prod-1" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="slice.kind === 'dashscope-cosyvoice'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="alibaba/cosyvoice-v2" required />
|
||||
<FieldSelect v-model="slice.region" label="Region" layout="vertical" :options="DASHSCOPE_REGION_OPTIONS" select-class="w-full" />
|
||||
<FieldInput v-model="slice.upstreamModel" input-class="font-mono text-xs" label="Upstream model" placeholder="cosyvoice-v2" required />
|
||||
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
|
||||
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="dashscope-tts-prod-1" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="slice.kind === 'stepfun'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="stepfun/stepaudio-2.5-tts" required />
|
||||
<FieldSelect v-model="slice.upstreamModel" label="Upstream model" layout="vertical" :options="STEPFUN_MODEL_OPTIONS" select-class="w-full" />
|
||||
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
|
||||
<FieldInput v-model="slice.defaultVoice" input-class="font-mono text-xs" label="Default voice" placeholder="cixingnansheng" />
|
||||
<FieldInput v-model="slice.instruction" input-class="font-mono text-xs" label="Instruction" placeholder="Speak warmly" />
|
||||
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="stepfun-tts-prod-1" />
|
||||
</div>
|
||||
|
||||
<div v-else :class="['space-y-4']">
|
||||
<FieldInput v-model="slice.restBaseURL" input-class="font-mono text-xs" label="REST base URL" placeholder="http://airi-unspeech.railway.internal:5933" required />
|
||||
|
||||
<label :class="['flex', 'items-start', 'gap-3', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
|
||||
<input v-model="slice.streamingEnabled" :class="['mt-1']" type="checkbox">
|
||||
<span>
|
||||
<span :class="['block', 'text-sm', 'font-medium']">Enable streaming upstream</span>
|
||||
<span :class="['block', 'text-xs', 'text-neutral-500']">Writes UNSPEECH_UPSTREAM.streaming for WebSocket TTS.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="slice.streamingEnabled" :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput v-model="slice.streamingUpstreamURL" input-class="font-mono text-xs" label="Streaming WebSocket URL" placeholder="ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream" required />
|
||||
<FieldInput v-model="slice.streamingPlaintextKey" autocomplete="new-password" :description="streamingKeyDescription" input-class="font-mono text-xs" label="Streaming provider key" :placeholder="streamingKeyPlaceholder" required type="password" />
|
||||
<FieldInput v-model="slice.streamingKeyEntryId" input-class="font-mono text-xs" label="Streaming key entry ID" placeholder="volcengine-prod-1" />
|
||||
<FieldInput v-model="slice.streamingDefaultModel" input-class="font-mono text-xs" label="Default streaming model" placeholder="volcengine/seed-tts-2.0" />
|
||||
<div :class="['md:col-span-2']">
|
||||
<FieldTextArea
|
||||
v-model="slice.streamingModelsJson"
|
||||
label="Streaming models"
|
||||
:required="false"
|
||||
:rows="6"
|
||||
textarea-class="font-mono text-xs leading-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -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<Record<string, unknown>>
|
||||
slices?: AdminRouterConfigSlice[]
|
||||
defaults?: {
|
||||
chatModel?: string
|
||||
ttsModel?: string
|
||||
|
|
@ -64,6 +126,13 @@ export interface AdminRouterConfigResult {
|
|||
preview: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminRouterConfigCurrent {
|
||||
request: AdminRouterConfigRequest
|
||||
preview: Record<string, unknown>
|
||||
loadedAt: string
|
||||
missingKeys: string[]
|
||||
}
|
||||
|
||||
export interface VoicePackParams {
|
||||
[key: string]: string | number | boolean | null
|
||||
}
|
||||
|
|
@ -173,7 +242,12 @@ async function fetchJson<T>(endpoint: URL, init: RequestInit = {}): Promise<T> {
|
|||
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<AdminRouterConfigCurrent>('/config/router'),
|
||||
speechModels: async () => {
|
||||
const data = await publicFetch<{ models?: SpeechModel[] }>('/audio/models')
|
||||
return Array.isArray(data.models) ? data.models : []
|
||||
|
|
|
|||
223
apps/ui-admin/src/modules/router-config-form.test.ts
Normal file
223
apps/ui-admin/src/modules/router-config-form.test.ts
Normal file
|
|
@ -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".')
|
||||
})
|
||||
})
|
||||
640
apps/ui-admin/src/modules/router-config-form.ts
Normal file
640
apps/ui-admin/src/modules/router-config-form.ts
Normal file
|
|
@ -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<AdminRouterStepfunSlice['upstreamModel']>
|
||||
|
||||
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<AdminRouterConfigRequest['defaults']> {
|
||||
const out: NonNullable<AdminRouterConfigRequest['defaults']> = {}
|
||||
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<string, Record<string, string>> {
|
||||
const parsed = JSON.parse(json) as unknown
|
||||
if (!isRecord(parsed))
|
||||
throw new Error('Default TTS voices must be a JSON object.')
|
||||
|
||||
const out: Record<string, Record<string, string>> = {}
|
||||
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<NonNullable<AdminRouterUnspeechSlice['streaming']>['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<T extends object>(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<string, unknown> {
|
||||
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'
|
||||
}
|
||||
211
apps/ui-admin/src/pages/LlmRouterPage.test.ts
Normal file
211
apps/ui-admin/src/pages/LlmRouterPage.test.ts
Normal file
|
|
@ -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<Element>
|
||||
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: '<ciphertext: 10 chars>' }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
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 = '<div id="app"></div>'
|
||||
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 = '<div id="app"></div>'
|
||||
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<HTMLInputElement>('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<HTMLInputElement>('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() ?? '')
|
||||
}
|
||||
|
|
@ -1,55 +1,205 @@
|
|||
<script setup lang="ts">
|
||||
import type { AdminRouterConfigRequest, AdminRouterConfigResult } from '../modules/api'
|
||||
import type { AdminRouterConfigCurrent, AdminRouterConfigRequest, AdminRouterConfigResult } from '../modules/api'
|
||||
import type { RouterSliceDraft, RouterSliceKind } from '../modules/router-config-form'
|
||||
|
||||
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { Button, Callout, FieldSelect } from '@proj-airi/ui'
|
||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import RouterAdvancedJsonPanel from '../components/llm-router/RouterAdvancedJsonPanel.vue'
|
||||
import RouterDefaultsEditor from '../components/llm-router/RouterDefaultsEditor.vue'
|
||||
import RouterModeControl from '../components/llm-router/RouterModeControl.vue'
|
||||
import RouterPreviewPanel from '../components/llm-router/RouterPreviewPanel.vue'
|
||||
import RouterSliceEditor from '../components/llm-router/RouterSliceEditor.vue'
|
||||
|
||||
import { adminApi } from '../modules/api'
|
||||
import {
|
||||
buildRouterConfigRequest,
|
||||
createRouterConfigFormState,
|
||||
createRouterSliceDraft,
|
||||
formatRouterConfigRequestJson,
|
||||
formStateFromRequestJson,
|
||||
ROUTER_SLICE_KIND_OPTIONS,
|
||||
routerConfigRequestFromFormDraft,
|
||||
validateRouterConfigForm,
|
||||
} from '../modules/router-config-form'
|
||||
|
||||
const DEFAULT_REQUEST_BODY = `{
|
||||
"mode": "merge",
|
||||
"slices": [
|
||||
{
|
||||
"kind": "openrouter",
|
||||
"modelName": "chat-default",
|
||||
"overrideModel": "openai/gpt-4o-mini",
|
||||
"plaintextKey": "",
|
||||
"baseURL": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
"chatModel": "chat-default"
|
||||
}
|
||||
}`
|
||||
type BusyState = 'preview' | 'apply' | 'advanced-preview' | 'advanced-apply'
|
||||
type ProviderTab = 'llm' | 'tts'
|
||||
|
||||
const requestBody = shallowRef(DEFAULT_REQUEST_BODY)
|
||||
const form = reactive(createRouterConfigFormState())
|
||||
const activeProviderTab = shallowRef<ProviderTab>('llm')
|
||||
const selectedKindByTab = reactive<Record<ProviderTab, RouterSliceKind>>({
|
||||
llm: 'openrouter',
|
||||
tts: 'azure',
|
||||
})
|
||||
const previewResult = shallowRef<AdminRouterConfigResult | null>(null)
|
||||
const applyResult = shallowRef<AdminRouterConfigResult | null>(null)
|
||||
const busy = shallowRef<'preview' | 'apply' | null>(null)
|
||||
const busy = shallowRef<BusyState | null>(null)
|
||||
const currentConfig = shallowRef<AdminRouterConfigCurrent | null>(null)
|
||||
const loadingCurrent = shallowRef(false)
|
||||
const currentError = shallowRef<string | null>(null)
|
||||
const advancedJson = shallowRef(formatRouterConfigRequestJson(routerConfigRequestFromFormDraft(form)))
|
||||
const advancedError = shallowRef<string | null>(null)
|
||||
|
||||
const parseError = computed(() => {
|
||||
try {
|
||||
parseRequestBody()
|
||||
return null
|
||||
}
|
||||
catch (error) {
|
||||
return errorMessageFromUnknown(error, 'Invalid JSON')
|
||||
const validationErrors = computed(() => validateRouterConfigForm(form))
|
||||
const hasValidationErrors = computed(() => validationErrors.value.length > 0)
|
||||
const pendingRequest = computed(() => routerConfigRequestFromFormDraft(form))
|
||||
const selectedKind = computed({
|
||||
get: () => selectedKindByTab[activeProviderTab.value],
|
||||
set: (kind: RouterSliceKind) => {
|
||||
selectedKindByTab[activeProviderTab.value] = kind
|
||||
},
|
||||
})
|
||||
const pendingSummary = computed(() => {
|
||||
const defaults = pendingRequest.value.defaults ?? {}
|
||||
return {
|
||||
slices: form.slices.length,
|
||||
llmSlices: form.slices.filter(slice => slice.kind === 'openrouter').length,
|
||||
ttsSlices: form.slices.filter(slice => slice.kind !== 'openrouter' && slice.kind !== 'unspeech').length,
|
||||
unspeech: form.slices.some(slice => slice.kind === 'unspeech'),
|
||||
defaults: Object.keys(defaults).length,
|
||||
}
|
||||
})
|
||||
const providerTabs = computed(() => [
|
||||
{ value: 'llm' as const, label: 'LLM', count: pendingSummary.value.llmSlices },
|
||||
{ value: 'tts' as const, label: 'TTS', count: pendingSummary.value.ttsSlices + (pendingSummary.value.unspeech ? 1 : 0) },
|
||||
])
|
||||
const providerKindOptions = computed(() => ROUTER_SLICE_KIND_OPTIONS.filter((option) => {
|
||||
if (activeProviderTab.value === 'llm')
|
||||
return option.value === 'openrouter'
|
||||
|
||||
return option.value !== 'openrouter'
|
||||
}))
|
||||
const visibleSlices = computed(() => form.slices
|
||||
.map((slice, index) => ({ slice, index }))
|
||||
.filter(({ slice }) => activeProviderTab.value === 'llm'
|
||||
? isLlmSlice(slice)
|
||||
: !isLlmSlice(slice)))
|
||||
const currentStatusLabel = computed(() => {
|
||||
if (loadingCurrent.value)
|
||||
return 'Loading current config'
|
||||
if (currentError.value)
|
||||
return 'Current config unavailable'
|
||||
if (!currentConfig.value)
|
||||
return 'Current config not loaded'
|
||||
if (currentConfig.value.request.slices?.length || Object.keys(currentConfig.value.request.defaults ?? {}).length > 0)
|
||||
return 'Loaded current config'
|
||||
return 'No current config'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentConfig()
|
||||
})
|
||||
|
||||
async function previewConfig() {
|
||||
await submit(true)
|
||||
await submitForm(true)
|
||||
}
|
||||
|
||||
async function applyConfig() {
|
||||
await submit(false)
|
||||
await submitForm(false)
|
||||
}
|
||||
|
||||
async function submit(dryRun: boolean) {
|
||||
busy.value = dryRun ? 'preview' : 'apply'
|
||||
async function previewAdvancedJson() {
|
||||
await submitAdvancedJson(true)
|
||||
}
|
||||
|
||||
async function applyAdvancedJson() {
|
||||
await submitAdvancedJson(false)
|
||||
}
|
||||
|
||||
async function loadCurrentConfig() {
|
||||
loadingCurrent.value = true
|
||||
currentError.value = null
|
||||
try {
|
||||
const result = await adminApi.applyRouterConfig(parseRequestBody(), dryRun)
|
||||
const current = await adminApi.routerConfig()
|
||||
if (!isCurrentConfigResponse(current))
|
||||
throw new Error('Current router config response is not a valid JSON object.')
|
||||
|
||||
currentConfig.value = current
|
||||
previewResult.value = {
|
||||
applied: [],
|
||||
invalidatedKeys: [],
|
||||
preview: current.preview,
|
||||
}
|
||||
|
||||
if (current.request.slices?.length || Object.keys(current.request.defaults ?? {}).length > 0) {
|
||||
Object.assign(form, formStateFromRequestJson(formatRouterConfigRequestJson(current.request)))
|
||||
advancedJson.value = formatRouterConfigRequestJson(routerConfigRequestFromFormDraft(form))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
currentError.value = errorMessageFromUnknown(error, 'Failed to load current router config')
|
||||
}
|
||||
finally {
|
||||
loadingCurrent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addSlice() {
|
||||
form.slices.push(createRouterSliceDraft(selectedKind.value))
|
||||
}
|
||||
|
||||
function selectProviderTab(tab: ProviderTab) {
|
||||
activeProviderTab.value = tab
|
||||
}
|
||||
|
||||
function duplicateSlice(index: number) {
|
||||
const source = form.slices[index]
|
||||
if (!source)
|
||||
return
|
||||
|
||||
const clone = createRouterSliceDraft(source.kind)
|
||||
const cloneId = clone.id
|
||||
Object.assign(clone, JSON.parse(JSON.stringify(source)) as RouterSliceDraft, { id: cloneId })
|
||||
form.slices.splice(index + 1, 0, clone)
|
||||
}
|
||||
|
||||
function removeSlice(index: number) {
|
||||
form.slices.splice(index, 1)
|
||||
}
|
||||
|
||||
function exportFormJson() {
|
||||
advancedJson.value = formatRouterConfigRequestJson(routerConfigRequestFromFormDraft(form))
|
||||
advancedError.value = null
|
||||
toast.success('Form exported to advanced JSON')
|
||||
}
|
||||
|
||||
function importAdvancedJson() {
|
||||
try {
|
||||
const imported = formStateFromRequestJson(advancedJson.value)
|
||||
Object.assign(form, imported)
|
||||
advancedError.value = null
|
||||
toast.success('Advanced JSON imported into form')
|
||||
}
|
||||
catch (error) {
|
||||
advancedError.value = errorMessageFromUnknown(error, 'Failed to import advanced JSON')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(dryRun: boolean) {
|
||||
const built = buildRouterConfigRequest(form)
|
||||
if (!built.request) {
|
||||
toast.error(built.errors[0] ?? 'Router config form is incomplete')
|
||||
return
|
||||
}
|
||||
|
||||
await submitRequest(built.request, dryRun, dryRun ? 'preview' : 'apply')
|
||||
}
|
||||
|
||||
async function submitAdvancedJson(dryRun: boolean) {
|
||||
const request = parseAdvancedJsonRequest()
|
||||
if (!request)
|
||||
return
|
||||
|
||||
await submitRequest(request, dryRun, dryRun ? 'advanced-preview' : 'advanced-apply')
|
||||
}
|
||||
|
||||
async function submitRequest(request: AdminRouterConfigRequest, dryRun: boolean, state: BusyState) {
|
||||
busy.value = state
|
||||
try {
|
||||
const result = await adminApi.applyRouterConfig(request, dryRun)
|
||||
if (dryRun) {
|
||||
previewResult.value = result
|
||||
toast.success('Router config preview generated')
|
||||
|
|
@ -68,83 +218,231 @@ async function submit(dryRun: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
function parseRequestBody(): AdminRouterConfigRequest {
|
||||
const parsed = JSON.parse(requestBody.value) as unknown
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('Request body must be a JSON object')
|
||||
function parseAdvancedJsonRequest(): AdminRouterConfigRequest | null {
|
||||
try {
|
||||
const parsed = JSON.parse(advancedJson.value) as unknown
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('Advanced JSON must be a request object.')
|
||||
|
||||
return parsed as AdminRouterConfigRequest
|
||||
advancedError.value = null
|
||||
return parsed as AdminRouterConfigRequest
|
||||
}
|
||||
catch (error) {
|
||||
advancedError.value = errorMessageFromUnknown(error, 'Invalid advanced JSON')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2)
|
||||
function isLlmSlice(slice: RouterSliceDraft) {
|
||||
return slice.kind === 'openrouter'
|
||||
}
|
||||
|
||||
function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value is AdminRouterConfigCurrent {
|
||||
return value != null
|
||||
&& typeof value === 'object'
|
||||
&& value.request != null
|
||||
&& typeof value.request === 'object'
|
||||
&& value.preview != null
|
||||
&& typeof value.preview === 'object'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section class="panel overflow-hidden">
|
||||
<div class="flex flex-col gap-3 border-b border-neutral-200 px-5 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div :class="['grid', 'gap-5', 'xl:grid-cols-[minmax(0,1fr)_420px]']">
|
||||
<section :class="['panel', 'overflow-hidden']">
|
||||
<div :class="['flex', 'flex-col', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
Router Config Request
|
||||
<h2 :class="['text-sm', 'font-semibold']">
|
||||
Router Config Form
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
Writes the active LLM_ROUTER_CONFIG, UNSPEECH_UPSTREAM, and default model aliases.
|
||||
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
|
||||
Builds LLM_ROUTER_CONFIG, UNSPEECH_UPSTREAM, and default model aliases without hand-written JSON.
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge" :class="parseError ? 'badge-amber' : 'badge-green'">
|
||||
<span :class="parseError ? 'i-lucide-alert-circle' : 'i-lucide-check-circle-2'" />
|
||||
{{ parseError ? 'Invalid JSON' : 'Ready' }}
|
||||
<span :class="['badge', hasValidationErrors ? 'badge-amber' : 'badge-green']">
|
||||
<span :class="[hasValidationErrors ? 'i-lucide-alert-circle' : 'i-lucide-check-circle-2']" />
|
||||
{{ hasValidationErrors ? `${validationErrors.length} issues` : 'Ready' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<textarea
|
||||
v-model="requestBody"
|
||||
class="textarea min-h-[520px] text-xs leading-5 font-mono"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<form :class="['space-y-5', 'p-5']" @submit.prevent="previewConfig">
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
|
||||
<div :class="['flex', 'flex-col', 'gap-3', 'md:flex-row', 'md:items-center', 'md:justify-between']">
|
||||
<div>
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Current Config
|
||||
</h3>
|
||||
<p :class="['mt-1', 'text-xs', currentError ? 'text-red-600' : 'text-neutral-500']">
|
||||
{{ currentError ?? currentStatusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
class="whitespace-nowrap"
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="Reload"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="busy != null || loadingCurrent"
|
||||
:loading="loadingCurrent"
|
||||
@click="loadCurrentConfig"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="parseError" class="border border-amber-200 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{{ parseError }}
|
||||
</div>
|
||||
<RouterModeControl v-model="form.mode" />
|
||||
<RouterDefaultsEditor v-model="form.defaults" />
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button class="btn btn-secondary" :disabled="busy != null || parseError != null" type="button" @click="previewConfig">
|
||||
<span :class="busy === 'preview' ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-eye'" />
|
||||
Preview
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="busy != null || parseError != null" type="button" @click="applyConfig">
|
||||
<span :class="busy === 'apply' ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-save'" />
|
||||
Apply
|
||||
</button>
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white']">
|
||||
<div :class="['border-b', 'border-neutral-200', 'p-4']">
|
||||
<div>
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Provider configuration
|
||||
</h3>
|
||||
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
|
||||
LLM and TTS providers are edited separately, then applied as one router config request.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['flex', 'flex-col', 'gap-4', 'p-4']">
|
||||
<div :class="['flex', 'flex-col', 'gap-3', 'lg:flex-row', 'lg:items-end', 'lg:justify-between']">
|
||||
<div
|
||||
:class="['inline-grid', 'w-full', 'grid-cols-2', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-1', 'sm:w-auto']"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="tab in providerTabs"
|
||||
:key="tab.value"
|
||||
:aria-selected="activeProviderTab === tab.value"
|
||||
:class="[
|
||||
'h-9 rounded-md px-4 text-sm font-medium transition-colors whitespace-nowrap',
|
||||
activeProviderTab === tab.value
|
||||
? 'bg-white text-primary-700 shadow-sm ring-1 ring-primary-200'
|
||||
: 'text-neutral-500 hover:text-neutral-900',
|
||||
]"
|
||||
role="tab"
|
||||
type="button"
|
||||
@click="selectProviderTab(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span :class="['ml-1', 'text-xs', activeProviderTab === tab.value ? 'text-neutral-500' : 'text-neutral-400']">
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="['grid', 'gap-2', 'sm:grid-cols-[minmax(220px,1fr)_auto]', 'sm:items-end']">
|
||||
<FieldSelect
|
||||
v-model="selectedKind"
|
||||
label="Provider"
|
||||
layout="vertical"
|
||||
:options="providerKindOptions"
|
||||
select-class="w-full"
|
||||
/>
|
||||
<Button class="self-end whitespace-nowrap" icon="i-lucide-plus" label="Add" size="sm" type="button" @click="addSlice" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50/60', 'px-3', 'py-2', 'text-xs', 'text-neutral-600']">
|
||||
<span :class="['font-medium', 'text-neutral-900']">{{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }}</span>
|
||||
<span v-if="activeProviderTab === 'llm'">
|
||||
config writes chat model aliases under LLM_ROUTER_CONFIG.
|
||||
</span>
|
||||
<span v-else>
|
||||
config writes speech model aliases and optional UNSPEECH_UPSTREAM settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSlices.length" :class="['space-y-3']">
|
||||
<RouterSliceEditor
|
||||
v-for="(item, visibleIndex) in visibleSlices"
|
||||
:key="item.slice.id"
|
||||
v-model:slice="form.slices[item.index]"
|
||||
:index="visibleIndex + 1"
|
||||
@duplicate="duplicateSlice(item.index)"
|
||||
@remove="removeSlice(item.index)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else :class="['empty-state', 'min-h-40', 'rounded-lg', 'border', 'border-dashed', 'border-neutral-200', 'bg-white']">
|
||||
No {{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }} provider slices
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['border-t', 'border-neutral-200', 'px-4', 'py-3', 'text-xs', 'text-neutral-500']">
|
||||
UnSpeech is limited to one TTS slice per request.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Callout v-if="hasValidationErrors" label="Fix before apply" theme="orange">
|
||||
<ul :class="['list-disc', 'space-y-1', 'pl-4']">
|
||||
<li v-for="error in validationErrors" :key="error">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<div :class="['flex', 'flex-col', 'gap-3', 'border-t', 'border-neutral-200', 'pt-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
|
||||
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-5']">
|
||||
<div>
|
||||
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.slices }}</span>
|
||||
slices
|
||||
</div>
|
||||
<div>
|
||||
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.llmSlices }}</span>
|
||||
LLM
|
||||
</div>
|
||||
<div>
|
||||
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.ttsSlices }}</span>
|
||||
TTS
|
||||
</div>
|
||||
<div>
|
||||
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.unspeech ? 'yes' : 'no' }}</span>
|
||||
unspeech
|
||||
</div>
|
||||
<div>
|
||||
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.defaults }}</span>
|
||||
defaults
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['flex', 'flex-wrap', 'justify-end', 'gap-2']">
|
||||
<Button
|
||||
icon="i-lucide-eye"
|
||||
label="Preview"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="busy != null || hasValidationErrors"
|
||||
:loading="busy === 'preview'"
|
||||
@click="previewConfig"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-save"
|
||||
label="Apply"
|
||||
size="sm"
|
||||
type="button"
|
||||
:disabled="busy != null || hasValidationErrors"
|
||||
:loading="busy === 'apply'"
|
||||
@click="applyConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<section class="panel overflow-hidden">
|
||||
<div class="border-b border-neutral-200 px-4 py-3 text-sm font-semibold">
|
||||
Preview
|
||||
</div>
|
||||
<pre v-if="previewResult" class="max-h-[420px] overflow-auto p-4 text-xs leading-5">{{ formatJson(previewResult) }}</pre>
|
||||
<div v-else class="empty-state min-h-40">
|
||||
<span class="i-lucide-clipboard-list text-2xl" />
|
||||
No preview yet
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel overflow-hidden">
|
||||
<div class="border-b border-neutral-200 px-4 py-3 text-sm font-semibold">
|
||||
Last Apply
|
||||
</div>
|
||||
<pre v-if="applyResult" class="max-h-[420px] overflow-auto p-4 text-xs leading-5">{{ formatJson(applyResult) }}</pre>
|
||||
<div v-else class="empty-state min-h-40">
|
||||
<span class="i-lucide-history text-2xl" />
|
||||
No applied changes
|
||||
</div>
|
||||
</section>
|
||||
<aside :class="['space-y-4']">
|
||||
<RouterPreviewPanel title="Preview" :result="previewResult" />
|
||||
<RouterPreviewPanel title="Last Apply" :result="applyResult" />
|
||||
<RouterAdvancedJsonPanel
|
||||
v-model="advancedJson"
|
||||
:busy="busy"
|
||||
:disabled="busy != null"
|
||||
:error="advancedError"
|
||||
@apply="applyAdvancedJson"
|
||||
@export-form="exportFormJson"
|
||||
@import-form="importAdvancedJson"
|
||||
@preview="previewAdvancedJson"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import { join } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import VueMacros from 'vue-macros/vite'
|
||||
|
||||
import { loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [
|
||||
VueMacros({
|
||||
plugins: {
|
||||
vue: Vue({
|
||||
include: [/\.vue$/, /\.md$/],
|
||||
}),
|
||||
vueJsx: false,
|
||||
},
|
||||
betterDefine: false,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
env: loadEnv(mode, join(cwd(), 'apps', 'ui-admin'), ''),
|
||||
|
|
|
|||
|
|
@ -448,6 +448,7 @@ All Field components wrap a base input with `label`, `description`, and consiste
|
|||
| `placeholder` | `string?` | — | Placeholder |
|
||||
| `required` | `boolean?` | — | Required indicator |
|
||||
| `type` | `InputType?` | — | Input type |
|
||||
| `autocomplete` | `string?` | — | Native autocomplete hint |
|
||||
| `inputClass` | `string?` | — | Custom input class |
|
||||
| `singleLine` | `boolean?` | `true` | `true` = input, `false` = textarea |
|
||||
|
||||
|
|
|
|||
210
docs/plans/2026-06-10-001-feat-llm-router-admin-form-plan.md
Normal file
210
docs/plans/2026-06-10-001-feat-llm-router-admin-form-plan.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
---
|
||||
title: "feat: Replace LLM Router JSON editor with admin form"
|
||||
type: feat
|
||||
date: 2026-06-10
|
||||
origin: apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md
|
||||
---
|
||||
|
||||
# feat: Replace LLM Router JSON editor with admin form
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the LLM Router admin page's raw request JSON editor with a provider-aware form that builds the existing `POST /api/admin/config/router` body for OpenRouter, Azure, DashScope cosyvoice, StepFun, unspeech, and default aliases. Keep preview/apply as the safety gate and preserve an advanced JSON view as an escape hatch, not the default editing path.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The current LLM Router admin page exposes the backend request body directly as JSON. That matches the server contract but makes routine operator work brittle: admins must remember each slice kind's required fields, know which URLs are HTTP or WebSocket roots, and avoid mistakes around merge/reset and default aliases while handling plaintext provider keys.
|
||||
|
||||
The backend already owns validation, encryption, merge semantics, and redacted previews. This plan keeps that backend boundary intact and improves the admin UI so the common configuration flows are discoverable, structured, and reviewable before apply.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**Form Coverage**
|
||||
|
||||
- R1. The page must let admins compose every currently supported slice kind: `openrouter`, `azure`, `dashscope-cosyvoice`, `stepfun`, and `unspeech`.
|
||||
- R2. The page must expose defaults for chat model, TTS model, and recommended TTS voices without requiring manual JSON editing.
|
||||
- R3. The page must preserve `merge` versus `reset` mode as an explicit, visible choice before preview or apply.
|
||||
- R4. The page must support multiple LLM/TTS slices in one request while keeping unspeech constrained to one slice.
|
||||
|
||||
**Safety And Review**
|
||||
|
||||
- R5. The UI must build the same `AdminRouterConfigRequest` shape the server validates today; the backend remains the source of truth for encryption and final validation.
|
||||
- R6. Plaintext keys must stay in input state only long enough to submit; preview and apply panels must only show the server's redacted response.
|
||||
- R7. Preview remains the primary review step and apply must be disabled while client-side required fields are incomplete.
|
||||
- R8. Advanced JSON must remain available for inspection or emergency unsupported fields, but the default path must not require writing JSON.
|
||||
|
||||
**Operator Experience**
|
||||
|
||||
- R9. Provider-specific fields must be grouped and labeled by operational meaning, including endpoint defaults, key entry id, model alias, upstream model, region, and streaming settings.
|
||||
- R10. The page must show a compact summary of pending slices, touched config keys, previewed changes, and last apply result.
|
||||
- R11. The layout must stay usable on narrow admin viewports without overlapping controls or forcing JSON-editor-sized panes.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Keep the backend route contract unchanged.** The form should compile UI state into `AdminRouterConfigRequest` and call `adminApi.applyRouterConfig` exactly as the JSON page does. This avoids duplicating encryption, merge, validation, and invalidation behavior in the browser.
|
||||
- **Extract request-building logic into a pure UI module.** A small builder module should own form-state defaults, provider-kind projections, validation messages, and JSON import/export. This gives the risky payload conversion focused Vitest coverage without introducing component-test dependencies the admin app does not currently use.
|
||||
- **Use existing admin and UI primitives.** Follow the Voice Pack form pattern with `@proj-airi/ui` primitives, `DatalistField`, global `.panel`/`.badge` styling, and Vue class arrays. Do not invent a separate mini design system for this one page.
|
||||
- **Treat advanced JSON as a synchronized escape hatch.** The default view is the form. Advanced JSON can import into the form when it matches supported slice kinds, and export the current form payload for audit/debugging. Unsupported advanced edits should be previewable only through an explicit advanced-submit path so normal form state stays typed.
|
||||
- **Do client-side validation for ergonomics, not authority.** Client checks should catch empty required fields, invalid URL schemes, duplicate unspeech slices, and missing defaults early. Server Valibot validation remains authoritative, and server errors still surface through the existing toast path.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
FORM[Provider-aware form state] --> BUILD[Request builder module]
|
||||
BUILD --> ERRORS[Client validation summary]
|
||||
BUILD --> PAYLOAD[AdminRouterConfigRequest]
|
||||
PAYLOAD --> PREVIEW[Preview submit with dryRun true]
|
||||
PAYLOAD --> APPLY[Apply submit with dryRun false]
|
||||
PREVIEW --> REDACTED[Server redacted preview panel]
|
||||
APPLY --> REDACTED
|
||||
FORM --> EXPORT[Advanced JSON export]
|
||||
IMPORT[Advanced JSON import] --> BUILD
|
||||
ADVANCED[Advanced JSON submit] --> PREVIEW
|
||||
```
|
||||
|
||||
The form state is the primary editing model. The request builder is the single bridge from UI concepts to the server body. Preview and apply both use the same built payload so admins do not review one shape and apply another.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- This plan does not change `POST /api/admin/config/router`, its Valibot schemas, encryption behavior, configKV writes, or Redis invalidation.
|
||||
- This plan does not add provider discovery, key health management, cost routing, or enable/disable controls. Those remain router operational follow-ups from the original router scope.
|
||||
- This plan does not introduce a new component library or component-testing dependency. If implementation discovers component-level assertions are necessary, prefer a narrow repo-consistent mounting pattern before adding dependencies.
|
||||
- This plan does not extend the public stage-web, stage-tamagotchi, or mobile app surfaces. `apps/ui-admin` is the admin surface; responsive behavior still needs narrow viewport verification.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Router config form state and request builder
|
||||
|
||||
- **Goal:** Define typed UI state, provider defaults, validation, import/export, and `AdminRouterConfigRequest` projection outside the Vue page.
|
||||
- **Requirements:** R1, R2, R3, R4, R5, R6, R8, R9
|
||||
- **Dependencies:** None
|
||||
- **Files:**
|
||||
- `apps/ui-admin/src/modules/api.ts`
|
||||
- `apps/ui-admin/src/modules/router-config-form.ts`
|
||||
- `apps/ui-admin/src/modules/router-config-form.test.ts`
|
||||
- **Approach:** Replace `Array<Record<string, unknown>>` for router slices with a discriminated union mirroring the admin route's existing slice kinds. Add form-facing state that can represent editable drafts, provider defaults, validation errors, and the compiled request payload. Keep plaintext keys out of previews and summaries.
|
||||
- **Execution note:** Start with request-builder tests before replacing the page, because payload drift is the main regression risk.
|
||||
- **Patterns to follow:** `apps/server/src/routes/admin/config/router/index.ts` for required fields and URL rules; `apps/server/src/services/domain/admin/router-config/index.ts` for default key entry ids and provider defaults; `apps/ui-admin/src/pages/VoicePackFormPage.vue` for form-state normalization.
|
||||
- **Test scenarios:**
|
||||
- OpenRouter draft with model alias, override model, plaintext key, default base URL, and chat default compiles to one `openrouter` slice and `defaults.chatModel`.
|
||||
- Azure draft compiles region, default voice, key entry id, and TTS default without adding OpenRouter-only fields.
|
||||
- DashScope cosyvoice draft preserves `intl` versus `cn` region and upstream model.
|
||||
- StepFun draft defaults missing upstream model to the server-supported default only when the UI chooses to omit it, and preserves explicit instruction/default voice fields.
|
||||
- Unspeech REST-only draft compiles without `streaming`; unspeech streaming draft compiles WebSocket URL, key, models, and default model.
|
||||
- Validation reports missing required plaintext keys, invalid HTTP/WS URL schemes, empty aliases, and more than one unspeech draft.
|
||||
- Exported JSON round-trips through import for supported slice kinds and defaults.
|
||||
- **Verification:** The builder produces server-schema-compatible payloads for every supported provider kind and reports client-side errors before page code submits.
|
||||
|
||||
### U2. Provider-aware LLM Router form UI
|
||||
|
||||
- **Goal:** Replace the raw textarea-first page with a form-first interface for adding, editing, duplicating, and removing router slices.
|
||||
- **Requirements:** R1, R2, R3, R4, R7, R9, R11
|
||||
- **Dependencies:** U1
|
||||
- **Files:**
|
||||
- `apps/ui-admin/src/pages/LlmRouterPage.vue`
|
||||
- `apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue`
|
||||
- `apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue`
|
||||
- `apps/ui-admin/src/components/llm-router/RouterModeControl.vue`
|
||||
- `apps/ui-admin/src/pages/LlmRouterPage.test.ts`
|
||||
- `apps/ui-admin/src/modules/router-config-form.test.ts`
|
||||
- **Approach:** Split the page into a main form column and an operations sidebar. Use a provider-kind selector to add slices, render provider-specific fields in compact panels, and keep defaults in their own section. Make reset mode visually distinct because it drops existing router models not included in the request.
|
||||
- **Patterns to follow:** `apps/ui-admin/src/pages/FluxPage.vue` for preview/apply action flow; `apps/ui-admin/src/pages/VoicePackFormPage.vue` for `@proj-airi/ui` fields, status badges, `Callout`, and grouped class arrays; `docs/ai/context/ui-components.md` for primitive props.
|
||||
- **Test scenarios:**
|
||||
- Empty page starts with a useful OpenRouter draft matching the current screenshot's common chat-default path.
|
||||
- Adding each provider kind shows only that provider's relevant fields.
|
||||
- Removing a slice updates validation and pending summary.
|
||||
- Reset mode displays a warning state while merge mode remains the normal path.
|
||||
- Apply and preview buttons are disabled while validation errors exist or a request is in flight.
|
||||
- Narrow viewport stacks form and sidebar without overlapping labels, buttons, or preview output.
|
||||
- **Verification:** The form can create each provider kind, validation state updates as fields change, and the built request is identical to the builder output covered in U1.
|
||||
|
||||
### U3. Preview, apply, and advanced JSON workflow
|
||||
|
||||
- **Goal:** Preserve the existing dry-run/apply behavior while making preview output easier to scan and keeping JSON available as a controlled advanced path.
|
||||
- **Requirements:** R5, R6, R7, R8, R10, R11
|
||||
- **Dependencies:** U1, U2
|
||||
- **Files:**
|
||||
- `apps/ui-admin/src/pages/LlmRouterPage.vue`
|
||||
- `apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue`
|
||||
- `apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue`
|
||||
- `apps/ui-admin/src/pages/LlmRouterPage.test.ts`
|
||||
- `apps/ui-admin/src/modules/router-config-form.test.ts`
|
||||
- **Approach:** Submit built form payloads through `adminApi.applyRouterConfig`. Render `applied`, `invalidatedKeys`, and `preview` as separate scan-friendly sections before the raw JSON block. Add advanced JSON export/import and an explicit advanced preview/apply path for cases the typed form cannot represent yet.
|
||||
- **Patterns to follow:** Existing `formatJson` panels in `LlmRouterPage.vue` and `FluxPage.vue`; server response shape in `apps/server/src/services/domain/admin/router-config/index.ts`.
|
||||
- **Test scenarios:**
|
||||
- Preview sends `dryRun: true`, stores preview result, leaves last apply untouched, and renders invalidated keys as empty for dry run.
|
||||
- Apply sends `dryRun: false`, updates both preview and last apply state, and renders invalidated keys from the server.
|
||||
- Server validation errors surface through the existing toast path without clearing form input.
|
||||
- Preview panel never renders plaintext keys from form state.
|
||||
- Advanced JSON export matches the built form payload.
|
||||
- Advanced JSON import rejects non-object JSON and unsupported slice kind with actionable errors.
|
||||
- **Verification:** Preview/apply calls use the same compiled payload, server errors preserve form state, and advanced JSON cannot silently diverge from the visible form without an explicit advanced action.
|
||||
|
||||
### U4. Admin styling, responsive polish, and verification notes
|
||||
|
||||
- **Goal:** Make the new form feel like a dense operations tool rather than a marketing page or raw schema editor.
|
||||
- **Requirements:** R9, R10, R11
|
||||
- **Dependencies:** U2, U3
|
||||
- **Files:**
|
||||
- `apps/ui-admin/src/styles/main.css`
|
||||
- `apps/ui-admin/src/pages/LlmRouterPage.vue`
|
||||
- `docs/ai/context/ui-components.md` only if implementation changes `packages/ui` primitives
|
||||
- **Approach:** Reuse existing admin panels, badges, buttons, and field styling. Add only page-specific layout classes if repeated class arrays become unreadable. Keep cards for individual slice editors and result panels, not nested decorative sections. Verify desktop and narrow viewport behavior after implementation.
|
||||
- **Patterns to follow:** `apps/ui-admin/src/styles/main.css` for admin shell primitives; `VoicePackFormPage.vue` for responsive form density.
|
||||
- **Test scenarios:**
|
||||
- Test expectation: none -- this unit is visual/layout polish; automated behavioral coverage lives in U1-U3.
|
||||
- **Verification:** Use local admin app rendering to inspect the LLM Router page at desktop and narrow widths; confirm fields, buttons, badges, and preview panels remain readable and non-overlapping.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Examples
|
||||
|
||||
- AE1. Given an admin wants the screenshot's OpenRouter setup, when they fill chat model alias, upstream model, OpenRouter key, and base URL in the form, preview sends the same request shape the JSON editor previously contained and returns a redacted preview.
|
||||
- AE2. Given an admin adds Azure, DashScope, StepFun, and unspeech entries in one merge request, when they preview, the pending summary lists each slice and the server response separates `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and default aliases.
|
||||
- AE3. Given an admin selects reset mode, when they prepare to preview or apply, the UI displays reset as a destructive configuration mode and still requires a valid slice or defaults entry.
|
||||
- AE4. Given the form contains a plaintext key, when preview or apply finishes, the page does not echo that key in summaries, JSON preview panels, or last apply panels.
|
||||
- AE5. Given an unsupported future field is needed before the form catches up, when the admin opens advanced JSON, they can export the current payload, edit it, and submit through an explicit advanced path without corrupting normal typed form state.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
| Surface | Impact |
|
||||
|---|---|
|
||||
| `apps/ui-admin` | Primary user-facing change; LLM Router page becomes form-first with typed request building. |
|
||||
| `apps/server` | No planned runtime changes; route schemas and service builders remain the contract the UI mirrors. |
|
||||
| ConfigKV / Redis invalidation | No behavior change; preview/apply still goes through the existing admin endpoint. |
|
||||
| Public web, Electron, mobile | No direct product UI change; only admin app responsive behavior is in scope. |
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Schema drift risk:** The UI will mirror server slice fields. Mitigate by deriving names from current server route/service code during implementation and keeping request-builder tests focused on every provider kind.
|
||||
- **Plaintext key handling risk:** Browser state necessarily contains keys before submit. Mitigate by never copying form keys into preview summaries, logs, or exported results unless the admin explicitly exports advanced JSON.
|
||||
- **Advanced JSON ambiguity:** A JSON escape hatch can accidentally preserve the old complexity. Mitigate by keeping it collapsed/secondary and requiring explicit advanced submit for unsupported edits.
|
||||
- **Testing gap:** `apps/ui-admin` currently has only module-level Vitest coverage. Mitigate with pure request-builder tests plus manual browser verification; add component tests only if implementation introduces behavior that cannot be covered through the builder.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- `apps/ui-admin/src/pages/LlmRouterPage.vue` currently owns the raw JSON textarea, preview, and apply flow.
|
||||
- `apps/server/src/routes/admin/config/router/index.ts` defines the Valibot body schema and supported slice kinds.
|
||||
- `apps/server/src/services/domain/admin/router-config/index.ts` defines provider defaults, request application semantics, redacted previews, and invalidated key behavior.
|
||||
- `apps/ui-admin/src/pages/VoicePackFormPage.vue` shows the current admin form pattern with `@proj-airi/ui` primitives and responsive class arrays.
|
||||
- `apps/ui-admin/src/pages/FluxPage.vue` shows the preview-first admin mutation pattern.
|
||||
- `docs/ai/context/ui-components.md` documents the UI primitives to reuse if implementation touches shared components.
|
||||
|
|
@ -27,6 +27,7 @@ const props = withDefaults(defineProps<{
|
|||
*/
|
||||
hideRequiredMark?: boolean
|
||||
type?: InputType
|
||||
autocomplete?: string
|
||||
inputClass?: string
|
||||
singleLine?: boolean
|
||||
}>(), {
|
||||
|
|
@ -57,6 +58,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
v-model.number="modelValue"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:autocomplete="props.autocomplete"
|
||||
:required="props.required"
|
||||
:class="props.inputClass"
|
||||
/>
|
||||
|
|
@ -65,6 +67,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
v-model="modelValue"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:autocomplete="props.autocomplete"
|
||||
:required="props.required"
|
||||
:class="props.inputClass"
|
||||
/>
|
||||
|
|
@ -73,6 +76,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
v-model="modelValue as string | undefined"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:autocomplete="props.autocomplete"
|
||||
:required="props.required"
|
||||
:class="[
|
||||
props.inputClass,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue