mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
fix: preserve provider auth metadata
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="高级设置"
|
||||
description="冷却、健康、成本控制与其他高级参数"
|
||||
description="冷却、热池与其他高级参数"
|
||||
size="3xl"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
@@ -11,18 +11,18 @@
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
冷却与健康
|
||||
冷却与热池
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
核心策略
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制自动冷却、自适应热池、异常清理和全局调度优先级。
|
||||
控制冷却时间、自适应热池和异常清理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-3">
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="item in healthToggleCards"
|
||||
:key="item.key"
|
||||
@@ -134,175 +134,83 @@
|
||||
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
批量操作
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
任务效率
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-xl bg-muted/30 p-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
粘性会话 TTL
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
并发数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.sticky_session_ttl_seconds ?? ''"
|
||||
:model-value="form.batch_concurrency ?? ''"
|
||||
type="number"
|
||||
min="60"
|
||||
max="86400"
|
||||
placeholder="3600 (留空禁用)"
|
||||
@update:model-value="(v) => form.sticky_session_ttl_seconds = parseNum(v)"
|
||||
min="1"
|
||||
max="32"
|
||||
placeholder="8"
|
||||
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
|
||||
/>
|
||||
<p class="text-[11px] leading-5 text-muted-foreground">
|
||||
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测并发
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probe_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
placeholder="4"
|
||||
@update:model-value="(v) => form.probe_concurrency = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
全局优先级
|
||||
评分 Top-N
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.global_priority ?? ''"
|
||||
:model-value="form.score_top_n ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="999999"
|
||||
placeholder="留空回退 provider_priority"
|
||||
@update:model-value="(v) => form.global_priority = parseNum(v)"
|
||||
min="1"
|
||||
max="4096"
|
||||
placeholder="128"
|
||||
@update:model-value="(v) => form.score_top_n = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
回退扫描
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.score_fallback_scan_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100000"
|
||||
placeholder="1024"
|
||||
@update:model-value="(v) => form.score_fallback_scan_limit = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div :class="secondarySectionLayout.wrapperClass">
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
成本控制
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
额度保护
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制窗口期、Key 限额与软阈值,防止个别账号短时间内过度消耗。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-3 sm:grid-cols-2"
|
||||
:class="costFieldLayout.desktopColumnsClass"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
成本窗口
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_window_seconds ?? ''"
|
||||
type="number"
|
||||
min="3600"
|
||||
max="86400"
|
||||
placeholder="18000 (5 小时)"
|
||||
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
Key 窗口限额
|
||||
<span class="text-xs text-muted-foreground">(tokens)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_limit_per_key_tokens ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
软阈值
|
||||
<span class="text-xs text-muted-foreground">(%)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_soft_threshold_percent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="80"
|
||||
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
批量操作
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
任务效率
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-xl bg-muted/30 p-4 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
并发数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.batch_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
placeholder="8"
|
||||
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
|
||||
/>
|
||||
<p class="text-[11px] leading-5 text-muted-foreground">
|
||||
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测并发
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probe_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
placeholder="4"
|
||||
@update:model-value="(v) => form.probe_concurrency = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
评分 Top-N
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.score_top_n ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4096"
|
||||
placeholder="128"
|
||||
@update:model-value="(v) => form.score_top_n = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
回退扫描
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.score_fallback_scan_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100000"
|
||||
placeholder="1024"
|
||||
@update:model-value="(v) => form.score_fallback_scan_limit = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -625,9 +533,7 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
import {
|
||||
buildPoolCooldownFieldLayout,
|
||||
buildPoolCostFieldLayout,
|
||||
buildPoolHealthToggleCards,
|
||||
buildPoolSecondarySectionLayout,
|
||||
type PoolHealthToggleKey,
|
||||
} from '@/features/pool/utils/poolAdvancedDialog'
|
||||
import type {
|
||||
@@ -658,18 +564,10 @@ const isClaudeCode = computed(() => {
|
||||
|
||||
const healthToggleCards = buildPoolHealthToggleCards()
|
||||
const cooldownFieldLayout = buildPoolCooldownFieldLayout()
|
||||
const costFieldLayout = buildPoolCostFieldLayout()
|
||||
const secondarySectionLayout = buildPoolSecondarySectionLayout()
|
||||
|
||||
const form = ref({
|
||||
global_priority: null as number | null | undefined,
|
||||
sticky_session_ttl_seconds: null as number | null | undefined,
|
||||
health_policy_enabled: true,
|
||||
rate_limit_cooldown_seconds: null as number | null | undefined,
|
||||
overload_cooldown_seconds: null as number | null | undefined,
|
||||
cost_window_seconds: null as number | null | undefined,
|
||||
cost_limit_per_key_tokens: null as number | null | undefined,
|
||||
cost_soft_threshold_percent: null as number | null | undefined,
|
||||
batch_concurrency: null as number | null | undefined,
|
||||
probe_concurrency: null as number | null | undefined,
|
||||
score_top_n: null as number | null | undefined,
|
||||
@@ -721,8 +619,6 @@ function parseNum(v: string | number): number | undefined {
|
||||
|
||||
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':
|
||||
@@ -736,9 +632,6 @@ function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
|
||||
|
||||
function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void {
|
||||
switch (key) {
|
||||
case 'health_policy_enabled':
|
||||
form.value.health_policy_enabled = value
|
||||
return
|
||||
case 'probing_enabled':
|
||||
form.value.probing_enabled = value
|
||||
return
|
||||
@@ -760,14 +653,8 @@ watch(() => props.modelValue, (open) => {
|
||||
const scoreRules = cfg?.score_rules
|
||||
const scoreWeights = scoreRules?.weights
|
||||
form.value = {
|
||||
global_priority: cfg?.global_priority ?? null,
|
||||
sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null,
|
||||
health_policy_enabled: cfg?.health_policy_enabled !== false,
|
||||
rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null,
|
||||
overload_cooldown_seconds: cfg?.overload_cooldown_seconds ?? null,
|
||||
cost_window_seconds: cfg?.cost_window_seconds ?? null,
|
||||
cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null,
|
||||
cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null,
|
||||
batch_concurrency: cfg?.batch_concurrency ?? null,
|
||||
probe_concurrency: cfg?.probe_concurrency ?? null,
|
||||
score_top_n: cfg?.score_top_n ?? null,
|
||||
@@ -836,20 +723,20 @@ async function handleSave() {
|
||||
'self_check_method',
|
||||
'account_self_check_request',
|
||||
'self_check_request',
|
||||
'health_policy_enabled',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
]) {
|
||||
delete existingPoolAdvanced[key]
|
||||
}
|
||||
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
|
||||
const poolAdvanced: Record<string, unknown> = {
|
||||
...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,
|
||||
cost_limit_per_key_tokens: form.value.cost_limit_per_key_tokens ?? undefined,
|
||||
cost_soft_threshold_percent: form.value.cost_soft_threshold_percent ?? undefined,
|
||||
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
|
||||
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
|
||||
health_policy_enabled: form.value.health_policy_enabled,
|
||||
batch_concurrency: form.value.batch_concurrency ?? undefined,
|
||||
probe_concurrency: form.value.probe_concurrency ?? undefined,
|
||||
score_top_n: form.value.score_top_n ?? undefined,
|
||||
|
||||
@@ -3,14 +3,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPoolCooldownFieldLayout,
|
||||
buildPoolHealthToggleCards,
|
||||
buildPoolCostFieldLayout,
|
||||
buildPoolSecondarySectionLayout,
|
||||
} from '@/features/pool/utils/poolAdvancedDialog'
|
||||
|
||||
describe('poolAdvancedDialog', () => {
|
||||
it('returns health toggle cards in the desktop display order', () => {
|
||||
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
|
||||
'health_policy_enabled',
|
||||
'probing_enabled',
|
||||
'account_self_check_enabled',
|
||||
'auto_remove_banned_keys',
|
||||
@@ -20,11 +17,6 @@ describe('poolAdvancedDialog', () => {
|
||||
|
||||
it('provides tooltip copy for every desktop health toggle card', () => {
|
||||
expect(buildPoolHealthToggleCards()).toEqual([
|
||||
{
|
||||
key: 'health_policy_enabled',
|
||||
label: '健康策略',
|
||||
description: '按上游错误自动冷却并跳过异常账号。',
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '自适应热池',
|
||||
@@ -48,32 +40,13 @@ describe('poolAdvancedDialog', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the four cooldown-related fields in one desktop row order', () => {
|
||||
it('returns only cooldown-related fields in one desktop row order', () => {
|
||||
expect(buildPoolCooldownFieldLayout()).toEqual({
|
||||
fields: [
|
||||
'rate_limit_cooldown_seconds',
|
||||
'overload_cooldown_seconds',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-4',
|
||||
})
|
||||
})
|
||||
|
||||
it('stacks batch and cost sections as full-width rows on desktop', () => {
|
||||
expect(buildPoolSecondarySectionLayout()).toEqual({
|
||||
wrapperClass: 'space-y-4',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the three cost fields in one desktop row order', () => {
|
||||
expect(buildPoolCostFieldLayout()).toEqual({
|
||||
fields: [
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-3',
|
||||
desktopColumnsClass: 'xl:grid-cols-2',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export type PoolHealthToggleKey =
|
||||
| 'health_policy_enabled'
|
||||
| 'probing_enabled'
|
||||
| 'account_self_check_enabled'
|
||||
| 'auto_remove_banned_keys'
|
||||
@@ -16,22 +15,8 @@ export interface PoolCooldownFieldLayout {
|
||||
desktopColumnsClass: string
|
||||
}
|
||||
|
||||
export interface PoolSecondarySectionLayout {
|
||||
wrapperClass: string
|
||||
}
|
||||
|
||||
export interface PoolCostFieldLayout {
|
||||
fields: string[]
|
||||
desktopColumnsClass: string
|
||||
}
|
||||
|
||||
export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
|
||||
return [
|
||||
{
|
||||
key: 'health_policy_enabled',
|
||||
label: '健康策略',
|
||||
description: '按上游错误自动冷却并跳过异常账号。',
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '自适应热池',
|
||||
@@ -60,26 +45,7 @@ export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout {
|
||||
fields: [
|
||||
'rate_limit_cooldown_seconds',
|
||||
'overload_cooldown_seconds',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-4',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolSecondarySectionLayout(): PoolSecondarySectionLayout {
|
||||
return {
|
||||
wrapperClass: 'space-y-4',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolCostFieldLayout(): PoolCostFieldLayout {
|
||||
return {
|
||||
fields: [
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-3',
|
||||
desktopColumnsClass: 'xl:grid-cols-2',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,7 +1455,10 @@ const defaultBodyRulesByFormat = ref<Record<string, BodyRule[]>>({})
|
||||
const defaultBodyRulesLoaded = ref<Record<string, boolean>>({})
|
||||
const loadingDefaultBodyRulesByFormat = ref<Record<string, boolean>>({})
|
||||
|
||||
// 系统保留的 header 名称(不允许用户设置)
|
||||
// Endpoint 规则会经过通用 header/body rule 引擎执行。认证头、协议控制头和
|
||||
// 响应长度这类字段在后端同样是 protected keys;前端也保持同一语义,避免用户
|
||||
// 以为普通 endpoint rules 能改认证。OAuth/账号导入保留 headers 走 auth_config,
|
||||
// 不走这里的 endpoint header_rules。
|
||||
const RESERVED_HEADERS = new Set([
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
@@ -1463,6 +1466,7 @@ const RESERVED_HEADERS = new Set([
|
||||
'content-type',
|
||||
'content-length',
|
||||
'host',
|
||||
'proxy-authorization',
|
||||
])
|
||||
|
||||
const RESERVED_RESPONSE_HEADERS = new Set([
|
||||
@@ -1472,7 +1476,6 @@ const RESERVED_RESPONSE_HEADERS = new Set([
|
||||
const RESPONSE_HEADER_RULES_CONFIG_KEY = 'response_header_rules'
|
||||
const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules'
|
||||
|
||||
// 系统保留的 body 字段名(不允许用户设置)
|
||||
const RESERVED_BODY_FIELDS = new Set([
|
||||
'stream',
|
||||
])
|
||||
@@ -1539,7 +1542,41 @@ function requireJsonString(rule: Record<string, unknown>, key: string, label: st
|
||||
return typeof rule[key] === 'string' ? null : `${label}第 ${index + 1} 条:${key} 必须是字符串`
|
||||
}
|
||||
|
||||
function validateHeaderRuleJson(rule: unknown, label: string, index: number): string | null {
|
||||
function normalizeHeaderRuleName(raw: string): string {
|
||||
return raw.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function reservedHeaderRuleError(raw: string): string | null {
|
||||
const name = normalizeHeaderRuleName(raw)
|
||||
return name && RESERVED_HEADERS.has(name) ? `"${raw}" 是系统保留的请求头` : null
|
||||
}
|
||||
|
||||
function reservedResponseHeaderRuleError(raw: string): string | null {
|
||||
const name = normalizeHeaderRuleName(raw)
|
||||
return name && RESERVED_RESPONSE_HEADERS.has(name) ? `"${raw}" 是系统保留的响应头` : null
|
||||
}
|
||||
|
||||
function bodyRuleTopLevelField(rawPath: string): string | null {
|
||||
const raw = rawPath.trim()
|
||||
if (!raw) return null
|
||||
const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw
|
||||
const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw]
|
||||
return parts?.[0]?.trim().toLowerCase() || null
|
||||
}
|
||||
|
||||
function reservedBodyRuleFieldError(rawPath: string): string | null {
|
||||
const topField = bodyRuleTopLevelField(rawPath)
|
||||
return topField && RESERVED_BODY_FIELDS.has(topField)
|
||||
? `"${topField}" 是系统保留的顶层字段`
|
||||
: null
|
||||
}
|
||||
|
||||
function validateHeaderRuleJson(
|
||||
rule: unknown,
|
||||
label: string,
|
||||
index: number,
|
||||
reservedNameError: (raw: string) => string | null = reservedHeaderRuleError
|
||||
): string | null {
|
||||
if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
|
||||
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
|
||||
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
|
||||
@@ -1550,15 +1587,19 @@ function validateHeaderRuleJson(rule: unknown, label: string, index: number): st
|
||||
}
|
||||
if (action === 'set') {
|
||||
return requireJsonString(rule, 'key', label, index)
|
||||
|| reservedNameError(rule.key as string)
|
||||
|| requireJsonString(rule, 'value', label, index)
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'drop') {
|
||||
return requireJsonString(rule, 'key', label, index)
|
||||
|| reservedNameError(rule.key as string)
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
return requireJsonString(rule, 'from', label, index)
|
||||
|| requireJsonString(rule, 'to', label, index)
|
||||
|| reservedNameError(rule.from as string)
|
||||
|| reservedNameError(rule.to as string)
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
|
||||
@@ -1574,26 +1615,32 @@ function validateBodyRuleJson(rule: unknown, label: string, index: number): stri
|
||||
|
||||
if (action === 'set' || action === 'append') {
|
||||
return requireJsonString(rule, 'path', label, index)
|
||||
|| reservedBodyRuleFieldError(rule.path as string)
|
||||
|| (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`)
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'drop') {
|
||||
return requireJsonString(rule, 'path', label, index)
|
||||
|| reservedBodyRuleFieldError(rule.path as string)
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'rename') {
|
||||
return requireJsonString(rule, 'from', label, index)
|
||||
|| requireJsonString(rule, 'to', label, index)
|
||||
|| reservedBodyRuleFieldError(rule.from as string)
|
||||
|| reservedBodyRuleFieldError(rule.to as string)
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'insert') {
|
||||
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
|
||||
if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string)
|
||||
if (!Number.isInteger(rule.index)) return `${label}第 ${index + 1} 条:index 必须是整数`
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空`
|
||||
return validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'regex_replace') {
|
||||
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
|
||||
if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string)
|
||||
if (requireJsonString(rule, 'pattern', label, index)) return requireJsonString(rule, 'pattern', label, index)
|
||||
if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串`
|
||||
if (rule.flags !== undefined && typeof rule.flags !== 'string') return `${label}第 ${index + 1} 条:flags 必须是字符串`
|
||||
@@ -1633,7 +1680,12 @@ function parseEndpointRulesJsonDraft(draft: string): { value: EndpointRulesJsonP
|
||||
if (error) return { value: null, error }
|
||||
}
|
||||
for (let i = 0; i < response.value.length; i++) {
|
||||
const error = validateHeaderRuleJson(response.value[i], 'response_header_rules ', i)
|
||||
const error = validateHeaderRuleJson(
|
||||
response.value[i],
|
||||
'response_header_rules ',
|
||||
i,
|
||||
reservedResponseHeaderRuleError,
|
||||
)
|
||||
if (error) return { value: null, error }
|
||||
}
|
||||
|
||||
@@ -2334,9 +2386,8 @@ function validateRuleKeyForEndpoint(endpointId: string, key: string, index: numb
|
||||
const trimmedKey = key.trim().toLowerCase()
|
||||
if (!trimmedKey) return null
|
||||
|
||||
if (RESERVED_HEADERS.has(trimmedKey)) {
|
||||
return `"${key}" 是系统保留的请求头`
|
||||
}
|
||||
const reservedErr = reservedHeaderRuleError(key)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
const currentRule = rules[index]
|
||||
@@ -2359,6 +2410,9 @@ function validateRenameFromForEndpoint(endpointId: string, from: string, index:
|
||||
const trimmedFrom = from.trim().toLowerCase()
|
||||
if (!trimmedFrom) return null
|
||||
|
||||
const reservedErr = reservedHeaderRuleError(from)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
const currentRule = rules[index]
|
||||
if (currentRule && !currentRule.enabled) return null
|
||||
@@ -2380,9 +2434,8 @@ function validateRenameToForEndpoint(endpointId: string, to: string, index: numb
|
||||
const trimmedTo = to.trim().toLowerCase()
|
||||
if (!trimmedTo) return null
|
||||
|
||||
if (RESERVED_HEADERS.has(trimmedTo)) {
|
||||
return `"${to}" 是系统保留的请求头`
|
||||
}
|
||||
const reservedErr = reservedHeaderRuleError(to)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
const currentRule = rules[index]
|
||||
@@ -2525,11 +2578,8 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
||||
return '路径格式无效'
|
||||
}
|
||||
|
||||
// 提取顶层 key(去除数组索引部分)
|
||||
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||
}
|
||||
const reservedErr = reservedBodyRuleFieldError(raw)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const normalizedPath = raw.toLowerCase()
|
||||
|
||||
@@ -2560,10 +2610,8 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
|
||||
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||
}
|
||||
|
||||
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||
}
|
||||
const reservedErr = reservedBodyRuleFieldError(raw)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const normalizedFrom = raw.toLowerCase()
|
||||
|
||||
@@ -2593,10 +2641,8 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
||||
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||
}
|
||||
|
||||
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||
}
|
||||
const reservedErr = reservedBodyRuleFieldError(raw)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const normalizedTo = raw.toLowerCase()
|
||||
|
||||
@@ -3095,9 +3141,8 @@ function validateResponseHeaderNameForEndpoint(endpointId: string, name: string,
|
||||
const trimmedName = name.trim().toLowerCase()
|
||||
if (!trimmedName) return null
|
||||
|
||||
if ((field === 'key' || field === 'to') && RESERVED_RESPONSE_HEADERS.has(trimmedName)) {
|
||||
return `"${name}" 是系统保留的响应头`
|
||||
}
|
||||
const reservedErr = reservedResponseHeaderRuleError(name)
|
||||
if (reservedErr) return reservedErr
|
||||
|
||||
const rules = getEndpointEditResponseRules(endpointId)
|
||||
const currentRule = rules[index]
|
||||
|
||||
@@ -1352,6 +1352,7 @@ function parseImportText(text: string): {
|
||||
browser_profile?: string
|
||||
user_id?: string
|
||||
account_name?: string
|
||||
headers?: Record<string, string>
|
||||
} | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -1422,7 +1423,14 @@ function parseImportText(text: string): {
|
||||
const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim()
|
||||
? sessionToken.trim()
|
||||
: (typeof sessionTokenCamel === 'string' && sessionTokenCamel.trim() ? sessionTokenCamel.trim() : undefined)
|
||||
const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken
|
||||
const normalizedHeaders = normalizeHeadersField(obj.headers)
|
||||
?? normalizeHeadersField(obj.request_headers)
|
||||
?? normalizeHeadersField(obj.requestHeaders)
|
||||
?? normalizeHeadersField(obj.header_overrides)
|
||||
?? normalizeHeadersField(obj.headerOverrides)
|
||||
?? normalizeHeadersField(obj.extra_headers)
|
||||
?? normalizeHeadersField(obj.extraHeaders)
|
||||
const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken ?? bearerTokenFromHeaders(normalizedHeaders)
|
||||
if (normalizedRefreshToken || importedAccessToken) {
|
||||
return {
|
||||
refresh_token: normalizedRefreshToken,
|
||||
@@ -1441,6 +1449,7 @@ function parseImportText(text: string): {
|
||||
browser_profile: isGrokProvider.value ? normalizeStringField(obj.browser_profile) ?? normalizeStringField(obj.browserProfile) ?? normalizeStringField(obj.browser) ?? normalizeStringField(obj.impersonate) ?? grokCookieImport?.browser_profile : undefined,
|
||||
user_id: normalizeStringField(obj.user_id) ?? normalizeStringField(obj.userId) ?? normalizeStringField(obj.chatgpt_user_id) ?? normalizeStringField(obj.chatgptUserId),
|
||||
account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName),
|
||||
headers: normalizedHeaders,
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -1526,6 +1535,34 @@ function normalizeStringField(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function normalizeHeadersField(value: unknown): Record<string, string> | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
||||
const headers: Record<string, string> = {}
|
||||
for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const key = rawKey.trim().toLowerCase()
|
||||
if (!key || ['host', 'content-length', 'connection', 'transfer-encoding', 'proxy-authorization'].includes(key)) {
|
||||
continue
|
||||
}
|
||||
let headerValue: string | undefined
|
||||
if (typeof rawValue === 'string') {
|
||||
headerValue = rawValue.trim()
|
||||
} else if (typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
||||
headerValue = String(rawValue)
|
||||
}
|
||||
if (headerValue) {
|
||||
headers[key] = headerValue
|
||||
}
|
||||
}
|
||||
return Object.keys(headers).length > 0 ? headers : undefined
|
||||
}
|
||||
|
||||
function bearerTokenFromHeaders(headers: Record<string, string> | undefined): string | undefined {
|
||||
const authorization = headers?.authorization?.trim()
|
||||
if (!authorization) return undefined
|
||||
const match = authorization.match(/^bearer\s+(.+)$/i)
|
||||
return match?.[1]?.trim() || undefined
|
||||
}
|
||||
|
||||
function normalizeNumberField(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value)
|
||||
|
||||
@@ -323,14 +323,14 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[11px] font-mono text-muted-foreground">
|
||||
{{ isOAuthManagedCredential(key) ? '[Refresh Token]' : (isServiceAccountCredential(key) ? '[Service Account]' : key.api_key_masked) }}
|
||||
{{ getProviderMaskedSecretLabel(key, provider.provider_type) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="canExportOAuthCredential(key)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="下载 Refresh Token 授权文件"
|
||||
title="下载 OAuth 授权文件"
|
||||
@click.stop="downloadRefreshToken(key)"
|
||||
>
|
||||
<Download class="w-2.5 h-2.5" />
|
||||
@@ -1513,6 +1513,7 @@ import {
|
||||
canRefreshOAuthCredential,
|
||||
isOAuthManagedCredential,
|
||||
isServiceAccountCredential,
|
||||
getProviderMaskedSecretLabel,
|
||||
shouldShowOAuthRefreshControl,
|
||||
} from '@/utils/providerKeyAuth'
|
||||
import {
|
||||
|
||||
+36
@@ -260,6 +260,11 @@ function getButton(root: HTMLElement, text: string) {
|
||||
.find(button => button.textContent?.includes(text))
|
||||
}
|
||||
|
||||
function getExactButton(root: HTMLElement, text: string) {
|
||||
return Array.from(root.querySelectorAll('button'))
|
||||
.find(button => button.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
function getImportTextarea(root: HTMLElement) {
|
||||
const textarea = root.querySelector('textarea')
|
||||
if (!(textarea instanceof HTMLTextAreaElement)) {
|
||||
@@ -350,9 +355,40 @@ describe('OAuthAccountDialog Grok import', () => {
|
||||
account_id: undefined,
|
||||
account_user_id: undefined,
|
||||
user_id: undefined,
|
||||
headers: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Codex imported request headers on single JSON import', async () => {
|
||||
const root = mountDialog('codex')
|
||||
await settle()
|
||||
|
||||
getButton(root, '导入授权')?.click()
|
||||
await settle()
|
||||
|
||||
const textarea = getImportTextarea(root)
|
||||
textarea.value = JSON.stringify({
|
||||
access_token: 'jwt-access-token',
|
||||
headers: {
|
||||
authorization: 'Bearer session-token',
|
||||
},
|
||||
accountId: 'acct-1',
|
||||
})
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
getExactButton(root, '导入')?.click()
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', expect.objectContaining({
|
||||
access_token: 'jwt-access-token',
|
||||
headers: {
|
||||
authorization: 'Bearer session-token',
|
||||
},
|
||||
account_id: 'acct-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps Grok multiline token import on the batch task path', async () => {
|
||||
const root = mountDialog('grok')
|
||||
await settle()
|
||||
|
||||
@@ -931,7 +931,28 @@ const emit = defineEmits<{
|
||||
requestId?: string | null
|
||||
status?: RequestStateStatus
|
||||
statusCode?: number | null
|
||||
inputTokens?: number | null
|
||||
effectiveInputTokens?: number | null
|
||||
outputTokens?: number | null
|
||||
totalTokens?: number | null
|
||||
cacheCreationInputTokens?: number | null
|
||||
cacheCreationEphemeral5mInputTokens?: number | null
|
||||
cacheCreationEphemeral1hInputTokens?: number | null
|
||||
cacheReadInputTokens?: number | null
|
||||
cost?: number | null
|
||||
actualCost?: number | null
|
||||
responseTimeMs?: number | null
|
||||
firstByteTimeMs?: number | null
|
||||
isStream?: boolean | null
|
||||
upstreamIsStream?: boolean | null
|
||||
clientRequestedStream?: boolean | null
|
||||
clientIsStream?: boolean | null
|
||||
apiFormat?: string | null
|
||||
endpointApiFormat?: string | null
|
||||
hasFormatConversion?: boolean | null
|
||||
targetModel?: string | null
|
||||
reasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}]
|
||||
@@ -1092,6 +1113,28 @@ function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'st
|
||||
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
|
||||
}
|
||||
|
||||
function detailTotalCost(nextDetail: RequestDetail): number | null {
|
||||
const structuredCost = typeof nextDetail.cost === 'object' ? nextDetail.cost?.total : null
|
||||
const totalCost = toNumber(nextDetail.total_cost)
|
||||
const legacyCost = toNumber(nextDetail.cost)
|
||||
return [structuredCost, totalCost, legacyCost]
|
||||
.map(value => toNumber(value))
|
||||
.find((value): value is number => value != null && value > 0)
|
||||
?? toNumber(structuredCost)
|
||||
?? totalCost
|
||||
?? legacyCost
|
||||
}
|
||||
|
||||
function detailCacheCreation5mTokens(nextDetail: RequestDetail): number | null {
|
||||
return toNumber(nextDetail.cache_creation_input_tokens_5m)
|
||||
?? toNumber(nextDetail.cache_creation_ephemeral_5m_input_tokens)
|
||||
}
|
||||
|
||||
function detailCacheCreation1hTokens(nextDetail: RequestDetail): number | null {
|
||||
return toNumber(nextDetail.cache_creation_input_tokens_1h)
|
||||
?? toNumber(nextDetail.cache_creation_ephemeral_1h_input_tokens)
|
||||
}
|
||||
|
||||
function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
const id = props.requestId
|
||||
if (!id) return
|
||||
@@ -1101,7 +1144,28 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
requestId: nextDetail.request_id || nextDetail.id || null,
|
||||
status: resolveRequestStateStatusFromDetail(nextDetail),
|
||||
statusCode: nextDetail.status_code ?? undefined,
|
||||
inputTokens: nextDetail.input_tokens ?? nextDetail.tokens?.input ?? null,
|
||||
effectiveInputTokens: displayInputTokens.value,
|
||||
outputTokens: nextDetail.output_tokens ?? nextDetail.tokens?.output ?? null,
|
||||
totalTokens: nextDetail.total_tokens ?? nextDetail.tokens?.total ?? null,
|
||||
cacheCreationInputTokens: nextDetail.cache_creation_input_tokens ?? null,
|
||||
cacheCreationEphemeral5mInputTokens: detailCacheCreation5mTokens(nextDetail),
|
||||
cacheCreationEphemeral1hInputTokens: detailCacheCreation1hTokens(nextDetail),
|
||||
cacheReadInputTokens: nextDetail.cache_read_input_tokens ?? null,
|
||||
cost: detailTotalCost(nextDetail),
|
||||
actualCost: nextDetail.actual_cost ?? null,
|
||||
responseTimeMs: nextDetail.response_time_ms ?? undefined,
|
||||
firstByteTimeMs: nextDetail.first_byte_time_ms ?? null,
|
||||
isStream: nextDetail.is_stream ?? null,
|
||||
upstreamIsStream: nextDetail.upstream_is_stream ?? null,
|
||||
clientRequestedStream: nextDetail.client_requested_stream ?? null,
|
||||
clientIsStream: nextDetail.client_is_stream ?? null,
|
||||
apiFormat: nextDetail.api_format ?? null,
|
||||
endpointApiFormat: nextDetail.endpoint_api_format ?? null,
|
||||
hasFormatConversion: nextDetail.has_format_conversion ?? null,
|
||||
targetModel: nextDetail.target_model ?? null,
|
||||
reasoningEffort: nextDetail.reasoning_effort ?? null,
|
||||
serviceTier: nextDetail.service_tier ?? null,
|
||||
errorMessage: nextDetail.error_message ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,6 +147,172 @@ describe('useUsageData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves detail-filled usage metrics when a later list refresh is still empty', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
input_tokens: 0,
|
||||
effective_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_creation_ephemeral_5m_input_tokens: 0,
|
||||
cache_creation_ephemeral_1h_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cost: 0,
|
||||
actual_cost: 0,
|
||||
response_time_ms: null,
|
||||
first_byte_time_ms: null,
|
||||
is_stream: false,
|
||||
upstream_is_stream: false,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: false,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
Object.assign(currentRecords.value[0], {
|
||||
input_tokens: 1138,
|
||||
effective_input_tokens: 1138,
|
||||
output_tokens: 244,
|
||||
total_tokens: 81126,
|
||||
cache_creation_input_tokens: 17,
|
||||
cache_creation_ephemeral_5m_input_tokens: 5,
|
||||
cache_creation_ephemeral_1h_input_tokens: 12,
|
||||
cache_read_input_tokens: 79744,
|
||||
cost: 0.052882,
|
||||
actual_cost: 0.052882,
|
||||
response_time_ms: 5570,
|
||||
first_byte_time_ms: 1600,
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: true,
|
||||
client_is_stream: true,
|
||||
api_format: 'openai:responses',
|
||||
endpoint_api_format: 'openai:responses',
|
||||
has_format_conversion: false,
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
})
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
input_tokens: 0,
|
||||
effective_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_creation_ephemeral_5m_input_tokens: 0,
|
||||
cache_creation_ephemeral_1h_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cost: 0,
|
||||
actual_cost: 0,
|
||||
response_time_ms: null,
|
||||
first_byte_time_ms: null,
|
||||
is_stream: false,
|
||||
upstream_is_stream: false,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: false,
|
||||
api_format: undefined,
|
||||
endpoint_api_format: undefined,
|
||||
has_format_conversion: undefined,
|
||||
has_retry: false,
|
||||
target_model: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
input_tokens: 1138,
|
||||
effective_input_tokens: 1138,
|
||||
output_tokens: 244,
|
||||
total_tokens: 81126,
|
||||
cache_creation_input_tokens: 17,
|
||||
cache_creation_ephemeral_5m_input_tokens: 5,
|
||||
cache_creation_ephemeral_1h_input_tokens: 12,
|
||||
cache_read_input_tokens: 79744,
|
||||
cost: 0.052882,
|
||||
actual_cost: 0.052882,
|
||||
response_time_ms: 5570,
|
||||
first_byte_time_ms: 1600,
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: true,
|
||||
client_is_stream: true,
|
||||
api_format: 'openai:responses',
|
||||
endpoint_api_format: 'openai:responses',
|
||||
has_format_conversion: false,
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows finalized list metrics to replace larger detail estimates', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
cost: 0.09,
|
||||
actual_cost: 0.09,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
input_tokens: 1100,
|
||||
output_tokens: 250,
|
||||
total_tokens: 1350,
|
||||
cost: 0.07,
|
||||
actual_cost: 0.07,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
input_tokens: 1100,
|
||||
output_tokens: 250,
|
||||
total_tokens: 1350,
|
||||
cost: 0.07,
|
||||
actual_cost: 0.07,
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes exact admin record totals after an estimated first page', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
|
||||
|
||||
@@ -436,6 +436,38 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
return existingValue ?? nextValue
|
||||
}
|
||||
|
||||
function mergeSparseRecordMetric(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
): number | null | undefined {
|
||||
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
|
||||
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
|
||||
|
||||
if (nextIsPositive) {
|
||||
return nextValue
|
||||
}
|
||||
if (existingIsPositive) {
|
||||
return existingValue
|
||||
}
|
||||
return existingValue ?? nextValue
|
||||
}
|
||||
|
||||
function mergeBooleanTrueWins(
|
||||
existingValue: boolean | null | undefined,
|
||||
nextValue: boolean | null | undefined
|
||||
): boolean | undefined {
|
||||
if (existingValue === true || nextValue === true) {
|
||||
return true
|
||||
}
|
||||
if (typeof nextValue === 'boolean') {
|
||||
return nextValue
|
||||
}
|
||||
if (typeof existingValue === 'boolean') {
|
||||
return existingValue
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mergeRecordStatus(
|
||||
current: UsageRecord[],
|
||||
next: UsageRecord[]
|
||||
@@ -466,104 +498,94 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
(nextRank === currentRank && existing.status === record.status)
|
||||
)
|
||||
const mergedStatus = statusProgressed ? record.status : existing.status
|
||||
const protectStatus = mergedStatus !== record.status
|
||||
|
||||
// 确定是否需要保护 provider(避免 pending/unknown/unknow 覆盖已有的正确值)
|
||||
const isPendingProvider = !isUsageProviderVisible(record.provider)
|
||||
const hasValidExistingProvider = isUsageProviderVisible(existing.provider)
|
||||
const protectProvider = isPendingProvider && hasValidExistingProvider
|
||||
|
||||
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
|
||||
if (protectStatus) {
|
||||
const recordUpstreamIsStream = typeof record.upstream_is_stream === 'boolean'
|
||||
? record.upstream_is_stream
|
||||
: typeof record.is_stream === 'boolean'
|
||||
? record.is_stream
|
||||
: undefined
|
||||
const existingUpstreamIsStream = typeof existing.upstream_is_stream === 'boolean'
|
||||
? existing.upstream_is_stream
|
||||
: typeof existing.is_stream === 'boolean'
|
||||
? existing.is_stream
|
||||
: undefined
|
||||
const upstreamIsStream = recordUpstreamIsStream ?? existingUpstreamIsStream ?? false
|
||||
const recordUpstreamIsStream = typeof record.upstream_is_stream === 'boolean'
|
||||
? record.upstream_is_stream
|
||||
: typeof record.is_stream === 'boolean'
|
||||
? record.is_stream
|
||||
: undefined
|
||||
const existingUpstreamIsStream = typeof existing.upstream_is_stream === 'boolean'
|
||||
? existing.upstream_is_stream
|
||||
: typeof existing.is_stream === 'boolean'
|
||||
? existing.is_stream
|
||||
: undefined
|
||||
const upstreamIsStream = mergeBooleanTrueWins(existingUpstreamIsStream, recordUpstreamIsStream) ?? false
|
||||
|
||||
const recordClientRequestedStream = typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: undefined
|
||||
const existingClientRequestedStream = typeof existing.client_requested_stream === 'boolean'
|
||||
? existing.client_requested_stream
|
||||
: typeof existing.client_is_stream === 'boolean'
|
||||
? existing.client_is_stream
|
||||
: undefined
|
||||
const clientRequestedStream = recordClientRequestedStream ?? existingClientRequestedStream
|
||||
|
||||
const recordClientIsStream = typeof record.client_is_stream === 'boolean'
|
||||
const recordClientRequestedStream = typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: undefined
|
||||
const existingClientIsStream = typeof existing.client_is_stream === 'boolean'
|
||||
: undefined
|
||||
const existingClientRequestedStream = typeof existing.client_requested_stream === 'boolean'
|
||||
? existing.client_requested_stream
|
||||
: typeof existing.client_is_stream === 'boolean'
|
||||
? existing.client_is_stream
|
||||
: typeof existing.client_requested_stream === 'boolean'
|
||||
? existing.client_requested_stream
|
||||
: undefined
|
||||
const clientIsStream = recordClientIsStream ?? existingClientIsStream ?? clientRequestedStream
|
||||
: undefined
|
||||
const clientRequestedStream = mergeBooleanTrueWins(existingClientRequestedStream, recordClientRequestedStream)
|
||||
|
||||
return {
|
||||
...record,
|
||||
// 保留本地的状态和所有通过轮询更新的字段
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
input_tokens: Number.isFinite(record.input_tokens)
|
||||
? record.input_tokens
|
||||
: existing.input_tokens,
|
||||
effective_input_tokens: record.effective_input_tokens ?? existing.effective_input_tokens,
|
||||
output_tokens: existing.output_tokens || record.output_tokens,
|
||||
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens:
|
||||
existing.cache_creation_ephemeral_5m_input_tokens
|
||||
?? record.cache_creation_ephemeral_5m_input_tokens,
|
||||
cache_creation_ephemeral_1h_input_tokens:
|
||||
existing.cache_creation_ephemeral_1h_input_tokens
|
||||
?? record.cache_creation_ephemeral_1h_input_tokens,
|
||||
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
||||
cost: existing.cost || record.cost,
|
||||
actual_cost: existing.actual_cost ?? record.actual_cost,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
updated_at: existing.updated_at ?? record.updated_at,
|
||||
response_time_updated_at: existing.response_time_updated_at ?? record.response_time_updated_at,
|
||||
status_code: existing.status_code ?? record.status_code,
|
||||
error_message: existing.error_message ?? record.error_message,
|
||||
image_progress: existing.image_progress ?? record.image_progress,
|
||||
is_stream: upstreamIsStream,
|
||||
upstream_is_stream: upstreamIsStream,
|
||||
client_requested_stream: clientRequestedStream,
|
||||
client_is_stream: clientIsStream,
|
||||
api_format: existing.api_format || record.api_format,
|
||||
endpoint_api_format: existing.endpoint_api_format || record.endpoint_api_format,
|
||||
has_format_conversion: existing.has_format_conversion ?? record.has_format_conversion,
|
||||
has_fallback: existing.has_fallback === true || record.has_fallback === true,
|
||||
api_key_name: existing.api_key_name || record.api_key_name,
|
||||
provider_key_name: existing.provider_key_name || record.provider_key_name,
|
||||
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
|
||||
target_model: existing.target_model || record.target_model,
|
||||
reasoning_effort: existing.reasoning_effort || record.reasoning_effort,
|
||||
service_tier: existing.service_tier || record.service_tier
|
||||
}
|
||||
const recordClientIsStream = typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: undefined
|
||||
const existingClientIsStream = typeof existing.client_is_stream === 'boolean'
|
||||
? existing.client_is_stream
|
||||
: typeof existing.client_requested_stream === 'boolean'
|
||||
? existing.client_requested_stream
|
||||
: undefined
|
||||
const clientIsStream = mergeBooleanTrueWins(existingClientIsStream, recordClientIsStream) ?? clientRequestedStream
|
||||
|
||||
return {
|
||||
...record,
|
||||
// 保留详情抽屉/活跃轮询已经拿到的完整指标,避免列表刷新用 0 或空值回退。
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
input_tokens: mergeSparseRecordMetric(existing.input_tokens, record.input_tokens) ?? record.input_tokens,
|
||||
effective_input_tokens: mergeSparseRecordMetric(existing.effective_input_tokens, record.effective_input_tokens) ?? record.effective_input_tokens,
|
||||
output_tokens: mergeSparseRecordMetric(existing.output_tokens, record.output_tokens) ?? record.output_tokens,
|
||||
total_tokens: mergeSparseRecordMetric(existing.total_tokens, record.total_tokens) ?? record.total_tokens,
|
||||
cache_creation_input_tokens: mergeSparseRecordMetric(existing.cache_creation_input_tokens, record.cache_creation_input_tokens) ?? record.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens:
|
||||
mergeSparseRecordMetric(
|
||||
existing.cache_creation_ephemeral_5m_input_tokens,
|
||||
record.cache_creation_ephemeral_5m_input_tokens
|
||||
) ?? record.cache_creation_ephemeral_5m_input_tokens,
|
||||
cache_creation_ephemeral_1h_input_tokens:
|
||||
mergeSparseRecordMetric(
|
||||
existing.cache_creation_ephemeral_1h_input_tokens,
|
||||
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,
|
||||
actual_cost: mergeSparseRecordMetric(existing.actual_cost, record.actual_cost) ?? record.actual_cost,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
updated_at: record.updated_at ?? existing.updated_at,
|
||||
response_time_updated_at: record.response_time_updated_at ?? existing.response_time_updated_at,
|
||||
status_code: record.status_code ?? existing.status_code,
|
||||
error_message: record.error_message ?? existing.error_message,
|
||||
image_progress: record.image_progress ?? existing.image_progress,
|
||||
is_stream: upstreamIsStream,
|
||||
upstream_is_stream: upstreamIsStream,
|
||||
client_requested_stream: clientRequestedStream,
|
||||
client_is_stream: clientIsStream,
|
||||
api_format: record.api_format || existing.api_format,
|
||||
endpoint_api_format: record.endpoint_api_format || existing.endpoint_api_format,
|
||||
has_format_conversion: record.has_format_conversion ?? existing.has_format_conversion,
|
||||
has_fallback: existing.has_fallback === true || record.has_fallback === true,
|
||||
has_retry: existing.has_retry === true || record.has_retry === true,
|
||||
api_key_name: record.api_key_name || existing.api_key_name,
|
||||
provider_key_name: record.provider_key_name || existing.provider_key_name,
|
||||
rate_multiplier: record.rate_multiplier ?? existing.rate_multiplier,
|
||||
target_model: record.target_model ?? existing.target_model,
|
||||
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
|
||||
service_tier: record.service_tier ?? existing.service_tier
|
||||
}
|
||||
|
||||
// 只需要保护 provider
|
||||
if (protectProvider) {
|
||||
return {
|
||||
...record,
|
||||
provider: existing.provider
|
||||
}
|
||||
}
|
||||
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user