mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' }))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user