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: {