feat: add adaptive pool metrics and self-check

This commit is contained in:
fawney19
2026-05-15 01:46:24 +08:00
parent bf511f9f8c
commit 54a8312e46
34 changed files with 4521 additions and 300 deletions

View File

@@ -89,6 +89,75 @@
</div>
</div>
<div
v-if="form.account_self_check_enabled"
class="space-y-3 rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
>
<div class="grid gap-3 sm:grid-cols-3">
<div class="space-y-1.5">
<Label>
自检间隔
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="form.account_self_check_interval_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="60"
@update:model-value="(v) => form.account_self_check_interval_minutes = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
自检并发
</Label>
<Input
:model-value="form.account_self_check_concurrency ?? ''"
type="number"
min="1"
max="64"
placeholder="4"
@update:model-value="(v) => form.account_self_check_concurrency = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
自检方式
</Label>
<div class="flex w-fit gap-0.5 rounded-md bg-muted/40 p-0.5">
<button
v-for="opt in accountSelfCheckMethodOptions"
:key="opt.value"
type="button"
class="rounded px-2.5 py-1 text-xs font-medium transition-all"
:class="[
form.account_self_check_method === opt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'
]"
@click="form.account_self_check_method = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<div
v-if="form.account_self_check_method === 'custom_request'"
class="space-y-1.5"
>
<Label>请求配置</Label>
<Textarea
v-model="form.account_self_check_request_text"
class="min-h-[160px] font-mono text-xs leading-5"
spellcheck="false"
placeholder='{"path":"/v1/models","success_status_codes":[200],"blocked_status_codes":[401,403]}'
/>
</div>
</div>
<div
class="grid gap-3 sm:grid-cols-2"
:class="cooldownFieldLayout.desktopColumnsClass"
@@ -301,7 +370,7 @@
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
调整主动探测、健康、额度、延迟和使用成本进入号池候选排序时的权重。
调整探测结果、健康、额度、延迟和使用成本进入号池候选排序时的权重。
</p>
</div>
@@ -606,7 +675,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { CircleHelp } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { Dialog, Button, Input, Label, Switch, Textarea, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
@@ -647,6 +716,12 @@ const healthToggleCards = buildPoolHealthToggleCards()
const cooldownFieldLayout = buildPoolCooldownFieldLayout()
const costFieldLayout = buildPoolCostFieldLayout()
const secondarySectionLayout = buildPoolSecondarySectionLayout()
const accountSelfCheckMethodOptions = [
{ value: 'quota_refresh', label: '刷新额度' },
{ value: 'custom_request', label: '自定义请求' },
] as const
type AccountSelfCheckMethod = typeof accountSelfCheckMethodOptions[number]['value']
const form = ref({
global_priority: null as number | null | undefined,
@@ -674,6 +749,11 @@ const form = ref({
probe_failure_cooldown_threshold: null as number | null | undefined,
probing_enabled: false,
probing_interval_minutes: null as number | null | undefined,
account_self_check_enabled: false,
account_self_check_interval_minutes: null as number | null | undefined,
account_self_check_concurrency: null as number | null | undefined,
account_self_check_method: 'quota_refresh' as AccountSelfCheckMethod,
account_self_check_request_text: '',
auto_remove_banned_keys: false,
skip_exhausted_accounts: false,
})
@@ -704,12 +784,45 @@ function parseNum(v: string | number): number | undefined {
return Number.isNaN(n) ? undefined : n
}
function normalizeAccountSelfCheckMethod(value: unknown): AccountSelfCheckMethod {
return value === 'custom_request' ? 'custom_request' : 'quota_refresh'
}
function formatJsonForTextarea(value: unknown): string {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return ''
}
return JSON.stringify(value, null, 2)
}
function parseJsonObjectText(text: string): Record<string, unknown> | undefined | null {
const trimmed = text.trim()
if (!trimmed) return undefined
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
showError('账号自检请求 JSON 格式不正确')
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
showError('账号自检请求必须是 JSON 对象')
return null
}
return parsed as Record<string, unknown>
}
function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
switch (key) {
case 'health_policy_enabled':
return form.value.health_policy_enabled
case 'probing_enabled':
return form.value.probing_enabled
case 'account_self_check_enabled':
return form.value.account_self_check_enabled
case 'auto_remove_banned_keys':
return form.value.auto_remove_banned_keys
case 'skip_exhausted_accounts':
@@ -725,6 +838,9 @@ function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void
case 'probing_enabled':
form.value.probing_enabled = value
return
case 'account_self_check_enabled':
form.value.account_self_check_enabled = value
return
case 'auto_remove_banned_keys':
form.value.auto_remove_banned_keys = value
return
@@ -765,6 +881,11 @@ watch(() => props.modelValue, (open) => {
probe_failure_cooldown_threshold: scoreRules?.probe_failure_cooldown_threshold ?? null,
probing_enabled: cfg?.probing_enabled ?? false,
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
account_self_check_enabled: cfg?.account_self_check_enabled ?? false,
account_self_check_interval_minutes: cfg?.account_self_check_interval_minutes ?? null,
account_self_check_concurrency: cfg?.account_self_check_concurrency ?? null,
account_self_check_method: normalizeAccountSelfCheckMethod(cfg?.account_self_check_method),
account_self_check_request_text: formatJsonForTextarea(cfg?.account_self_check_request),
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
skip_exhausted_accounts: cfg?.skip_exhausted_accounts ?? false,
}
@@ -784,6 +905,12 @@ watch(() => props.modelValue, (open) => {
async function handleSave() {
loading.value = true
try {
const accountSelfCheckRequest = form.value.account_self_check_enabled
&& form.value.account_self_check_method === 'custom_request'
? parseJsonObjectText(form.value.account_self_check_request_text)
: undefined
if (accountSelfCheckRequest === null) return
const scoreRules = {
...(props.currentConfig?.score_rules ?? {}),
weights: {
@@ -801,9 +928,20 @@ async function handleSave() {
request_failure_penalty: form.value.request_failure_penalty ?? undefined,
probe_failure_cooldown_threshold: form.value.probe_failure_cooldown_threshold ?? undefined,
}
const existingPoolAdvanced: Record<string, unknown> = { ...(props.currentConfig ?? {}) }
for (const key of [
'probing_target_percent',
'probing_target_count',
'probing_active_target_percent',
'probing_active_target_count',
'active_probe_target_percent',
'active_probe_target_count',
]) {
delete existingPoolAdvanced[key]
}
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
const poolAdvanced: Record<string, unknown> = {
...(props.currentConfig ?? {}),
...existingPoolAdvanced,
global_priority: form.value.global_priority ?? undefined,
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
cost_window_seconds: form.value.cost_window_seconds ?? undefined,
@@ -821,6 +959,20 @@ async function handleSave() {
probing_interval_minutes: form.value.probing_enabled
? (form.value.probing_interval_minutes ?? undefined)
: undefined,
account_self_check_enabled: form.value.account_self_check_enabled,
account_self_check_interval_minutes: form.value.account_self_check_enabled
? (form.value.account_self_check_interval_minutes ?? undefined)
: undefined,
account_self_check_concurrency: form.value.account_self_check_enabled
? (form.value.account_self_check_concurrency ?? undefined)
: undefined,
account_self_check_method: form.value.account_self_check_enabled
? form.value.account_self_check_method
: undefined,
account_self_check_request: form.value.account_self_check_enabled
&& form.value.account_self_check_method === 'custom_request'
? accountSelfCheckRequest
: undefined,
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
skip_exhausted_accounts: form.value.skip_exhausted_accounts,
}

View File

@@ -0,0 +1,311 @@
<template>
<Dialog
:model-value="modelValue"
:no-padding="true"
size="3xl"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="border-b border-border px-4 py-4 sm:px-6">
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
自适应热池指标
</h3>
<p class="text-xs text-muted-foreground">
{{ providerName || '当前 Provider' }}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
title="关闭"
@click="emit('update:modelValue', false)"
>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
<div class="max-h-[calc(100dvh-13rem)] space-y-4 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:pr-2">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div
v-for="item in summaryCards"
:key="item.label"
class="rounded-lg border border-border/60 bg-card/70 px-3 py-3"
>
<div class="text-xs text-muted-foreground">
{{ item.label }}
</div>
<div class="mt-2 text-xl font-semibold tabular-nums">
{{ item.value }}
</div>
<div class="mt-1 text-[11px] text-muted-foreground">
{{ item.hint }}
</div>
</div>
</div>
<section class="rounded-lg border border-border/60 bg-card/70 p-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
最近采样
</h3>
<p class="text-xs text-muted-foreground">
{{ sampleWindowText }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<span
v-for="item in legendItems"
:key="item.label"
class="inline-flex items-center gap-1.5 text-muted-foreground"
>
<span
class="h-2 w-2 rounded-full"
:class="item.dotClass"
/>
{{ item.label }}
</span>
</div>
</div>
<div
v-if="samples.length === 0"
class="mt-4 flex h-56 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm text-muted-foreground"
>
暂无采样
</div>
<div
v-else
class="mt-4 h-56 rounded-lg border border-border/50 bg-background/60 p-3"
>
<svg
class="h-full w-full overflow-visible"
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
preserveAspectRatio="none"
role="img"
aria-label="自适应热池趋势"
>
<line
v-for="tick in yTicks"
:key="tick.y"
x1="0"
:x2="chartWidth"
:y1="tick.y"
:y2="tick.y"
class="stroke-border/70"
stroke-width="1"
/>
<polyline
v-if="desiredHotLine"
:points="desiredHotLine"
fill="none"
stroke="rgb(99 102 241)"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<polyline
v-if="hotLine"
:points="hotLine"
fill="none"
stroke="rgb(16 185 129)"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<polyline
v-if="inFlightLine"
:points="inFlightLine"
fill="none"
stroke="rgb(245 158 11)"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<polyline
v-if="emaLine"
:points="emaLine"
fill="none"
stroke="rgb(14 165 233)"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<circle
v-for="burst in burstPoints"
:key="`${burst.x}-${burst.y}`"
:cx="burst.x"
:cy="burst.y"
r="3"
fill="rgb(239 68 68)"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
<div class="mt-3 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>{{ firstSampleTime }}</span>
<span>峰值 {{ maxChartValueText }}</span>
<span>{{ lastSampleTime }}</span>
</div>
</section>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Button, Dialog } from '@/components/ui'
import { X } from 'lucide-vue-next'
export interface PoolDemandMetricSample {
providerId: string
sampledAt: number
hotCount: number
desiredHot: number
inFlight: number
emaInFlight: number
burstPending: boolean
}
const props = defineProps<{
modelValue: boolean
providerName?: string | null
samples: PoolDemandMetricSample[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const chartWidth = 640
const chartHeight = 220
const yTickRatios = [0, 0.25, 0.5, 0.75, 1]
const latest = computed(() => props.samples.at(-1) ?? null)
const maxChartValue = computed(() => {
const maxValue = props.samples.reduce((max, sample) => {
return Math.max(
max,
sample.hotCount,
sample.desiredHot,
sample.inFlight,
sample.emaInFlight,
)
}, 1)
return Math.max(1, Math.ceil(maxValue))
})
const yTicks = computed(() => {
return yTickRatios.map(ratio => ({
y: chartHeight * ratio,
}))
})
function formatMetric(value: number, fractionDigits = 0): string {
if (!Number.isFinite(value) || value <= 0) {
return fractionDigits > 0 ? '0.0' : '0'
}
return value.toFixed(fractionDigits)
}
function formatSampleTime(timestamp: number | undefined): string {
if (!timestamp) return '--:--:--'
const date = new Date(timestamp)
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function buildLine(valueOf: (sample: PoolDemandMetricSample) => number): string {
if (props.samples.length === 0) return ''
const maxValue = maxChartValue.value
const widthStep = props.samples.length > 1
? chartWidth / (props.samples.length - 1)
: 0
return props.samples
.map((sample, index) => {
const x = props.samples.length > 1 ? index * widthStep : chartWidth / 2
const normalized = Math.max(0, Math.min(valueOf(sample), maxValue))
const y = chartHeight - ((normalized / maxValue) * chartHeight)
return `${x.toFixed(2)},${y.toFixed(2)}`
})
.join(' ')
}
const hotLine = computed(() => buildLine(sample => sample.hotCount))
const desiredHotLine = computed(() => buildLine(sample => sample.desiredHot))
const inFlightLine = computed(() => buildLine(sample => sample.inFlight))
const emaLine = computed(() => buildLine(sample => sample.emaInFlight))
const burstPoints = computed(() => {
if (props.samples.length === 0) return []
const widthStep = props.samples.length > 1
? chartWidth / (props.samples.length - 1)
: 0
const maxValue = maxChartValue.value
return props.samples
.map((sample, index) => {
if (!sample.burstPending) return null
const x = props.samples.length > 1 ? index * widthStep : chartWidth / 2
const normalized = Math.max(0, Math.min(sample.desiredHot, maxValue))
const y = chartHeight - ((normalized / maxValue) * chartHeight)
return { x, y }
})
.filter((point): point is { x: number, y: number } => point !== null)
})
const summaryCards = computed(() => {
const sample = latest.value
return [
{
label: '热池',
value: sample ? `${sample.hotCount} / ${sample.desiredHot}` : '0 / 0',
hint: '当前 / 目标',
},
{
label: 'in-flight',
value: sample ? formatMetric(sample.inFlight) : '0',
hint: '正在执行',
},
{
label: 'EMA',
value: sample ? formatMetric(sample.emaInFlight, 1) : '0.0',
hint: '平滑热度',
},
{
label: 'Burst',
value: sample?.burstPending ? '补热中' : '空闲',
hint: '异步补位',
},
]
})
const legendItems = [
{ label: '目标', dotClass: 'bg-indigo-500' },
{ label: '热池', dotClass: 'bg-emerald-500' },
{ label: 'in-flight', dotClass: 'bg-amber-500' },
{ label: 'EMA', dotClass: 'bg-sky-500' },
{ label: 'Burst', dotClass: 'bg-red-500' },
]
const sampleWindowText = computed(() => {
const count = props.samples.length
if (count === 0) return '等待下一次采样'
return `最近 ${count} 个采样点`
})
const firstSampleTime = computed(() => formatSampleTime(props.samples[0]?.sampledAt))
const lastSampleTime = computed(() => formatSampleTime(latest.value?.sampledAt))
const maxChartValueText = computed(() => formatMetric(maxChartValue.value))
</script>

View File

@@ -12,6 +12,7 @@ describe('poolAdvancedDialog', () => {
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
'health_policy_enabled',
'probing_enabled',
'account_self_check_enabled',
'auto_remove_banned_keys',
'skip_exhausted_accounts',
])
@@ -27,7 +28,12 @@ describe('poolAdvancedDialog', () => {
{
key: 'probing_enabled',
label: '主动探测',
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
description: '自动维护热池,缺口时异步补位。',
},
{
key: 'account_self_check_enabled',
label: '账号自检',
description: '定时确认封号状态,默认刷新额度,也可使用自定义请求。',
},
{
key: 'auto_remove_banned_keys',

View File

@@ -1,6 +1,7 @@
export type PoolHealthToggleKey =
| 'health_policy_enabled'
| 'probing_enabled'
| 'account_self_check_enabled'
| 'auto_remove_banned_keys'
| 'skip_exhausted_accounts'
@@ -34,7 +35,12 @@ export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
{
key: 'probing_enabled',
label: '主动探测',
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
description: '自动维护热池,缺口时异步补位。',
},
{
key: 'account_self_check_enabled',
label: '账号自检',
description: '定时确认封号状态,默认刷新额度,也可使用自定义请求。',
},
{
key: 'auto_remove_banned_keys',

View File

@@ -3,7 +3,7 @@
<!-- Header -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
号池状态
</h3>
@@ -21,6 +21,34 @@
>
{{ poolStatus.total_sticky_sessions }} 个粘性会话
</Badge>
<Badge
v-if="poolStatus && poolStatus.provider_desired_hot > 0"
variant="outline"
class="text-xs"
>
热池 {{ poolStatus.provider_hot_count }} / {{ poolStatus.provider_desired_hot }}
</Badge>
<Badge
v-if="poolStatus && poolStatus.provider_in_flight > 0"
variant="outline"
class="text-xs"
>
in-flight {{ poolStatus.provider_in_flight }}
</Badge>
<Badge
v-if="poolStatus && poolStatus.provider_desired_hot > 0"
variant="outline"
class="text-xs"
>
EMA {{ formatEmaHeat(poolStatus.provider_ema_in_flight) }}
</Badge>
<Badge
v-if="poolStatus?.provider_burst_pending"
variant="secondary"
class="text-xs"
>
补热中
</Badge>
</div>
<RefreshButton
:loading="refreshing"
@@ -265,6 +293,11 @@ function formatTokens(tokens: number): string {
return String(tokens)
}
function formatEmaHeat(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0.0'
return value.toFixed(1)
}
function getCostBarColor(usage: number, limit: number): string {
const ratio = usage / limit
if (ratio >= 0.9) return 'bg-red-500'