feat: add selectable routing groups and composite billing

Support per-model provider enablement and compact model editing. Capture request-time billing factors, charge customer costs separately, and preserve historical statistics without backfills.
This commit is contained in:
elky
2026-10-07 14:49:57 +08:00
parent 310098a853
commit 911c7f8875
110 changed files with 6524 additions and 559 deletions
+15 -14
View File
@@ -160,7 +160,7 @@
:priority="getGroupPriority(provider)"
:edit-context="priorityEditContext"
:enabled="isGroupEnabled(provider.id)"
:disabled="schedulingBusy"
:disabled="priorityEditingDisabled"
:priority-disabled="priorityEditingDisabled"
:show-priority="false"
@update:priority="setGroupPriority(provider.id, $event)"
@@ -170,7 +170,7 @@
<ProviderGroupToggleButton
:provider-name="provider.name"
:enabled="isGroupEnabled(provider.id)"
:disabled="schedulingBusy"
:disabled="priorityEditingDisabled"
@update:enabled="setGroupEnabled(provider.id, $event)"
/>
</template>
@@ -213,7 +213,7 @@
:priority="getGroupPriority(provider)"
:edit-context="priorityEditContext"
:enabled="isGroupEnabled(provider.id)"
:disabled="schedulingBusy"
:disabled="priorityEditingDisabled"
:priority-disabled="priorityEditingDisabled"
@update:priority="setGroupPriority(provider.id, $event)"
/>
@@ -222,7 +222,7 @@
<ProviderGroupToggleButton
:provider-name="provider.name"
:enabled="isGroupEnabled(provider.id)"
:disabled="schedulingBusy"
:disabled="priorityEditingDisabled"
@update:enabled="setGroupEnabled(provider.id, $event)"
/>
</template>
@@ -325,7 +325,7 @@ import ProviderGroupControls from '@/features/providers/components/ProviderGroup
import ProviderGroupToggleButton from '@/features/providers/components/ProviderGroupToggleButton.vue'
import ProviderPriorityInput from '@/features/providers/components/ProviderPriorityInput.vue'
import { providerGroupPriority, sortGroupProviders, moveGroupProvider } from '@/features/providers/utils/groupPriority'
import { getDefaultModelPolicy, normalizeRoutingGroupConfig, type RoutingModelPolicy, type RoutingPriorityMode, type RoutingSchedulingMode, type RoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import { getDefaultModelPolicy, isRoutingProviderEnabled, normalizeRoutingGroupConfig, type RoutingModelPolicy, type RoutingPriorityMode, type RoutingSchedulingMode, type RoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import ProviderDragHandle from '@/features/providers/components/ProviderDragHandle.vue'
import ProviderDeleteProgressCard from '@/features/providers/components/ProviderDeleteProgressCard.vue'
import ProviderEmptyState from '@/features/providers/components/ProviderEmptyState.vue'
@@ -595,15 +595,16 @@ function getGroupPriority(provider: ProviderWithEndpointsSummary) {
return providerGroupPriority(priorityConfig.value, provider)
}
function isGroupEnabled(providerId: string) {
return !schedulingContext.value.config?.disabled_providers?.includes(providerId)
const config = schedulingContext.value.config
return config ? isRoutingProviderEnabled(config, providerId, selectedPolicy.value?.policy) : true
}
function setGroupEnabled(providerId: string, enabled: boolean) {
const config = schedulingContext.value.config
if (!config || schedulingBusy.value) return
const disabled = new Set(config.disabled_providers ?? [])
if (enabled) disabled.delete(providerId)
else disabled.add(providerId)
schedulingWorkspace.value?.updateDraftConfig({ ...config, disabled_providers: [...disabled] })
const policy = selectedPolicy.value?.policy
if (!policy || priorityEditingDisabled.value) return
schedulingWorkspace.value?.updatePriorityPolicy({
...policy,
provider_enabled_overrides: { ...policy.provider_enabled_overrides, [providerId]: enabled },
})
}
function setGroupPriority(providerId: string, priority: number) {
const policy = selectedPolicy.value?.policy
@@ -748,9 +749,9 @@ function handleRowClick(event: MouseEvent, providerId: string) {
// 打开添加提供商对话框
function openAddProviderDialog() {
const { groupId, groupName } = schedulingContext.value
const { groupId, groupName, config } = schedulingContext.value
if (!groupId || schedulingBusy.value) {
showInfo(legacyT('请先创建或选择策略分组'))
showInfo(legacyT(!groupId && config ? '请先保存新分组,再添加提供商' : '请先创建或选择策略分组'))
return
}
providerCreationGroup.value = { id: groupId, name: groupName }
@@ -24,6 +24,8 @@ const apiMocks = vi.hoisted(() => ({
updateProvider: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }))
vi.mock('@/api/endpoints', async (importOriginal) => ({
...await importOriginal<typeof import('@/api/endpoints')>(),
...apiMocks,
@@ -34,7 +36,7 @@ vi.mock('@/composables/useConfirm', () => ({
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
useToast: () => toastMocks,
}))
vi.mock('@/features/providers/composables/useProviderBalance', () => ({
@@ -417,10 +419,11 @@ describe('ProviderManagement group directory', () => {
expect(row.textContent).toContain('本组启用')
expect(row.textContent).not.toContain('本组禁用')
expect(row.textContent).toContain('全局停用')
expect(workspace.groups['group-b']!.disabled_providers).toEqual([])
expect(workspace.groups['group-b']!.disabled_providers).toEqual(['provider-4'])
expect(getDefaultModelPolicy(workspace.groups['group-b']!).provider_enabled_overrides).toEqual({ 'provider-4': true })
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
expect(workspace.updateDraftConfig).toHaveBeenCalledExactlyOnceWith(workspace.groups['group-b'])
expect(workspace.updatePriorityPolicy).not.toHaveBeenCalled()
expect(workspace.updateDraftConfig).not.toHaveBeenCalled()
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
expect(providers[3]!.is_active).toBe(false)
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
@@ -476,7 +479,8 @@ describe('ProviderManagement group directory', () => {
await settle()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
expect(groupDisabled.textContent).toContain('本组启用')
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
expect(workspace.groups['group-a']!.disabled_providers).toEqual(['provider-2'])
expect(getDefaultModelPolicy(workspace.groups['group-a']!).provider_enabled_overrides).toEqual({ 'provider-2': true })
globallyDisabled.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!.click()
await settle()
@@ -527,6 +531,46 @@ describe('ProviderManagement group directory', () => {
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4'])
})
it.each(['table', 'mobile list'] as const)('keeps model configuration membership independent in the %s', async layout => {
const providers = mockSortableProviders()
const config = createEmptyRoutingGroupConfig()
workspace.groups['group-a'] = writeSchedulingPolicies(config, [
{ ...createSchedulingPolicy(config), models: ['Model One', 'Model Two'] },
{ ...createSchedulingPolicy(config), models: ['Model Three'] },
createSchedulingPolicy(config, 'all'),
])
const root = await mountView()
const row = [...root.querySelectorAll<HTMLElement>('[data-provider-sort-id="provider-1"]')]
.find(element => layout === 'table' ? element.closest('table') : !element.closest('table'))!
const toggle = row.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!
toggle.click()
await settle()
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(row.textContent).toContain('本组禁用')
for (const model of ['Model One', 'Model Two']) {
expect(getModelPolicy(workspace.groups['group-a']!, model).provider_enabled_overrides).toEqual({ 'provider-1': false })
}
expect(getModelPolicy(workspace.groups['group-a']!, 'Model Three').provider_enabled_overrides).toEqual({})
expect(getDefaultModelPolicy(workspace.groups['group-a']!).provider_enabled_overrides).toEqual({})
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
root.querySelector<HTMLButtonElement>('[data-select-policy="1"]')!.click()
await settle()
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(row.textContent).toContain('本组启用')
expect(row.textContent).not.toContain('本组禁用')
root.querySelector<HTMLButtonElement>('[data-select-policy="0"]')!.click()
await settle()
expect(toggle.getAttribute('aria-pressed')).toBe('false')
toggle.click()
await settle()
expect(getModelPolicy(workspace.groups['group-a']!, 'Model One').provider_enabled_overrides).toEqual({ 'provider-1': true })
expect(getModelPolicy(workspace.groups['group-a']!, 'Model Three').provider_enabled_overrides).toEqual({})
expect(workspace.updateDraftConfig).not.toHaveBeenCalled()
expect(providers[0]!.is_active).toBe(true)
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
})
it('drags the shared model configuration without splitting its models', async () => {
mockSortableProviders()
const config = createEmptyRoutingGroupConfig()
@@ -585,11 +629,12 @@ describe('ProviderManagement group directory', () => {
expect(toggle.disabled).toBe(false)
toggle.click()
await settle()
expect(workspace.groups['group-a']!.disabled_providers).toContain('provider-1')
expect(getDefaultModelPolicy(workspace.groups['group-a']!).provider_enabled_overrides['provider-1']).toBe(false)
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
expect(workspace.updatePriorityPolicy).toHaveBeenCalled()
})
it('disables ranking before a configuration is selected without blocking provider creation', async () => {
it('disables ranking and membership before a configuration is selected without blocking provider creation', async () => {
mockSortableProviders()
workspace.selectionReady = false
const root = await mountView()
@@ -599,10 +644,10 @@ describe('ProviderManagement group directory', () => {
const handle = root.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')
expect(handle == null || handle.disabled).toBe(true)
const toggle = providerElements(root)[0]!.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!
expect(toggle.disabled).toBe(false)
expect(toggle.disabled).toBe(true)
toggle.click()
await settle()
expect(workspace.groups['group-a']!.disabled_providers).toContain('provider-1')
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
findButton(root, '新增提供商').click()
await settle()
expect(root.querySelector('[data-routing-group-id="group-a"]')).not.toBeNull()
@@ -631,9 +676,10 @@ describe('ProviderManagement group directory', () => {
expect(toggle.disabled).toBe(false)
toggle.click()
await settle()
expect(workspace.groups['group-a']!.disabled_providers).toContain('provider-1')
expect(getModelPolicy(workspace.groups['group-a']!, 'Missing Model').provider_enabled_overrides).toEqual({ 'provider-1': false })
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
expect(workspace.updatePriorityPolicy).toHaveBeenCalledTimes(2)
})
it('collects every API page before applying group priority', async () => {
@@ -663,6 +709,54 @@ describe('ProviderManagement group directory', () => {
findButton(root, '新增提供商').click()
await settle()
expect(root.querySelector('[data-save-edited-provider]')).toBeNull()
expect(toastMocks.info).toHaveBeenCalledWith('请先保存新分组,再添加提供商')
expect(workspace.ensureSaved).not.toHaveBeenCalled()
})
it('edits provider priority and membership in an unsaved group without saving it', async () => {
const providers = mockSortableProviders()
workspace.groups.new = {
...createEmptyRoutingGroupConfig(),
disabled_providers: ['provider-4'],
}
const savedGroups = {
'group-a': structuredClone(workspace.groups['group-a']),
'group-b': structuredClone(workspace.groups['group-b']),
}
const root = await mountView('/admin/providers?group=new')
expect(root.querySelector('[data-scheduling-group="new"]')).not.toBeNull()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
const input = await openPriorityInput(root, 'Provider 4')
expect(input.disabled).toBe(false)
input.value = '0'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await settle()
expect(getDefaultModelPolicy(workspace.groups.new!).provider_priority_overrides['provider-4']).toBe(0)
expect(providerOrder(root)).toEqual(['provider-4', 'provider-1', 'provider-2', 'provider-3'])
const row = providerElements(root)[0]!
const toggle = row.querySelector<HTMLButtonElement>('[aria-label="Provider 4 本组启用"]')!
expect(toggle.disabled).toBe(false)
expect(toggle.getAttribute('aria-pressed')).toBe('false')
toggle.click()
await settle()
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(workspace.groups.new!.disabled_providers).toEqual(['provider-4'])
expect(getDefaultModelPolicy(workspace.groups.new!).provider_enabled_overrides).toEqual({ 'provider-4': true })
expect(getDefaultModelPolicy(workspace.groups.new!).provider_priority_overrides['provider-4']).toBe(0)
expect(workspace.updatePriorityPolicy).toHaveBeenCalledTimes(2)
expect(workspace.updateDraftConfig).not.toHaveBeenCalled()
expect(workspace.groups['group-a']).toEqual(savedGroups['group-a'])
expect(workspace.groups['group-b']).toEqual(savedGroups['group-b'])
expect(workspace.ensureSaved).not.toHaveBeenCalled()
expect(workspace.refreshGroups).not.toHaveBeenCalled()
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(providers.map(provider => provider.provider_priority)).toEqual([10, 20, 30, 40])
expect(mountedRouter!.currentRoute.value.query.group).toBe('new')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('opens details and applies edited snapshots for providers outside the loaded directory', async () => {
@@ -195,15 +195,22 @@ describe('ProviderSchedulingView failover persistence', () => {
button(root, '区分模型').click()
await flush()
expect(button(root, '保存调度').disabled).toBe(true)
const modelPicker = root.querySelector<HTMLButtonElement>('[aria-label="选择适用模型"]')
?? root.querySelector<HTMLButtonElement>('[aria-label="编辑模型"]')
if (!modelPicker) throw new Error('Missing model picker')
modelPicker.click()
await flush()
element<HTMLInputElement>(document.body, 'input[aria-label="选择模型 model-a"]').click()
await flush()
element<HTMLInputElement>(root, 'input[aria-label="选择模型 model-a"]').click()
element<HTMLInputElement>(document.body, 'input[aria-label="选择模型 model-a"]').click()
await flush()
expect(button(root, '保存调度').disabled).toBe(true)
for (const model of ['model-a', 'model-b']) {
element<HTMLInputElement>(document.body, `input[aria-label="选择模型 ${model}"]`).click()
await nextTick()
}
button(document.body, '完成选择').click()
await flush()
byText('负载均衡').click()
await nextTick()
expect(button(root, '保存调度').disabled).toBe(false)
+14 -1
View File
@@ -170,6 +170,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { mergeUsageBillingSnapshot } from '@/features/usage/utils/usageBilling'
import { useRoute, useRouter } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
@@ -620,9 +621,10 @@ async function pollActiveRequests() {
record.cache_creation_ephemeral_1h_input_tokens =
update.cache_creation_ephemeral_1h_input_tokens ?? undefined
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
Object.assign(record, mergeUsageBillingSnapshot(record, update))
record.cost = update.cost
record.actual_cost = update.actual_cost ?? undefined
record.rate_multiplier = update.rate_multiplier ?? undefined
record.rate_multiplier = update.rate_multiplier ?? record.rate_multiplier
const responseTiming = mergeUsageRecordResponseTiming(
{
response_time_ms: record.response_time_ms,
@@ -1277,6 +1279,10 @@ function handleDetailRequestState(update: {
cacheReadInputTokens?: number | null
cost?: number | null
actualCost?: number | null
billingMultiplier?: number | null
billingCost?: number | null
routingGroupId?: string | null
routingGroupName?: string | null
responseTimeMs?: number | null
firstByteTimeMs?: number | null
isStream?: boolean | null
@@ -1342,6 +1348,13 @@ function handleDetailRequestState(update: {
if ('cacheReadInputTokens' in update && update.cacheReadInputTokens != null) {
record.cache_read_input_tokens = update.cacheReadInputTokens
}
Object.assign(record, mergeUsageBillingSnapshot(record, {
cost: update.cost,
billing_multiplier: update.billingMultiplier,
billing_cost: update.billingCost,
routing_group_id: update.routingGroupId,
routing_group_name: update.routingGroupName,
}))
if ('cost' in update && update.cost != null) {
record.cost = update.cost
}
+126 -1
View File
@@ -111,6 +111,9 @@
>
{{ apiKey.name }}
</div>
<div class="text-xs text-muted-foreground mt-0.5 truncate">
策略分组:{{ apiKeyRoutingGroupName(apiKey) }}
</div>
<div class="text-xs text-muted-foreground mt-0.5">
创建于 {{ formatDate(apiKey.created_at) }}
</div>
@@ -358,6 +361,9 @@
<!-- 第二行:密钥、时间、统计 -->
<div class="space-y-1.5">
<div class="text-xs text-muted-foreground truncate">
策略分组:{{ apiKeyRoutingGroupName(apiKey) }}
</div>
<div class="flex items-center gap-2 text-xs">
<code class="font-mono text-muted-foreground">{{ apiKey.key_display || 'sk-••••••••' }}</code>
<span class="text-muted-foreground">•</span>
@@ -437,6 +443,70 @@
</p>
</div>
<div class="space-y-2">
<Label
for="key-routing-group"
class="text-sm font-semibold"
>策略分组</Label>
<Select
v-model="newKeyRoutingGroup"
:disabled="routingGroupsLoading || Boolean(routingGroupsError) || creating"
>
<SelectTrigger
id="key-routing-group"
aria-label="策略分组"
class="h-11 border-border/60"
>
<SelectValue>{{ selectedRoutingGroupLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="DEFAULT_ROUTING_GROUP">
跟随默认
</SelectItem>
<SelectItem
v-if="unavailableRoutingGroup"
:value="unavailableRoutingGroup.id"
disabled
>
{{ unavailableRoutingGroup.name }}(当前绑定)
</SelectItem>
<SelectItem
v-for="group in routingGroups"
:key="group.id"
:value="group.id"
>
{{ group.name }} · {{ group.billing_multiplier }} 倍{{ group.is_default ? ' · 默认' : '' }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="routingGroupsLoading"
class="text-xs text-muted-foreground"
>
正在加载策略分组…
</p>
<div
v-else-if="routingGroupsError"
class="flex items-center justify-between gap-2 text-xs text-muted-foreground"
>
<span role="status">{{ routingGroupsError }},当前选择保持不变。</span>
<Button
variant="ghost"
size="sm"
class="h-6 shrink-0 px-2 text-xs"
@click="loadRoutingGroups"
>
重试
</Button>
</div>
<p
v-else
class="text-xs text-muted-foreground"
>
{{ unavailableRoutingGroup ? '当前分组已不可用,请选择其他分组或跟随默认。' : routingGroups.length ? '选择此密钥使用的调度与计费策略;跟随默认会使用管理员设置的默认调度。' : '暂无可选策略分组,可使用默认调度。' }}
</p>
</div>
<div class="space-y-2">
<Label
for="key-rate-limit"
@@ -914,7 +984,7 @@
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, onMounted, onBeforeUnmount, computed, watch, reactive } from 'vue'
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
import { meApi, type ApiKey, type UserRoutingGroup, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -1006,6 +1076,12 @@ const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
const newKeyConcurrentLimit = ref<number | undefined>(undefined)
const newKeyIpRulesText = ref('')
const DEFAULT_ROUTING_GROUP = '__follow_default__'
const newKeyRoutingGroup = ref(DEFAULT_ROUTING_GROUP)
const routingGroups = ref<UserRoutingGroup[]>([])
const routingGroupsLoading = ref(false)
const routingGroupsError = ref('')
let routingGroupsRequest = 0
const keyRedactionMode = ref<'inherit' | 'custom'>('inherit')
const newKeyRedactionEnabled = ref(false)
const newKeyRedactionInjectNotice = ref(true)
@@ -1013,6 +1089,17 @@ const newKeyValue = ref('')
const createdApiKey = ref<ApiKey | null>(null)
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
const unavailableRoutingGroup = computed(() => {
const key = editingApiKey.value
return key?.routing_group_id && !routingGroups.value.some(group => group.id === key.routing_group_id)
? { id: key.routing_group_id, name: apiKeyRoutingGroupName(key) }
: null
})
const selectedRoutingGroupLabel = computed(() => {
if (newKeyRoutingGroup.value === DEFAULT_ROUTING_GROUP) return '跟随默认'
const group = routingGroups.value.find(group => group.id === newKeyRoutingGroup.value)
return group ? `${group.name} · ${group.billing_multiplier} 倍` : unavailableRoutingGroup.value?.name ?? '已选择分组'
})
const selectedInstallApiKey = ref<ApiKey | null>(null)
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
const installCli = ref<InstallTargetCli>('claude_code')
@@ -1140,6 +1227,25 @@ function resetInstallCopiedState() {
installCopied.value = false
}
function apiKeyRoutingGroupName(apiKey: ApiKey): string {
return apiKey.routing_group_id ? apiKey.routing_group_name || '已绑定分组' : '跟随默认'
}
async function loadRoutingGroups() {
const request = ++routingGroupsRequest
routingGroupsLoading.value = true
routingGroupsError.value = ''
try {
const response = await meApi.getRoutingGroups()
if (request === routingGroupsRequest) routingGroups.value = response.items
} catch (error) {
if (request === routingGroupsRequest) routingGroupsError.value = '策略分组加载失败'
log.error('加载用户策略分组失败:', error)
} finally {
if (request === routingGroupsRequest) routingGroupsLoading.value = false
}
}
function openEditApiKeyDialog(apiKey: ApiKey) {
const hasRedactionFeature = hasChatPiiRedactionFeatureSettings(apiKey.feature_settings)
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
@@ -1148,6 +1254,9 @@ function openEditApiKeyDialog(apiKey: ApiKey) {
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
newKeyConcurrentLimit.value = apiKey.concurrent_limit ?? undefined
newKeyIpRulesText.value = apiKey.ip_rules?.join(', ') ?? ''
newKeyRoutingGroup.value = apiKey.routing_group_id ?? DEFAULT_ROUTING_GROUP
routingGroups.value = []
void loadRoutingGroups()
keyRedactionMode.value = hasRedactionFeature ? 'custom' : 'inherit'
newKeyRedactionEnabled.value = redactionFeature.enabled
newKeyRedactionInjectNotice.value = redactionFeature.inject_model_instruction
@@ -1161,6 +1270,9 @@ function openCreateApiKeyDialog() {
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
newKeyIpRulesText.value = ''
newKeyRoutingGroup.value = DEFAULT_ROUTING_GROUP
routingGroups.value = []
void loadRoutingGroups()
keyRedactionMode.value = 'inherit'
newKeyRedactionEnabled.value = false
newKeyRedactionInjectNotice.value = true
@@ -1435,6 +1547,8 @@ function closeCreatedKeyDialog() {
}
function closeApiKeyDialog() {
++routingGroupsRequest
routingGroupsLoading.value = false
showCreateDialog.value = false
editingApiKey.value = null
if (!showKeyDialog.value) {
@@ -1444,6 +1558,7 @@ function closeApiKeyDialog() {
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
newKeyIpRulesText.value = ''
newKeyRoutingGroup.value = DEFAULT_ROUTING_GROUP
keyRedactionMode.value = 'inherit'
newKeyRedactionEnabled.value = false
newKeyRedactionInjectNotice.value = true
@@ -1454,6 +1569,14 @@ async function saveApiKey() {
showError('请输入密钥名称')
return
}
const routingGroupId = newKeyRoutingGroup.value === DEFAULT_ROUTING_GROUP ? null : newKeyRoutingGroup.value
const routingGroupChanged = routingGroupId !== (editingApiKey.value?.routing_group_id ?? null)
if (routingGroupChanged && routingGroupId && (
routingGroupsLoading.value || routingGroupsError.value || !routingGroups.value.some(group => group.id === routingGroupId)
)) {
showError('请重新加载并选择可用策略分组')
return
}
creating.value = true
try {
@@ -1465,6 +1588,7 @@ async function saveApiKey() {
rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
ip_rules: ipRules,
...(routingGroupChanged ? { routing_group_id: routingGroupId } : {}),
feature_settings: keyRedactionMode.value === 'custom'
? mergeChatPiiRedactionFeatureSettings(editingApiKey.value.feature_settings, {
enabled: newKeyRedactionEnabled.value,
@@ -1479,6 +1603,7 @@ async function saveApiKey() {
rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
ip_rules: ipRules,
routing_group_id: routingGroupId,
...(keyRedactionMode.value === 'custom'
? {
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
@@ -10,6 +10,7 @@ const toastMock = vi.hoisted(() => ({
const meApiMock = vi.hoisted(() => ({
getApiKeys: vi.fn(),
getRoutingGroups: vi.fn(),
createApiKey: vi.fn(),
getFullApiKey: vi.fn(),
getClientConfig: vi.fn(),
@@ -96,6 +97,7 @@ async function mountMyApiKeys() {
beforeEach(() => {
vi.clearAllMocks()
meApiMock.getRoutingGroups.mockResolvedValue({ items: [], total: 0 })
meApiMock.getClientConfig.mockResolvedValue({
base_url: 'https://aether.example.com',
site_name: 'Aether Local',
@@ -243,3 +245,105 @@ describe('MyApiKeys CC Switch import', () => {
expect(toastMock.success).toHaveBeenCalledWith('密钥已禁用')
})
})
describe('MyApiKeys routing groups', () => {
async function setName(name: string) {
const input = document.querySelector<HTMLInputElement>('#key-name')!
input.value = name
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function save(label: '创建' | '保存') {
const button = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.trim() === label)
expect(button).toBeDefined()
button!.click()
await flushPromises()
}
async function chooseGroup(label: string) {
const trigger = document.querySelector<HTMLButtonElement>('#key-routing-group')!
trigger.focus()
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await flushPromises()
const option = Array.from(document.querySelectorAll<HTMLElement>('[role="listbox"] [role="option"]'))
.find(option => option.textContent?.trim().startsWith(label))
expect(option).toBeDefined()
option!.focus()
option!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
await flushPromises()
}
it('creates a key with an explicitly selected visible group', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.getRoutingGroups.mockResolvedValue({
items: [{ id: 'economy', name: '经济分组', billing_multiplier: 0.5, is_default: false }], total: 1,
})
meApiMock.createApiKey.mockResolvedValue(apiKey({ id: 'new-key', key: 'sk-new' }))
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="创建新 API Key"]')!.click()
await flushPromises()
await setName('test group')
await chooseGroup('经济分组')
expect(document.querySelector('#key-routing-group')?.textContent).toContain('0.5 倍')
await save('创建')
expect(meApiMock.createApiKey).toHaveBeenCalledWith(expect.objectContaining({ name: 'test group', routing_group_id: 'economy' }))
})
it('creates a key following default when no groups are available', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.createApiKey.mockResolvedValue(apiKey({ id: 'new-key', key: 'sk-new' }))
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="创建新 API Key"]')!.click()
await flushPromises()
expect(document.body.textContent).toContain('暂无可选策略分组')
await setName('default group')
await save('创建')
expect(meApiMock.createApiKey).toHaveBeenCalledWith(expect.objectContaining({ routing_group_id: null }))
})
it.each(['hidden', 'failed'] as const)('preserves an existing binding when group options are %s and the name is edited', async (state) => {
meApiMock.getApiKeys.mockResolvedValue([apiKey({ routing_group_id: 'retained', routing_group_name: '原有分组' })])
meApiMock.updateApiKey.mockResolvedValue(apiKey())
if (state === 'failed') meApiMock.getRoutingGroups.mockRejectedValue(new Error('offline'))
await mountMyApiKeys()
expect(document.body.textContent?.match(/策略分组:原有分组/g)).toHaveLength(2)
document.querySelector<HTMLButtonElement>('[title="编辑"]')!.click()
await flushPromises()
expect(document.querySelector('#key-routing-group')?.textContent).toContain('原有分组')
if (state === 'failed') expect(document.body.textContent).toContain('策略分组加载失败')
await setName('renamed')
await save('保存')
expect(meApiMock.updateApiKey).toHaveBeenCalledWith('user-key-1', expect.objectContaining({ name: 'renamed' }))
expect(meApiMock.updateApiKey.mock.calls[0]?.[1]).not.toHaveProperty('routing_group_id')
})
it('allows clearing an unavailable binding to follow default explicitly', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey({ routing_group_id: 'retained', routing_group_name: '原有分组' })])
meApiMock.updateApiKey.mockResolvedValue(apiKey())
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="编辑"]')!.click()
await flushPromises()
await chooseGroup('跟随默认')
await save('保存')
expect(meApiMock.updateApiKey).toHaveBeenCalledWith('user-key-1', expect.objectContaining({ routing_group_id: null }))
})
it('can retry loading options without replacing the key binding', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey({ routing_group_id: 'retained', routing_group_name: '原有分组' })])
meApiMock.getRoutingGroups.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({
items: [{ id: 'economy', name: '经济分组', billing_multiplier: 0.5, is_default: false }], total: 1,
})
meApiMock.updateApiKey.mockResolvedValue(apiKey())
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="编辑"]')!.click()
await flushPromises()
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '重试')!.click()
await flushPromises()
expect(document.querySelector('#key-routing-group')?.textContent).toContain('原有分组')
await chooseGroup('经济分组')
await save('保存')
expect(meApiMock.updateApiKey).toHaveBeenCalledWith('user-key-1', expect.objectContaining({ routing_group_id: 'economy' }))
})
})