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