mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
feat(routing): consolidate scheduling strategy configuration
This commit is contained in:
@@ -13,6 +13,7 @@ export interface RoutingGroupRecord {
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
is_system_default: boolean
|
||||
sort_order: number
|
||||
config_json: RoutingGroupConfig
|
||||
version: number
|
||||
created_at: number
|
||||
@@ -61,6 +62,7 @@ export interface RoutingGroupCreateRequest {
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
is_system_default?: boolean
|
||||
sort_order?: number
|
||||
config_json?: RoutingGroupConfig
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ export interface RoutingGroupUpdateRequest {
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
is_system_default?: boolean
|
||||
sort_order?: number
|
||||
config_json?: RoutingGroupConfig
|
||||
version?: number
|
||||
published_at?: number | null
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { CircleHelp } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="group relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="`${props.label}说明`"
|
||||
:aria-expanded="open"
|
||||
:title="props.text"
|
||||
@click.stop="open = !open"
|
||||
>
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
class="pointer-events-none invisible absolute left-1/2 top-full z-[230] mt-2 w-max max-w-xs -translate-x-1/2 rounded-md border bg-popover px-3 py-2 text-xs leading-5 text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
|
||||
:class="open ? 'visible opacity-100' : ''"
|
||||
>
|
||||
{{ props.text }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@
|
||||
:provider-proxy-node-name="getProviderProxyNodeName()"
|
||||
:saving-provider-proxy="savingProviderProxy"
|
||||
@toggle-format-conversion="toggleFormatConversion"
|
||||
@toggle-keep-priority-on-conversion="toggleKeepPriorityOnConversion"
|
||||
@open-failover-rules="failoverRulesDialogOpen = true"
|
||||
@set-provider-proxy="setProviderProxy"
|
||||
@clear-provider-proxy="clearProviderProxy"
|
||||
@@ -1412,6 +1413,24 @@ async function toggleFormatConversion() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleKeepPriorityOnConversion() {
|
||||
if (!provider.value) return
|
||||
const formatConversionAvailable =
|
||||
provider.value.enable_format_conversion || systemFormatConversionEnabled.value
|
||||
if (!formatConversionAvailable) return
|
||||
const newValue = !provider.value.keep_priority_on_conversion
|
||||
try {
|
||||
const updated = await updateProvider(provider.value.id, {
|
||||
keep_priority_on_conversion: newValue,
|
||||
})
|
||||
applyProviderSnapshot(updated)
|
||||
showSuccess(legacyT(newValue ? '已启用格式转换保持优先级' : '已禁用格式转换保持优先级'))
|
||||
emit('refresh')
|
||||
} catch {
|
||||
showError(legacyT('切换格式转换保持优先级失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderProxyNodeName(): string {
|
||||
const nodeId = provider.value?.proxy?.node_id
|
||||
if (!nodeId) return legacyT('未知节点')
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
<Shuffle class="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<span :title="keepPriorityTitle">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="provider.keep_priority_on_conversion ? 'text-primary' : ''"
|
||||
:disabled="!formatConversionAvailable"
|
||||
@click="$emit('toggleKeepPriorityOnConversion')"
|
||||
>
|
||||
<Layers class="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<span :title="legacyT(hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -163,7 +174,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Edit, GitBranch, Globe, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next'
|
||||
import { Edit, GitBranch, Globe, Layers, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
@@ -185,6 +196,7 @@ const props = defineProps<{
|
||||
|
||||
defineEmits<{
|
||||
(e: 'toggleFormatConversion'): void
|
||||
(e: 'toggleKeepPriorityOnConversion'): void
|
||||
(e: 'openFailoverRules'): void
|
||||
(e: 'update:providerProxyPopoverOpen', value: boolean): void
|
||||
(e: 'setProviderProxy', value: string): void
|
||||
@@ -203,4 +215,15 @@ const formatConversionTitle = computed(() => {
|
||||
if (props.provider.enable_format_conversion) return legacyT('已启用格式转换(点击关闭)')
|
||||
return legacyT('启用格式转换')
|
||||
})
|
||||
|
||||
const formatConversionAvailable = computed(() => (
|
||||
props.provider.enable_format_conversion || props.systemFormatConversionEnabled
|
||||
))
|
||||
|
||||
const keepPriorityTitle = computed(() => {
|
||||
if (!formatConversionAvailable.value) return legacyT('请先启用格式转换')
|
||||
return props.provider.keep_priority_on_conversion
|
||||
? legacyT('已启用格式转换保持优先级(点击关闭)')
|
||||
: legacyT('启用格式转换保持优先级')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -280,19 +280,6 @@
|
||||
{{ legacyT('功能开关') }}
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">{{ legacyT('格式转换保持优先级') }}</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.keep_priority_on_conversion"
|
||||
@update:model-value="(v: boolean) => form.keep_priority_on_conversion = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">{{ legacyT('号池调度模式') }}</span>
|
||||
@@ -475,7 +462,6 @@ const form = ref({
|
||||
quota_last_reset_at: '', // 周期开始时间
|
||||
quota_expires_at: '',
|
||||
provider_priority: 100,
|
||||
keep_priority_on_conversion: false, // 格式转换时是否保持优先级
|
||||
// 状态配置
|
||||
is_active: true,
|
||||
rate_limit: undefined as number | undefined,
|
||||
@@ -510,7 +496,6 @@ function resetForm() {
|
||||
quota_last_reset_at: '',
|
||||
quota_expires_at: '',
|
||||
provider_priority: defaultPriority.value,
|
||||
keep_priority_on_conversion: false,
|
||||
is_active: true,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
@@ -548,7 +533,6 @@ function loadProviderData() {
|
||||
quota_last_reset_at: formatDateTimeLocalInput(props.provider.quota_last_reset_at),
|
||||
quota_expires_at: formatDateTimeLocalInput(props.provider.quota_expires_at),
|
||||
provider_priority: props.provider.provider_priority || 999,
|
||||
keep_priority_on_conversion: props.provider.keep_priority_on_conversion ?? false,
|
||||
is_active: props.provider.is_active,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
@@ -625,7 +609,6 @@ const handleSubmit = async () => {
|
||||
quota_reset_day: form.value.quota_reset_day,
|
||||
quota_last_reset_at: quotaLastResetAt,
|
||||
quota_expires_at: quotaExpiresAt,
|
||||
keep_priority_on_conversion: form.value.keep_priority_on_conversion,
|
||||
responses_websocket_enabled: form.value.responses_websocket_enabled,
|
||||
is_active: form.value.is_active,
|
||||
// 请求配置
|
||||
|
||||
@@ -98,19 +98,6 @@
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 调度策略 -->
|
||||
<button
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
:title="legacyT('点击调整调度策略')"
|
||||
@click="$emit('openPriorityDialog')"
|
||||
>
|
||||
<span class="text-muted-foreground/80 hidden sm:inline">{{ legacyT('调度:') }}</span>
|
||||
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -141,7 +128,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, Plus, ChevronDown, FilterX, Users } from 'lucide-vue-next'
|
||||
import { Search, Plus, FilterX, Users } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
@@ -162,7 +149,6 @@ defineProps<{
|
||||
apiFormatFilters: FilterOption[]
|
||||
modelFilters: FilterOption[]
|
||||
hasActiveFilters: boolean
|
||||
priorityModeLabel: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
@@ -172,7 +158,6 @@ defineEmits<{
|
||||
'update:filterApiFormat': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'resetFilters': []
|
||||
'openPriorityDialog': []
|
||||
'batchProcess': []
|
||||
'addProvider': []
|
||||
'refresh': []
|
||||
|
||||
@@ -9,7 +9,6 @@ export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
|
||||
export { default as KeyFormDialog } from './KeyFormDialog.vue'
|
||||
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
|
||||
export { default as KeyAllowedModelsEditDialog } from './KeyAllowedModelsEditDialog.vue'
|
||||
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
|
||||
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
|
||||
@@ -2,27 +2,17 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
allowedModelsMirrorPerModelPolicies,
|
||||
clearAllowedModels,
|
||||
copyPerModelRoutingConfig,
|
||||
createEmptyModelPolicy,
|
||||
createEmptyRoutingGroupConfig,
|
||||
formatAllowedModelsInput,
|
||||
getDefaultModelPolicy,
|
||||
getModelScheduling,
|
||||
modelSchedulingRuleId,
|
||||
normalizeRoutingGroupConfig,
|
||||
normalizeStickyKeyAttempts,
|
||||
parseAllowedModelsInput,
|
||||
removePerModelRoutingConfig,
|
||||
resolveModelKeyPriorityOverride,
|
||||
routingModelScopeLabel,
|
||||
savePerModelRoutingConfig,
|
||||
setDefaultPoolPriorityOverrides,
|
||||
setDefaultProviderPriorityOverrides,
|
||||
setModelKeyPriorityOverridesForFormat,
|
||||
setRoutingSortingScope,
|
||||
updateAllowedModelsFromInput,
|
||||
upsertModelSchedulingRule,
|
||||
upsertModelPolicy,
|
||||
} from '../utils/routingPolicy'
|
||||
@@ -30,13 +20,18 @@ import { sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace }
|
||||
|
||||
describe('routingPolicy', () => {
|
||||
it('normalizes partial configs with stable defaults', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
allowed_models: ['gpt-5'],
|
||||
})
|
||||
const config = normalizeRoutingGroupConfig({})
|
||||
|
||||
expect(config.default_policy.priority_mode).toBe('provider')
|
||||
expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
|
||||
expect(config.allowed_models).toEqual(['gpt-5'])
|
||||
})
|
||||
|
||||
it('drops the legacy group model allowlist while normalizing config', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
allowed_models: ['legacy-model'],
|
||||
} as unknown as Parameters<typeof normalizeRoutingGroupConfig>[0])
|
||||
|
||||
expect(config).not.toHaveProperty('allowed_models')
|
||||
})
|
||||
|
||||
it('upserts model policies by model name', () => {
|
||||
@@ -155,115 +150,6 @@ describe('routingPolicy', () => {
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the model allowlist only through explicit scope controls', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
allowed_models: ['legacy-model'],
|
||||
})
|
||||
|
||||
expect(parseAllowedModelsInput(' gpt-5\nclaude-*\nlegacy-model\ngpt-5 ')).toEqual([
|
||||
'gpt-5',
|
||||
'claude-*',
|
||||
'legacy-model',
|
||||
])
|
||||
|
||||
const restricted = updateAllowedModelsFromInput(
|
||||
config,
|
||||
'gpt-5\nclaude-*\nlegacy-model\ngpt-5',
|
||||
)
|
||||
expect(restricted.allowed_models).toEqual(['gpt-5', 'claude-*', 'legacy-model'])
|
||||
expect(formatAllowedModelsInput(restricted.allowed_models)).toBe('gpt-5\nclaude-*\nlegacy-model')
|
||||
expect(routingModelScopeLabel(restricted)).toBe('3 个模型')
|
||||
|
||||
const unrestricted = clearAllowedModels(restricted)
|
||||
expect(unrestricted.allowed_models).toEqual([])
|
||||
expect(routingModelScopeLabel(unrestricted)).toBe('全部模型')
|
||||
})
|
||||
|
||||
it('round-trips selectors containing commas and labels wildcard scope as unrestricted', () => {
|
||||
const selectors = ['vendor,model', 'gpt-*']
|
||||
expect(parseAllowedModelsInput(formatAllowedModelsInput(selectors))).toEqual(selectors)
|
||||
|
||||
const wildcard = normalizeRoutingGroupConfig({ allowed_models: ['gpt-*', '*'] })
|
||||
expect(routingModelScopeLabel(wildcard)).toBe('全部模型')
|
||||
})
|
||||
|
||||
it('preserves historical empty selectors until unrestricted scope is explicit', () => {
|
||||
const legacy = normalizeRoutingGroupConfig({ allowed_models: ['', ' '] })
|
||||
|
||||
expect(updateAllowedModelsFromInput(legacy, ' \n')).toMatchObject({
|
||||
allowed_models: ['', ' '],
|
||||
})
|
||||
expect(clearAllowedModels(legacy).allowed_models).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves an explicit model allowlist across per-model editing actions', () => {
|
||||
const allowlist = ['gpt-*', 'legacy-model']
|
||||
let config = normalizeRoutingGroupConfig({
|
||||
allowed_models: allowlist,
|
||||
model_policies: [{
|
||||
...createEmptyModelPolicy('special-model'),
|
||||
allowed_providers: ['provider-special'],
|
||||
}],
|
||||
})
|
||||
config = upsertModelSchedulingRule(config, 'special-model', {
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
|
||||
const perModel = setRoutingSortingScope(config, 'per_model')
|
||||
expect(perModel.allowed_models).toEqual(allowlist)
|
||||
expect(getModelScheduling(perModel, 'special-model')).toMatchObject({
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
|
||||
const saved = savePerModelRoutingConfig(perModel, 'new-special-model')
|
||||
expect(saved.allowed_models).toEqual(allowlist)
|
||||
expect(saved.model_policies.map(policy => policy.model)).toContain('new-special-model')
|
||||
|
||||
const copied = copyPerModelRoutingConfig(
|
||||
saved,
|
||||
saved,
|
||||
'special-model',
|
||||
'copied-special-model',
|
||||
)
|
||||
expect(copied.allowed_models).toEqual(allowlist)
|
||||
expect(copied.model_policies.find(policy => policy.model === 'copied-special-model'))
|
||||
.toMatchObject({ allowed_providers: ['provider-special'] })
|
||||
expect(getModelScheduling(copied, 'copied-special-model')).toMatchObject({
|
||||
priority_mode: 'global_key',
|
||||
scheduling_mode: 'fixed_order',
|
||||
})
|
||||
|
||||
const removed = removePerModelRoutingConfig(copied, 'special-model')
|
||||
expect(removed.allowed_models).toEqual(allowlist)
|
||||
expect(removed.model_policies.map(policy => policy.model)).not.toContain('special-model')
|
||||
expect(removed.rules.map(rule => rule.id)).not.toContain(modelSchedulingRuleId('special-model'))
|
||||
|
||||
const unified = setRoutingSortingScope(removed, 'unified')
|
||||
expect(unified.allowed_models).toEqual(allowlist)
|
||||
expect(unified.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL))
|
||||
.toEqual([])
|
||||
expect(unified.rules.some(rule => rule.id.startsWith('ui_model_scheduling:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes legacy allowlist mirrors without mutating historical values', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
allowed_models: [' model-b ', 'model-a', 'model-a'],
|
||||
model_policies: [
|
||||
createEmptyModelPolicy('model-a'),
|
||||
createEmptyModelPolicy('model-b'),
|
||||
],
|
||||
})
|
||||
|
||||
expect(allowedModelsMirrorPerModelPolicies(config)).toBe(true)
|
||||
expect(config.allowed_models).toEqual([' model-b ', 'model-a', 'model-a'])
|
||||
expect(allowedModelsMirrorPerModelPolicies({
|
||||
...config,
|
||||
allowed_models: ['model-*'],
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('routingTrace', () => {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<label class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">允许模型</span>
|
||||
<input
|
||||
v-model="allowedModelsText"
|
||||
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
placeholder="gpt-5, claude-sonnet-*"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<RoutingModelPolicyEditor
|
||||
:model-policies="config.model_policies"
|
||||
@update:model-policies="updateModelPolicies"
|
||||
@@ -34,16 +23,6 @@ const emit = defineEmits<{
|
||||
|
||||
const config = computed(() => normalizeRoutingGroupConfig(props.config))
|
||||
|
||||
const allowedModelsText = computed({
|
||||
get: () => config.value.allowed_models.join(', '),
|
||||
set: value => {
|
||||
emit('update:config', {
|
||||
...config.value,
|
||||
allowed_models: value.split(',').map(item => item.trim()).filter(Boolean),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) {
|
||||
emit('update:config', {
|
||||
...config.value,
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface RoutingDefaultPolicy {
|
||||
priority_mode: RoutingPriorityMode
|
||||
scheduling_mode: RoutingSchedulingMode
|
||||
keep_priority_on_conversion: boolean
|
||||
enable_cf_heartbeat: boolean
|
||||
cyber_continue_failover: boolean
|
||||
/** 首个候选的总尝试次数;后续候选始终只尝试 1 次。0 或 1 表示不重试 */
|
||||
sticky_key_attempts: number
|
||||
}
|
||||
@@ -60,7 +62,6 @@ export interface RoutingSetSchedulingAction {
|
||||
}
|
||||
|
||||
export interface RoutingGroupConfig {
|
||||
allowed_models: string[]
|
||||
default_policy: RoutingDefaultPolicy
|
||||
model_policies: RoutingModelPolicy[]
|
||||
rules: RoutingRule[]
|
||||
@@ -71,11 +72,12 @@ export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
|
||||
|
||||
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
|
||||
return {
|
||||
allowed_models: [],
|
||||
default_policy: {
|
||||
priority_mode: 'provider',
|
||||
scheduling_mode: 'cache_affinity',
|
||||
keep_priority_on_conversion: false,
|
||||
enable_cf_heartbeat: false,
|
||||
cyber_continue_failover: false,
|
||||
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
},
|
||||
model_policies: [],
|
||||
@@ -104,14 +106,25 @@ export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
|
||||
|
||||
export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
|
||||
const base = createEmptyRoutingGroupConfig()
|
||||
const rawDefaultPolicy = (value?.default_policy ?? {}) as Partial<RoutingDefaultPolicy> & {
|
||||
enable_openai_image_sync_heartbeat?: boolean
|
||||
enable_standard_text_sync_heartbeat?: boolean
|
||||
}
|
||||
const {
|
||||
enable_openai_image_sync_heartbeat: legacyImageHeartbeat,
|
||||
enable_standard_text_sync_heartbeat: legacyTextHeartbeat,
|
||||
...defaultPolicyWithoutLegacyHeartbeat
|
||||
} = rawDefaultPolicy
|
||||
|
||||
return {
|
||||
allowed_models: Array.isArray(value?.allowed_models) ? [...value.allowed_models] : base.allowed_models,
|
||||
default_policy: {
|
||||
...base.default_policy,
|
||||
...(value?.default_policy ?? {}),
|
||||
...defaultPolicyWithoutLegacyHeartbeat,
|
||||
enable_cf_heartbeat: Boolean(
|
||||
rawDefaultPolicy.enable_cf_heartbeat || legacyImageHeartbeat || legacyTextHeartbeat,
|
||||
),
|
||||
sticky_key_attempts: normalizeStickyKeyAttempts(
|
||||
value?.default_policy?.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
rawDefaultPolicy.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
),
|
||||
},
|
||||
model_policies: Array.isArray(value?.model_policies)
|
||||
@@ -133,74 +146,6 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAllowedModelsInput(value: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
return value
|
||||
.split(/\r\n?|\n/u)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((model) => {
|
||||
if (seen.has(model)) return false
|
||||
seen.add(model)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAllowedModelsInput(models: string[]): string {
|
||||
return models.join('\n')
|
||||
}
|
||||
|
||||
export function updateAllowedModelsFromInput(
|
||||
config: RoutingGroupConfig,
|
||||
value: string,
|
||||
): RoutingGroupConfig {
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
// Preserve the historical "empty selector" form until the user explicitly
|
||||
// chooses the unrestricted scope. It is distinct from an empty allowlist in
|
||||
// the routing core, where it matches no normal model.
|
||||
const hasHistoricalEmptySelector = next.allowed_models.length > 0
|
||||
&& next.allowed_models.every(model => model.trim() === '')
|
||||
if (value.trim() === '' && hasHistoricalEmptySelector) {
|
||||
return next
|
||||
}
|
||||
next.allowed_models = parseAllowedModelsInput(value)
|
||||
return next
|
||||
}
|
||||
|
||||
export function clearAllowedModels(config: RoutingGroupConfig): RoutingGroupConfig {
|
||||
const next = normalizeRoutingGroupConfig(config)
|
||||
next.allowed_models = []
|
||||
return next
|
||||
}
|
||||
|
||||
export function routingModelScopeLabel(config: RoutingGroupConfig): string {
|
||||
const models = normalizeRoutingGroupConfig(config).allowed_models
|
||||
if (models.length === 0 || models.some(model => model.trim() === '*')) {
|
||||
return '全部模型'
|
||||
}
|
||||
return `${models.length} 个模型`
|
||||
}
|
||||
|
||||
export function allowedModelsMirrorPerModelPolicies(config: RoutingGroupConfig): boolean {
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
const allowedModels = normalized.allowed_models
|
||||
.map(model => model.trim())
|
||||
.filter(Boolean)
|
||||
const perModelNames = normalized.model_policies
|
||||
.map(policy => policy.model)
|
||||
.map(model => model.trim())
|
||||
.filter(Boolean)
|
||||
.filter(model => model !== DEFAULT_ROUTING_POLICY_MODEL)
|
||||
|
||||
if (allowedModels.length === 0 || perModelNames.length === 0) return false
|
||||
if (allowedModels.some(model => model.includes('*'))) return false
|
||||
|
||||
const allowedSet = new Set(allowedModels)
|
||||
const perModelSet = new Set(perModelNames)
|
||||
return allowedSet.size === perModelSet.size
|
||||
&& [...allowedSet].every(model => perModelSet.has(model))
|
||||
}
|
||||
|
||||
export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig {
|
||||
const model = policy.model.trim()
|
||||
if (!model) {
|
||||
@@ -427,6 +372,8 @@ export function getModelScheduling(
|
||||
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
|
||||
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
|
||||
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
|
||||
enable_cf_heartbeat: normalized.default_policy.enable_cf_heartbeat,
|
||||
cyber_continue_failover: normalized.default_policy.cyber_continue_failover,
|
||||
sticky_key_attempts: action?.sticky_key_attempts ?? normalized.default_policy.sticky_key_attempts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3264,7 +3264,6 @@ const legacyFallbackTokens: Array<[string, string]> = [
|
||||
['策略分组', 'policy groups'],
|
||||
['策略', 'policy'],
|
||||
['维度', 'dimension'],
|
||||
['模型范围', 'model scope'],
|
||||
['默认策略', 'default policy'],
|
||||
['更新时间', 'updated at'],
|
||||
['回溯时间', 'lookback time'],
|
||||
|
||||
@@ -24,14 +24,12 @@
|
||||
:api-format-filters="apiFormatFilters"
|
||||
:model-filters="modelFilters"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:priority-mode-label="priorityModeConfig.label"
|
||||
:loading="loading"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@update:filter-status="filterStatus = $event"
|
||||
@update:filter-api-format="filterApiFormat = $event"
|
||||
@update:filter-model="filterModel = $event"
|
||||
@reset-filters="resetFilters"
|
||||
@open-priority-dialog="openPriorityDialog"
|
||||
@batch-process="openProviderBatchDialog"
|
||||
@add-provider="openAddProviderDialog"
|
||||
@refresh="loadProviders"
|
||||
@@ -215,11 +213,6 @@
|
||||
@changed="handleProviderBatchChanged"
|
||||
/>
|
||||
|
||||
<PriorityManagementDialog
|
||||
v-model="priorityDialogOpen"
|
||||
@saved="handlePrioritySaved"
|
||||
/>
|
||||
|
||||
<ProviderDetailDrawer
|
||||
v-if="providerDrawerMounted"
|
||||
:open="providerDrawerOpen"
|
||||
@@ -250,7 +243,7 @@ import TableHead from '@/components/ui/table-head.vue'
|
||||
import SortableTableHead from '@/components/ui/sortable-table-head.vue'
|
||||
import TableFilterMenu from '@/components/ui/table-filter-menu.vue'
|
||||
import Pagination from '@/components/ui/pagination.vue'
|
||||
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
|
||||
import { ProviderFormDialog, ProviderAuthDialog } from '@/features/providers/components'
|
||||
import ProviderBatchActionDialog from '@/features/providers/components/ProviderBatchActionDialog.vue'
|
||||
import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue'
|
||||
import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue'
|
||||
@@ -271,9 +264,6 @@ import {
|
||||
getGlobalModels,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { listRoutingGroups } from '@/api/routing-profiles'
|
||||
import { normalizeRoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
@@ -309,8 +299,6 @@ let providersRequestId = 0
|
||||
const providerDialogOpen = ref(false)
|
||||
const providerBatchDialogOpen = ref(false)
|
||||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
const priorityDialogOpen = ref(false)
|
||||
const priorityMode = ref<'provider' | 'global_key'>('provider')
|
||||
const providerDrawerOpen = ref(false)
|
||||
const providerDrawerMounted = ref(false)
|
||||
const selectedProviderId = ref<string | null>(null)
|
||||
@@ -325,7 +313,6 @@ const DELETE_POLL_INTERVAL_MS = 2000
|
||||
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
|
||||
const DELETE_POLL_MAX_FAILURES = 3
|
||||
const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000
|
||||
const PROVIDER_PRIORITY_MODE_CACHE_TTL_MS = 30 * 1000
|
||||
const PROVIDER_MODEL_FILTER_CACHE_TTL_MS = 10 * 1000
|
||||
|
||||
async function pollProviderDeleteTask(providerId: string, taskId: string) {
|
||||
@@ -519,13 +506,6 @@ async function saveDescription(_event: Event, provider: ProviderWithEndpointsSum
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级模式配置
|
||||
const priorityModeConfig = computed(() => {
|
||||
return {
|
||||
label: legacyT(priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'),
|
||||
}
|
||||
})
|
||||
|
||||
// 当前已有提供商的最大优先级
|
||||
const maxProviderPriority = computed(() => {
|
||||
if (providers.value.length === 0) return undefined
|
||||
@@ -535,30 +515,6 @@ const maxProviderPriority = computed(() => {
|
||||
return priorities.length > 0 ? Math.max(...priorities) : undefined
|
||||
})
|
||||
|
||||
// 加载优先级模式:优先使用启用中的系统默认调度策略,旧的系统配置键仅作兜底
|
||||
async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
|
||||
try {
|
||||
const groups = await listRoutingGroups()
|
||||
const systemDefault = groups.items.find(group => group.is_system_default && group.enabled)
|
||||
if (systemDefault) {
|
||||
priorityMode.value = normalizeRoutingGroupConfig(systemDefault.config_json).default_policy.priority_mode
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 路由策略不可用时继续尝试旧配置
|
||||
}
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('provider_priority_mode', {
|
||||
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||
})
|
||||
if (response.value) {
|
||||
priorityMode.value = response.value as 'provider' | 'global_key'
|
||||
}
|
||||
} catch {
|
||||
priorityMode.value = 'provider'
|
||||
}
|
||||
}
|
||||
|
||||
// 加载全局模型列表(用于模型筛选下拉)
|
||||
async function loadGlobalModelList(options: { cacheTtlMs?: number } = {}) {
|
||||
try {
|
||||
@@ -636,11 +592,6 @@ function openAddProviderDialog() {
|
||||
providerDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 打开优先级管理对话框
|
||||
function openPriorityDialog() {
|
||||
priorityDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openProviderBatchDialog() {
|
||||
providerBatchDialogOpen.value = true
|
||||
}
|
||||
@@ -709,12 +660,6 @@ async function handleDrawerRefresh() {
|
||||
await refreshProviderSnapshot(selectedProviderId.value)
|
||||
}
|
||||
|
||||
// 优先级保存成功回调
|
||||
async function handlePrioritySaved() {
|
||||
await loadProviders()
|
||||
await loadPriorityMode()
|
||||
}
|
||||
|
||||
// 处理提供商添加
|
||||
function handleProviderAdded() {
|
||||
void loadProviders()
|
||||
@@ -791,7 +736,6 @@ function handleGlobalClick(event: MouseEvent) {
|
||||
|
||||
onMounted(() => {
|
||||
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
|
||||
void loadPriorityMode({ cacheTtlMs: PROVIDER_PRIORITY_MODE_CACHE_TTL_MS })
|
||||
void loadGlobalModelList({ cacheTtlMs: PROVIDER_MODEL_FILTER_CACHE_TTL_MS })
|
||||
void loadArchitectureSchemas()
|
||||
document.addEventListener('click', handleGlobalClick, true)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,9 +81,6 @@
|
||||
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
|
||||
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
|
||||
:enable-format-conversion="systemConfig.enable_format_conversion"
|
||||
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
|
||||
:enable-standard-text-sync-heartbeat="systemConfig.enable_standard_text_sync_heartbeat"
|
||||
:cyber-continue-failover="systemConfig.cyber_continue_failover"
|
||||
:loading="systemConfigLoading || basicConfigLoading"
|
||||
:has-changes="hasBasicConfigChanges"
|
||||
@save="saveBasicConfig"
|
||||
@@ -107,9 +104,6 @@
|
||||
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
|
||||
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
|
||||
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
||||
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
|
||||
@update:enable-standard-text-sync-heartbeat="systemConfig.enable_standard_text_sync_heartbeat = $event"
|
||||
@update:cyber-continue-failover="systemConfig.cyber_continue_failover = $event"
|
||||
/>
|
||||
|
||||
<!-- 请求记录配置 -->
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import RoutingProfiles from '../RoutingProfiles.vue'
|
||||
import type {
|
||||
RoutingGroupCreateRequest,
|
||||
RoutingGroupRecord,
|
||||
RoutingGroupUpdateRequest,
|
||||
} from '@/api/routing-profiles'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
listRoutingGroups: vi.fn(),
|
||||
createRoutingGroup: vi.fn(),
|
||||
updateRoutingGroup: vi.fn(),
|
||||
deleteRoutingGroup: vi.fn(),
|
||||
getGlobalModels: vi.fn(),
|
||||
}))
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
route: null as null | { name: string; params: Record<string, string> },
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}))
|
||||
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
routeMocks.route = reactive({
|
||||
name: 'RoutingProfileDetail',
|
||||
params: { groupId: 'group-1' },
|
||||
})
|
||||
return {
|
||||
useRoute: () => routeMocks.route,
|
||||
useRouter: () => ({ push: routeMocks.push, replace: routeMocks.replace }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/routing-profiles', () => ({
|
||||
listRoutingGroups: apiMocks.listRoutingGroups,
|
||||
createRoutingGroup: apiMocks.createRoutingGroup,
|
||||
updateRoutingGroup: apiMocks.updateRoutingGroup,
|
||||
deleteRoutingGroup: apiMocks.deleteRoutingGroup,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/global-models', () => ({ getGlobalModels: apiMocks.getGlobalModels }))
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMocks }))
|
||||
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/components/layout', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
PageContainer: defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h('main', slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
|
||||
const wrapper = (tag = 'div') => defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { class: String },
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h(tag, { ...attrs, class: props.class }, [
|
||||
slots.header?.(),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const Input = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
class: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
class: props.class,
|
||||
disabled: props.disabled,
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit(
|
||||
'update:modelValue',
|
||||
(event.target as HTMLInputElement).value,
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const Textarea = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
class: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('textarea', {
|
||||
...attrs,
|
||||
class: props.class,
|
||||
disabled: props.disabled,
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit(
|
||||
'update:modelValue',
|
||||
(event.target as HTMLTextAreaElement).value,
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const Button = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
class: String,
|
||||
disabled: Boolean,
|
||||
type: { type: String, default: 'button' },
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
class: props.class,
|
||||
disabled: props.disabled,
|
||||
type: props.type,
|
||||
}, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const Switch = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: 'button',
|
||||
role: 'switch',
|
||||
'aria-checked': props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit('update:modelValue', !props.modelValue),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
Badge: wrapper(),
|
||||
Button,
|
||||
Card: wrapper('section'),
|
||||
Input,
|
||||
Switch,
|
||||
Table: wrapper('table'),
|
||||
TableBody: wrapper('tbody'),
|
||||
TableCard: wrapper(),
|
||||
TableCell: wrapper('td'),
|
||||
TableHead: wrapper('th'),
|
||||
TableHeader: wrapper('thead'),
|
||||
TableRow: wrapper('tr'),
|
||||
Textarea,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const wrapper = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.())
|
||||
},
|
||||
})
|
||||
return {
|
||||
DropdownMenu: wrapper,
|
||||
DropdownMenuContent: wrapper,
|
||||
DropdownMenuItem: wrapper,
|
||||
DropdownMenuTrigger: wrapper,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/common', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return { AlertDialog: defineComponent({ setup: () => () => h('div') }) }
|
||||
})
|
||||
|
||||
vi.mock('@/features/routing/components', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
RoutingPriorityPolicyEditor: defineComponent({
|
||||
setup: () => () => h('div', { 'data-testid': 'routing-policy-editor' }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
let app: App | undefined
|
||||
let root: HTMLElement | undefined
|
||||
|
||||
function routingGroup(
|
||||
allowedModels: string[] = [],
|
||||
overrides: Partial<RoutingGroupRecord> = {},
|
||||
): RoutingGroupRecord {
|
||||
return {
|
||||
id: 'group-1',
|
||||
name: 'Default routing',
|
||||
description: null,
|
||||
enabled: true,
|
||||
is_system_default: true,
|
||||
config_json: {
|
||||
allowed_models: allowedModels,
|
||||
default_policy: {
|
||||
priority_mode: 'provider',
|
||||
scheduling_mode: 'cache_affinity',
|
||||
keep_priority_on_conversion: false,
|
||||
sticky_key_attempts: 2,
|
||||
},
|
||||
model_policies: [],
|
||||
rules: [],
|
||||
},
|
||||
version: 1,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPromises(iterations = 5): Promise<void> {
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
input: RoutingGroupRecord | RoutingGroupRecord[] = routingGroup(),
|
||||
): Promise<void> {
|
||||
const groups = Array.isArray(input) ? input : [input]
|
||||
apiMocks.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
|
||||
apiMocks.getGlobalModels.mockResolvedValue({ models: [] })
|
||||
apiMocks.updateRoutingGroup.mockImplementation(
|
||||
async (groupId: string, payload: RoutingGroupUpdateRequest) => {
|
||||
const group = groups.find(item => item.id === groupId)
|
||||
if (!group) throw new Error(`unknown routing group: ${groupId}`)
|
||||
return {
|
||||
...group,
|
||||
...payload,
|
||||
config_json: payload.config_json ?? group.config_json,
|
||||
updated_at: 2,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
app = createApp(defineComponent({
|
||||
setup: () => () => h(RoutingProfiles),
|
||||
}))
|
||||
app.mount(root)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
|
||||
textarea.value = value
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (!routeMocks.route) throw new Error('route mock was not initialized')
|
||||
routeMocks.route.name = 'RoutingProfileDetail'
|
||||
routeMocks.route.params = { groupId: 'group-1' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount()
|
||||
root?.remove()
|
||||
app = undefined
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe('RoutingProfiles model allowlist', () => {
|
||||
it('saves one selector per line without an extra apply step', async () => {
|
||||
await mountPage()
|
||||
|
||||
const textarea = root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement
|
||||
expect(textarea).toBeInstanceOf(HTMLTextAreaElement)
|
||||
|
||||
setTextareaValue(textarea, 'gpt-5\nclaude-*\nvendor,model')
|
||||
await nextTick()
|
||||
|
||||
const saveButton = root?.querySelector(
|
||||
'button[aria-label="保存"]',
|
||||
) as HTMLButtonElement
|
||||
expect(saveButton.disabled).toBe(false)
|
||||
saveButton.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
expect.objectContaining({
|
||||
config_json: expect.objectContaining({
|
||||
allowed_models: ['gpt-5', 'claude-*', 'vendor,model'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('locks the editor while a save is in flight', async () => {
|
||||
const group = routingGroup(['model-a'])
|
||||
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
|
||||
let submittedPayload: RoutingGroupUpdateRequest | undefined
|
||||
|
||||
await mountPage(group)
|
||||
apiMocks.updateRoutingGroup.mockImplementationOnce(
|
||||
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
|
||||
submittedPayload = payload
|
||||
return await new Promise<RoutingGroupRecord>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const textarea = root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement
|
||||
setTextareaValue(textarea, 'model-a\nmodel-b')
|
||||
await nextTick()
|
||||
|
||||
const saveButton = root?.querySelector(
|
||||
'button[aria-label="保存"]',
|
||||
) as HTMLButtonElement
|
||||
saveButton.click()
|
||||
await nextTick()
|
||||
|
||||
const editor = root?.querySelector('[aria-busy="true"]') as HTMLElement
|
||||
const clearButton = root?.querySelector(
|
||||
'[data-testid="clear-allowed-models"]',
|
||||
) as HTMLButtonElement
|
||||
expect(editor.hasAttribute('inert')).toBe(true)
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(clearButton.disabled).toBe(true)
|
||||
expect(saveButton.disabled).toBe(true)
|
||||
|
||||
setTextareaValue(textarea, 'model-c')
|
||||
await nextTick()
|
||||
expect(submittedPayload?.config_json?.allowed_models).toEqual(['model-a', 'model-b'])
|
||||
|
||||
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
|
||||
resolveUpdate({
|
||||
...group,
|
||||
...submittedPayload,
|
||||
config_json: submittedPayload.config_json ?? group.config_json,
|
||||
updated_at: 2,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(root?.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect((root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
|
||||
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps another group selected when an earlier save response arrives', async () => {
|
||||
const firstGroup = routingGroup(['model-a'], {
|
||||
id: 'group-1',
|
||||
name: 'First routing',
|
||||
})
|
||||
const secondGroup = routingGroup(['model-b'], {
|
||||
id: 'group-2',
|
||||
name: 'Second routing',
|
||||
is_system_default: false,
|
||||
})
|
||||
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
|
||||
let submittedPayload: RoutingGroupUpdateRequest | undefined
|
||||
|
||||
await mountPage([firstGroup, secondGroup])
|
||||
apiMocks.updateRoutingGroup.mockImplementationOnce(
|
||||
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
|
||||
submittedPayload = payload
|
||||
return await new Promise<RoutingGroupRecord>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const textarea = root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement
|
||||
setTextareaValue(textarea, 'model-a\nmodel-a-new')
|
||||
await nextTick()
|
||||
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
|
||||
await nextTick()
|
||||
|
||||
if (!routeMocks.route) throw new Error('route mock was not initialized')
|
||||
routeMocks.route.params = { groupId: 'group-2' }
|
||||
await nextTick()
|
||||
expect((root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement).value).toBe('model-b')
|
||||
|
||||
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
|
||||
resolveUpdate({
|
||||
...firstGroup,
|
||||
...submittedPayload,
|
||||
config_json: submittedPayload.config_json ?? firstGroup.config_json,
|
||||
updated_at: 2,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(root?.querySelector('h2')?.textContent).toContain('Second routing')
|
||||
expect((root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement).value).toBe('model-b')
|
||||
expect(routeMocks.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes a clean draft when returning to the saved group before the response arrives', async () => {
|
||||
const group = routingGroup(['model-a'])
|
||||
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
|
||||
let submittedPayload: RoutingGroupUpdateRequest | undefined
|
||||
|
||||
await mountPage(group)
|
||||
apiMocks.updateRoutingGroup.mockImplementationOnce(
|
||||
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
|
||||
submittedPayload = payload
|
||||
return await new Promise<RoutingGroupRecord>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const textarea = root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement
|
||||
setTextareaValue(textarea, 'model-a\nmodel-b')
|
||||
await nextTick()
|
||||
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
|
||||
await nextTick()
|
||||
|
||||
if (!routeMocks.route) throw new Error('route mock was not initialized')
|
||||
routeMocks.route.name = 'RoutingProfiles'
|
||||
routeMocks.route.params = {}
|
||||
await nextTick()
|
||||
routeMocks.route.name = 'RoutingProfileDetail'
|
||||
routeMocks.route.params = { groupId: 'group-1' }
|
||||
await nextTick()
|
||||
expect((root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement).value).toBe('model-a')
|
||||
|
||||
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
|
||||
resolveUpdate({
|
||||
...group,
|
||||
...submittedPayload,
|
||||
config_json: submittedPayload.config_json ?? group.config_json,
|
||||
updated_at: 2,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect((root?.querySelector(
|
||||
'[data-testid="allowed-models-input"]',
|
||||
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
|
||||
expect((root?.querySelector(
|
||||
'button[aria-label="保存"]',
|
||||
) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not attach an old create response to a recreated draft', async () => {
|
||||
if (!routeMocks.route) throw new Error('route mock was not initialized')
|
||||
routeMocks.route.name = 'RoutingProfileCreate'
|
||||
routeMocks.route.params = {}
|
||||
|
||||
let resolveCreate: ((value: RoutingGroupRecord) => void) | undefined
|
||||
let submittedPayload: RoutingGroupCreateRequest | undefined
|
||||
await mountPage([])
|
||||
apiMocks.createRoutingGroup.mockImplementationOnce(
|
||||
async (payload: RoutingGroupCreateRequest) => {
|
||||
submittedPayload = payload
|
||||
return await new Promise<RoutingGroupRecord>((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
|
||||
await nextTick()
|
||||
|
||||
routeMocks.route.name = 'RoutingProfiles'
|
||||
await nextTick()
|
||||
routeMocks.route.name = 'RoutingProfileCreate'
|
||||
await nextTick()
|
||||
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
|
||||
|
||||
if (!resolveCreate || !submittedPayload) throw new Error('create request did not start')
|
||||
const config = submittedPayload.config_json
|
||||
resolveCreate({
|
||||
...routingGroup(config?.allowed_models ?? [], {
|
||||
id: 'created-group',
|
||||
name: submittedPayload.name,
|
||||
description: submittedPayload.description,
|
||||
enabled: submittedPayload.enabled ?? false,
|
||||
is_system_default: submittedPayload.is_system_default ?? false,
|
||||
}),
|
||||
config_json: config ?? routingGroup().config_json,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
|
||||
expect(routeMocks.replace).not.toHaveBeenCalled()
|
||||
expect(apiMocks.createRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -151,69 +151,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-openai-image-sync-heartbeat"
|
||||
:checked="enableOpenaiImageSyncHeartbeat"
|
||||
@update:checked="$emit('update:enableOpenaiImageSyncHeartbeat', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="enable-openai-image-sync-heartbeat"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
同步生图心跳
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
开启后同步生图外层 HTTP 状态固定为 200,上游失败需读取响应体 error.upstream_status
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-standard-text-sync-heartbeat"
|
||||
:checked="enableStandardTextSyncHeartbeat"
|
||||
@update:checked="$emit('update:enableStandardTextSyncHeartbeat', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="enable-standard-text-sync-heartbeat"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
标准文本非流式心跳
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
开启后标准文本非流式接口外层 HTTP 状态固定为 200,上游失败需读取响应体 error.upstream_status
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="cyber-continue-failover"
|
||||
:checked="cyberContinueFailover"
|
||||
@update:checked="$emit('update:cyberContinueFailover', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="cyber-continue-failover"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
Cyber继续转移
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭时Cyber Policy错误直接返回客户端;开启后在响应内容开始前按普通错误继续故障转移,可能增加首字等待时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -539,9 +476,6 @@ defineProps<{
|
||||
registrationPrivacyPolicyVersion: string
|
||||
autoDeleteExpiredKeys: boolean
|
||||
enableFormatConversion: boolean
|
||||
enableOpenaiImageSyncHeartbeat: boolean
|
||||
enableStandardTextSyncHeartbeat: boolean
|
||||
cyberContinueFailover: boolean
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
@@ -568,8 +502,5 @@ defineEmits<{
|
||||
'update:registrationPrivacyPolicyVersion': [value: string]
|
||||
'update:autoDeleteExpiredKeys': [value: boolean]
|
||||
'update:enableFormatConversion': [value: boolean]
|
||||
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
|
||||
'update:enableStandardTextSyncHeartbeat': [value: boolean]
|
||||
'update:cyberContinueFailover': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,6 @@ describe('useSystemConfig', () => {
|
||||
resolveConfigs?.([
|
||||
{ key: 'request_record_level', value: 'basic' },
|
||||
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000 },
|
||||
{ key: 'enable_standard_text_sync_heartbeat', value: false },
|
||||
])
|
||||
await loadPromise
|
||||
|
||||
@@ -71,50 +70,6 @@ describe('useSystemConfig', () => {
|
||||
expect(state.hasLogConfigChanges.value).toBe(true)
|
||||
})
|
||||
|
||||
it('loads and saves the standard text sync heartbeat flag as a basic config item', async () => {
|
||||
getAllSystemConfigsMock.mockResolvedValue([
|
||||
{ key: 'enable_standard_text_sync_heartbeat', value: false },
|
||||
])
|
||||
updateSystemConfigMock.mockResolvedValue({})
|
||||
|
||||
const state = useSystemConfig()
|
||||
await state.loadSystemConfig()
|
||||
|
||||
expect(state.systemConfig.value.enable_standard_text_sync_heartbeat).toBe(false)
|
||||
state.systemConfig.value.enable_standard_text_sync_heartbeat = true
|
||||
expect(state.hasBasicConfigChanges.value).toBe(true)
|
||||
|
||||
await state.saveBasicConfig()
|
||||
|
||||
expect(updateSystemConfigMock).toHaveBeenCalledWith(
|
||||
'enable_standard_text_sync_heartbeat',
|
||||
true,
|
||||
'标准文本非流式心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体'
|
||||
)
|
||||
expect(state.hasBasicConfigChanges.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Cyber failover disabled by default and saves the enabled state', async () => {
|
||||
getAllSystemConfigsMock.mockResolvedValue([])
|
||||
updateSystemConfigMock.mockResolvedValue({})
|
||||
|
||||
const state = useSystemConfig()
|
||||
await state.loadSystemConfig()
|
||||
|
||||
expect(state.systemConfig.value.cyber_continue_failover).toBe(false)
|
||||
state.systemConfig.value.cyber_continue_failover = true
|
||||
expect(state.hasBasicConfigChanges.value).toBe(true)
|
||||
|
||||
await state.saveBasicConfig()
|
||||
|
||||
expect(updateSystemConfigMock).toHaveBeenCalledWith(
|
||||
'cyber_continue_failover',
|
||||
true,
|
||||
'Cyber继续转移开关:开启后在响应内容开始前将Cyber Policy错误按普通错误继续故障转移,可能增加首字等待时间'
|
||||
)
|
||||
expect(state.hasBasicConfigChanges.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses backend-compatible defaults when config rows have not been persisted yet', async () => {
|
||||
getAllSystemConfigsMock.mockResolvedValue([])
|
||||
|
||||
|
||||
@@ -33,12 +33,6 @@ export interface SystemConfig {
|
||||
auto_delete_expired_keys: boolean
|
||||
// 格式转换
|
||||
enable_format_conversion: boolean
|
||||
// 同步生图心跳
|
||||
enable_openai_image_sync_heartbeat: boolean
|
||||
// 标准文本非流式心跳
|
||||
enable_standard_text_sync_heartbeat: boolean
|
||||
// Cyber Policy 错误继续故障转移
|
||||
cyber_continue_failover: boolean
|
||||
// 请求记录
|
||||
request_record_level: string
|
||||
sensitive_headers: string[]
|
||||
@@ -89,12 +83,6 @@ const CONFIG_KEYS = [
|
||||
'auto_delete_expired_keys',
|
||||
// 格式转换
|
||||
'enable_format_conversion',
|
||||
// 同步生图心跳
|
||||
'enable_openai_image_sync_heartbeat',
|
||||
// 标准文本非流式心跳
|
||||
'enable_standard_text_sync_heartbeat',
|
||||
// Cyber Policy 错误继续故障转移
|
||||
'cyber_continue_failover',
|
||||
// 请求记录
|
||||
'request_record_level',
|
||||
'sensitive_headers',
|
||||
@@ -147,12 +135,6 @@ function createDefaultConfig(): SystemConfig {
|
||||
auto_delete_expired_keys: false,
|
||||
// 格式转换
|
||||
enable_format_conversion: false,
|
||||
// 同步生图心跳
|
||||
enable_openai_image_sync_heartbeat: false,
|
||||
// 标准文本非流式心跳
|
||||
enable_standard_text_sync_heartbeat: false,
|
||||
// Cyber Policy 错误继续故障转移
|
||||
cyber_continue_failover: false,
|
||||
// 请求记录
|
||||
request_record_level: 'full',
|
||||
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
||||
@@ -235,13 +217,7 @@ export function useSystemConfig() {
|
||||
systemConfig.value.registration_privacy_policy_version !==
|
||||
originalConfig.value.registration_privacy_policy_version ||
|
||||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
||||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
|
||||
systemConfig.value.enable_openai_image_sync_heartbeat !==
|
||||
originalConfig.value.enable_openai_image_sync_heartbeat ||
|
||||
systemConfig.value.enable_standard_text_sync_heartbeat !==
|
||||
originalConfig.value.enable_standard_text_sync_heartbeat ||
|
||||
systemConfig.value.cyber_continue_failover !==
|
||||
originalConfig.value.cyber_continue_failover
|
||||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
|
||||
)
|
||||
})
|
||||
|
||||
@@ -490,21 +466,6 @@ export function useSystemConfig() {
|
||||
value: systemConfig.value.enable_format_conversion,
|
||||
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换',
|
||||
},
|
||||
{
|
||||
key: 'enable_openai_image_sync_heartbeat',
|
||||
value: systemConfig.value.enable_openai_image_sync_heartbeat,
|
||||
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
|
||||
},
|
||||
{
|
||||
key: 'enable_standard_text_sync_heartbeat',
|
||||
value: systemConfig.value.enable_standard_text_sync_heartbeat,
|
||||
description: '标准文本非流式心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
|
||||
},
|
||||
{
|
||||
key: 'cyber_continue_failover',
|
||||
value: systemConfig.value.cyber_continue_failover,
|
||||
description: 'Cyber继续转移开关:开启后在响应内容开始前将Cyber Policy错误按普通错误继续故障转移,可能增加首字等待时间',
|
||||
},
|
||||
]
|
||||
const turnstileSecret = systemConfig.value.turnstile_secret_key.trim()
|
||||
if (turnstileSecret) {
|
||||
@@ -555,12 +516,6 @@ export function useSystemConfig() {
|
||||
systemConfig.value.auto_delete_expired_keys
|
||||
originalConfig.value.enable_format_conversion =
|
||||
systemConfig.value.enable_format_conversion
|
||||
originalConfig.value.enable_openai_image_sync_heartbeat =
|
||||
systemConfig.value.enable_openai_image_sync_heartbeat
|
||||
originalConfig.value.enable_standard_text_sync_heartbeat =
|
||||
systemConfig.value.enable_standard_text_sync_heartbeat
|
||||
originalConfig.value.cyber_continue_failover =
|
||||
systemConfig.value.cyber_continue_failover
|
||||
}
|
||||
success('基础配置已保存')
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user