mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(admin-dashboard): add support for Bedrock and OpenAI-compatible LLM slices in admin router config
- Implemented `buildBedrockSlice` function to handle multi-kilobyte Bedrock bearer tokens. - Enhanced `createAdminRouterConfigService` to classify Bedrock and OpenAI-compatible LLM upstreams by baseURL. - Added new interfaces for `AdminRouterBedrockSlice` and `AdminRouterOpenAICompatibleSlice`. - Updated router config form to support Bedrock and OpenAI-compatible slices. - Created tests for Bedrock and OpenAI-compatible slice compilation and behavior. - Modified UI components to accommodate new slice types and improve user experience. - Ensured proper normalization of API server URLs to HTTPS when necessary.
This commit is contained in:
@@ -32,10 +32,10 @@ import { createBadRequestError } from '../../../../utils/error'
|
||||
const MAX_SLICES_PER_REQUEST = 20
|
||||
|
||||
/**
|
||||
* Hard cap on plaintext key length. Real provider keys are 30–200 chars;
|
||||
* 1KB leaves headroom for unusual formats while keeping the body lean.
|
||||
* Hard cap on plaintext key length. Most provider keys are short, but
|
||||
* Bedrock bearer tokens can be multi-kilobyte signed payloads.
|
||||
*/
|
||||
const MAX_KEY_LENGTH = 1024
|
||||
const MAX_KEY_LENGTH = 8192
|
||||
|
||||
/** AAD separator constraint mirrored from `keyEntrySchema` in config-kv. */
|
||||
const NO_PIPE = regex(/^[^|]+$/, 'must not contain "|" (reserved AAD separator)')
|
||||
@@ -51,6 +51,28 @@ const OpenRouterSliceSchema = object({
|
||||
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
})
|
||||
|
||||
const BedrockSliceSchema = object({
|
||||
kind: literal('bedrock'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
overrideModel: pipe(string(), nonEmpty('overrideModel is required'), maxLength(200)),
|
||||
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))),
|
||||
})
|
||||
|
||||
const OpenAICompatibleSliceSchema = object({
|
||||
kind: literal('openai-compatible'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
overrideModel: pipe(string(), nonEmpty('overrideModel is required'), maxLength(200)),
|
||||
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))),
|
||||
})
|
||||
|
||||
const AzureSliceSchema = object({
|
||||
kind: literal('azure'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
@@ -135,6 +157,8 @@ const UnspeechSliceSchema = object({
|
||||
|
||||
const SliceSchema = variant('kind', [
|
||||
OpenRouterSliceSchema,
|
||||
BedrockSliceSchema,
|
||||
OpenAICompatibleSliceSchema,
|
||||
AzureSliceSchema,
|
||||
DashscopeSliceSchema,
|
||||
StepfunSliceSchema,
|
||||
@@ -180,6 +204,12 @@ const BodySchema = object({
|
||||
* "slices": [ // optional when only defaults change
|
||||
* { "kind": "openrouter", "modelName": "chat-default",
|
||||
* "overrideModel": "openai/gpt-4o-mini", "plaintextKey": "..." },
|
||||
* { "kind": "bedrock", "modelName": "chat-bedrock",
|
||||
* "overrideModel": "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
* "plaintextKey": "...", "baseURL": "https://bedrock-mantle.us-east-1.api.aws/v1" },
|
||||
* { "kind": "openai-compatible", "modelName": "chat-compatible",
|
||||
* "overrideModel": "gpt-4o-mini", "plaintextKey": "...",
|
||||
* "baseURL": "https://api.example.com/v1" },
|
||||
* { "kind": "azure", "modelName": "microsoft/v1",
|
||||
* "region": "eastasia", "plaintextKey": "..." },
|
||||
* { "kind": "dashscope-cosyvoice", "modelName": "alibaba/cosyvoice-v2",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AdminRouterConfigService } from '../../../../services/domain/admin/router-config'
|
||||
import type { HonoEnv } from '../../../../types/hono'
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAdminRouterConfigRoutes } from '.'
|
||||
import { ApiError } from '../../../../utils/error'
|
||||
|
||||
function createTestApp(service: AdminRouterConfigService) {
|
||||
return new Hono<HonoEnv>()
|
||||
.use('*', async (c, next) => {
|
||||
c.set('user', { id: 'admin-1', email: 'admin@example.com', role: 'admin' } as HonoEnv['Variables']['user'])
|
||||
await next()
|
||||
})
|
||||
.route('/api/admin/config/router', createAdminRouterConfigRoutes(service))
|
||||
.onError((err, c) => {
|
||||
if (err instanceof ApiError)
|
||||
return c.json({ error: err.errorCode, message: err.message, details: err.details }, err.statusCode)
|
||||
return c.json({ error: 'internal', message: (err as Error).message }, 500)
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin router config route', () => {
|
||||
it('accepts Bedrock bearer tokens longer than ordinary provider keys', async () => {
|
||||
const service: AdminRouterConfigService = {
|
||||
apply: vi.fn(async () => ({
|
||||
applied: [],
|
||||
invalidatedKeys: [],
|
||||
preview: {},
|
||||
})),
|
||||
current: vi.fn(),
|
||||
}
|
||||
const app = createTestApp(service)
|
||||
const body = {
|
||||
mode: 'merge',
|
||||
slices: [{
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
plaintextKey: `bedrock-api-key-${'x'.repeat(2180)}`,
|
||||
}],
|
||||
dryRun: false,
|
||||
}
|
||||
|
||||
const res = await app.request('/api/admin/config/router', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(service.apply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
slices: [expect.objectContaining({
|
||||
kind: 'bedrock',
|
||||
plaintextKey: expect.stringMatching(/^bedrock-api-key-/u),
|
||||
})],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,8 @@ const STREAMING_TTS_AAD_MODEL_NAME = 'streaming-tts'
|
||||
/** Default key entry id per provider. Operator can override per request. */
|
||||
const DEFAULT_KEY_ENTRY_IDS = {
|
||||
'openrouter': 'openrouter-prod-1',
|
||||
'bedrock': 'bedrock-prod-1',
|
||||
'openai-compatible': 'openai-compatible-prod-1',
|
||||
'azure': 'azure-tts-prod-1',
|
||||
'dashscope-cosyvoice': 'dashscope-tts-prod-1',
|
||||
'stepfun': 'stepfun-tts-prod-1',
|
||||
@@ -38,6 +40,7 @@ type TtsModel = InferOutput<typeof ttsModelSchema>
|
||||
type AsrModel = InferOutput<typeof asrModelSchema>
|
||||
type UnspeechUpstream = InferOutput<typeof unspeechUpstreamSchema>
|
||||
type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
|
||||
type LlmSliceKind = 'openrouter' | 'bedrock' | 'openai-compatible'
|
||||
|
||||
/**
|
||||
* Per-provider input. The admin route validates the shape with Valibot
|
||||
@@ -49,6 +52,8 @@ type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
|
||||
*/
|
||||
export type SliceInput
|
||||
= | OpenRouterSliceInput
|
||||
| BedrockSliceInput
|
||||
| OpenAICompatibleSliceInput
|
||||
| AzureSliceInput
|
||||
| DashscopeSliceInput
|
||||
| StepfunSliceInput
|
||||
@@ -73,6 +78,42 @@ export interface OpenRouterSliceInput {
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface BedrockSliceInput {
|
||||
kind: 'bedrock'
|
||||
/** Key under `LLM_ROUTER_CONFIG.llm.models`. */
|
||||
modelName: string
|
||||
/** Upstream Bedrock model id sent to the OpenAI-compatible Bedrock gateway. */
|
||||
overrideModel: string
|
||||
/** Plaintext provider key or Bedrock bearer token. Encrypted in-place; never echoed back. */
|
||||
plaintextKey?: string
|
||||
/** @default 'https://bedrock-mantle.us-east-1.api.aws/v1' */
|
||||
baseURL?: string
|
||||
/** @default 'bedrock-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
/** @default 'Bearer {KEY}' */
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleSliceInput {
|
||||
kind: 'openai-compatible'
|
||||
/** Key under `LLM_ROUTER_CONFIG.llm.models`. */
|
||||
modelName: string
|
||||
/** Upstream OpenAI-compatible model id. */
|
||||
overrideModel: string
|
||||
/** Plaintext provider key. Encrypted in-place; never echoed back. */
|
||||
plaintextKey?: string
|
||||
/** @default 'https://api.openai.com/v1' */
|
||||
baseURL?: string
|
||||
/** @default 'openai-compatible-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
/** @default 'Bearer {KEY}' */
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface AzureSliceInput {
|
||||
kind: 'azure'
|
||||
/** Key under `LLM_ROUTER_CONFIG.tts.models` (e.g. `microsoft/v1`). */
|
||||
@@ -162,7 +203,7 @@ export interface AliyunNlsAsrSliceInput {
|
||||
interface LlmModelSlice {
|
||||
target: 'llm-router'
|
||||
surface: 'llm'
|
||||
kind: 'openrouter'
|
||||
kind: LlmSliceKind
|
||||
modelName: string
|
||||
model: LlmModel
|
||||
keyEntryId: string
|
||||
@@ -207,7 +248,19 @@ type BuiltSlice = LlmModelSlice | TtsModelSlice | AsrModelSlice | UnspeechSlice
|
||||
* envelope-encrypted plaintext key with AAD `{modelName, keyEntryId}`.
|
||||
*/
|
||||
export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.openrouter
|
||||
return buildLlmSlice(input, envelope)
|
||||
}
|
||||
|
||||
export function buildBedrockSlice(input: BedrockSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
return buildLlmSlice(input, envelope)
|
||||
}
|
||||
|
||||
export function buildOpenAICompatibleSlice(input: OpenAICompatibleSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
return buildLlmSlice(input, envelope)
|
||||
}
|
||||
|
||||
function buildLlmSlice(input: OpenRouterSliceInput | BedrockSliceInput | OpenAICompatibleSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS[input.kind]
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
@@ -215,12 +268,12 @@ export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: Enve
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'llm',
|
||||
kind: 'openrouter',
|
||||
kind: input.kind,
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
model: {
|
||||
upstreams: [{
|
||||
baseURL: input.baseURL ?? 'https://openrouter.ai/api/v1',
|
||||
baseURL: input.baseURL ?? defaultLlmBaseURL(input.kind),
|
||||
overrideModel: input.overrideModel,
|
||||
keys: [{ id: keyEntryId, ciphertext }],
|
||||
headerTemplate: input.headerTemplate ?? 'Bearer {KEY}',
|
||||
@@ -230,6 +283,17 @@ export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: Enve
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLlmBaseURL(kind: LlmSliceKind): string {
|
||||
switch (kind) {
|
||||
case 'openrouter':
|
||||
return 'https://openrouter.ai/api/v1'
|
||||
case 'bedrock':
|
||||
return 'https://bedrock-mantle.us-east-1.api.aws/v1'
|
||||
case 'openai-compatible':
|
||||
return 'https://api.openai.com/v1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an Azure TTS slice into the LLM_ROUTER_CONFIG.tts shape.
|
||||
*
|
||||
@@ -447,21 +511,21 @@ function preservedKeyOrThrow(upstream: { keys: KeyEntry[] } | undefined, preferr
|
||||
return key
|
||||
}
|
||||
|
||||
function buildOpenRouterSlicePreservingKey(input: OpenRouterSliceInput, envelope: EnvelopeCrypto, existing: LlmModel | undefined): LlmModelSlice {
|
||||
function buildLlmSlicePreservingKey(input: OpenRouterSliceInput | BedrockSliceInput | OpenAICompatibleSliceInput, envelope: EnvelopeCrypto, existing: LlmModel | undefined): LlmModelSlice {
|
||||
if (input.plaintextKey?.trim())
|
||||
return buildOpenRouterSlice(input, envelope)
|
||||
return buildLlmSlice(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',
|
||||
kind: input.kind,
|
||||
modelName: input.modelName,
|
||||
keyEntryId: key.id,
|
||||
model: {
|
||||
upstreams: [{
|
||||
baseURL: input.baseURL ?? existingUpstream?.baseURL ?? 'https://openrouter.ai/api/v1',
|
||||
baseURL: input.baseURL ?? existingUpstream?.baseURL ?? defaultLlmBaseURL(input.kind),
|
||||
overrideModel: input.overrideModel,
|
||||
keys: [key],
|
||||
headerTemplate: input.headerTemplate ?? existingUpstream?.headerTemplate ?? 'Bearer {KEY}',
|
||||
@@ -618,7 +682,9 @@ export function buildSlice(
|
||||
): BuiltSlice {
|
||||
switch (input.kind) {
|
||||
case 'openrouter':
|
||||
return buildOpenRouterSlicePreservingKey(input, envelope, existing?.routerConfig?.llm.models[input.modelName])
|
||||
case 'bedrock':
|
||||
case 'openai-compatible':
|
||||
return buildLlmSlicePreservingKey(input, envelope, existing?.routerConfig?.llm.models[input.modelName])
|
||||
case 'azure':
|
||||
return buildAzureSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
|
||||
case 'dashscope-cosyvoice':
|
||||
@@ -769,7 +835,7 @@ function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
|
||||
|
||||
const slices: SliceInput[] = []
|
||||
for (const [modelName, model] of Object.entries(config.llm.models)) {
|
||||
const slice = openRouterSliceFromModel(modelName, model)
|
||||
const slice = llmSliceFromModel(modelName, model)
|
||||
if (slice)
|
||||
slices.push(slice)
|
||||
}
|
||||
@@ -786,14 +852,14 @@ function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
|
||||
return slices
|
||||
}
|
||||
|
||||
function openRouterSliceFromModel(modelName: string, model: LlmModel): OpenRouterSliceInput | null {
|
||||
function llmSliceFromModel(modelName: string, model: LlmModel): OpenRouterSliceInput | BedrockSliceInput | OpenAICompatibleSliceInput | null {
|
||||
const upstream = model.upstreams[0]
|
||||
const key = upstream?.keys[0]
|
||||
if (!upstream || !key)
|
||||
return null
|
||||
|
||||
return {
|
||||
kind: 'openrouter',
|
||||
kind: llmKindFromBaseURL(upstream.baseURL),
|
||||
modelName,
|
||||
overrideModel: upstream.overrideModel ?? modelName,
|
||||
baseURL: upstream.baseURL,
|
||||
@@ -803,6 +869,20 @@ function openRouterSliceFromModel(modelName: string, model: LlmModel): OpenRoute
|
||||
}
|
||||
}
|
||||
|
||||
function llmKindFromBaseURL(baseURL: string): LlmSliceKind {
|
||||
try {
|
||||
const host = new URL(baseURL).hostname
|
||||
if (host === 'openrouter.ai')
|
||||
return 'openrouter'
|
||||
if (host.includes('bedrock') || host.endsWith('.api.aws'))
|
||||
return 'bedrock'
|
||||
return 'openai-compatible'
|
||||
}
|
||||
catch {
|
||||
return 'openai-compatible'
|
||||
}
|
||||
}
|
||||
|
||||
function ttsSliceFromModel(modelName: string, model: TtsModel): AzureSliceInput | DashscopeSliceInput | StepfunSliceInput | null {
|
||||
const upstream = model.upstreams[0]
|
||||
const key = upstream?.keys[0]
|
||||
|
||||
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildAliyunNlsAsrSlice,
|
||||
buildAzureSlice,
|
||||
buildBedrockSlice,
|
||||
buildDashscopeSlice,
|
||||
buildNextRouterConfig,
|
||||
buildOpenRouterSlice,
|
||||
@@ -151,6 +152,29 @@ describe('buildOpenRouterSlice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBedrockSlice', () => {
|
||||
it('accepts and encrypts multi-kilobyte Bedrock bearer tokens', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const token = `bedrock-api-key-${'x'.repeat(2200)}`
|
||||
const built = buildBedrockSlice({
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
plaintextKey: token,
|
||||
}, envelope)
|
||||
|
||||
expect(built.kind).toBe('bedrock')
|
||||
expect(built.keyEntryId).toBe('bedrock-prod-1')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'chat-bedrock',
|
||||
keyEntryId: 'bedrock-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe(token)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAzureSlice', () => {
|
||||
it('builds the cognitiveservices baseURL from region and surfaces region in adapterParams', () => {
|
||||
const envelope = freshEnvelope()
|
||||
@@ -595,6 +619,59 @@ describe('createAdminRouterConfigService', () => {
|
||||
expect(JSON.stringify(current.preview)).not.toContain('secret-ciphertext')
|
||||
})
|
||||
|
||||
it('current classifies Bedrock and generic OpenAI-compatible LLM upstreams by baseURL', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-bedrock': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
keys: [{ id: 'bedrock-live', ciphertext: 'bedrock-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
'chat-compatible': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
keys: [{ id: 'compatible-live', ciphertext: 'compatible-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 })
|
||||
const current = await service.current()
|
||||
|
||||
expect(current.request.slices).toEqual([
|
||||
{
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'bedrock-live',
|
||||
existingKeyEntryId: 'bedrock-live',
|
||||
},
|
||||
{
|
||||
kind: 'openai-compatible',
|
||||
modelName: 'chat-compatible',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'compatible-live',
|
||||
existingKeyEntryId: 'compatible-live',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an existing key entry when an applied slice omits plaintextKey', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { App as VueApp } from 'vue'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import { AdminApiError } from './modules/api'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
me: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./modules/api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./modules/api')>()
|
||||
return {
|
||||
...actual,
|
||||
adminApi: {
|
||||
me: mocks.me,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin app shell', () => {
|
||||
let app: VueApp<Element>
|
||||
let host: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.me.mockRejectedValue(new AdminApiError('unauthorized', 401, null))
|
||||
window.history.replaceState(null, '', '/llm-router?api_server_url=https%3A%2F%2Fapi.airi.build')
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
host = document.querySelector('#app')!
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
app.unmount()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows a sign-in page with backend switching instead of immediately redirecting on 401', async () => {
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/'),
|
||||
routes: [
|
||||
{ path: '/llm-router', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount(host)
|
||||
await router.isReady()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/llm-router')
|
||||
expect(host.textContent).toContain('Sign in to AIRI Admin')
|
||||
expect(host.textContent).toContain('Production - api.airi.build')
|
||||
const href = host.querySelector('a')?.getAttribute('href')
|
||||
expect(href).toContain('https://api.airi.build/auth/sign-in?redirect=')
|
||||
expect(decodeURIComponent(href ?? '')).toContain('api_server_url=https%3A%2F%2Fapi.airi.build')
|
||||
})
|
||||
})
|
||||
|
||||
async function flushPromises() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const route = useRoute()
|
||||
const loading = shallowRef(true)
|
||||
const me = shallowRef<AdminMe | null>(null)
|
||||
const accessError = shallowRef<string | null>(null)
|
||||
const needsSignIn = shallowRef(false)
|
||||
const currentApiServerUrl = apiServerUrl()
|
||||
|
||||
const navItems = [
|
||||
@@ -42,7 +43,7 @@ onMounted(async () => {
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof AdminApiError && error.status === 401) {
|
||||
window.location.href = signInUrl()
|
||||
needsSignIn.value = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,16 +64,19 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="accessError" class="grid min-h-screen place-items-center px-6">
|
||||
<div v-else-if="needsSignIn || accessError" :class="['grid', 'min-h-screen', 'place-items-center', 'px-6']">
|
||||
<div :class="['fixed', 'right-6', 'top-5']">
|
||||
<ApiEnvironmentSelect :api-server-url="currentApiServerUrl" />
|
||||
</div>
|
||||
<section class="max-w-md w-full border border-neutral-200 rounded-lg bg-white p-6 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="mb-4 h-10 w-10 flex items-center justify-center rounded-lg bg-red-50 text-red-600">
|
||||
<span class="i-lucide-shield-alert text-xl" />
|
||||
<div :class="['mb-4', 'h-10', 'w-10', 'flex', 'items-center', 'justify-center', 'rounded-lg', needsSignIn ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600']">
|
||||
<span :class="[needsSignIn ? 'i-lucide-lock-keyhole' : 'i-lucide-shield-alert', 'text-xl']" />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold">
|
||||
Admin access required
|
||||
{{ needsSignIn ? 'Sign in to AIRI Admin' : 'Admin access required' }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ accessError }}
|
||||
{{ needsSignIn ? 'Choose the backend environment, then continue to its auth page.' : accessError }}
|
||||
</p>
|
||||
<a class="mt-5 h-9 inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 text-sm text-white" :href="signInUrl()">
|
||||
<span class="i-lucide-log-in" />
|
||||
|
||||
@@ -25,6 +25,10 @@ const title = computed(() => {
|
||||
switch (slice.value.kind) {
|
||||
case 'openrouter':
|
||||
return 'OpenRouter'
|
||||
case 'bedrock':
|
||||
return 'Bedrock'
|
||||
case 'openai-compatible':
|
||||
return 'OpenAI Compatible'
|
||||
case 'azure':
|
||||
return 'Azure Speech'
|
||||
case 'dashscope-cosyvoice':
|
||||
@@ -56,6 +60,17 @@ const providerKeyPlaceholder = computed(() => {
|
||||
return slice.value.existingKeyEntryId ? 'Leave blank to keep existing key' : 'Paste provider key'
|
||||
})
|
||||
|
||||
const baseUrlPlaceholder = computed(() => {
|
||||
switch (slice.value.kind) {
|
||||
case 'bedrock':
|
||||
return 'https://bedrock-mantle.us-east-1.api.aws/v1'
|
||||
case 'openai-compatible':
|
||||
return 'https://api.example.com/v1'
|
||||
default:
|
||||
return 'https://openrouter.ai/api/v1'
|
||||
}
|
||||
})
|
||||
|
||||
const streamingKeyDescription = computed(() => {
|
||||
if (slice.value.kind !== 'unspeech' || !slice.value.streamingExistingKeyEntryId)
|
||||
return undefined
|
||||
@@ -88,11 +103,11 @@ const streamingKeyPlaceholder = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="slice.kind === 'openrouter'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<div v-if="slice.kind === 'openrouter' || slice.kind === 'bedrock' || slice.kind === 'openai-compatible'" :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.baseURL" input-class="font-mono text-xs" label="Base URL" :placeholder="baseUrlPlaceholder" 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>
|
||||
|
||||
@@ -58,6 +58,28 @@ export interface AdminRouterOpenRouterSlice {
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface AdminRouterBedrockSlice {
|
||||
kind: 'bedrock'
|
||||
modelName: string
|
||||
overrideModel: string
|
||||
plaintextKey?: string
|
||||
baseURL?: string
|
||||
keyEntryId?: string
|
||||
existingKeyEntryId?: string
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface AdminRouterOpenAICompatibleSlice {
|
||||
kind: 'openai-compatible'
|
||||
modelName: string
|
||||
overrideModel: string
|
||||
plaintextKey?: string
|
||||
baseURL?: string
|
||||
keyEntryId?: string
|
||||
existingKeyEntryId?: string
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface AdminRouterAzureSlice {
|
||||
kind: 'azure'
|
||||
modelName: string
|
||||
@@ -115,6 +137,8 @@ export interface AdminRouterAliyunNlsAsrSlice {
|
||||
|
||||
export type AdminRouterConfigSlice
|
||||
= | AdminRouterOpenRouterSlice
|
||||
| AdminRouterBedrockSlice
|
||||
| AdminRouterOpenAICompatibleSlice
|
||||
| AdminRouterAzureSlice
|
||||
| AdminRouterDashscopeSlice
|
||||
| AdminRouterStepfunSlice
|
||||
@@ -181,6 +205,11 @@ export interface SpeechModel {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SpeechModelsResult {
|
||||
models: SpeechModel[]
|
||||
default: string | null
|
||||
}
|
||||
|
||||
export interface SpeechVoice {
|
||||
id: string
|
||||
name: string
|
||||
@@ -373,9 +402,12 @@ export const adminApi = {
|
||||
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 : []
|
||||
speechModels: async (): Promise<SpeechModelsResult> => {
|
||||
const data = await publicFetch<{ default?: unknown, models?: SpeechModel[] }>('/audio/models')
|
||||
return {
|
||||
models: Array.isArray(data.models) ? data.models : [],
|
||||
default: typeof data.default === 'string' ? data.default : null,
|
||||
}
|
||||
},
|
||||
speechVoices: async (model: string): Promise<SpeechVoicesResult> => {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
@@ -36,6 +36,37 @@ describe('router config form builder', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('compiles Bedrock and OpenAI-compatible LLM slices', () => {
|
||||
const bedrock = createRouterSliceDraft('bedrock', 'bedrock-test')
|
||||
bedrock.plaintextKey = 'bedrock-token'
|
||||
const compatible = createRouterSliceDraft('openai-compatible', 'compatible-test')
|
||||
compatible.plaintextKey = 'sk-compatible'
|
||||
compatible.baseURL = 'https://llm.example.com/v1'
|
||||
|
||||
expect(buildRouterConfigRequest({
|
||||
mode: 'merge',
|
||||
slices: [bedrock, compatible],
|
||||
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
|
||||
}).request?.slices).toEqual([
|
||||
{
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
plaintextKey: 'bedrock-token',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
keyEntryId: 'bedrock-prod-1',
|
||||
},
|
||||
{
|
||||
kind: 'openai-compatible',
|
||||
modelName: 'chat-compatible',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
plaintextKey: 'sk-compatible',
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
keyEntryId: 'openai-compatible-prod-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('compiles Azure speech defaults without OpenRouter-only fields', () => {
|
||||
const azure = createRouterSliceDraft('azure', 'azure-test')
|
||||
azure.plaintextKey = 'azure-key'
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
AdminRouterAliyunNlsAsrSlice,
|
||||
AdminRouterAzureSlice,
|
||||
AdminRouterBedrockSlice,
|
||||
AdminRouterConfigRequest,
|
||||
AdminRouterConfigSlice,
|
||||
AdminRouterDashscopeSlice,
|
||||
AdminRouterOpenAICompatibleSlice,
|
||||
AdminRouterOpenRouterSlice,
|
||||
AdminRouterStepfunSlice,
|
||||
AdminRouterUnspeechSlice,
|
||||
@@ -39,6 +41,30 @@ export interface OpenRouterSliceDraft extends SliceDraftBase {
|
||||
headerTemplate: string
|
||||
}
|
||||
|
||||
export interface BedrockSliceDraft extends SliceDraftBase {
|
||||
kind: 'bedrock'
|
||||
modelName: string
|
||||
overrideModel: string
|
||||
plaintextKey: string
|
||||
baseURL: string
|
||||
keyEntryId: string
|
||||
existingKeyEntryId: string
|
||||
headerTemplate: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleSliceDraft extends SliceDraftBase {
|
||||
kind: 'openai-compatible'
|
||||
modelName: string
|
||||
overrideModel: string
|
||||
plaintextKey: string
|
||||
baseURL: string
|
||||
keyEntryId: string
|
||||
existingKeyEntryId: string
|
||||
headerTemplate: string
|
||||
}
|
||||
|
||||
type LlmSliceDraft = OpenRouterSliceDraft | BedrockSliceDraft | OpenAICompatibleSliceDraft
|
||||
|
||||
export interface AzureSliceDraft extends SliceDraftBase {
|
||||
kind: 'azure'
|
||||
modelName: string
|
||||
@@ -95,6 +121,8 @@ export interface AliyunNlsAsrSliceDraft extends SliceDraftBase {
|
||||
|
||||
export type RouterSliceDraft
|
||||
= | OpenRouterSliceDraft
|
||||
| BedrockSliceDraft
|
||||
| OpenAICompatibleSliceDraft
|
||||
| AzureSliceDraft
|
||||
| DashscopeSliceDraft
|
||||
| StepfunSliceDraft
|
||||
@@ -119,6 +147,8 @@ 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: 'Bedrock', value: 'bedrock', description: 'Amazon Bedrock OpenAI-compatible chat alias' },
|
||||
{ label: 'OpenAI Compatible', value: 'openai-compatible', description: 'Custom OpenAI-compatible chat 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' },
|
||||
@@ -178,6 +208,8 @@ export function createRouterConfigFormState(): RouterConfigFormState {
|
||||
* - A draft with provider-specific operational defaults.
|
||||
*/
|
||||
export function createRouterSliceDraft(kind: 'openrouter', id?: string): OpenRouterSliceDraft
|
||||
export function createRouterSliceDraft(kind: 'bedrock', id?: string): BedrockSliceDraft
|
||||
export function createRouterSliceDraft(kind: 'openai-compatible', id?: string): OpenAICompatibleSliceDraft
|
||||
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
|
||||
@@ -199,6 +231,30 @@ export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): Rout
|
||||
existingKeyEntryId: '',
|
||||
headerTemplate: '',
|
||||
}
|
||||
case 'bedrock':
|
||||
return {
|
||||
id: sliceId,
|
||||
kind,
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
plaintextKey: '',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
keyEntryId: 'bedrock-prod-1',
|
||||
existingKeyEntryId: '',
|
||||
headerTemplate: '',
|
||||
}
|
||||
case 'openai-compatible':
|
||||
return {
|
||||
id: sliceId,
|
||||
kind,
|
||||
modelName: 'chat-compatible',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
plaintextKey: '',
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
keyEntryId: 'openai-compatible-prod-1',
|
||||
existingKeyEntryId: '',
|
||||
headerTemplate: '',
|
||||
}
|
||||
case 'azure':
|
||||
return {
|
||||
id: sliceId,
|
||||
@@ -359,6 +415,8 @@ function validateSlice(slice: RouterSliceDraft, ordinal: number): string[] {
|
||||
const label = `Slice ${ordinal} (${kindLabel(slice.kind)})`
|
||||
switch (slice.kind) {
|
||||
case 'openrouter':
|
||||
case 'bedrock':
|
||||
case 'openai-compatible':
|
||||
return [
|
||||
required(slice.modelName, `${label}: model alias is required.`),
|
||||
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
|
||||
@@ -435,8 +493,10 @@ function validateStreamingModels(json: string, label: string): string | undefine
|
||||
|
||||
function sliceToRequest(slice: RouterSliceDraft): AdminRouterConfigSlice {
|
||||
switch (slice.kind) {
|
||||
case 'openrouter': {
|
||||
const request: AdminRouterOpenRouterSlice = {
|
||||
case 'openrouter':
|
||||
case 'bedrock':
|
||||
case 'openai-compatible': {
|
||||
const request: AdminRouterOpenRouterSlice | AdminRouterBedrockSlice | AdminRouterOpenAICompatibleSlice = {
|
||||
kind: slice.kind,
|
||||
modelName: trim(slice.modelName),
|
||||
overrideModel: trim(slice.overrideModel),
|
||||
@@ -534,8 +594,10 @@ function draftFromRequestSlice(value: unknown, ordinal: number): RouterSliceDraf
|
||||
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
|
||||
case 'openrouter':
|
||||
case 'bedrock':
|
||||
case 'openai-compatible': {
|
||||
const draft = createRouterSliceDraft(value.kind, `imported-${value.kind}-${ordinal}`) as LlmSliceDraft
|
||||
draft.modelName = stringValue(value.modelName)
|
||||
draft.overrideModel = stringValue(value.overrideModel)
|
||||
draft.plaintextKey = stringValue(value.plaintextKey)
|
||||
|
||||
@@ -26,6 +26,12 @@ describe('ui-admin bootstrap context', () => {
|
||||
)?.apiServerUrl).toBe('http://127.0.0.1:3000')
|
||||
})
|
||||
|
||||
it('normalizes known production API hosts to HTTPS when the query param is typed with HTTP', () => {
|
||||
expect(resolveStandaloneServerAdminContext(
|
||||
'http://localhost:5178/llm-router?api_server_url=http%3A%2F%2Fapi.airi.build',
|
||||
)?.apiServerUrl).toBe('https://api.airi.build')
|
||||
})
|
||||
|
||||
it('defaults local standalone dev UI origins to the local API port', () => {
|
||||
expect(defaultStandaloneApiServerUrl('http://localhost:5178')).toBe('http://localhost:3000')
|
||||
expect(defaultStandaloneApiServerUrl('http://127.0.0.1:5178')).toBe('http://127.0.0.1:3000')
|
||||
|
||||
@@ -35,6 +35,13 @@ const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
|
||||
'https://airi-server-dev.up.railway.app',
|
||||
]
|
||||
|
||||
const TRUSTED_HTTPS_API_SERVER_HOSTS = new Map(
|
||||
TRUSTED_STANDALONE_API_SERVER_ORIGINS.map((origin) => {
|
||||
const url = new URL(origin)
|
||||
return [url.hostname, origin]
|
||||
}),
|
||||
)
|
||||
|
||||
const DEFAULT_API_SERVER_ORIGINS_BY_ADMIN_UI_ORIGIN = new Map([
|
||||
['https://admin.airi.build', 'https://api.airi.build'],
|
||||
['https://server-dev.airi-server-admin.pages.dev', 'https://airi-server-dev.up.railway.app'],
|
||||
@@ -155,7 +162,12 @@ function normalizeTrustedApiServerUrl(value: string | null): string | null {
|
||||
return null
|
||||
|
||||
try {
|
||||
const origin = new URL(value).origin
|
||||
const url = new URL(value)
|
||||
const normalizedHttpsOrigin = TRUSTED_HTTPS_API_SERVER_HOSTS.get(url.hostname)
|
||||
if (normalizedHttpsOrigin)
|
||||
return normalizedHttpsOrigin
|
||||
|
||||
const origin = url.origin
|
||||
|
||||
if (TRUSTED_STANDALONE_API_SERVER_ORIGINS.includes(origin))
|
||||
return origin
|
||||
|
||||
@@ -58,7 +58,7 @@ const pendingSummary = computed(() => {
|
||||
const defaults = pendingRequest.value.defaults ?? {}
|
||||
return {
|
||||
slices: form.slices.length,
|
||||
llmSlices: form.slices.filter(slice => slice.kind === 'openrouter').length,
|
||||
llmSlices: form.slices.filter(isLlmSlice).length,
|
||||
ttsSlices: form.slices.filter(isTtsSlice).length,
|
||||
streamingTtsSlices: form.slices.filter(isStreamingTtsSlice).length,
|
||||
asrSlices: form.slices.filter(isAsrSlice).length,
|
||||
@@ -73,7 +73,7 @@ const providerTabs = computed(() => [
|
||||
])
|
||||
const providerKindOptions = computed(() => ROUTER_SLICE_KIND_OPTIONS.filter((option) => {
|
||||
if (activeProviderTab.value === 'llm')
|
||||
return option.value === 'openrouter'
|
||||
return isLlmSliceKind(option.value)
|
||||
if (activeProviderTab.value === 'streamingTts')
|
||||
return option.value === 'unspeech'
|
||||
if (activeProviderTab.value === 'asr')
|
||||
@@ -249,7 +249,7 @@ function parseAdvancedJsonRequest(): AdminRouterConfigRequest | null {
|
||||
}
|
||||
|
||||
function isLlmSlice(slice: RouterSliceDraft) {
|
||||
return slice.kind === 'openrouter'
|
||||
return isLlmSliceKind(slice.kind)
|
||||
}
|
||||
|
||||
function isTtsSlice(slice: RouterSliceDraft) {
|
||||
@@ -270,6 +270,12 @@ function isTtsSliceKind(kind: RouterSliceKind) {
|
||||
|| kind === 'stepfun'
|
||||
}
|
||||
|
||||
function isLlmSliceKind(kind: RouterSliceKind) {
|
||||
return kind === 'openrouter'
|
||||
|| kind === 'bedrock'
|
||||
|| kind === 'openai-compatible'
|
||||
}
|
||||
|
||||
function activeProviderLabel() {
|
||||
switch (activeProviderTab.value) {
|
||||
case 'llm':
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { App } from 'vue'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import VoicePackFormPage from './VoicePackFormPage.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createVoicePack: vi.fn(),
|
||||
disableVoicePack: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
route: {
|
||||
name: 'voice-pack-new',
|
||||
params: {},
|
||||
},
|
||||
speechModels: vi.fn(),
|
||||
speechVoices: vi.fn(),
|
||||
testSpeech: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
updateVoicePack: vi.fn(),
|
||||
voicePacks: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../modules/api', () => ({
|
||||
adminApi: {
|
||||
createVoicePack: mocks.createVoicePack,
|
||||
disableVoicePack: mocks.disableVoicePack,
|
||||
speechModels: mocks.speechModels,
|
||||
speechVoices: mocks.speechVoices,
|
||||
testSpeech: mocks.testSpeech,
|
||||
updateVoicePack: mocks.updateVoicePack,
|
||||
voicePacks: mocks.voicePacks,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => ({
|
||||
replace: mocks.replace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('voice pack form page', () => {
|
||||
let app: App<Element>
|
||||
let host: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.route.name = 'voice-pack-new'
|
||||
mocks.route.params = {}
|
||||
mocks.voicePacks.mockResolvedValue([])
|
||||
mocks.speechModels.mockResolvedValue({
|
||||
models: [
|
||||
{ id: 'alibaba/cosyvoice-v1', name: 'alibaba/cosyvoice-v1' },
|
||||
{ id: 'stepfun/stepaudio-2.5-tts', name: 'stepfun/stepaudio-2.5-tts' },
|
||||
],
|
||||
default: null,
|
||||
})
|
||||
mocks.speechVoices.mockResolvedValue({
|
||||
voices: [{ id: 'longxiaochun', name: 'Long Xiaochun' }],
|
||||
recommended: { 'zh-CN': 'longxiaochun' },
|
||||
})
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
host = document.querySelector('#app')!
|
||||
app = createApp(VoicePackFormPage)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
app.unmount()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the configured speech catalog model when creating a new Voice Pack', async () => {
|
||||
app.mount(host)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.speechModels).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.speechVoices).toHaveBeenCalledWith('alibaba/cosyvoice-v1')
|
||||
expect(mocks.speechVoices).not.toHaveBeenCalledWith('volcengine/seed-tts-2.0')
|
||||
})
|
||||
|
||||
it('prefers the server speech catalog default when it is available', async () => {
|
||||
mocks.speechModels.mockResolvedValueOnce({
|
||||
models: [
|
||||
{ id: 'alibaba/cosyvoice-v1', name: 'alibaba/cosyvoice-v1' },
|
||||
{ id: 'stepfun/stepaudio-2.5-tts', name: 'stepfun/stepaudio-2.5-tts' },
|
||||
],
|
||||
default: 'stepfun/stepaudio-2.5-tts',
|
||||
})
|
||||
|
||||
app.mount(host)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.speechVoices).toHaveBeenCalledWith('stepfun/stepaudio-2.5-tts')
|
||||
})
|
||||
})
|
||||
|
||||
async function flushPromises() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const router = useRouter()
|
||||
|
||||
const packs = shallowRef<VoicePack[]>([])
|
||||
const models = shallowRef<{ id: string, name: string }[]>([])
|
||||
const catalogDefaultModel = shallowRef<string | null>(null)
|
||||
const voices = shallowRef<SpeechVoice[]>([])
|
||||
const recommendedVoices = shallowRef<Record<string, string>>({})
|
||||
const loading = shallowRef(false)
|
||||
@@ -29,15 +30,16 @@ const saving = shallowRef(false)
|
||||
const testing = shallowRef(false)
|
||||
const testAudioUrl = shallowRef<string | null>(null)
|
||||
const testText = shallowRef(TEST_TEXT)
|
||||
const previousDerived = shallowRef(deriveModelParts('volcengine/seed-tts-2.0'))
|
||||
const previousDerived = shallowRef(deriveModelParts(''))
|
||||
const modelChangeVoiceLoadingEnabled = shallowRef(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
provider: '',
|
||||
model: '',
|
||||
voiceId: '',
|
||||
ttsModelId: 'volcengine/seed-tts-2.0',
|
||||
ttsModelId: '',
|
||||
paramsJson: DEFAULT_PARAMS,
|
||||
costMultiplier: 1,
|
||||
status: 'enabled',
|
||||
@@ -86,6 +88,10 @@ const voiceOptions = computed(() =>
|
||||
description: voiceOptionDescription(voice),
|
||||
})),
|
||||
)
|
||||
const ttsModelPlaceholder = computed(() => models.value[0]?.id ?? 'provider/model')
|
||||
const voicePlaceholder = computed(() => voices.value[0]?.id ?? 'voice-id')
|
||||
const providerPlaceholder = computed(() => providerOptions.value[0]?.value ?? 'provider')
|
||||
const baseModelPlaceholder = computed(() => baseModelOptions.value[0]?.value ?? 'model')
|
||||
|
||||
const paramsError = computed(() => {
|
||||
try {
|
||||
@@ -118,8 +124,9 @@ onMounted(async () => {
|
||||
if (isEditing.value)
|
||||
fillSelectedPack()
|
||||
else
|
||||
previousDerived.value = deriveModelParts(form.ttsModelId)
|
||||
resetForm()
|
||||
await loadVoices(form.ttsModelId, { autoPick: !form.voiceId.trim() })
|
||||
modelChangeVoiceLoadingEnabled.value = true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -129,7 +136,6 @@ onBeforeUnmount(() => {
|
||||
watch(() => route.params.id, async () => {
|
||||
if (!isEditing.value) {
|
||||
resetForm()
|
||||
await loadVoices(form.ttsModelId, { autoPick: true })
|
||||
return
|
||||
}
|
||||
fillSelectedPack()
|
||||
@@ -143,6 +149,8 @@ watch(() => form.ttsModelId, (next) => {
|
||||
if (!form.model.trim() || form.model === oldDerived.model)
|
||||
form.model = nextDerived.model
|
||||
previousDerived.value = nextDerived
|
||||
if (!modelChangeVoiceLoadingEnabled.value)
|
||||
return
|
||||
void loadVoices(next, { autoPick: true })
|
||||
})
|
||||
|
||||
@@ -162,7 +170,9 @@ async function loadPacks() {
|
||||
async function loadCatalog() {
|
||||
loadingCatalog.value = true
|
||||
try {
|
||||
models.value = await adminApi.speechModels()
|
||||
const catalog = await adminApi.speechModels()
|
||||
models.value = catalog.models
|
||||
catalogDefaultModel.value = catalog.default
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load speech models'))
|
||||
@@ -221,20 +231,29 @@ function fillForm(pack: VoicePack) {
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
const modelId = initialCatalogModelId()
|
||||
const modelParts = deriveModelParts(modelId)
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.provider = 'volcengine'
|
||||
form.model = 'seed-tts-2.0'
|
||||
form.provider = modelParts.provider
|
||||
form.model = modelParts.model
|
||||
form.voiceId = ''
|
||||
form.ttsModelId = 'volcengine/seed-tts-2.0'
|
||||
form.ttsModelId = modelId
|
||||
form.paramsJson = DEFAULT_PARAMS
|
||||
form.costMultiplier = 1
|
||||
form.status = 'enabled'
|
||||
testText.value = TEST_TEXT
|
||||
previousDerived.value = deriveModelParts(form.ttsModelId)
|
||||
previousDerived.value = modelParts
|
||||
revokeTestAudio()
|
||||
}
|
||||
|
||||
function initialCatalogModelId(): string {
|
||||
const defaultModel = catalogDefaultModel.value
|
||||
if (defaultModel && models.value.some(model => model.id === defaultModel))
|
||||
return defaultModel
|
||||
return models.value[0]?.id ?? ''
|
||||
}
|
||||
|
||||
function parseParams(): VoicePackParams {
|
||||
const parsed = JSON.parse(form.paramsJson || '{}') as unknown
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
@@ -472,7 +491,7 @@ function normalizeRateOption(value: string | number | boolean | null | undefined
|
||||
label="TTS model ID"
|
||||
list-id="voice-pack-tts-models"
|
||||
:options="modelOptions"
|
||||
placeholder="volcengine/seed-tts-2.0"
|
||||
:placeholder="ttsModelPlaceholder"
|
||||
required
|
||||
/>
|
||||
<DatalistField
|
||||
@@ -482,7 +501,7 @@ function normalizeRateOption(value: string | number | boolean | null | undefined
|
||||
label="Voice ID"
|
||||
list-id="voice-pack-voices"
|
||||
:options="voiceOptions"
|
||||
placeholder="zh_female_vv_uranus_bigtts"
|
||||
:placeholder="voicePlaceholder"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -494,7 +513,7 @@ function normalizeRateOption(value: string | number | boolean | null | undefined
|
||||
label="Provider"
|
||||
list-id="voice-pack-providers"
|
||||
:options="providerOptions"
|
||||
placeholder="volcengine"
|
||||
:placeholder="providerPlaceholder"
|
||||
required
|
||||
/>
|
||||
<DatalistField
|
||||
@@ -503,7 +522,7 @@ function normalizeRateOption(value: string | number | boolean | null | undefined
|
||||
label="Model"
|
||||
list-id="voice-pack-provider-models"
|
||||
:options="baseModelOptions"
|
||||
placeholder="seed-tts-2.0"
|
||||
:placeholder="baseModelPlaceholder"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,13 @@ describe('ui-server-auth bootstrap context', () => {
|
||||
)?.apiServerUrl).toBe('http://127.0.0.1:3000')
|
||||
})
|
||||
|
||||
it('normalizes known production API hosts to HTTPS when typed with HTTP', () => {
|
||||
expect(resolveStandaloneServerAuthContext(
|
||||
'https://accounts.airi.build/ui/sign-in?api_server_url=http%3A%2F%2Fapi.airi.build',
|
||||
'http://localhost:3000',
|
||||
)?.apiServerUrl).toBe('https://api.airi.build')
|
||||
})
|
||||
|
||||
it('falls back to the standalone query context when the static placeholder script is still present', () => {
|
||||
document.body.innerHTML = '<script id="airi-server-auth-context" type="application/json">__AIRI_SERVER_AUTH_CONTEXT__</script>'
|
||||
window.history.replaceState(
|
||||
|
||||
@@ -19,6 +19,13 @@ const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
|
||||
'https://airi-server-dev.up.railway.app',
|
||||
]
|
||||
|
||||
const TRUSTED_HTTPS_API_SERVER_HOSTS = new Map(
|
||||
TRUSTED_STANDALONE_API_SERVER_ORIGINS.map((origin) => {
|
||||
const url = new URL(origin)
|
||||
return [url.hostname, origin]
|
||||
}),
|
||||
)
|
||||
|
||||
const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [
|
||||
/^http:\/\/localhost(:\d+)?$/,
|
||||
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
|
||||
@@ -89,7 +96,12 @@ function normalizeTrustedApiServerUrl(value: string | null): string | null {
|
||||
return null
|
||||
|
||||
try {
|
||||
const origin = new URL(value).origin
|
||||
const url = new URL(value)
|
||||
const normalizedHttpsOrigin = TRUSTED_HTTPS_API_SERVER_HOSTS.get(url.hostname)
|
||||
if (normalizedHttpsOrigin)
|
||||
return normalizedHttpsOrigin
|
||||
|
||||
const origin = url.origin
|
||||
|
||||
if (TRUSTED_STANDALONE_API_SERVER_ORIGINS.includes(origin))
|
||||
return origin
|
||||
|
||||
Reference in New Issue
Block a user