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:
RainbowBird
2026-07-01 02:14:12 +08:00
parent f9f20b88ff
commit 4f80affcd3
17 changed files with 684 additions and 50 deletions
@@ -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 30200 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: {
+69
View File
@@ -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()
}
+10 -6
View File
@@ -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>
+35 -3
View File
@@ -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
+9 -3
View File
@@ -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()
}
+34 -15
View File
@@ -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