feat(admin-dashboard): add support for Bedrock and OpenAI-compatible LLM slices in admin router config
- Implemented `buildBedrockSlice` function to handle multi-kilobyte Bedrock bearer tokens. - Enhanced `createAdminRouterConfigService` to classify Bedrock and OpenAI-compatible LLM upstreams by baseURL. - Added new interfaces for `AdminRouterBedrockSlice` and `AdminRouterOpenAICompatibleSlice`. - Updated router config form to support Bedrock and OpenAI-compatible slices. - Created tests for Bedrock and OpenAI-compatible slice compilation and behavior. - Modified UI components to accommodate new slice types and improve user experience. - Ensured proper normalization of API server URLs to HTTPS when necessary.
This commit is contained in:
@@ -32,10 +32,10 @@ import { createBadRequestError } from '../../../../utils/error'
|
||||
const MAX_SLICES_PER_REQUEST = 20
|
||||
|
||||
/**
|
||||
* Hard cap on plaintext key length. Real provider keys are 30–200 chars;
|
||||
* 1KB leaves headroom for unusual formats while keeping the body lean.
|
||||
* Hard cap on plaintext key length. Most provider keys are short, but
|
||||
* Bedrock bearer tokens can be multi-kilobyte signed payloads.
|
||||
*/
|
||||
const MAX_KEY_LENGTH = 1024
|
||||
const MAX_KEY_LENGTH = 8192
|
||||
|
||||
/** AAD separator constraint mirrored from `keyEntrySchema` in config-kv. */
|
||||
const NO_PIPE = regex(/^[^|]+$/, 'must not contain "|" (reserved AAD separator)')
|
||||
@@ -51,6 +51,28 @@ const OpenRouterSliceSchema = object({
|
||||
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
})
|
||||
|
||||
const BedrockSliceSchema = object({
|
||||
kind: literal('bedrock'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
overrideModel: pipe(string(), nonEmpty('overrideModel is required'), maxLength(200)),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
baseURL: optional(pipe(string(), url('baseURL must be a valid URL'))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
})
|
||||
|
||||
const OpenAICompatibleSliceSchema = object({
|
||||
kind: literal('openai-compatible'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
overrideModel: pipe(string(), nonEmpty('overrideModel is required'), maxLength(200)),
|
||||
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
|
||||
baseURL: optional(pipe(string(), url('baseURL must be a valid URL'))),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
headerTemplate: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
})
|
||||
|
||||
const AzureSliceSchema = object({
|
||||
kind: literal('azure'),
|
||||
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
|
||||
@@ -135,6 +157,8 @@ const UnspeechSliceSchema = object({
|
||||
|
||||
const SliceSchema = variant('kind', [
|
||||
OpenRouterSliceSchema,
|
||||
BedrockSliceSchema,
|
||||
OpenAICompatibleSliceSchema,
|
||||
AzureSliceSchema,
|
||||
DashscopeSliceSchema,
|
||||
StepfunSliceSchema,
|
||||
@@ -180,6 +204,12 @@ const BodySchema = object({
|
||||
* "slices": [ // optional when only defaults change
|
||||
* { "kind": "openrouter", "modelName": "chat-default",
|
||||
* "overrideModel": "openai/gpt-4o-mini", "plaintextKey": "..." },
|
||||
* { "kind": "bedrock", "modelName": "chat-bedrock",
|
||||
* "overrideModel": "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
* "plaintextKey": "...", "baseURL": "https://bedrock-mantle.us-east-1.api.aws/v1" },
|
||||
* { "kind": "openai-compatible", "modelName": "chat-compatible",
|
||||
* "overrideModel": "gpt-4o-mini", "plaintextKey": "...",
|
||||
* "baseURL": "https://api.example.com/v1" },
|
||||
* { "kind": "azure", "modelName": "microsoft/v1",
|
||||
* "region": "eastasia", "plaintextKey": "..." },
|
||||
* { "kind": "dashscope-cosyvoice", "modelName": "alibaba/cosyvoice-v2",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AdminRouterConfigService } from '../../../../services/domain/admin/router-config'
|
||||
import type { HonoEnv } from '../../../../types/hono'
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAdminRouterConfigRoutes } from '.'
|
||||
import { ApiError } from '../../../../utils/error'
|
||||
|
||||
function createTestApp(service: AdminRouterConfigService) {
|
||||
return new Hono<HonoEnv>()
|
||||
.use('*', async (c, next) => {
|
||||
c.set('user', { id: 'admin-1', email: 'admin@example.com', role: 'admin' } as HonoEnv['Variables']['user'])
|
||||
await next()
|
||||
})
|
||||
.route('/api/admin/config/router', createAdminRouterConfigRoutes(service))
|
||||
.onError((err, c) => {
|
||||
if (err instanceof ApiError)
|
||||
return c.json({ error: err.errorCode, message: err.message, details: err.details }, err.statusCode)
|
||||
return c.json({ error: 'internal', message: (err as Error).message }, 500)
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin router config route', () => {
|
||||
it('accepts Bedrock bearer tokens longer than ordinary provider keys', async () => {
|
||||
const service: AdminRouterConfigService = {
|
||||
apply: vi.fn(async () => ({
|
||||
applied: [],
|
||||
invalidatedKeys: [],
|
||||
preview: {},
|
||||
})),
|
||||
current: vi.fn(),
|
||||
}
|
||||
const app = createTestApp(service)
|
||||
const body = {
|
||||
mode: 'merge',
|
||||
slices: [{
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
plaintextKey: `bedrock-api-key-${'x'.repeat(2180)}`,
|
||||
}],
|
||||
dryRun: false,
|
||||
}
|
||||
|
||||
const res = await app.request('/api/admin/config/router', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(service.apply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
slices: [expect.objectContaining({
|
||||
kind: 'bedrock',
|
||||
plaintextKey: expect.stringMatching(/^bedrock-api-key-/u),
|
||||
})],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,8 @@ const STREAMING_TTS_AAD_MODEL_NAME = 'streaming-tts'
|
||||
/** Default key entry id per provider. Operator can override per request. */
|
||||
const DEFAULT_KEY_ENTRY_IDS = {
|
||||
'openrouter': 'openrouter-prod-1',
|
||||
'bedrock': 'bedrock-prod-1',
|
||||
'openai-compatible': 'openai-compatible-prod-1',
|
||||
'azure': 'azure-tts-prod-1',
|
||||
'dashscope-cosyvoice': 'dashscope-tts-prod-1',
|
||||
'stepfun': 'stepfun-tts-prod-1',
|
||||
@@ -38,6 +40,7 @@ type TtsModel = InferOutput<typeof ttsModelSchema>
|
||||
type AsrModel = InferOutput<typeof asrModelSchema>
|
||||
type UnspeechUpstream = InferOutput<typeof unspeechUpstreamSchema>
|
||||
type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
|
||||
type LlmSliceKind = 'openrouter' | 'bedrock' | 'openai-compatible'
|
||||
|
||||
/**
|
||||
* Per-provider input. The admin route validates the shape with Valibot
|
||||
@@ -49,6 +52,8 @@ type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
|
||||
*/
|
||||
export type SliceInput
|
||||
= | OpenRouterSliceInput
|
||||
| BedrockSliceInput
|
||||
| OpenAICompatibleSliceInput
|
||||
| AzureSliceInput
|
||||
| DashscopeSliceInput
|
||||
| StepfunSliceInput
|
||||
@@ -73,6 +78,42 @@ export interface OpenRouterSliceInput {
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface BedrockSliceInput {
|
||||
kind: 'bedrock'
|
||||
/** Key under `LLM_ROUTER_CONFIG.llm.models`. */
|
||||
modelName: string
|
||||
/** Upstream Bedrock model id sent to the OpenAI-compatible Bedrock gateway. */
|
||||
overrideModel: string
|
||||
/** Plaintext provider key or Bedrock bearer token. Encrypted in-place; never echoed back. */
|
||||
plaintextKey?: string
|
||||
/** @default 'https://bedrock-mantle.us-east-1.api.aws/v1' */
|
||||
baseURL?: string
|
||||
/** @default 'bedrock-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
/** @default 'Bearer {KEY}' */
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleSliceInput {
|
||||
kind: 'openai-compatible'
|
||||
/** Key under `LLM_ROUTER_CONFIG.llm.models`. */
|
||||
modelName: string
|
||||
/** Upstream OpenAI-compatible model id. */
|
||||
overrideModel: string
|
||||
/** Plaintext provider key. Encrypted in-place; never echoed back. */
|
||||
plaintextKey?: string
|
||||
/** @default 'https://api.openai.com/v1' */
|
||||
baseURL?: string
|
||||
/** @default 'openai-compatible-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Existing key entry to preserve when `plaintextKey` is omitted. */
|
||||
existingKeyEntryId?: string
|
||||
/** @default 'Bearer {KEY}' */
|
||||
headerTemplate?: string
|
||||
}
|
||||
|
||||
export interface AzureSliceInput {
|
||||
kind: 'azure'
|
||||
/** Key under `LLM_ROUTER_CONFIG.tts.models` (e.g. `microsoft/v1`). */
|
||||
@@ -162,7 +203,7 @@ export interface AliyunNlsAsrSliceInput {
|
||||
interface LlmModelSlice {
|
||||
target: 'llm-router'
|
||||
surface: 'llm'
|
||||
kind: 'openrouter'
|
||||
kind: LlmSliceKind
|
||||
modelName: string
|
||||
model: LlmModel
|
||||
keyEntryId: string
|
||||
@@ -207,7 +248,19 @@ type BuiltSlice = LlmModelSlice | TtsModelSlice | AsrModelSlice | UnspeechSlice
|
||||
* envelope-encrypted plaintext key with AAD `{modelName, keyEntryId}`.
|
||||
*/
|
||||
export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS.openrouter
|
||||
return buildLlmSlice(input, envelope)
|
||||
}
|
||||
|
||||
export function buildBedrockSlice(input: BedrockSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
return buildLlmSlice(input, envelope)
|
||||
}
|
||||
|
||||
export function buildOpenAICompatibleSlice(input: OpenAICompatibleSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
return buildLlmSlice(input, envelope)
|
||||
}
|
||||
|
||||
function buildLlmSlice(input: OpenRouterSliceInput | BedrockSliceInput | OpenAICompatibleSliceInput, envelope: EnvelopeCrypto): LlmModelSlice {
|
||||
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS[input.kind]
|
||||
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
@@ -215,12 +268,12 @@ export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: Enve
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'llm',
|
||||
kind: 'openrouter',
|
||||
kind: input.kind,
|
||||
modelName: input.modelName,
|
||||
keyEntryId,
|
||||
model: {
|
||||
upstreams: [{
|
||||
baseURL: input.baseURL ?? 'https://openrouter.ai/api/v1',
|
||||
baseURL: input.baseURL ?? defaultLlmBaseURL(input.kind),
|
||||
overrideModel: input.overrideModel,
|
||||
keys: [{ id: keyEntryId, ciphertext }],
|
||||
headerTemplate: input.headerTemplate ?? 'Bearer {KEY}',
|
||||
@@ -230,6 +283,17 @@ export function buildOpenRouterSlice(input: OpenRouterSliceInput, envelope: Enve
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLlmBaseURL(kind: LlmSliceKind): string {
|
||||
switch (kind) {
|
||||
case 'openrouter':
|
||||
return 'https://openrouter.ai/api/v1'
|
||||
case 'bedrock':
|
||||
return 'https://bedrock-mantle.us-east-1.api.aws/v1'
|
||||
case 'openai-compatible':
|
||||
return 'https://api.openai.com/v1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an Azure TTS slice into the LLM_ROUTER_CONFIG.tts shape.
|
||||
*
|
||||
@@ -447,21 +511,21 @@ function preservedKeyOrThrow(upstream: { keys: KeyEntry[] } | undefined, preferr
|
||||
return key
|
||||
}
|
||||
|
||||
function buildOpenRouterSlicePreservingKey(input: OpenRouterSliceInput, envelope: EnvelopeCrypto, existing: LlmModel | undefined): LlmModelSlice {
|
||||
function buildLlmSlicePreservingKey(input: OpenRouterSliceInput | BedrockSliceInput | OpenAICompatibleSliceInput, envelope: EnvelopeCrypto, existing: LlmModel | undefined): LlmModelSlice {
|
||||
if (input.plaintextKey?.trim())
|
||||
return buildOpenRouterSlice(input, envelope)
|
||||
return buildLlmSlice(input, envelope)
|
||||
|
||||
const existingUpstream = existing?.upstreams[0]
|
||||
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
|
||||
return {
|
||||
target: 'llm-router',
|
||||
surface: 'llm',
|
||||
kind: 'openrouter',
|
||||
kind: input.kind,
|
||||
modelName: input.modelName,
|
||||
keyEntryId: key.id,
|
||||
model: {
|
||||
upstreams: [{
|
||||
baseURL: input.baseURL ?? existingUpstream?.baseURL ?? 'https://openrouter.ai/api/v1',
|
||||
baseURL: input.baseURL ?? existingUpstream?.baseURL ?? defaultLlmBaseURL(input.kind),
|
||||
overrideModel: input.overrideModel,
|
||||
keys: [key],
|
||||
headerTemplate: input.headerTemplate ?? existingUpstream?.headerTemplate ?? 'Bearer {KEY}',
|
||||
@@ -618,7 +682,9 @@ export function buildSlice(
|
||||
): BuiltSlice {
|
||||
switch (input.kind) {
|
||||
case 'openrouter':
|
||||
return buildOpenRouterSlicePreservingKey(input, envelope, existing?.routerConfig?.llm.models[input.modelName])
|
||||
case 'bedrock':
|
||||
case 'openai-compatible':
|
||||
return buildLlmSlicePreservingKey(input, envelope, existing?.routerConfig?.llm.models[input.modelName])
|
||||
case 'azure':
|
||||
return buildAzureSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
|
||||
case 'dashscope-cosyvoice':
|
||||
@@ -769,7 +835,7 @@ function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
|
||||
|
||||
const slices: SliceInput[] = []
|
||||
for (const [modelName, model] of Object.entries(config.llm.models)) {
|
||||
const slice = openRouterSliceFromModel(modelName, model)
|
||||
const slice = llmSliceFromModel(modelName, model)
|
||||
if (slice)
|
||||
slices.push(slice)
|
||||
}
|
||||
@@ -786,14 +852,14 @@ function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
|
||||
return slices
|
||||
}
|
||||
|
||||
function openRouterSliceFromModel(modelName: string, model: LlmModel): OpenRouterSliceInput | null {
|
||||
function llmSliceFromModel(modelName: string, model: LlmModel): OpenRouterSliceInput | BedrockSliceInput | OpenAICompatibleSliceInput | null {
|
||||
const upstream = model.upstreams[0]
|
||||
const key = upstream?.keys[0]
|
||||
if (!upstream || !key)
|
||||
return null
|
||||
|
||||
return {
|
||||
kind: 'openrouter',
|
||||
kind: llmKindFromBaseURL(upstream.baseURL),
|
||||
modelName,
|
||||
overrideModel: upstream.overrideModel ?? modelName,
|
||||
baseURL: upstream.baseURL,
|
||||
@@ -803,6 +869,20 @@ function openRouterSliceFromModel(modelName: string, model: LlmModel): OpenRoute
|
||||
}
|
||||
}
|
||||
|
||||
function llmKindFromBaseURL(baseURL: string): LlmSliceKind {
|
||||
try {
|
||||
const host = new URL(baseURL).hostname
|
||||
if (host === 'openrouter.ai')
|
||||
return 'openrouter'
|
||||
if (host.includes('bedrock') || host.endsWith('.api.aws'))
|
||||
return 'bedrock'
|
||||
return 'openai-compatible'
|
||||
}
|
||||
catch {
|
||||
return 'openai-compatible'
|
||||
}
|
||||
}
|
||||
|
||||
function ttsSliceFromModel(modelName: string, model: TtsModel): AzureSliceInput | DashscopeSliceInput | StepfunSliceInput | null {
|
||||
const upstream = model.upstreams[0]
|
||||
const key = upstream?.keys[0]
|
||||
|
||||
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildAliyunNlsAsrSlice,
|
||||
buildAzureSlice,
|
||||
buildBedrockSlice,
|
||||
buildDashscopeSlice,
|
||||
buildNextRouterConfig,
|
||||
buildOpenRouterSlice,
|
||||
@@ -151,6 +152,29 @@ describe('buildOpenRouterSlice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBedrockSlice', () => {
|
||||
it('accepts and encrypts multi-kilobyte Bedrock bearer tokens', () => {
|
||||
const envelope = freshEnvelope()
|
||||
const token = `bedrock-api-key-${'x'.repeat(2200)}`
|
||||
const built = buildBedrockSlice({
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
plaintextKey: token,
|
||||
}, envelope)
|
||||
|
||||
expect(built.kind).toBe('bedrock')
|
||||
expect(built.keyEntryId).toBe('bedrock-prod-1')
|
||||
expect(built.model.upstreams[0].baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
|
||||
|
||||
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
|
||||
modelName: 'chat-bedrock',
|
||||
keyEntryId: 'bedrock-prod-1',
|
||||
})
|
||||
expect(decrypted.toString('utf8')).toBe(token)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAzureSlice', () => {
|
||||
it('builds the cognitiveservices baseURL from region and surfaces region in adapterParams', () => {
|
||||
const envelope = freshEnvelope()
|
||||
@@ -595,6 +619,59 @@ describe('createAdminRouterConfigService', () => {
|
||||
expect(JSON.stringify(current.preview)).not.toContain('secret-ciphertext')
|
||||
})
|
||||
|
||||
it('current classifies Bedrock and generic OpenAI-compatible LLM upstreams by baseURL', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
models: {
|
||||
'chat-bedrock': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
keys: [{ id: 'bedrock-live', ciphertext: 'bedrock-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
'chat-compatible': {
|
||||
upstreams: [{
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
keys: [{ id: 'compatible-live', ciphertext: 'compatible-ciphertext' }],
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
}],
|
||||
fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS,
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: { models: {} },
|
||||
defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] },
|
||||
})
|
||||
|
||||
const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis })
|
||||
const current = await service.current()
|
||||
|
||||
expect(current.request.slices).toEqual([
|
||||
{
|
||||
kind: 'bedrock',
|
||||
modelName: 'chat-bedrock',
|
||||
overrideModel: 'us.amazon.nova-pro-v1:0',
|
||||
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'bedrock-live',
|
||||
existingKeyEntryId: 'bedrock-live',
|
||||
},
|
||||
{
|
||||
kind: 'openai-compatible',
|
||||
modelName: 'chat-compatible',
|
||||
overrideModel: 'gpt-4o-mini',
|
||||
baseURL: 'https://llm.example.com/v1',
|
||||
headerTemplate: 'Bearer {KEY}',
|
||||
keyEntryId: 'compatible-live',
|
||||
existingKeyEntryId: 'compatible-live',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an existing key entry when an applied slice omits plaintextKey', async () => {
|
||||
kv.store.set('LLM_ROUTER_CONFIG', {
|
||||
llm: {
|
||||
|
||||
Reference in New Issue
Block a user