diff --git a/apps/server/src/routes/admin/config/router/index.ts b/apps/server/src/routes/admin/config/router/index.ts index 514e93c2b..b452cae83 100644 --- a/apps/server/src/routes/admin/config/router/index.ts +++ b/apps/server/src/routes/admin/config/router/index.ts @@ -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", diff --git a/apps/server/src/routes/admin/config/router/route.test.ts b/apps/server/src/routes/admin/config/router/route.test.ts new file mode 100644 index 000000000..9a2e1b315 --- /dev/null +++ b/apps/server/src/routes/admin/config/router/route.test.ts @@ -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() + .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), + })], + })) + }) +}) diff --git a/apps/server/src/services/domain/admin/router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts index cf31fb342..4b34cb14d 100644 --- a/apps/server/src/services/domain/admin/router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -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 type AsrModel = InferOutput type UnspeechUpstream = InferOutput 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] diff --git a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts index 9d61f12d5..82e7cbde5 100644 --- a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts +++ b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts @@ -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: { diff --git a/apps/ui-admin/src/App.test.ts b/apps/ui-admin/src/App.test.ts new file mode 100644 index 000000000..a5bbd5743 --- /dev/null +++ b/apps/ui-admin/src/App.test.ts @@ -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() + return { + ...actual, + adminApi: { + me: mocks.me, + }, + } +}) + +describe('admin app shell', () => { + let app: VueApp + 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 = '
' + 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: '
' } }, + ], + }) + 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() +} diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue index 8446871e1..2d9707fe2 100644 --- a/apps/ui-admin/src/App.vue +++ b/apps/ui-admin/src/App.vue @@ -15,6 +15,7 @@ const route = useRoute() const loading = shallowRef(true) const me = shallowRef(null) const accessError = shallowRef(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 () => {
-
+
+
+ +
-
-
+
- +
diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index ade5d00b3..8c30de26d 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -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('/config/router'), - speechModels: async () => { - const data = await publicFetch<{ models?: SpeechModel[] }>('/audio/models') - return Array.isArray(data.models) ? data.models : [] + speechModels: async (): Promise => { + 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 => { const query = new URLSearchParams() diff --git a/apps/ui-admin/src/modules/router-config-form.test.ts b/apps/ui-admin/src/modules/router-config-form.test.ts index 648ffdea5..c9b2e3fd3 100644 --- a/apps/ui-admin/src/modules/router-config-form.test.ts +++ b/apps/ui-admin/src/modules/router-config-form.test.ts @@ -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' diff --git a/apps/ui-admin/src/modules/router-config-form.ts b/apps/ui-admin/src/modules/router-config-form.ts index d141ec8a9..49eef8631 100644 --- a/apps/ui-admin/src/modules/router-config-form.ts +++ b/apps/ui-admin/src/modules/router-config-form.ts @@ -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) diff --git a/apps/ui-admin/src/modules/server-admin-context.test.ts b/apps/ui-admin/src/modules/server-admin-context.test.ts index 94a78bd78..cfe651640 100644 --- a/apps/ui-admin/src/modules/server-admin-context.test.ts +++ b/apps/ui-admin/src/modules/server-admin-context.test.ts @@ -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') diff --git a/apps/ui-admin/src/modules/server-admin-context.ts b/apps/ui-admin/src/modules/server-admin-context.ts index 396835fa5..1c27924a8 100644 --- a/apps/ui-admin/src/modules/server-admin-context.ts +++ b/apps/ui-admin/src/modules/server-admin-context.ts @@ -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 diff --git a/apps/ui-admin/src/pages/LlmRouterPage.vue b/apps/ui-admin/src/pages/LlmRouterPage.vue index c6bfee22a..b8ad5a96a 100644 --- a/apps/ui-admin/src/pages/LlmRouterPage.vue +++ b/apps/ui-admin/src/pages/LlmRouterPage.vue @@ -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': diff --git a/apps/ui-admin/src/pages/VoicePackFormPage.test.ts b/apps/ui-admin/src/pages/VoicePackFormPage.test.ts new file mode 100644 index 000000000..3b7ceea41 --- /dev/null +++ b/apps/ui-admin/src/pages/VoicePackFormPage.test.ts @@ -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 + 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 = '
' + 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() +} diff --git a/apps/ui-admin/src/pages/VoicePackFormPage.vue b/apps/ui-admin/src/pages/VoicePackFormPage.vue index 8666f76a6..dc311ee7b 100644 --- a/apps/ui-admin/src/pages/VoicePackFormPage.vue +++ b/apps/ui-admin/src/pages/VoicePackFormPage.vue @@ -20,6 +20,7 @@ const router = useRouter() const packs = shallowRef([]) const models = shallowRef<{ id: string, name: string }[]>([]) +const catalogDefaultModel = shallowRef(null) const voices = shallowRef([]) const recommendedVoices = shallowRef>({}) const loading = shallowRef(false) @@ -29,15 +30,16 @@ const saving = shallowRef(false) const testing = shallowRef(false) const testAudioUrl = shallowRef(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 />
@@ -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 />
diff --git a/apps/ui-server-auth/src/modules/server-auth-context.test.ts b/apps/ui-server-auth/src/modules/server-auth-context.test.ts index e52daad5a..b20acc5d0 100644 --- a/apps/ui-server-auth/src/modules/server-auth-context.test.ts +++ b/apps/ui-server-auth/src/modules/server-auth-context.test.ts @@ -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 = '' window.history.replaceState( diff --git a/apps/ui-server-auth/src/modules/server-auth-context.ts b/apps/ui-server-auth/src/modules/server-auth-context.ts index eab0d8b73..87de6a144 100644 --- a/apps/ui-server-auth/src/modules/server-auth-context.ts +++ b/apps/ui-server-auth/src/modules/server-auth-context.ts @@ -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