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
+4
View File
@@ -250,6 +250,10 @@ export interface RequestDetail {
output_cost?: number
total_cost?: number
actual_cost?: number
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
cache_creation_cost?: number
cache_read_cost?: number
image_output_cost?: number
+29 -3
View File
@@ -27,6 +27,13 @@ export interface Profile {
feature_settings?: FeatureSettingsMap | null
}
export interface UserRoutingGroup {
id: string
name: string
billing_multiplier: number
is_default: boolean
}
export interface UserPreferences {
avatar_url?: string
bio?: string
@@ -66,7 +73,11 @@ export interface UsageRecordDetail {
output_tokens: number
total_tokens: number
cost: number // 官方费率
actual_cost?: number // 倍率消耗(仅管理员可见)
actual_cost?: number // 提供商 Key 成本(仅管理员可见);旧记录也用于兼容历史扣费
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
rate_multiplier?: number // 成本倍率(仅管理员可见)
response_time_ms?: number | null
first_byte_time_ms?: number | null
@@ -196,6 +207,8 @@ export interface ApiKey {
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置
feature_settings?: FeatureSettingsMap | null
routing_group_id?: string | null
routing_group_name?: string | null
}
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
@@ -278,7 +291,12 @@ export const meApi = {
return response.data
},
async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null; ip_rules?: string[] | null; feature_settings?: FeatureSettingsMap | null }): Promise<ApiKey> {
async getRoutingGroups(): Promise<{ items: UserRoutingGroup[]; total: number }> {
const response = await apiClient.get<{ items: UserRoutingGroup[]; total: number }>('/api/users/me/routing-groups')
return response.data
},
async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null; ip_rules?: string[] | null; feature_settings?: FeatureSettingsMap | null; routing_group_id?: string | null }): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
return response.data
},
@@ -318,7 +336,7 @@ export const meApi = {
async updateApiKey(
keyId: string,
data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null; ip_rules?: string[] | null; feature_settings?: FeatureSettingsMap | null | undefined }
data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null; ip_rules?: string[] | null; feature_settings?: FeatureSettingsMap | null | undefined; routing_group_id?: string | null }
): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>(
`/api/users/me/api-keys/${keyId}`,
@@ -370,6 +388,10 @@ export const meApi = {
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
end_to_end_time_ms?: number | null
@@ -417,6 +439,10 @@ export const meApi = {
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
end_to_end_time_ms?: number | null
+12
View File
@@ -30,6 +30,10 @@ export interface UsageRecord {
cache_read_input_tokens?: number
total_tokens: number
cost?: number
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
response_time?: number
response_time_ms?: number | null
first_byte_time_ms?: number | null
@@ -617,6 +621,10 @@ export const usageApi = {
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
end_to_end_time_ms?: number | null
@@ -689,6 +697,10 @@ export const usageApi = {
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
billing_cost?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
end_to_end_time_ms?: number | null
+6
View File
@@ -39,6 +39,12 @@ export interface UsageRecord {
total_tokens: number
cost: number
actual_cost?: number
/** Combined customer billing multiplier captured for this request. */
billing_multiplier?: number | null
routing_group_id?: string | null
routing_group_name?: string | null
/** Captured customer charge. Null means unavailable and must not be recalculated. */
billing_cost?: number | null
response_time_ms?: number | null
first_byte_time_ms?: number | null // 首字时间 (TTFB)
end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时
@@ -19,6 +19,10 @@ const props = withDefaults(defineProps<{
collisionPadding: 0,
ariaLabel: undefined,
})
const emit = defineEmits<{
openAutoFocus: [event: Event]
}>()
</script>
<template>
@@ -39,6 +43,7 @@ const props = withDefaults(defineProps<{
:align-offset="props.alignOffset"
:collision-padding="props.collisionPadding"
:aria-label="props.ariaLabel"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</PopoverContent>
@@ -28,6 +28,12 @@
<SelectValue :placeholder="loading ? '正在加载分组' : '选择策略分组'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-if="isNewDraft"
value="new"
>
新建策略
</SelectItem>
<SelectItem
v-for="group in groups"
:key="group.id"
@@ -42,7 +48,7 @@
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="busy"
:disabled="busy || isNewDraft"
title="新建分组"
aria-label="新建策略"
@click="openCreate"
@@ -85,7 +91,7 @@
class="h-8 w-8"
:class="{ 'text-primary': draftDirty }"
:disabled="!canSaveDraft"
:title="saving ? '正在保存…' : !routingSchedulingValid ? '请先选择适用模型' : draftDirty ? '保存修改' : '已保存'"
:title="saving ? '正在保存…' : billingMultiplierError ?? (!routingSchedulingValid ? '请先选择适用模型' : draftDirty ? '保存修改' : '已保存')"
aria-label="保存调度"
:aria-busy="saving"
@click="saveDraft"
@@ -101,12 +107,12 @@
<div
v-if="draft"
ref="groupMetadata"
class="min-w-0 border-b border-border/50 p-3"
class="min-w-0 space-y-2 border-b border-border/50 p-3"
aria-label="分组信息"
:inert="busy"
>
<div class="flex min-w-0 items-center gap-2">
<label class="min-w-0 flex-1">
<label class="block min-w-0 flex-1">
<span class="sr-only">策略名称</span>
<Input
v-model="draft.name"
@@ -126,6 +132,40 @@
/>
</label>
</div>
<div class="flex min-w-0 items-center justify-between gap-3">
<label class="flex min-w-0 items-center gap-2 text-xs">
<span class="shrink-0">分组倍率</span>
<Input
:model-value="billingMultiplierInput"
type="number"
min="0"
step="any"
size="sm"
class="w-24 min-w-0"
aria-label="分组倍率"
:aria-invalid="Boolean(billingMultiplierError)"
:aria-describedby="billingMultiplierError ? 'group-billing-multiplier-error' : undefined"
:disabled="busy"
@update:model-value="updateBillingMultiplier"
/>
<span class="shrink-0 text-muted-foreground">倍</span>
</label>
<label class="flex shrink-0 items-center gap-1 text-xs">
<span>用户可见</span>
<Switch
v-model="draft.config_json.user_visible"
:disabled="busy"
aria-label="用户可见"
/>
</label>
</div>
<p
v-if="billingMultiplierError"
id="group-billing-multiplier-error"
class="text-xs text-destructive"
>
{{ billingMultiplierError }}
</p>
</div>
<RoutingSchedulingPolicyEditor
v-if="draft"
@@ -353,49 +393,6 @@
<slot />
</div>
</div>
<Dialog
:model-value="createDialogOpen"
title="新建策略分组"
description="创建独立分组,创建成功后切换到新分组。"
size="md"
:persistent="busy"
@update:model-value="closeCreate"
>
<div
class="space-y-4"
:inert="busy"
>
<label class="block space-y-1.5 text-sm"><span>分组名称</span><Input
v-model="createForm.name"
aria-label="新分组名称"
placeholder="例如:日常使用"
/></label>
<label class="flex items-center justify-between gap-3 text-sm"><span>启用分组</span><Switch
v-model="createForm.enabled"
aria-label="启用新分组"
/></label>
<p class="text-xs text-muted-foreground">
使用默认调度配置;创建后可在提供商目录中调整成员和顺序。
</p>
</div>
<template #footer>
<Button
:disabled="busy || !createForm.name.trim()"
aria-label="创建策略分组"
@click="createGroup"
>
{{ creating ? '创建中…' : '创建分组' }}
</Button>
<Button
variant="outline"
:disabled="busy"
aria-label="取消新建分组"
@click="closeCreate(false)"
>
取消
</Button>
</template>
</Dialog>
<AlertDialog
v-model="deleteDialogOpen"
type="destructive"
@@ -409,16 +406,17 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter, type RouteLocationNormalized } from 'vue-router'
import { ChevronRight, Plus, Save, Star, Trash2 } from 'lucide-vue-next'
import { Button, Card, Dialog, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch } from '@/components/ui'
import { Button, Card, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch } from '@/components/ui'
import { AlertDialog } from '@/components/common'
import HelpHint from '@/components/common/HelpHint.vue'
import {
DEFAULT_STICKY_KEY_ATTEMPTS,
createEmptyRoutingGroupConfig,
normalizeStickyKeyAttempts,
parseBillingMultiplier,
type RoutingModelPolicy,
type RoutingPriorityMode,
type RoutingSchedulingMode,
@@ -484,10 +482,8 @@ const loadingError = ref<string | null>(null)
const saving = ref(false)
const saveConflict = ref(false)
const deleting = ref(false)
const creating = ref(false)
const busy = computed(() => loading.value || saving.value || deleting.value || creating.value)
const createDialogOpen = ref(false)
const createForm = ref({ name: '', enabled: true })
const busy = computed(() => loading.value || saving.value || deleting.value)
const billingMultiplierInput = ref('1')
const draftGeneration = ref(0)
const groupMetadata = ref<HTMLElement | null>(null)
const advancedOpen = ref(false)
@@ -498,7 +494,8 @@ let discardConfirmation: Promise<boolean> | null = null
const routeGroupId = computed(() => queryToString(route.query.group))
const defaultGroupId = computed(() => groups.value.find(group => group.is_system_default)?.id ?? groups.value[0]?.id ?? null)
const selectedValue = computed(() => draft.value?.id ?? '')
const isNewDraft = computed(() => draft.value != null && !draft.value.id)
const selectedValue = computed(() => isNewDraft.value ? 'new' : draft.value?.id ?? '')
const priorityMode = 'provider' as const
const schedulingMode = computed(() => activePolicy.value?.schedulingMode ?? draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity')
const emptyMessage = computed(() => loading.value ? '正在加载调度策略' : loadingError.value ?? (groups.value.length ? '未找到调度策略' : '还没有调度策略'))
@@ -507,8 +504,9 @@ const stickyKeyAttempts = computed(() => draft.value?.config_json.default_policy
const cfHeartbeat = computed(() => draft.value?.config_json.default_policy.enable_cf_heartbeat ?? false)
const cyberContinueFailover = computed(() => draft.value?.config_json.default_policy.cyber_continue_failover ?? false)
const cancelOnClientDisconnect = computed(() => draft.value?.config_json.default_policy.cancel_on_client_disconnect ?? false)
const draftDirty = computed(() => draft.value != null && (routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)))
const canSaveDraft = computed(() => Boolean(draft.value) && !busy.value && draftDirty.value && routingSchedulingValid.value)
const billingMultiplierError = computed(() => parseBillingMultiplier(billingMultiplierInput.value) == null ? '分组倍率必须是大于或等于 0 的有效数字' : null)
const draftDirty = computed(() => draft.value != null && (Boolean(billingMultiplierError.value) || routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)))
const canSaveDraft = computed(() => Boolean(draft.value) && !busy.value && draftDirty.value && routingSchedulingValid.value && !billingMultiplierError.value)
function queryToString(value: unknown): string | null {
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : null
@@ -545,22 +543,31 @@ function resetEditors(): void {
}
function selectGroup(group: RoutingGroupRecord, preserveSelection = false): void {
const selection = preserveSelection && draft.value?.id === group.id ? activePolicy.value : null
const selection = preserveSelection ? activePolicy.value : null
resetEditors()
initialSchedulingSelection.value = selection
? { id: selection.id, scope: selection.scope, modelNames: [...selection.modelNames] }
: null
draft.value = { id: group.id, version: group.version, name: group.name, enabled: group.enabled, is_system_default: group.is_system_default, config_json: cloneConfig(group.config_json) }
billingMultiplierInput.value = String(draft.value.config_json.billing_multiplier)
savedDraftSnapshot.value = draftSnapshotValue(draft.value)
}
function openCreate(): void {
if (busy.value) return
createForm.value = { name: '', enabled: true }
createDialogOpen.value = true
if (busy.value || isNewDraft.value) return
openGroup('new')
}
function closeCreate(value: boolean): void { if (!busy.value) createDialogOpen.value = value }
function startNewDraft(): void {
resetEditors()
draft.value = { version: 0, name: '', enabled: true, is_system_default: groups.value.length === 0, config_json: createEmptyRoutingGroupConfig() }
billingMultiplierInput.value = '1'
savedDraftSnapshot.value = null
void nextTick(() => {
groupMetadata.value?.scrollIntoView?.({ block: 'nearest' })
groupMetadata.value?.querySelector<HTMLInputElement>('[aria-label="策略名称"]')?.focus()
})
}
function clearDraft(): void {
resetEditors()
@@ -571,10 +578,7 @@ function clearDraft(): void {
function syncRouteState(): void {
if (loading.value) return
if (routeGroupId.value === 'new') {
if (!draft.value) { const group = groups.value.find(item => item.id === defaultGroupId.value); if (group) selectGroup(group) }
openCreate()
internalNavigation = true
void router.replace({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: draft.value?.id } }).finally(() => { internalNavigation = false })
if (!isNewDraft.value) startNewDraft()
return
}
const group = groups.value.find(item => item.id === (routeGroupId.value ?? defaultGroupId.value))
@@ -603,7 +607,7 @@ async function confirmDiscard(): Promise<boolean> {
async function guardNavigation(to: RouteLocationNormalized): Promise<boolean> {
if (internalNavigation) return true
const targetGroup = queryToString(to.query.group) ?? defaultGroupId.value
const staysOnDraft = to.name === 'ProviderManagement' && (targetGroup === selectedValue.value || targetGroup === 'new')
const staysOnDraft = to.name === 'ProviderManagement' && targetGroup === selectedValue.value
if (staysOnDraft) return true
if (busy.value) {
showError('正在保存调度设置,请稍候再切换')
@@ -625,6 +629,13 @@ function updateDraftConfig(value: RoutingGroupConfig): void {
if (draft.value) draft.value.config_json = normalizeProviderSchedulingConfig(value)
}
function updateBillingMultiplier(value: string | number): void {
if (!draft.value || busy.value) return
billingMultiplierInput.value = String(value)
const parsed = parseBillingMultiplier(value)
if (parsed != null) draft.value.config_json.billing_multiplier = parsed
}
function updatePriorityPolicy(policy: RoutingModelPolicy): void {
if (busy.value) return
routingSchedulingPolicyEditor.value?.updateSelectedPolicy(policy)
@@ -700,7 +711,7 @@ async function loadGlobalModels(options: { cacheTtlMs?: number } = {}): Promise<
}
async function saveDraft(): Promise<boolean> {
if (!draft.value?.id || busy.value) return false
if (!draft.value || busy.value) return false
const name = draft.value.name.trim()
if (!name) {
groupMetadata.value?.scrollIntoView?.({ block: 'nearest' })
@@ -708,6 +719,12 @@ async function saveDraft(): Promise<boolean> {
showError('策略名称不能为空')
return false
}
if (billingMultiplierError.value) {
groupMetadata.value?.scrollIntoView?.({ block: 'nearest' })
groupMetadata.value?.querySelector<HTMLInputElement>('[aria-label="分组倍率"]')?.focus()
showError(billingMultiplierError.value)
return false
}
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) { failoverOpen.value = true; return false }
const failoverError = validateRoutingFailoverPolicy(draft.value.config_json.default_policy)
if (failoverError) { failoverOpen.value = true; showError(failoverError); return false }
@@ -715,19 +732,33 @@ async function saveDraft(): Promise<boolean> {
const targetGroupId = draft.value.id
const submittedGeneration = draftGeneration.value
const submittedSnapshot = draftSnapshotValue(draft.value)
const payload = { name, enabled: draft.value.enabled, is_system_default: draft.value.is_system_default, expected_version: draft.value.version, config_json: cloneConfig(draft.value.config_json) }
const payload = { name, enabled: draft.value.enabled, is_system_default: draft.value.is_system_default, config_json: cloneConfig(draft.value.config_json) }
const expectedVersion = draft.value.version
saving.value = true
try {
const saved = await updateRoutingGroup(targetGroupId, payload)
const saved = targetGroupId
? await updateRoutingGroup(targetGroupId, { ...payload, expected_version: expectedVersion })
: await createRoutingGroup({ ...payload, sort_order: groups.value.length })
const unchanged = draftGeneration.value === submittedGeneration && draft.value?.id === targetGroupId && draftSnapshotValue(draft.value) === submittedSnapshot
replaceGroup(saved, unchanged, true)
success('调度策略已保存')
if (!targetGroupId) {
// Retain any newer edits while attaching the server ID, so retries update the created group.
if (!unchanged && draft.value && draftGeneration.value === submittedGeneration && !draft.value.id) {
draft.value.id = saved.id
draft.value.version = saved.version
savedDraftSnapshot.value = draftSnapshotValue({ ...saved, config_json: cloneConfig(saved.config_json) })
}
internalNavigation = true
try { await router.replace({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: saved.id } }) }
finally { internalNavigation = false }
}
success(targetGroupId ? '调度策略已保存' : '策略分组已创建')
emit('saved')
return unchanged
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status
saveConflict.value = status === 409
showError(status === 409 ? '此分组已在其他操作中更新。当前修改已保留,请重新加载最新分组后再编辑。' : parseApiError(err, '保存调度策略失败'))
saveConflict.value = Boolean(targetGroupId) && status === 409
showError(saveConflict.value ? '此分组已在其他操作中更新。当前修改已保留,请重新加载最新分组后再编辑。' : parseApiError(err, targetGroupId ? '保存调度策略失败' : '创建策略分组失败'))
log.error('保存调度策略失败:', err)
return false
} finally { saving.value = false }
@@ -738,22 +769,6 @@ async function ensureSaved(): Promise<boolean> {
const approved = await confirm({ title: '先保存当前分组', message: '当前分组有未保存的修改。保存后继续?', confirmText: '保存并继续', cancelText: '继续编辑', variant: 'question' })
return approved && await saveDraft()
}
async function createGroup(): Promise<void> {
if (busy.value || !createForm.value.name.trim()) return
if (draftDirty.value && !await ensureSaved()) return
creating.value = true
try {
const saved = await createRoutingGroup({ name: createForm.value.name.trim(), enabled: createForm.value.enabled, is_system_default: groups.value.length === 0, sort_order: groups.value.length, config_json: createEmptyRoutingGroupConfig() })
replaceGroup(saved, true)
createDialogOpen.value = false
internalNavigation = true
try { await router.replace({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: saved.id } }) }
finally { internalNavigation = false }
success('策略分组已创建')
emit('saved')
} catch (err) { showError(parseApiError(err, '创建策略分组失败')); log.error('创建策略分组失败:', err) }
finally { creating.value = false }
}
async function confirmDeleteDraft(): Promise<void> {
if (!draft.value?.id || busy.value) return
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type LocationQueryRaw } from 'vue-router'
import ProviderSchedulingView from '../ProviderSchedulingView.vue'
import { createEmptyRoutingGroupConfig, type RoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import { createEmptyRoutingGroupConfig, setDefaultProviderPriorityOverrides, type RoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
const routingApi = vi.hoisted(() => ({ listRoutingGroups: vi.fn(), updateRoutingGroup: vi.fn(), createRoutingGroup: vi.fn(), deleteRoutingGroup: vi.fn() }))
@@ -101,6 +101,12 @@ async function editName(root: HTMLElement, name: string) {
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
async function editMultiplier(root: HTMLElement, value: string, label = '分组倍率') {
const input = element<HTMLInputElement>(root, `[aria-label="${label}"]`)
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
@@ -116,6 +122,116 @@ afterEach(() => {
})
describe('ProviderSchedulingView workspace navigation', () => {
it('defaults legacy groups to private and saves visibility without losing scheduling or rankings', async () => {
const legacyConfig = createEmptyRoutingGroupConfig()
Reflect.deleteProperty(legacyConfig, 'user_visible')
const { root, workspace } = await mountWorkspace({}, [group('default', { is_system_default: true, config_json: legacyConfig })])
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('false')
expect(button(root, '保存调度').disabled).toBe(true)
button(root, '用户可见').click()
await nextTick()
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('true')
expect(button(root, '启用策略').getAttribute('aria-checked')).toBe('true')
expect(button(root, '保存调度').disabled).toBe(false)
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
const config = setDefaultProviderPriorityOverrides(
JSON.parse(JSON.stringify(contextChange.mock.lastCall?.[0].config)),
{ 'provider-a': 4, 'provider-b': 1 },
)
config.default_policy.scheduling_mode = 'fixed_order'
config.disabled_providers = ['provider-c']
workspace.value?.updateDraftConfig(config)
await nextTick()
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('true')
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenLastCalledWith('default', expect.objectContaining({
enabled: true,
config_json: { ...config, user_visible: true },
}))
expect(button(root, '保存调度').disabled).toBe(true)
button(root, '用户可见').click()
await nextTick()
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenLastCalledWith('default', expect.objectContaining({
config_json: { ...config, user_visible: false },
}))
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('false')
expect(button(root, '保存调度').disabled).toBe(true)
})
it('defaults old groups to a multiplier of one and saves decimal or zero values with scheduling changes', async () => {
const legacyConfig = createEmptyRoutingGroupConfig()
Reflect.deleteProperty(legacyConfig, 'billing_multiplier')
const { root, workspace } = await mountWorkspace({}, [group('default', { is_system_default: true, config_json: legacyConfig })])
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1')
await editMultiplier(root, '1.25')
const config = JSON.parse(JSON.stringify(contextChange.mock.lastCall?.[0].config))
config.disabled_providers = ['provider-a']
workspace.value?.updateDraftConfig(config)
await nextTick()
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenLastCalledWith('default', expect.objectContaining({ config_json: expect.objectContaining({ billing_multiplier: 1.25, disabled_providers: ['provider-a'] }) }))
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1.25')
expect(button(root, '保存调度').disabled).toBe(true)
await editMultiplier(root, '0')
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenLastCalledWith('default', expect.objectContaining({ config_json: expect.objectContaining({ billing_multiplier: 0 }) }))
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('0')
})
it('keeps invalid multiplier input unsaved across scheduling changes and cancelled navigation', async () => {
const { root, router, workspace } = await mountWorkspace()
await editMultiplier(root, '2')
await editMultiplier(root, '')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('')
expect(element(root, '[aria-label="分组倍率"]').getAttribute('aria-invalid')).toBe('true')
expect(button(root, '保存调度').disabled).toBe(true)
const config = JSON.parse(JSON.stringify(contextChange.mock.lastCall?.[0].config))
config.disabled_providers = ['provider-a']
workspace.value?.updateDraftConfig(config)
await nextTick()
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('')
expect(contextChange.mock.lastCall?.[0].config.billing_multiplier).toBe(2)
confirm.mockResolvedValueOnce(true)
expect(await workspace.value?.ensureSaved()).toBe(false)
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('分组倍率必须是大于或等于 0 的有效数字')
await chooseGroup(root, 'last')
expect(router.currentRoute.value.query.group).toBeUndefined()
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('')
await editMultiplier(root, '-1')
expect(button(root, '保存调度').disabled).toBe(true)
confirm.mockResolvedValue(true)
await chooseGroup(root, 'last')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1')
expect(button(root, '保存调度').disabled).toBe(true)
})
it('validates new group multipliers without replacing blank input and accepts zero', async () => {
const { root, router } = await mountWorkspace({ group: 'new' })
expect(router.currentRoute.value.query.group).toBe('new')
expect(selector(root).textContent?.trim()).toBe('新建策略')
expect(document.querySelector('[role="dialog"]')).toBeNull()
await editName(root, '免费分组')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1')
await editMultiplier(root, '')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('')
expect(button(root, '保存调度').disabled).toBe(true)
button(root, '保存调度').click()
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
await editMultiplier(root, '0')
expect(button(root, '保存调度').disabled).toBe(false)
button(root, '保存调度').click()
await flush()
expect(routingApi.createRoutingGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '免费分组', config_json: expect.objectContaining({ billing_multiplier: 0 }) }))
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('0')
expect(router.currentRoute.value.query.group).toBe('created')
})
it('opens the system default immediately and forwards provider inspection and refreshes', async () => {
const { root, providerRevision } = await mountWorkspace()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
@@ -245,42 +361,151 @@ describe('ProviderSchedulingView workspace navigation', () => {
expect(router.currentRoute.value.query.group).toBeUndefined()
})
it('opens creation independently and cancellation preserves the selected group and draft', async () => {
it('asks before replacing existing edits with a new inline group draft', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '保留的分组草稿')
button(root, '新建策略').click()
await nextTick()
await flush()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
expect(router.currentRoute.value.query.group).toBeUndefined()
expect(document.querySelector('[role="dialog"][aria-label="新建策略分组"]')).not.toBeNull()
button(root, '取消新建分组').click()
await nextTick()
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(button(root, '保存调度').disabled).toBe(false)
expect(contextChange.mock.lastCall?.[0].groupName).toBe('保留的分组草稿')
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
expect(confirm).not.toHaveBeenCalled()
expect(confirm).toHaveBeenCalledOnce()
confirm.mockResolvedValue(true)
button(root, '新建策略').click()
await flush()
expect(router.currentRoute.value.query.group).toBe('new')
expect(selector(root).textContent?.trim()).toBe('新建策略')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('')
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
})
it('creates in a dialog and switches only after the request succeeds', async () => {
const { root, router } = await mountWorkspace({ group: 'new' })
expect(selector(root).textContent?.trim()).toBe('default · 默认')
expect(router.currentRoute.value.query.group).toBe('default')
const name = element<HTMLInputElement>(root, '[aria-label="新分组名称"]')
name.value = '新策略'
name.dispatchEvent(new Event('input', { bubbles: true }))
it('starts an inline draft and creates the complete edited configuration only on header save', async () => {
const { root, router, workspace } = await mountWorkspace()
button(root, '新建策略').click()
await flush()
expect(router.currentRoute.value.query.group).toBe('new')
expect(selector(root).textContent?.trim()).toBe('新建策略')
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(root.querySelector('button[aria-label="删除策略"]')).toBeNull()
expect(root.querySelector('[data-testid="provider-directory"]')).not.toBeNull()
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1')
expect(button(root, '启用策略').getAttribute('aria-checked')).toBe('true')
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('false')
expect(button(root, '设为系统默认').getAttribute('aria-pressed')).toBe('false')
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
await editName(root, '新策略')
await editMultiplier(root, '1.25')
button(root, '启用策略').click()
button(root, '用户可见').click()
await nextTick()
const config = JSON.parse(JSON.stringify(contextChange.mock.lastCall?.[0].config)) as RoutingGroupConfig
config.disabled_providers = ['provider-a']
config.default_policy.scheduling_mode = 'fixed_order'
config.default_policy.sticky_key_attempts = 3
workspace.value?.updateDraftConfig(config)
await nextTick()
button(root, '新建策略').click()
await flush()
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('新策略')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1.25')
expect(button(root, '启用策略').getAttribute('aria-checked')).toBe('false')
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('true')
expect(config.user_visible).toBe(true)
expect(contextChange.mock.lastCall?.[0].config).toEqual(config)
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
let finish!: (group: RoutingGroupRecord) => void
routingApi.createRoutingGroup.mockReturnValue(new Promise(resolve => { finish = resolve }))
button(root, '创建策略分组').click()
button(root, '保存调度').click()
await nextTick()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
finish(group('created', { name: '新策略' }))
expect(router.currentRoute.value.query.group).toBe('new')
expect(selector(root).textContent?.trim()).toBe('新建策略')
expect(button(root, '保存调度').disabled).toBe(true)
button(root, '保存调度').click()
expect(routingApi.createRoutingGroup).toHaveBeenCalledOnce()
expect(routingApi.createRoutingGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '新策略', enabled: false, is_system_default: false, config_json: config }))
finish(group('created', { name: '新策略', enabled: false, config_json: config }))
await flush()
expect(routingApi.createRoutingGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '新策略', config_json: expect.objectContaining({ disabled_providers: [] }) }))
expect(router.currentRoute.value.query.group).toBe('created')
expect(selector(root).textContent?.trim()).toBe('新策略')
expect(selector(root).textContent?.trim()).toBe('新策略 · 停用')
expect(button(root, '保存调度').disabled).toBe(true)
expect(confirm).not.toHaveBeenCalled()
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
})
it.each([false, true])('opens a new draft from its deep link with first-group default=%s', async firstGroup => {
const { root, router } = await mountWorkspace({ group: 'new' }, firstGroup ? [] : [group('existing')])
expect(router.currentRoute.value.query.group).toBe('new')
expect(selector(root).textContent?.trim()).toBe('新建策略')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('1')
expect(button(root, '启用策略').getAttribute('aria-checked')).toBe('true')
expect(button(root, '用户可见').getAttribute('aria-checked')).toBe('false')
expect(button(root, '设为系统默认').getAttribute('aria-pressed')).toBe(String(firstGroup))
expect(root.querySelector('button[aria-label="删除策略"]')).toBeNull()
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
await editName(root, '深链新建')
button(root, '保存调度').click()
await flush()
expect(routingApi.createRoutingGroup).toHaveBeenCalledWith(expect.objectContaining({
name: '深链新建',
enabled: true,
is_system_default: firstGroup,
config_json: expect.objectContaining({ billing_multiplier: 1, user_visible: false }),
}))
expect(router.currentRoute.value.query.group).toBe('created')
})
it.each([500, 409])('keeps a failed new draft intact and retries the same create payload after status %s', async status => {
const { root, router } = await mountWorkspace({ group: 'new' })
await editName(root, '重试创建')
await editMultiplier(root, '0.75')
routingApi.createRoutingGroup.mockRejectedValueOnce({ response: { status } })
button(root, '保存调度').click()
await flush()
expect(router.currentRoute.value.query.group).toBe('new')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('重试创建')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('0.75')
expect(button(root, '保存调度').disabled).toBe(false)
expect(toast.error).toHaveBeenCalled()
expect(root.querySelector('button[aria-label="重新加载分组"]')).toBeNull()
const failedPayload = routingApi.createRoutingGroup.mock.calls[0][0]
button(root, '保存调度').click()
await flush()
expect(routingApi.createRoutingGroup).toHaveBeenCalledTimes(2)
expect(routingApi.createRoutingGroup).toHaveBeenLastCalledWith(failedPayload)
expect(router.currentRoute.value.query.group).toBe('created')
expect(button(root, '保存调度').disabled).toBe(true)
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
})
it.each(['switch', 'leave'] as const)('confirms discarding a new draft before %s navigation', async navigation => {
const { root, router } = await mountWorkspace({ group: 'new' })
await editName(root, '保留新建草稿')
await editMultiplier(root, '2')
const navigate = () => navigation === 'switch'
? chooseGroup(root, 'last')
: router.push({ name: 'Other' })
await navigate()
expect(confirm).toHaveBeenCalledOnce()
expect(router.currentRoute.value.name).toBe('ProviderManagement')
expect(router.currentRoute.value.query.group).toBe('new')
expect(selector(root).textContent?.trim()).toBe('新建策略')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('保留新建草稿')
expect(element<HTMLInputElement>(root, '[aria-label="分组倍率"]').value).toBe('2')
confirm.mockResolvedValue(true)
await navigate()
expect(router.currentRoute.value.name).toBe(navigation === 'switch' ? 'ProviderManagement' : 'Other')
if (navigation === 'switch') {
expect(router.currentRoute.value.query.group).toBe('last')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('last')
}
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
})
it('saves strategy metadata with scheduling and updates the default marker', async () => {
@@ -150,16 +150,17 @@ describe('RoutingFailoverPolicyEditor', () => {
expect(editor.value?.commitJsonDrafts()).toBe(false)
})
it('edits independent global budgets and documents sticky retry exclusion', async () => {
it('edits independent global transfer budgets', async () => {
const { root, policy } = mountEditor()
expect(root.textContent).toContain('首次尝试和粘性同 Key 重试不计入')
expect(root.textContent).toContain('不会中断已开始的调用')
const count = control<HTMLInputElement>(root, '全局最大转移次数')
count.value = '4'
count.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(policy.value.max_transfer_count).toBe(4)
expect(policy.value.max_transfer_timeout_seconds).toBe(0)
await input(control<HTMLInputElement>(root, '全局最大转移时间'), '15')
expect(policy.value.max_transfer_count).toBe(4)
expect(policy.value.max_transfer_timeout_seconds).toBe(15)
})
it('adds regex and status-only rules and reports invalid drafts', async () => {
@@ -29,7 +29,7 @@ function mountEditor(options: { modelIds?: string[], keyMode?: boolean } = {}) {
initial.model_policies = [{
...getDefaultModelPolicy(initial),
provider_priority_overrides: { outside: 99 },
key_priority_overrides_by_format: { 'openai:chat': { outside: 99 }, 'claude:chat': { 'claude-key': 45 } },
key_priority_overrides_by_format: { 'openai:chat': { outside: 99 }, 'claude:messages': { 'claude-key': 45 } },
pool_priority_overrides: { 'other-pool': 88 },
}]
const config = ref(initial)
@@ -183,6 +183,20 @@ describe('RoutingPriorityPolicyEditor ordering', () => {
expect(rows.every(row => !row.textContent?.includes('停用'))).toBe(true)
})
it('shows the selected model policy membership while retaining legacy exclusions as defaults', async () => {
const { root, config } = mountEditor()
config.value.disabled_providers = ['A', 'C']
config.value.model_policies[0].provider_enabled_overrides = { A: true, B: false }
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
const rows = [...root.querySelectorAll<HTMLElement>('[draggable="true"]')]
expect(rows.find(row => row.textContent?.includes('提供商 A'))?.textContent).not.toContain('本组禁用')
expect(rows.find(row => row.textContent?.includes('提供商 B'))?.textContent).toContain('本组禁用')
expect(rows.find(row => row.textContent?.includes('提供商 C'))?.textContent).toContain('本组禁用')
await click(root, '置顶 提供商 C')
expect(getDefaultModelPolicy(config.value).provider_enabled_overrides).toEqual({ A: true, B: false })
expect(config.value.disabled_providers).toEqual(['A', 'C'])
})
it('emits provider inspection and refreshes health and keys without altering the draft', async () => {
const { root, config, revision, inspect } = mountEditor()
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
@@ -83,8 +83,8 @@ async function clickText(root: HTMLElement, text: string) {
}
async function select(root: HTMLElement, model: string) {
await openModels(root)
control<HTMLInputElement>(root, `选择模型 ${model}`).click()
const picker = await openModels(root)
control<HTMLInputElement>(picker, `选择模型 ${model}`).click()
await flush()
}
@@ -98,9 +98,19 @@ async function openModels(root: HTMLElement) {
if (control(root, '全部模型').getAttribute('aria-pressed') === 'true') {
await clickText(root, '区分模型')
}
if (root.querySelector('[aria-label="全局模型选择列表"]')) return
const activeCard = root.querySelector('[aria-label^="选择调度配置 "][aria-pressed="true"]')?.closest('section')
const edit = activeCard?.querySelector<HTMLButtonElement>('[aria-label="编辑模型"]')
if (edit) {
if (!document.querySelector('[aria-label="编辑适用模型"]')) {
edit.click()
await flush()
}
return control(document.body, '编辑适用模型')
}
if (root.querySelector('[aria-label="全局模型选择列表"]')) return root
control<HTMLButtonElement>(root, '选择适用模型').click()
await flush()
return root
}
beforeEach(() => {
@@ -208,7 +218,7 @@ describe('RoutingSchedulingPolicyEditor', () => {
const { root, config, selection } = mountEditor(writeSchedulingPolicies(initial, [selected, fallback]), 'config-only')
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull()
expect(control(root, '调度配置 2').querySelector('[aria-label="编辑模型"]')).toBeNull()
expect(control(root, '当前配置的适用模型').textContent).toContain('未单独指定的模型')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'all', modelNames: [], schedulingMode: 'cache_affinity' }))
await clickText(root, '负载均衡')
@@ -216,8 +226,8 @@ describe('RoutingSchedulingPolicyEditor', () => {
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
control<HTMLButtonElement>(root, '选择调度配置 1').click()
await flush()
expect(root.querySelectorAll('[aria-label="选择适用模型"]')).toHaveLength(1)
expect(control(root, '选择适用模型').textContent).toContain('模型 A')
expect(root.querySelectorAll('[aria-label="编辑模型"]')).toHaveLength(1)
expect(control(root, '已配置模型').textContent).toContain('模型 A')
})
it('expands only the selected model editor and keeps every shared ranking attached to its configuration', async () => {
@@ -225,17 +235,33 @@ describe('RoutingSchedulingPolicyEditor', () => {
const first = { ...createSchedulingPolicy(initial), models: ['model-a', 'model-c'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, config, selection, editor } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only')
expect(root.querySelectorAll('[aria-label="选择适用模型"]')).toHaveLength(1)
expect(root.querySelector('button[aria-label="选择适用模型"]')).toBeNull()
expect(control(root, '调度配置 1').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(root, '调度配置 2').querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(root.querySelectorAll('[aria-label="编辑模型"]')).toHaveLength(2)
for (const index of [1, 2]) {
const card = control(root, `调度配置 ${index}`)
const edit = control<HTMLButtonElement>(card, '编辑模型')
expect(edit.textContent?.trim()).toBe('')
expect(edit.querySelector('svg')).not.toBeNull()
expect(edit.compareDocumentPosition(control(card, `删除调度配置 ${index}`)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
expect(control(root, '调度配置 2').querySelector('[aria-label="当前配置的适用模型"]')).toBeNull()
expect(document.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(control(root, '已配置模型').querySelector('[title="model-a"]')?.textContent).toBe('模型 A')
expect(control(root, '已配置模型').querySelector('[title="model-c"]')?.textContent).toBe('模型 C')
expect(control(root, '选择调度配置 1').textContent).toContain('模型 A +1')
expect(root.querySelector('[aria-label="调整排序"]')).toBeNull()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: first.id, modelNames: ['model-a', 'model-c'], schedulingMode: 'fixed_order' }))
control<HTMLButtonElement>(root, '选择调度配置 2').click()
const secondEdit = control<HTMLButtonElement>(control(root, '调度配置 2'), '编辑模型')
secondEdit.click()
await flush()
expect(control(root, '调度配置 2').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(root, '调度配置 1').querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(control(root, '选择调度配置 2').getAttribute('aria-expanded')).toBe('true')
expect(control(root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '调度配置 1').querySelector('[aria-label="当前配置的适用模型"]')).toBeNull()
expect(control<HTMLInputElement>(control(document.body, '编辑适用模型'), '选择模型 model-b').checked).toBe(true)
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'], schedulingMode: 'load_balance' }))
control<HTMLButtonElement>(control(document.body, '编辑适用模型'), '完成选择').click()
await flush()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
await vi.waitFor(() => expect(document.activeElement).toBe(secondEdit))
const policy = { ...getDefaultModelPolicy(initial), provider_priority_overrides: { provider: 6 } }
editor.value!.updateSelectedPolicy(policy)
await flush()
@@ -247,7 +273,7 @@ describe('RoutingSchedulingPolicyEditor', () => {
await flush()
expect(getModelPolicy(config.value, 'model-a').provider_priority_overrides).toEqual({ shared: 3 })
expect(getModelPolicy(config.value, 'model-c').provider_priority_overrides).toEqual({ shared: 3 })
expect(root.querySelectorAll('[aria-label="选择适用模型"]')).toHaveLength(1)
expect(root.querySelectorAll('[aria-label="编辑模型"]')).toHaveLength(2)
control<HTMLButtonElement>(root, '删除调度配置 1').click()
await flush()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'] }))
@@ -271,12 +297,14 @@ describe('RoutingSchedulingPolicyEditor', () => {
const { root, selection, config, editor } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only')
const firstButton = control<HTMLButtonElement>(root, '选择调度配置 1')
expect(firstButton.getAttribute('aria-expanded')).toBe('true')
await openModels(root)
selection.mockClear()
firstButton.click()
await flush()
expect(firstButton.getAttribute('aria-expanded')).toBe('false')
expect(firstButton.getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
expect(root.querySelectorAll('[aria-label="编辑模型"]')).toHaveLength(2)
expect(root.querySelector('[aria-label="调度策略"]')).toBeNull()
expect(selection).not.toHaveBeenCalled()
editor.value!.updateSelectedPolicy({ ...getDefaultModelPolicy(initial), provider_priority_overrides: { provider: 8 } })
@@ -289,7 +317,7 @@ describe('RoutingSchedulingPolicyEditor', () => {
firstButton.click()
await flush()
expect(firstButton.getAttribute('aria-expanded')).toBe('true')
expect(control(root, '调度配置 1').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(root, '调度配置 1').contains(control(root, '编辑模型'))).toBe(true)
expect(control(root, '调度配置 1').contains(control(root, '调度策略'))).toBe(true)
firstButton.click()
await flush()
@@ -302,8 +330,9 @@ describe('RoutingSchedulingPolicyEditor', () => {
await flush()
expect(control(root, '选择调度配置 3').getAttribute('aria-expanded')).toBe('true')
expect(control(root, '选择调度配置 3').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '调度配置 3').contains(control(root, '搜索全局模型'))).toBe(true)
expect(root.querySelectorAll('[aria-label="全局模型选择列表"]')).toHaveLength(1)
expect(control(control(root, '调度配置 3'), '编辑模型')).toBeTruthy()
expect(control(root, '当前配置的适用模型').textContent).toContain('请选择适用模型')
expect(document.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ policy: null, modelNames: [] }))
})
@@ -316,7 +345,7 @@ describe('RoutingSchedulingPolicyEditor', () => {
id: 'previous-generated-id', scope: 'selected', modelNames: ['model-c', 'model-b'],
})
expect(control(root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '调度配置 2').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(control(root, '调度配置 2'), '编辑模型')).toBeTruthy()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ modelNames: ['model-b', 'model-c'] }))
const legacy = writeSchedulingPolicies(initial, [first, createSchedulingPolicy(initial, 'all')])
@@ -335,12 +364,12 @@ describe('RoutingSchedulingPolicyEditor', () => {
const secondCard = control<HTMLElement>(root, '调度配置 2')
expect(firstCard.contains(control(root, '调度策略'))).toBe(true)
expect(secondCard.querySelector('[aria-label="调度策略"]')).toBeNull()
expect(control(firstCard, '全局模型选择列表').compareDocumentPosition(control(firstCard, '调度策略')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(control(firstCard, '当前配置的适用模型').compareDocumentPosition(control(firstCard, '调度策略')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
control<HTMLButtonElement>(secondCard, '选择调度配置 2').click()
await flush()
expect(firstCard.querySelector('[aria-label="调度策略"]')).toBeNull()
const secondStrategy = control(secondCard, '调度策略')
expect(control(secondCard, '全局模型选择列表').compareDocumentPosition(secondStrategy) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(control(secondCard, '当前配置的适用模型').compareDocumentPosition(secondStrategy) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect([...secondStrategy.querySelectorAll('button')].find(button => button.textContent?.trim() === '负载均衡')?.getAttribute('aria-pressed')).toBe('true')
await clickText(secondStrategy, '缓存亲和')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'], priorityMode: 'provider' }))
@@ -352,44 +381,86 @@ describe('RoutingSchedulingPolicyEditor', () => {
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('cache_affinity')
control<HTMLButtonElement>(secondCard, '选择调度配置 2').click()
await flush()
control<HTMLInputElement>(root, '选择模型 model-c').click()
await flush()
await select(root, 'model-c')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b', 'model-c'] }))
})
it('searches and adds models inside the active card without carrying its search or edits to another configuration', async () => {
it('keeps live model edits when finishing or escaping and isolates the next configuration picker', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, config } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only', {}, true)
const firstCard = control(root, '调度配置 1')
const search = control<HTMLInputElement>(firstCard, '搜索全局模型')
const { root, config, selection } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only', {}, true)
const picker = await openModels(root)
expect(picker.querySelector('[aria-label="清空已选"]')).toBeNull()
expect(picker.textContent).not.toMatch(/已选\s*\d/)
const search = control<HTMLInputElement>(picker, '搜索全局模型')
search.value = '模型 C'
search.dispatchEvent(new Event('input', { bubbles: true }))
await flush()
control<HTMLInputElement>(firstCard, '选择模型 model-c').click()
control<HTMLInputElement>(picker, '选择模型 model-c').click()
await flush()
expect(getModelScheduling(config.value, 'model-c').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: first.id, modelNames: ['model-a', 'model-c'] }))
control<HTMLButtonElement>(picker, '完成选择').click()
await flush()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a', 'model-c'])
expect(control(root, '已配置模型').textContent).toContain('模型 C')
const reopened = await openModels(root)
expect(control<HTMLInputElement>(reopened, '选择模型 model-c').checked).toBe(true)
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(root.querySelectorAll('[aria-label="搜索全局模型"]')).toHaveLength(1)
const nextSearch = control<HTMLInputElement>(root, '搜索全局模型')
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'] }))
const nextPicker = await openModels(root)
expect(document.querySelectorAll('[aria-label="搜索全局模型"]')).toHaveLength(1)
const nextSearch = control<HTMLInputElement>(nextPicker, '搜索全局模型')
expect(nextSearch.value).toBe('')
expect(control<HTMLInputElement>(root, '选择模型 model-b').checked).toBe(true)
expect(control<HTMLInputElement>(nextPicker, '选择模型 model-b').checked).toBe(true)
nextSearch.value = 'model-c'
nextSearch.dispatchEvent(new Event('input', { bubbles: true }))
await flush()
expect(control<HTMLInputElement>(root, '选择模型 model-c').disabled).toBe(true)
expect(control(root, '全局模型选择列表').textContent).toContain('已用于配置 1')
expect(control<HTMLInputElement>(nextPicker, '选择模型 model-c').disabled).toBe(true)
expect(control(nextPicker, '全局模型选择列表').textContent).toContain('已用于配置 1')
nextSearch.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flush()
expect(root.querySelector('[aria-label="全局模型选择列表"]')).not.toBeNull()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
expect(readSchedulingPolicies(config.value).map(entry => entry.models)).toEqual([['model-a', 'model-c'], ['model-b']])
expect(control(root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
await vi.waitFor(() => expect(document.activeElement).toBe(control(control(root, '调度配置 2'), '编辑模型')))
const add = control<HTMLButtonElement>(root, '添加调度配置')
for (const card of root.querySelectorAll('section[aria-label^="调度配置 "]')) {
expect(card.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
expect(root.querySelector('[aria-label="完成选择"]')).toBeNull()
expect(document.querySelector('[aria-label="完成选择"]')).toBeNull()
})
it('keeps live model edits when Escape or saving closes the popover', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'] }
const { root, config, disabled, selection } = mountEditor(writeSchedulingPolicies(initial, [first]), 'config-only', {}, true)
await select(root, 'model-c')
const saved = JSON.stringify(config.value)
control(document.body, '搜索全局模型').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flush()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
expect(JSON.stringify(config.value)).toBe(saved)
await openModels(root)
selection.mockClear()
disabled.value = true
await flush()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
expect(control<HTMLButtonElement>(root, '编辑模型').disabled).toBe(true)
expect(control(root, '选择调度配置 1').getAttribute('aria-pressed')).toBe('true')
expect(JSON.stringify(config.value)).toBe(saved)
expect(selection).not.toHaveBeenCalled()
disabled.value = false
await flush()
expect(document.querySelector('[aria-label="编辑适用模型"]')).toBeNull()
const reopened = await openModels(root)
expect(control<HTMLInputElement>(reopened, '选择模型 model-a').checked).toBe(true)
expect(control<HTMLInputElement>(reopened, '选择模型 model-c').checked).toBe(true)
})
it('keeps a strategy chosen before model selection and lets unfinished configurations choose their strategy', async () => {
@@ -402,8 +473,7 @@ describe('RoutingSchedulingPolicyEditor', () => {
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
await clickText(root, '负载均衡')
control<HTMLInputElement>(root, '选择模型 model-b').click()
await flush()
await select(root, 'model-b')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(root.querySelectorAll('[aria-label="调度策略"]')).toHaveLength(1)
@@ -6,9 +6,11 @@ import {
createEmptyRoutingGroupConfig,
getDefaultModelPolicy,
getModelScheduling,
isRoutingProviderEnabled,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
normalizeStickyKeyAttempts,
parseBillingMultiplier,
resolveModelKeyPriorityOverride,
setDefaultPoolPriorityOverrides,
setDefaultProviderPriorityOverrides,
@@ -25,6 +27,27 @@ describe('routingPolicy', () => {
expect(config.default_policy.priority_mode).toBe('provider')
expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
expect(config.default_policy.cancel_on_client_disconnect).toBe(false)
expect(config.billing_multiplier).toBe(1)
expect(config.user_visible).toBe(false)
})
it('keeps new and legacy groups private unless user visibility is explicitly true', () => {
expect(createEmptyRoutingGroupConfig().user_visible).toBe(false)
expect(normalizeRoutingGroupConfig({ user_visible: true }).user_visible).toBe(true)
for (const user_visible of [undefined, null, false, 'true', 1]) {
expect(normalizeRoutingGroupConfig({ user_visible } as unknown as Parameters<typeof normalizeRoutingGroupConfig>[0]).user_visible).toBe(false)
}
})
it('preserves a nonnegative billing multiplier while defaulting legacy configs to one', () => {
expect(createEmptyRoutingGroupConfig().billing_multiplier).toBe(1)
expect(normalizeRoutingGroupConfig({ billing_multiplier: 0 }).billing_multiplier).toBe(0)
expect(normalizeRoutingGroupConfig({ billing_multiplier: 1.25 }).billing_multiplier).toBe(1.25)
expect(parseBillingMultiplier('0')).toBe(0)
expect(parseBillingMultiplier('1.25')).toBe(1.25)
for (const value of ['', ' ', '-0.1', 'Infinity', '1e309', Number.NaN, Infinity, null, undefined, true]) {
expect(parseBillingMultiplier(value)).toBeNull()
}
})
it('preserves cancellation policy across model scheduling edits', () => {
@@ -59,14 +82,43 @@ describe('routingPolicy', () => {
expect(next.model_policies[0].allowed_providers).toEqual(['provider-a'])
})
it('normalizes model membership independently and preserves explicit false overrides', () => {
const overrides = { enabled: true, disabled: false }
const config = normalizeRoutingGroupConfig({ model_policies: [
{ ...createEmptyModelPolicy('model-a'), provider_enabled_overrides: overrides },
{ model: 'legacy-model' } as ReturnType<typeof createEmptyModelPolicy>,
{ ...createEmptyModelPolicy('invalid-model'), provider_enabled_overrides: { '': true, valid: false, string: 'false' } as unknown as Record<string, boolean> },
] })
expect(config.model_policies[0].provider_enabled_overrides).toEqual(overrides)
expect(config.model_policies[0].provider_enabled_overrides).not.toBe(overrides)
expect(config.model_policies[1].provider_enabled_overrides).toEqual({})
expect(config.model_policies[2].provider_enabled_overrides).toEqual({ valid: false })
expect(createEmptyModelPolicy().provider_enabled_overrides).toEqual({})
})
it('resolves model membership before default membership and legacy group exclusions', () => {
const config = createEmptyRoutingGroupConfig()
config.disabled_providers = ['provider-a', 'provider-b']
config.model_policies = [{ ...createEmptyModelPolicy('*'), provider_enabled_overrides: { 'provider-a': true, 'provider-c': false } }]
const selected = { ...createEmptyModelPolicy('model-a'), provider_enabled_overrides: { 'provider-b': true, 'provider-c': true, 'provider-d': false } }
expect(isRoutingProviderEnabled(config, 'provider-a', selected)).toBe(true)
expect(isRoutingProviderEnabled(config, 'provider-b', selected)).toBe(true)
expect(isRoutingProviderEnabled(config, 'provider-c', selected)).toBe(true)
expect(isRoutingProviderEnabled(config, 'provider-d', selected)).toBe(false)
expect(isRoutingProviderEnabled(config, 'provider-b', createEmptyModelPolicy('model-b'))).toBe(false)
expect(isRoutingProviderEnabled(config, 'provider-c')).toBe(false)
expect(isRoutingProviderEnabled(config, 'provider-d')).toBe(true)
})
it('stores default priority overrides on the wildcard model policy', () => {
const config = upsertModelPolicy(createEmptyRoutingGroupConfig(), createEmptyModelPolicy('gpt-5'))
const config = upsertModelPolicy({ ...createEmptyRoutingGroupConfig(), user_visible: true }, createEmptyModelPolicy('gpt-5'))
const next = setDefaultProviderPriorityOverrides(config, {
'provider-a': 0,
'provider-b': 2,
})
const policy = getDefaultModelPolicy(next)
expect(next.user_visible).toBe(true)
expect(policy.model).toBe(DEFAULT_ROUTING_POLICY_MODEL)
expect(next.model_policies.map(item => item.model)).toEqual([DEFAULT_ROUTING_POLICY_MODEL, 'gpt-5'])
expect(policy.provider_priority_overrides).toEqual({
@@ -5,6 +5,7 @@ import {
getDefaultModelPolicy,
getModelPolicy,
getModelScheduling,
isRoutingProviderEnabled,
modelSchedulingRuleId,
setDefaultProviderPriorityOverrides,
setModelKeyPriorityOverridesForFormat,
@@ -22,6 +23,23 @@ import {
} from '../utils/schedulingPolicies'
describe('strategy-scoped scheduling policies', () => {
it.each([false, true])('preserves user visibility %s and billing multipliers through scheduling changes and provider editor projections', userVisible => {
for (const multiplier of [0, 1.25]) {
const config = { ...createEmptyRoutingGroupConfig(), billing_multiplier: multiplier, user_visible: userVisible }
const entry = createSchedulingPolicy(config, 'selected')
entry.models = ['gpt-5']
entry.schedulingMode = 'fixed_order'
entry.policy.provider_priority_overrides = { provider: 2 }
const updated = writeSchedulingPolicies(config, [entry])
expect(updated.billing_multiplier).toBe(multiplier)
expect(updated.user_visible).toBe(userVisible)
expect(normalizeProviderSchedulingConfig(updated).billing_multiplier).toBe(multiplier)
expect(normalizeProviderSchedulingConfig(updated).user_visible).toBe(userVisible)
expect(schedulingPolicyEditorConfig(updated, readSchedulingPolicies(updated)[0]).billing_multiplier).toBe(multiplier)
expect(schedulingPolicyEditorConfig(updated, readSchedulingPolicies(updated)[0]).user_visible).toBe(userVisible)
}
})
it('normalizes legacy Key scheduling without mutating unrelated rules, scopes, or historical priorities', () => {
const config = createEmptyRoutingGroupConfig()
config.default_policy.priority_mode = 'global_key'
@@ -94,6 +112,7 @@ describe('strategy-scoped scheduling policies', () => {
entry.policy = {
...entry.policy,
allowed_providers: ['provider-a'],
provider_enabled_overrides: { 'provider-a': false, 'provider-b': true },
provider_priority_overrides: { 'provider-a': 2 },
key_priority_overrides_by_format: { 'openai:chat': { 'key-a': 1 } },
pool_priority_overrides: { 'pool-a': 3 },
@@ -126,6 +145,45 @@ describe('strategy-scoped scheduling policies', () => {
expect(config.disabled_providers).toEqual(['disabled-provider'])
})
it('preserves independent model membership through serialization without changing group defaults', () => {
const config = createEmptyRoutingGroupConfig()
config.disabled_providers = ['legacy-disabled']
const first = { ...createSchedulingPolicy(config), models: ['model-a', 'model-b'] }
first.policy.provider_enabled_overrides = { provider: false, 'legacy-disabled': true }
const second = { ...createSchedulingPolicy(config), models: ['model-c'] }
const fallback = createSchedulingPolicy(config, 'all')
fallback.policy.provider_enabled_overrides = { 'default-disabled': false }
const saved = writeSchedulingPolicies(config, [first, second, fallback])
const reloaded = readSchedulingPolicies(JSON.parse(JSON.stringify(saved)))
expect(reloaded).toHaveLength(3)
expect(reloaded[0].policy.provider_enabled_overrides).toEqual(first.policy.provider_enabled_overrides)
expect(reloaded[1].policy.provider_enabled_overrides).toEqual({})
expect(saved.disabled_providers).toEqual(['legacy-disabled'])
expect(getDefaultModelPolicy(saved).provider_enabled_overrides).toEqual({ 'default-disabled': false })
for (const model of ['model-a', 'model-b']) {
expect(getModelPolicy(saved, model).provider_enabled_overrides).toEqual(first.policy.provider_enabled_overrides)
}
const firstEditor = schedulingPolicyEditorConfig(saved, reloaded[0])
const secondEditor = schedulingPolicyEditorConfig(saved, reloaded[1])
expect(isRoutingProviderEnabled(firstEditor, 'provider')).toBe(false)
expect(isRoutingProviderEnabled(secondEditor, 'provider')).toBe(true)
expect(isRoutingProviderEnabled(firstEditor, 'legacy-disabled')).toBe(true)
expect(isRoutingProviderEnabled(secondEditor, 'legacy-disabled')).toBe(false)
expect(isRoutingProviderEnabled(firstEditor, 'default-disabled')).toBe(false)
expect(getDefaultModelPolicy(firstEditor).provider_enabled_overrides).toEqual(first.policy.provider_enabled_overrides)
getDefaultModelPolicy(firstEditor).provider_enabled_overrides.provider = true
expect(getModelPolicy(saved, 'model-a').provider_enabled_overrides.provider).toBe(false)
})
it('does not merge legacy models with different membership into one configuration', () => {
const config = createEmptyRoutingGroupConfig()
config.model_policies = [
{ ...createEmptyModelPolicy('model-a'), provider_enabled_overrides: { provider: false } },
{ ...createEmptyModelPolicy('model-b'), provider_enabled_overrides: { provider: true } },
]
expect(readSchedulingPolicies(config).map(entry => entry.models)).toEqual([['model-a'], ['model-b']])
})
it('retains separate strategies even when their settings are identical', () => {
const config = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(config), models: ['model-a'] }
@@ -0,0 +1,103 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground"
:disabled="disabled"
aria-label="编辑模型"
title="编辑模型"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
:side-offset="6"
:collision-padding="12"
class="w-[min(22rem,calc(100vw-1.5rem))] overflow-hidden p-0"
aria-label="编辑适用模型"
@open-auto-focus.prevent="focusSearch"
>
<div class="flex items-center justify-between gap-2 px-3 pt-2 text-xs font-medium">
<span>适用模型</span>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground"
aria-label="关闭模型选择"
@click="open = false"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<RoutingModelSelector
ref="selector"
inline
compact
narrow
:model-value="modelValue"
:models="models"
:assigned-models="assignedModels"
:loading="loading"
:error="error"
:disabled="disabled"
@update:model-value="emit('update:modelValue', $event)"
@reload="emit('reload')"
@close="open = false"
/>
<div class="flex justify-end border-t border-border/50 px-2 py-1.5">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
aria-label="完成选择"
@click="open = false"
>
完成
</Button>
</div>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Pencil, X } from 'lucide-vue-next'
import { Button, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingModelSelector from './RoutingModelSelector.vue'
const props = defineProps<{
modelValue: string[]
models: GlobalModelResponse[]
assignedModels: Record<string, number>
loading?: boolean
error?: string | null
disabled?: boolean
open: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [models: string[]]
reload: []
'update:open': [open: boolean]
}>()
const open = computed({
get: () => props.open,
set: value => emit('update:open', value),
})
const selector = ref<InstanceType<typeof RoutingModelSelector> | null>(null)
watch(() => props.disabled, disabled => { if (disabled) open.value = false })
async function focusSearch(): Promise<void> {
await nextTick()
selector.value?.focusSearch()
}
</script>
@@ -217,6 +217,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [models: string[]]
reload: []
close: []
}>()
const trigger = ref<HTMLButtonElement | null>(null)
@@ -255,15 +256,24 @@ async function openModels(): Promise<void> {
if (props.disabled) return
open.value = true
await nextTick()
searchInput.value?.inputRef?.focus({ preventScroll: true })
focusSearch()
}
function closeModels(): void {
if (props.inline) return
if (props.inline) {
emit('close')
return
}
open.value = false
trigger.value?.focus({ preventScroll: true })
}
function focusSearch(): void {
searchInput.value?.inputRef?.focus({ preventScroll: true })
}
defineExpose({ focusSearch })
function modelLabel(name: string): string {
return props.models.find(model => model.name === name)?.display_name || name
}
@@ -167,7 +167,7 @@
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>停用</span>
<span
v-if="config.disabled_providers.includes(row.id)"
v-if="!isRoutingProviderEnabled(config, row.id, targetModelPolicy)"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>本组禁用</span>
</div>
@@ -214,6 +214,7 @@ import {
DEFAULT_ROUTING_POLICY_MODEL,
getDefaultModelPolicy,
getModelPolicy,
isRoutingProviderEnabled,
setModelProviderPriorityOverrides,
type RoutingDefaultPolicy,
type RoutingGroupConfig,
@@ -132,19 +132,19 @@
<div
role="group"
aria-label="模型调度配置"
class="space-y-1.5"
class="divide-y divide-border/60 border-b border-border/60"
>
<section
v-for="(entry, index) in entries"
:key="entry.id"
class="min-w-0 overflow-hidden rounded-md border transition-colors"
:class="selectedEntryId === entry.id ? 'border-primary/40 bg-primary/5' : 'border-border/60 bg-background'"
class="min-w-0"
:aria-label="`调度配置 ${index + 1}`"
>
<div class="flex min-w-0 items-center">
<button
type="button"
class="flex min-h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:opacity-50"
class="flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-md px-1 text-left text-xs hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:opacity-50"
:class="selectedEntryId === entry.id ? 'text-primary' : 'text-foreground'"
:aria-label="`选择调度配置 ${index + 1}`"
:aria-pressed="selectedEntryId === entry.id"
:aria-expanded="expandedId === entry.id"
@@ -152,7 +152,7 @@
@click="toggleEntry(entry.id)"
>
<ChevronRight
class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform"
class="h-3.5 w-3.5 shrink-0 transition-transform"
:class="expandedId === entry.id ? 'rotate-90' : ''"
/>
<span
@@ -161,6 +161,19 @@
>{{ scopeSummary(entry) }}</span>
<span class="shrink-0 text-muted-foreground">{{ schedulingModeLabel(entry.schedulingMode) }}</span>
</button>
<RoutingModelSelectionPopover
v-if="entry.scope === 'selected'"
:open="editingModelsId === entry.id"
:model-value="entry.models"
:models="globalModels"
:assigned-models="otherModelOwners(entry.id)"
:loading="loadingModels"
:error="modelsError"
:disabled="disabled"
@update:open="open => setModelEditorOpen(entry.id, open)"
@update:model-value="models => updateEntry(entry.id, { models })"
@reload="emit('reload-models')"
/>
<Button
v-if="entries.length > 1"
type="button"
@@ -178,32 +191,40 @@
v-if="selectedEntryId === entry.id && expandedId === entry.id"
role="region"
aria-label="当前配置的适用模型"
class="min-w-0 border-t border-border/50"
class="min-w-0 px-1 pb-2"
>
<RoutingModelSelector
<span
v-if="entry.scope === 'selected'"
compact
inline
:narrow="sidebar"
:model-value="entry.models"
:models="globalModels"
:assigned-models="otherModelOwners(entry.id)"
:loading="loadingModels"
:error="modelsError"
:disabled="disabled"
@update:model-value="models => updateEntry(entry.id, { models })"
@reload="emit('reload-models')"
/>
class="mb-1.5 block text-xs font-medium text-muted-foreground"
>适用模型</span>
<div
v-if="entry.models.length"
class="flex min-w-0 flex-wrap gap-1.5 py-1"
aria-label="已配置模型"
>
<span
v-for="name in entry.models"
:key="name"
class="max-w-full break-words rounded-md bg-muted/70 px-2 py-1 text-xs text-foreground [overflow-wrap:anywhere]"
:title="name"
>{{ modelDisplayName(name) }}</span>
</div>
<p
v-else-if="entry.scope === 'selected'"
class="py-2 text-xs leading-5 text-muted-foreground"
>
请选择适用模型
</p>
<p
v-else
class="px-2 py-2 text-xs leading-5 text-muted-foreground"
class="py-2 text-xs leading-5 text-muted-foreground"
>
此默认配置适用于未单独指定的模型,新增模型也会自动使用。
</p>
</div>
<div
v-if="selectedEntryId === entry.id && expandedId === entry.id"
class="min-w-0 space-y-1.5 border-t border-border/50 p-2"
class="min-w-0 space-y-1.5 px-1 pb-3 pt-1"
>
<div class="flex h-6 items-center gap-1 text-xs font-medium text-muted-foreground">
<span>调度策略</span>
@@ -421,6 +442,7 @@ import HelpHint from '@/components/common/HelpHint.vue'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingPriorityPolicyEditor from './RoutingPriorityPolicyEditor.vue'
import RoutingModelSelector from './RoutingModelSelector.vue'
import RoutingModelSelectionPopover from './RoutingModelSelectionPopover.vue'
import { getDefaultModelPolicy, normalizeRoutingGroupConfig, type RoutingGroupConfig, type RoutingModelPolicy, type RoutingPriorityMode, type RoutingSchedulingMode } from '../utils/routingPolicy'
import {
createSchedulingPolicy,
@@ -490,6 +512,7 @@ const fallbackScheduling = {
scheduling_mode: props.config.default_policy.scheduling_mode,
}
const expandedId = ref<string | null>(initialSelectedEntry?.id ?? null)
const editingModelsId = ref<string | null>(null)
const validationError = computed(() => validateSchedulingPolicies(entries.value))
const hasAllModels = computed(() => entries.value.some(entry => entry.scope === 'all'))
const assignedModels = computed(() => new Set(entries.value.filter(entry => entry.scope === 'selected').flatMap(entry => entry.models)))
@@ -535,6 +558,7 @@ function emitSelection(): void {
function toggleEntry(id: string): void {
if (props.disabled) return
editingModelsId.value = null
if (layout.value === 'config-only') {
if (selectedEntryId.value === id) expandedId.value = expandedId.value === id ? null : id
else selectEntry(id)
@@ -548,11 +572,22 @@ function toggleEntry(id: string): void {
function selectEntry(id: string): void {
if (props.disabled) return
editingModelsId.value = null
selectedEntryId.value = id
expandedId.value = id
emitSelection()
}
function setModelEditorOpen(id: string, open: boolean): void {
if (!open) {
if (editingModelsId.value === id) editingModelsId.value = null
return
}
if (props.disabled) return
selectEntry(id)
editingModelsId.value = id
}
function updateSelectedPolicy(policy: RoutingModelPolicy): void {
const entry = selectedEntry.value
if (!entry || props.disabled || entry.scope === 'selected' && !entry.models.length) return
@@ -571,10 +606,19 @@ function schedulingModeLabel(mode: RoutingSchedulingMode): string {
function scopeSummary(entry: SchedulingPolicy): string {
if (entry.scope === 'all') return '默认配置'
if (entry.models.length === 0) return '请选择适用模型'
if (layout.value === 'config-only') {
const first = entry.models[0] ?? ''
const label = props.globalModels.find(model => model.name === first)?.display_name || first
return label + (entry.models.length > 1 ? ` +${entry.models.length - 1}` : '')
}
const labels = entry.models.slice(0, 2).map(name => props.globalModels.find(model => model.name === name)?.display_name || name)
return labels.join('、') + (entry.models.length > 2 ? ` 等 ${entry.models.length} 个模型` : '')
}
function modelDisplayName(name: string): string {
return props.globalModels.find(model => model.name === name)?.display_name || name
}
function otherModelOwners(entryId: string): Record<string, number> {
return Object.fromEntries(entries.value.flatMap((entry, index) => entry.id !== entryId && entry.scope === 'selected'
? entry.models.map(model => [model, index + 1])
@@ -595,6 +639,7 @@ function publish(): void {
function setScopeMode(scope: SchedulingPolicy['scope']): void {
if (props.disabled || scopeMode.value === scope) return
editingModelsId.value = null
if (scopeMode.value === 'all') allModelsDraft = entries.value
else selectedModelsDraft = entries.value
@@ -636,6 +681,7 @@ function updateEntry(id: string, patch: Partial<SchedulingPolicy>): void {
function addEntry(): void {
if (!canAddEntry.value) return
editingModelsId.value = null
const entry = createSchedulingPolicy(props.config)
entries.value.push(entry)
selectedEntryId.value = entry.id
@@ -645,6 +691,7 @@ function addEntry(): void {
function removeEntry(id: string): void {
if (props.disabled || entries.value.length === 1) return
editingModelsId.value = null
entries.value = entries.value.filter(entry => entry.id !== id)
if (entries.value.every(entry => entry.scope === 'all')) {
scopeMode.value = 'all'
@@ -33,6 +33,7 @@ export interface RoutingModelPolicy {
model: string
allowed_providers: string[]
allowed_keys: string[]
provider_enabled_overrides: Record<string, boolean>
provider_priority_overrides: Record<string, number>
key_priority_overrides: Record<string, number>
/** api_format -> key_id -> priority;同一 Key 在不同 API 格式下可独立排序 */
@@ -65,6 +66,8 @@ export interface RoutingSetSchedulingAction {
}
export interface RoutingGroupConfig {
billing_multiplier: number
user_visible: boolean
disabled_providers: string[]
default_policy: RoutingDefaultPolicy
model_policies: RoutingModelPolicy[]
@@ -77,6 +80,8 @@ export const SCHEDULING_POLICY_RULE_PREFIX = 'ui_scheduling_policy:'
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
return {
billing_multiplier: 1,
user_visible: false,
default_policy: {
...normalizeRoutingFailoverPolicy(),
priority_mode: 'provider',
@@ -93,6 +98,13 @@ export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
}
}
export function parseBillingMultiplier(value: unknown): number | null {
if (typeof value !== 'number' && typeof value !== 'string') return null
if (typeof value === 'string' && !value.trim()) return null
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
}
export function normalizeStickyKeyAttempts(value: unknown): number {
const parsed = Math.trunc(Number(value))
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_STICKY_KEY_ATTEMPTS
@@ -104,6 +116,7 @@ export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
model,
allowed_providers: [],
allowed_keys: [],
provider_enabled_overrides: {},
provider_priority_overrides: {},
key_priority_overrides: {},
key_priority_overrides_by_format: {},
@@ -125,6 +138,8 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
} = rawDefaultPolicy
return {
billing_multiplier: parseBillingMultiplier(value?.billing_multiplier) ?? base.billing_multiplier,
user_visible: value?.user_visible === true,
default_policy: {
...base.default_policy,
...defaultPolicyWithoutLegacyHeartbeat,
@@ -145,6 +160,8 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
...policy,
allowed_providers: Array.isArray(policy.allowed_providers) ? [...policy.allowed_providers] : [],
allowed_keys: Array.isArray(policy.allowed_keys) ? [...policy.allowed_keys] : [],
provider_enabled_overrides: Object.fromEntries(Object.entries(policy.provider_enabled_overrides ?? {})
.filter(([id, enabled]) => id.length > 0 && typeof enabled === 'boolean')),
provider_priority_overrides: { ...(policy.provider_priority_overrides ?? {}) },
key_priority_overrides: { ...(policy.key_priority_overrides ?? {}) },
key_priority_overrides_by_format: normalizeKeyPriorityOverridesByFormat(
@@ -199,6 +216,16 @@ export function getModelPolicy(config: RoutingGroupConfig, model: string): Routi
?? createEmptyModelPolicy(normalizedModel)
}
export function isRoutingProviderEnabled(
config: RoutingGroupConfig,
providerId: string,
policy?: RoutingModelPolicy | null,
): boolean {
return policy?.provider_enabled_overrides?.[providerId]
?? getDefaultModelPolicy(config).provider_enabled_overrides[providerId]
?? !config.disabled_providers.includes(providerId)
}
export function upsertDefaultModelPolicy(
config: RoutingGroupConfig,
patch: Partial<Omit<RoutingModelPolicy, 'model'>>,
@@ -18,7 +18,9 @@ export interface RoutingCandidateTrace {
export interface RoutingDecisionTrace {
group_id?: string | null
group_name?: string | null
group_version?: number | null
billing_multiplier?: number | null
selection_source: string
selected_rules: string[]
original_model: string
@@ -2,6 +2,7 @@ import {
DEFAULT_ROUTING_POLICY_MODEL,
SCHEDULING_POLICY_RULE_PREFIX,
createEmptyModelPolicy,
getDefaultModelPolicy,
getModelPolicy,
getModelScheduling,
isGeneratedModelSchedulingRule,
@@ -202,8 +203,16 @@ export function writeSchedulingPolicies(config: RoutingGroupConfig, entries: Sch
}
export function schedulingPolicyEditorConfig(config: RoutingGroupConfig, entry: SchedulingPolicy): RoutingGroupConfig {
// Project inherited membership for display without copying it into the editable policy.
const disabledProviders = new Set(config.disabled_providers)
for (const [providerId, enabled] of Object.entries(getDefaultModelPolicy(config).provider_enabled_overrides)) {
if (enabled) disabledProviders.delete(providerId)
else disabledProviders.add(providerId)
}
return normalizeRoutingGroupConfig({
disabled_providers: config.disabled_providers,
billing_multiplier: config.billing_multiplier,
user_visible: config.user_visible,
disabled_providers: [...disabledProviders],
default_policy: {
...config.default_policy,
priority_mode: 'provider',
@@ -996,6 +996,10 @@ const emit = defineEmits<{
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
@@ -1319,6 +1323,10 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
cacheReadInputTokens: nextDetail.cache_read_input_tokens ?? null,
cost: detailTotalCost(nextDetail),
actualCost: nextDetail.actual_cost ?? null,
billingMultiplier: nextDetail.billing_multiplier,
billingCost: nextDetail.billing_cost,
routingGroupId: nextDetail.routing_group_id ?? null,
routingGroupName: nextDetail.routing_group_name ?? null,
responseTimeMs: nextDetail.response_time_ms ?? undefined,
firstByteTimeMs: nextDetail.first_byte_time_ms ?? null,
isStream: nextDetail.is_stream ?? null,
@@ -0,0 +1,59 @@
<template>
<div
class="flex flex-col items-end gap-0.5"
:class="compact ? 'text-[10px]' : 'text-xs'"
>
<template v-if="record.usage_available !== false && record.usage_pricing_available !== false">
<span
data-usage-cost="base"
class="text-primary"
:class="compact ? 'text-sm font-semibold leading-5' : 'font-medium'"
>{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="billing.cost !== null"
data-usage-cost="routing-group"
class="whitespace-nowrap text-muted-foreground"
title="实际扣费"
>{{ formatCurrency(billing.cost) }}</span>
<span
v-if="showKeyCost"
data-usage-cost="provider-key"
class="text-muted-foreground"
:title="`提供商 Key 成本(Key 倍率 ${record.rate_multiplier}×)`"
>{{ formatCurrency(record.actual_cost ?? 0) }}</span>
</template>
<span
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-muted-foreground"
:class="compact ? 'text-sm font-medium leading-5' : ''"
title="上游未提供可验证的 token/费用用量"
>不可用</span>
<span
v-else
data-usage-unpriced="cost"
class="text-muted-foreground"
:class="compact ? 'text-sm font-medium leading-5' : ''"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>未计价</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { UsageRecord } from '../types'
import { formatCurrency } from '@/utils/format'
import { resolveUsageBilling } from '../utils/usageBilling'
const props = defineProps<{
record: UsageRecord
showActualCost: boolean
compact?: boolean
}>()
const billing = computed(() => resolveUsageBilling(props.record))
const showKeyCost = computed(() => props.showActualCost
&& typeof props.record.actual_cost === 'number' && Number.isFinite(props.record.actual_cost)
&& typeof props.record.rate_multiplier === 'number' && Number.isFinite(props.record.rate_multiplier)
&& props.record.rate_multiplier >= 0 && props.record.rate_multiplier !== 1)
</script>
@@ -0,0 +1,24 @@
<template>
<div class="flex min-w-0 flex-col gap-0.5">
<span
data-usage-provider="routing-group"
class="truncate text-foreground"
:title="groupLabel"
>{{ groupLabel }}</span>
<span
data-usage-provider="provider-key"
class="truncate text-muted-foreground"
:title="providerKeyLabel"
>{{ providerKeyLabel }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { UsageRecord } from '../types'
const props = defineProps<{ record: UsageRecord }>()
const groupLabel = computed(() => props.record.routing_group_name?.trim()
|| props.record.routing_group_id?.trim() || '未记录分组')
const providerKeyLabel = computed(() => `${props.record.provider?.trim() || '-'} · ${props.record.provider_key_name?.trim() || '-'}`)
</script>
@@ -293,28 +293,12 @@
</Badge>
</div>
</div>
<div class="flex flex-col items-end flex-shrink-0">
<span
v-if="record.usage_available !== false && record.usage_pricing_available !== false"
class="text-sm text-primary font-semibold leading-5"
>{{ formatCurrency(record.cost || 0) }}</span>
<span
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-sm text-muted-foreground font-medium leading-5"
title="上游未提供可验证的 token/费用用量"
>不可用</span>
<span
v-else
data-usage-unpriced="cost"
class="text-sm text-muted-foreground font-medium leading-5"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>未计价</span>
<span
v-if="record.usage_available !== false && record.usage_pricing_available !== false && showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-[10px] text-muted-foreground"
>{{ formatCurrency(record.actual_cost) }}</span>
</div>
<UsageCostDisplay
:record="record"
:show-actual-cost="showActualCost"
compact
class="shrink-0"
/>
</div>
<!-- 第二行:时间 + API格式 -->
@@ -331,19 +315,22 @@
</template>
</div>
<!-- 第三行:用户 + 提供商 -->
<!-- 用户与上游提供商信息 -->
<div
v-if="isAdmin"
class="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] leading-3.5 text-muted-foreground"
class="mt-1 min-w-0 truncate text-[10px] leading-3.5 text-muted-foreground"
:title="formatRecordUserSegment(record)"
>
<span
class="min-w-0 truncate"
:title="formatRecordUserProviderLine(record)"
>
{{ formatRecordUserSegment(record) }}
</span>
<span class="shrink-0 text-muted-foreground/40">·</span>
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
{{ formatRecordUserSegment(record) }}
</div>
<div
v-if="isAdmin"
class="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] leading-3.5"
>
<UsageProviderDisplay
:record="record"
class="flex-1"
/>
<!-- 手机与桌面保持相同的标记优先级:发生故障转移时优先显示转移标记。 -->
<Shuffle
v-if="record.has_fallback"
@@ -774,20 +761,10 @@
class="py-4 w-[16%]"
>
<div class="flex min-w-0 items-center gap-1">
<div class="flex min-w-0 flex-col text-xs gap-0.5">
<span class="truncate">{{ record.provider }}</span>
<span
v-if="record.provider_key_name"
class="text-muted-foreground truncate"
:title="record.provider_key_name"
>
{{ record.provider_key_name }}
<span
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-foreground/60"
>({{ record.rate_multiplier }}x)</span>
</span>
</div>
<UsageProviderDisplay
:record="record"
class="text-xs"
/>
<Shuffle
v-if="record.has_fallback"
data-usage-attempt-marker="fallback"
@@ -974,34 +951,10 @@
v-if="isColumnVisible('cost')"
class="text-right py-4 w-[6%]"
>
<div
v-if="record.usage_available !== false && record.usage_pricing_available !== false"
class="flex flex-col items-end text-xs gap-0.5"
>
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-muted-foreground"
>
{{ formatCurrency(record.actual_cost) }}
</span>
</div>
<div
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-xs text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>
不可用
</div>
<div
v-else
data-usage-unpriced="cost"
class="text-xs text-muted-foreground"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>
未计价
</div>
<UsageCostDisplay
:record="record"
:show-actual-cost="showActualCost"
/>
</TableCell>
<TableCell
v-if="isColumnVisible('performance')"
@@ -1110,7 +1063,7 @@ import {
TableFilterMenu,
} from '@/components/ui'
import { Ban, EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { formatTokens } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatOutputRate,
@@ -1140,6 +1093,8 @@ import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
import UsageModelDisplay from './UsageModelDisplay.vue'
import UsageCostDisplay from './UsageCostDisplay.vue'
import UsageProviderDisplay from './UsageProviderDisplay.vue'
export interface UserOption {
id: string
@@ -1466,18 +1421,10 @@ function getRecordUserName(record: UsageRecord): string {
return record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')
}
function formatRecordUserProviderLine(record: UsageRecord): string {
return `${formatRecordUserSegment(record)} · ${formatRecordProviderSegment(record)}`
}
function formatRecordUserSegment(record: UsageRecord): string {
return `${getRecordUserName(record)} / ${record.api_key?.name || '-'}`
}
function formatRecordProviderSegment(record: UsageRecord): string {
return `${record.provider || '-'} / ${record.provider_key_name || '-'}`
}
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
cancelPendingSearchEmit()
@@ -113,6 +113,48 @@ function buildFastTierDetail(): RequestDetail {
}
describe('RequestDetailDrawer settlement pricing', () => {
it.each([
{ billingMultiplier: 0, billingCost: 0 },
{ billingMultiplier: undefined, billingCost: undefined },
{ billingMultiplier: 2, billingCost: null },
])('preserves zero, missing, and explicitly unavailable billing facts in list updates: %o', async ({ billingMultiplier, billingCost }) => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
billing_multiplier: billingMultiplier,
billing_cost: billingCost,
routing_group_id: 'group-1',
routing_group_name: '历史分组',
actual_cost: 0.000005,
} satisfies RequestDetail)
const updates = vi.fn()
let isOpen!: Ref<boolean>
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-embedding-1',
onRequestState: updates,
})
},
})
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => expect(updates).toHaveBeenCalledWith(expect.objectContaining({
id: 'usage-embedding-1',
cost: 0.00001,
actualCost: 0.000005,
billingMultiplier,
billingCost,
routingGroupId: 'group-1',
routingGroupName: '历史分组',
})))
})
it('labels an unmetered OpenAI Live WebSocket detail without rendering zero usage as billing', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
@@ -188,6 +188,100 @@ afterEach(() => {
})
describe('UsageRecordsTable', () => {
it.each([
[{ routing_group_name: '生产策略', routing_group_id: 'group-1' }, '生产策略'],
[{ routing_group_name: ' ', routing_group_id: 'group-history' }, 'group-history'],
[{}, '未记录分组'],
])('shows group then the provider Key in desktop and mobile layouts', (group, expected) => {
const root = mountUsageRecordsTable([buildRecord({
...group,
provider: '上游 A',
provider_key_name: '供应商 Key',
api_key: { id: 'user-key', name: '用户 API Key', display: 'sk-user' },
})])
expect([...root.querySelectorAll('[data-usage-provider="routing-group"]')].map(element => element.textContent?.trim()))
.toEqual([expected, expected])
const providerLines = [...root.querySelectorAll<HTMLElement>('[data-usage-provider="provider-key"]')]
expect(providerLines.map(element => element.textContent?.trim())).toEqual(['上游 A · 供应商 Key', '上游 A · 供应商 Key'])
for (const providerLine of providerLines) {
expect(providerLine.previousElementSibling?.getAttribute('data-usage-provider')).toBe('routing-group')
expect(providerLine.title).toBe('上游 A · 供应商 Key')
}
})
it('does not substitute a user API Key when the provider Key is unavailable', () => {
const root = mountUsageRecordsTable([buildRecord({
provider: '上游 A',
api_key: { id: 'user-key', name: '用户 API Key', display: 'sk-user' },
})])
expect([...root.querySelectorAll('[data-usage-provider="provider-key"]')].map(element => element.textContent?.trim()))
.toEqual(['上游 A · -', '上游 A · -'])
})
it.each([
[0, '$0.00'],
[1, '$10.00'],
[0.5, '$5.00'],
[2, '$20.00'],
[undefined, '$3.00'],
])('shows customer charges in both layouts for multiplier %s, including legacy charges', (multiplier, expected) => {
const root = mountUsageRecordsTable([buildRecord({
cost: 10,
actual_cost: 3,
rate_multiplier: 0.3,
billing_multiplier: multiplier as number | undefined,
})], { isAdmin: false })
const subtitles = [...root.querySelectorAll('[data-usage-cost="routing-group"]')]
expect(subtitles).toHaveLength(2)
expect(subtitles.map(element => element.textContent?.trim())).toEqual([expected, expected])
expect(subtitles.every(element => element.getAttribute('title') === '实际扣费')).toBe(true)
expect([...root.querySelectorAll('[data-usage-cost="base"]')].map(element => element.textContent?.trim())).toEqual(['$10.00', '$10.00'])
expect(root.querySelector('[data-usage-cost="provider-key"]')).toBeNull()
expect(root.querySelector('[data-usage-provider]')).toBeNull()
})
it('uses the historical customer charge independently of the administrator-only provider Key cost', () => {
const root = mountUsageRecordsTable([buildRecord({
cost: 10,
actual_cost: 3,
rate_multiplier: 0.3,
billing_multiplier: 2,
billing_cost: 19.99,
})], { showActualCost: true })
const subtitles = [...root.querySelectorAll('[data-usage-cost="routing-group"]')]
expect(subtitles.map(element => element.textContent?.trim())).toEqual(['$19.99', '$19.99'])
const keyCosts = [...root.querySelectorAll<HTMLElement>('[data-usage-cost="provider-key"]')]
expect(keyCosts.map(element => element.textContent?.trim())).toEqual(['$3.00', '$3.00'])
expect(keyCosts.every(element => element.title.includes('提供商 Key 成本'))).toBe(true)
})
it('does not replace an unavailable customer charge with the base or provider Key cost', () => {
const root = mountUsageRecordsTable([buildRecord({
cost: 10,
actual_cost: 3,
rate_multiplier: 0.3,
billing_multiplier: 2,
billing_cost: null,
})], { showActualCost: true })
expect(root.querySelector('[data-usage-cost="routing-group"]')).toBeNull()
expect([...root.querySelectorAll('[data-usage-cost="base"]')].map(element => element.textContent?.trim()))
.toEqual(['$10.00', '$10.00'])
expect([...root.querySelectorAll('[data-usage-cost="provider-key"]')].map(element => element.textContent?.trim()))
.toEqual(['$3.00', '$3.00'])
})
it.each(['usage_available', 'usage_pricing_available'] as const)('hides all numeric costs when %s is false', field => {
const root = mountUsageRecordsTable([buildRecord({
[field]: false,
actual_cost: 3,
rate_multiplier: 0.3,
billing_multiplier: 2,
billing_cost: 0.02,
})], { showActualCost: true })
expect(root.querySelector('[data-usage-cost]')).toBeNull()
expect(root.querySelectorAll(field === 'usage_available' ? '[data-usage-unavailable="cost"]' : '[data-usage-unpriced="cost"]')).toHaveLength(2)
})
it('shows output TPS after the request completes', () => {
const root = mountUsageRecordsTable([buildRecord()])
@@ -49,6 +49,7 @@ vi.mock('@/utils/logger', () => ({
import { useUsageData } from '../useUsageData'
import type { UsageRecord } from '../../types'
import { resolveUsageBilling } from '../../utils/usageBilling'
function buildUsageRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
return {
@@ -522,6 +523,47 @@ describe('useUsageData', () => {
})
})
it('preserves billing snapshots across sparse refreshes but accepts newer zero or unavailable amounts', async () => {
const { loadRecords, currentRecords } = useUsageData({ isAdminPage: ref(true) })
async function refresh(overrides: Partial<UsageRecord>) {
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord(overrides)], total: 1, limit: 20, offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 })
}
await refresh({ updated_at: '2026-05-01T00:00:02Z', billing_multiplier: 2, billing_cost: 0.02, routing_group_id: 'group-1', routing_group_name: '历史分组' })
await refresh({ updated_at: '2026-05-01T00:00:03Z' })
expect(currentRecords.value[0]).toMatchObject({ billing_multiplier: 2, billing_cost: 0.02, routing_group_id: 'group-1', routing_group_name: '历史分组' })
await refresh({ updated_at: '2026-05-01T00:00:01Z', billing_multiplier: 0, billing_cost: 0, routing_group_name: '过时分组' })
expect(currentRecords.value[0]).toMatchObject({ billing_multiplier: 2, billing_cost: 0.02, routing_group_id: 'group-1', routing_group_name: '历史分组' })
await refresh({ updated_at: '2026-05-01T00:00:04Z', billing_multiplier: 0, billing_cost: 0 })
expect(currentRecords.value[0]).toMatchObject({ cost: 0.01, billing_multiplier: 0, billing_cost: 0 })
await refresh({ updated_at: '2026-05-01T00:00:05Z', billing_multiplier: null, billing_cost: null })
expect(currentRecords.value[0]).toMatchObject({ billing_multiplier: 0, billing_cost: null })
await refresh({ updated_at: '2026-05-01T00:00:06Z', cost: 0.02, billing_multiplier: 2 })
expect(resolveUsageBilling(currentRecords.value[0])).toEqual({ multiplier: 2, cost: null })
})
it('recalculates a completed group cost when a mixed-version list omits the amount and resets costs for a changed group', async () => {
const { loadRecords, currentRecords } = useUsageData({ isAdminPage: ref(true) })
async function refresh(overrides: Partial<UsageRecord>) {
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord(overrides)], total: 1, limit: 20, offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 })
}
await refresh({ status: 'pending', cost: 0, routing_group_id: 'g1', billing_multiplier: 2, billing_cost: 0 })
await refresh({ status: 'completed', cost: 3, routing_group_id: 'g1', billing_multiplier: 2 })
expect(resolveUsageBilling(currentRecords.value[0])).toEqual({ cost: 6, multiplier: 2 })
await refresh({ status: 'completed', cost: 3, routing_group_id: 'g1', billing_multiplier: 2, billing_cost: 5.99999999 })
// A sparse list zero that the base-cost merger rejects must not erase the precise captured amount.
await refresh({ status: 'completed', cost: 0, routing_group_id: 'g1' })
expect(currentRecords.value[0].billing_cost).toBe(5.99999999)
await refresh({ status: 'completed', cost: 3, routing_group_id: 'g2' })
expect(resolveUsageBilling(currentRecords.value[0])).toEqual({ cost: null, multiplier: 1 })
expect(currentRecords.value[0].billing_cost).toBeNull()
})
it('refreshes exact admin record totals after an estimated first page', async () => {
const isAdminPage = ref(true)
const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
@@ -11,6 +11,7 @@ import type {
EnhancedModelStatsItem
} from '../types'
import { createDefaultStats } from '../types'
import { mergeUsageBillingSnapshot } from '../utils/usageBilling'
import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error'
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
@@ -610,8 +611,10 @@ export function useUsageData(options: UseUsageDataOptions) {
{ preferNext: nextTimingIsAuthoritative },
)
const mergedCost = mergeSparseRecordMetric(existing.cost, record.cost) ?? record.cost
return {
...record,
...mergeUsageBillingSnapshot(existing, { ...record, cost: mergedCost }, statusProgressed),
// 保留详情抽屉/活跃轮询已经拿到的完整指标,避免列表刷新用 0 或空值回退。
status: mergedStatus,
provider: statusProgressed
@@ -634,7 +637,7 @@ export function useUsageData(options: UseUsageDataOptions) {
record.cache_creation_ephemeral_1h_input_tokens
) ?? record.cache_creation_ephemeral_1h_input_tokens,
cache_read_input_tokens: mergeSparseRecordMetric(existing.cache_read_input_tokens, record.cache_read_input_tokens) ?? record.cache_read_input_tokens,
cost: mergeSparseRecordMetric(existing.cost, record.cost) ?? record.cost,
cost: mergedCost,
actual_cost: mergeSparseRecordMetric(existing.actual_cost, record.actual_cost) ?? record.actual_cost,
response_time_ms: responseTiming.response_time_ms,
first_byte_time_ms: mergeUsageRecordFirstByteTimeMs(
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { mergeUsageBillingSnapshot, resolveUsageBilling } from '../usageBilling'
describe('request billing snapshots', () => {
it.each([undefined, null, -1, NaN, Infinity])('uses the historical default for invalid or absent multiplier %s', multiplier => {
expect(resolveUsageBilling({ cost: 2, actual_cost: 0.5, billing_multiplier: multiplier }))
.toEqual({ cost: 0.5, multiplier: 1 })
})
it('does not invent a historical charge when actual_cost is unavailable', () => {
expect(resolveUsageBilling({ cost: 2 })).toEqual({ cost: null, multiplier: 1 })
expect(resolveUsageBilling({ cost: 2, actual_cost: 0 })).toEqual({ cost: 0, multiplier: 1 })
})
it.each([null, NaN, Infinity, -1])('does not recalculate an explicit unavailable billing cost %s', billingCost => {
expect(resolveUsageBilling({ cost: 2, actual_cost: 0.5, billing_multiplier: 3, billing_cost: billingCost }))
.toEqual({ cost: null, multiplier: 3 })
})
it('keeps an overflowed or unavailable base-cost product unknown', () => {
expect(resolveUsageBilling({ cost: Number.MAX_VALUE, billing_multiplier: 2 }))
.toEqual({ cost: null, multiplier: 2 })
expect(resolveUsageBilling({ cost: NaN, billing_multiplier: 0 }))
.toEqual({ cost: null, multiplier: 0 })
})
it('preserves a captured zero cost instead of recalculating it', () => {
expect(resolveUsageBilling({ cost: 2, billing_multiplier: 3, billing_cost: 0 }))
.toEqual({ cost: 0, multiplier: 3 })
})
it('keeps a snapshot across sparse or stale updates and accepts authoritative zeroes', () => {
const existing = { billing_multiplier: 2, billing_cost: 4 }
expect(mergeUsageBillingSnapshot(existing, {})).toEqual(existing)
expect(mergeUsageBillingSnapshot(existing, { billing_multiplier: null })).toEqual(existing)
const free = { billing_multiplier: 0, billing_cost: 0 }
expect(mergeUsageBillingSnapshot(existing, free, false)).toEqual(existing)
expect(mergeUsageBillingSnapshot(existing, free)).toEqual(free)
})
it.each([null, NaN, Infinity, -1])('retains an authoritative unavailable amount %s across sparse refreshes', billingCost => {
const unavailable = mergeUsageBillingSnapshot(
{ cost: 2, billing_multiplier: 2, billing_cost: 4 },
{ billing_cost: billingCost },
)
expect(unavailable.billing_cost).toBeNull()
const refreshed = mergeUsageBillingSnapshot({ ...unavailable, cost: 2 }, { cost: 3, billing_multiplier: 4 })
expect(resolveUsageBilling({ ...refreshed, cost: 3 })).toEqual({ cost: null, multiplier: 4 })
expect(mergeUsageBillingSnapshot(refreshed, { billing_cost: 12 }).billing_cost).toBe(12)
})
it('keeps historical names through sparse updates without attaching one group name to another ID', () => {
const existing = { routing_group_id: 'g1', routing_group_name: '历史分组', billing_multiplier: 2, billing_cost: 4 }
expect(mergeUsageBillingSnapshot(existing, {})).toEqual(existing)
expect(mergeUsageBillingSnapshot(existing, { routing_group_id: 'g2', routing_group_name: '新分组' }, false)).toEqual(existing)
const changedGroup = mergeUsageBillingSnapshot(existing, { routing_group_id: 'g2' })
expect(changedGroup).toEqual({
routing_group_id: 'g2', routing_group_name: undefined,
billing_multiplier: undefined, billing_cost: null,
})
expect(resolveUsageBilling({ ...changedGroup, cost: 2, actual_cost: 0.5 })).toEqual({ cost: null, multiplier: 1 })
const replacementSnapshot = mergeUsageBillingSnapshot(existing, {
routing_group_id: 'g2', billing_multiplier: 3, billing_cost: 6,
})
expect(resolveUsageBilling({ ...replacementSnapshot, cost: 2 })).toEqual({ cost: 6, multiplier: 3 })
})
it('recalculates fallback cost when a new multiplier arrives without its paired amount', () => {
const next = mergeUsageBillingSnapshot(
{ billing_multiplier: 2, billing_cost: 4 },
{ billing_multiplier: 0 },
)
expect(resolveUsageBilling({ ...next, cost: 2 })).toEqual({ cost: 0, multiplier: 0 })
const previouslyMissingMultiplier = mergeUsageBillingSnapshot(
{ billing_cost: 4 },
{ billing_multiplier: 0 },
)
expect(resolveUsageBilling({ ...previouslyMissingMultiplier, cost: 2 })).toEqual({ cost: 0, multiplier: 0 })
})
it('does not reuse a paired amount when another billing factor changes within the same routing group', () => {
const updated = mergeUsageBillingSnapshot(
{ cost: 2, routing_group_id: 'g1', billing_multiplier: 2, billing_cost: 4 },
{ routing_group_id: 'g1', billing_multiplier: 6 },
)
expect(resolveUsageBilling({ ...updated, cost: 2 })).toEqual({ cost: 12, multiplier: 6 })
})
it('invalidates a pending amount when a trusted base cost advances without a paired amount', () => {
const pending = { cost: 0, routing_group_id: 'g1', billing_multiplier: 2, billing_cost: 0 }
const completed = mergeUsageBillingSnapshot(pending, { cost: 3, routing_group_id: 'g1', billing_multiplier: 2 })
expect(resolveUsageBilling({ ...completed, cost: 3 })).toEqual({ cost: 6, multiplier: 2 })
const stale = mergeUsageBillingSnapshot(pending, { cost: 3 }, false)
expect(stale.billing_cost).toBe(0)
const paired = mergeUsageBillingSnapshot(pending, { cost: 3, billing_cost: 5.99999999 })
expect(paired.billing_cost).toBe(5.99999999)
})
})
@@ -0,0 +1,68 @@
import type { UsageRecord } from '@/api/usageRecords'
export type UsageBillingSnapshot = Pick<
UsageRecord,
'billing_multiplier' | 'billing_cost' | 'routing_group_id' | 'routing_group_name'
>
function nonnegativeFinite(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
}
export function resolveUsageBilling(record: UsageBillingSnapshot & { cost: number, actual_cost?: number | null }) {
const capturedMultiplier = nonnegativeFinite(record.billing_multiplier)
const multiplier = capturedMultiplier ?? 1
// A present billing_cost is authoritative, including null (for example when
// the server rejected an overflowed product). Never replace it with a guess.
if (record.billing_cost !== undefined) {
const capturedCost = nonnegativeFinite(record.billing_cost)
return { multiplier, cost: capturedCost ?? null }
}
if (capturedMultiplier !== undefined) {
const baseCost = nonnegativeFinite(record.cost)
const derivedCost = baseCost === undefined ? null : baseCost * multiplier
return { multiplier, cost: derivedCost !== null && Number.isFinite(derivedCost) ? derivedCost : null }
}
// Before group snapshots were introduced, actual_cost was the only captured
// charge. Falling back to the catalogue/base cost would misstate old records.
const historicalCost = nonnegativeFinite(record.actual_cost)
return { multiplier, cost: historicalCost ?? null }
}
/** Request snapshots survive sparse updates; an accepted zero is a real value. */
export function mergeUsageBillingSnapshot(
existing: UsageBillingSnapshot & { cost?: number | null },
next: UsageBillingSnapshot & { cost?: number | null },
acceptNext = true,
): UsageBillingSnapshot {
const groupId = (acceptNext ? next.routing_group_id?.trim() : undefined) || existing.routing_group_id
const sameGroup = !groupId || !existing.routing_group_id || groupId === existing.routing_group_id
const nextMultiplier = acceptNext ? nonnegativeFinite(next.billing_multiplier) : undefined
const existingMultiplier = sameGroup ? nonnegativeFinite(existing.billing_multiplier) : undefined
const multiplierChanged = nextMultiplier !== undefined && nextMultiplier !== (existingMultiplier ?? 1)
const nextBaseCost = acceptNext ? nonnegativeFinite(next.cost) : undefined
const baseCostChanged = nextBaseCost !== undefined && nextBaseCost !== nonnegativeFinite(existing.cost)
const nextCapturedCost = acceptNext && next.billing_cost !== undefined
? (nonnegativeFinite(next.billing_cost) ?? null)
: undefined
const existingCapturedCost = existing.billing_cost !== undefined
? (nonnegativeFinite(existing.billing_cost) ?? null)
: undefined
return {
routing_group_id: groupId,
routing_group_name: (acceptNext ? next.routing_group_name?.trim() : undefined)
|| (sameGroup ? existing.routing_group_name : undefined),
billing_multiplier: nextMultiplier ?? existingMultiplier,
billing_cost: nextCapturedCost !== undefined
? nextCapturedCost
: !sameGroup && nextMultiplier === undefined
? null
: sameGroup && (existingCapturedCost === null || (!multiplierChanged && !baseCostChanged))
? existingCapturedCost
: undefined,
}
}
+77 -6
View File
@@ -727,6 +727,7 @@ interface MockManagedUserApiKey {
is_locked: boolean
is_standalone: false
feature_settings?: Record<string, unknown> | null
routing_group_id?: string | null
rate_limit?: number | null
concurrent_limit?: number | null
ip_rules?: string[] | null
@@ -735,6 +736,30 @@ interface MockManagedUserApiKey {
force_capabilities?: Record<string, unknown> | null
}
const mockSelfUserApiKeys: MockManagedUserApiKey[] = MOCK_USER_API_KEYS.map((key, index) => ({
...key,
fullKey: `sk-ae-demo-user-${index + 1}`,
is_locked: false,
is_standalone: false,
routing_group_id: null,
}))
function mockSelfUserApiKeyPayload(key: MockManagedUserApiKey) {
return {
...publicMockManagedUserApiKey(key),
routing_group_id: key.routing_group_id ?? null,
routing_group_name: MOCK_ROUTING_GROUPS.find(group => group.id === key.routing_group_id)?.name ?? null,
}
}
function mockSelectableRoutingGroupId(value: unknown): string | null {
if (value == null) return null
const group = MOCK_ROUTING_GROUPS.find(group => group.id === value
&& group.enabled && group.config_json.user_visible === true)
if (!group) throw { response: createMockResponse({ detail: '该策略分组不可选择' }, 403) }
return group.id
}
const mockManagedUserApiKeysByUserId = new Map<string, MockManagedUserApiKey[]>([
[MOCK_NORMAL_USER.id ?? '', MOCK_USER_API_KEYS.map((key, index) => ({
...key,
@@ -1418,7 +1443,19 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'GET /api/users/me/api-keys': async () => {
await delay()
return createMockResponse(MOCK_USER_API_KEYS)
return createMockResponse(mockSelfUserApiKeys.map(mockSelfUserApiKeyPayload))
},
'GET /api/users/me/routing-groups': async () => {
await delay()
const items = MOCK_ROUTING_GROUPS.filter(group => group.enabled && group.config_json.user_visible === true)
.map(group => ({
id: group.id,
name: group.name,
billing_multiplier: group.config_json.billing_multiplier ?? 1,
is_default: group.is_system_default,
}))
return createMockResponse({ items, total: items.length })
},
'GET /api/users/me/client-config': async () => {
@@ -1435,18 +1472,25 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'POST /api/users/me/api-keys': async (config) => {
await delay()
const body = JSON.parse(config.data || '{}')
const newKey = {
const newKey: MockManagedUserApiKey = {
id: `key-demo-${Date.now()}`,
key: `sk-aether-demo-${Math.random().toString(36).substring(2, 15)}`,
fullKey: `sk-aether-demo-${Math.random().toString(36).substring(2, 15)}`,
key_display: 'sk-ae...demo',
name: body.name || '新密钥(演示)',
created_at: new Date().toISOString(),
is_active: true,
is_locked: false,
is_standalone: false,
routing_group_id: mockSelectableRoutingGroupId(body.routing_group_id),
feature_settings: body.feature_settings ?? null,
rate_limit: body.rate_limit ?? 0,
concurrent_limit: body.concurrent_limit ?? null,
ip_rules: body.ip_rules ?? null,
total_requests: 0,
total_cost_usd: 0
}
return createMockResponse(newKey)
mockSelfUserApiKeys.unshift(newKey)
return createMockResponse({ ...mockSelfUserApiKeyPayload(newKey), key: newKey.fullKey })
},
'GET /api/users/me/usage': async () => {
@@ -3990,13 +4034,40 @@ registerDynamicRoute('DELETE', '/api/admin/api-keys/:keyId', async (_config, par
return createMockResponse({ message: '删除成功(演示模式)' })
})
registerDynamicRoute('GET', '/api/users/me/api-keys/:keyId', async (config, params) => {
await delay()
const key = mockSelfUserApiKeys.find(key => key.id === params.keyId)
if (!key) throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
return createMockResponse(config.params?.include_key
? { key: key.fullKey }
: mockSelfUserApiKeyPayload(key))
})
registerDynamicRoute('PUT', '/api/users/me/api-keys/:keyId', async (config, params) => {
await delay()
const key = mockSelfUserApiKeys.find(key => key.id === params.keyId)
if (!key) throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
const body = mockRequestObject(config)
const groupId = 'routing_group_id' in body && body.routing_group_id !== key.routing_group_id
? mockSelectableRoutingGroupId(body.routing_group_id)
: key.routing_group_id
if (typeof body.name === 'string') key.name = body.name
if (typeof body.rate_limit === 'number') key.rate_limit = body.rate_limit
if (typeof body.concurrent_limit === 'number') key.concurrent_limit = body.concurrent_limit
if ('ip_rules' in body) key.ip_rules = body.ip_rules as string[] | null
if ('feature_settings' in body) key.feature_settings = body.feature_settings as Record<string, unknown> | null
key.routing_group_id = groupId
return createMockResponse({ ...mockSelfUserApiKeyPayload(key), message: 'API密钥已更新' })
})
// 用户 API Key 删除
registerDynamicRoute('DELETE', '/api/users/me/api-keys/:keyId', async (_config, params) => {
await delay()
const key = MOCK_USER_API_KEYS.find(k => k.id === params.keyId)
if (!key) {
const index = mockSelfUserApiKeys.findIndex(key => key.id === params.keyId)
if (index < 0) {
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
}
mockSelfUserApiKeys.splice(index, 1)
return createMockResponse({ message: '删除成功(演示模式)' })
})
+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' }))
})
})