Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
@@ -36,6 +36,21 @@
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
{{ getFinalStatusLabel(computedFinalStatus) }}
</Badge>
<!-- 被跳过的候选可以收起,避免长时间线上排查时干扰视线 -->
<button
v-if="skippedTraceCandidates.length > 0"
type="button"
class="skipped-toggle"
:class="{ active: showSkippedCandidates }"
:aria-pressed="showSkippedCandidates"
:title="showSkippedCandidates
? '隐藏被跳过的候选'
: '被跳过的候选在调度阶段就被判定本次不可用,从未向上游发起请求'"
data-timeline-skipped-toggle
@click="showSkippedCandidates = !showSkippedCandidates"
>
{{ showSkippedCandidates ? '隐藏' : '显示' }}被跳过候选({{ skippedTraceCandidates.length }})
</button>
</div>
<div class="text-sm text-muted-foreground">
{{ formatLatency(totalTraceLatency) }}
@@ -449,7 +464,7 @@
v-if="currentAttemptSkipReasonDisplay"
class="skip-reason"
>
<span class="reason-label">跳过原因</span>
<span class="reason-label">{{ currentAttemptSkipReasonLabel }}</span>
<span class="reason-content">
<span class="reason-value">{{ currentAttemptSkipReasonDisplay }}</span>
<span
@@ -586,6 +601,7 @@ import { resolveTimelineFinalStatus } from '../utils/status'
import { useClipboard } from '@/composables/useClipboard'
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../utils/failureDiagnostic'
import { prepareDiagnosticExport, sanitizeDiagnostic } from '../utils/diagnosticExport'
import { formatCandidateSkipReason } from '../utils/skipReason'
import {
buildPoolGroupVisibleAttempts,
buildPoolParticipatedCandidates,
@@ -841,13 +857,30 @@ const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): numbe
}
// 候选时间线(按调度顺序排序;lazy 加载的跳过候选通常没有 started_at)
const rawTimeline = computed<CandidateRecord[]>(() => {
const allTraceCandidates = computed<CandidateRecord[]>(() => {
if (!trace.value) return []
return [...trace.value.candidates]
.filter(c => TIMELINE_STATUS.includes(c.status))
.sort(compareBySchedulingOrder)
})
/**
* 被跳过的候选:调度阶段就判定"这次不能用",从未真正向上游发起请求。
*
* 默认展示:它是"为什么这次没用优先级更高的提供商"的直接答案,
* 隐藏后容易让人误以为该提供商从未参与调度。开关供长时间排查时收起。
*/
const showSkippedCandidates = ref(true)
const skippedTraceCandidates = computed<CandidateRecord[]>(
() => allTraceCandidates.value.filter(c => c.status === 'skipped'),
)
const rawTimeline = computed<CandidateRecord[]>(() => {
if (showSkippedCandidates.value) return allTraceCandidates.value
return allTraceCandidates.value.filter(c => c.status !== 'skipped')
})
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
const metadata = props.requestMetadata
@@ -1233,7 +1266,12 @@ const latestTraceAttemptForState = computed<CandidateRecord | null>(() => {
const candidates = rawTimeline.value
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const candidate = candidates[index]
if (candidate.status !== 'available' && candidate.status !== 'unused') {
// 跳过/未使用候选没有状态码与错误信息,不能代表本次请求的最终结果。
if (
candidate.status !== 'available' &&
candidate.status !== 'unused' &&
candidate.status !== 'skipped'
) {
return candidate
}
}
@@ -1418,35 +1456,30 @@ const currentAttemptKeyFormatsDisplay = computed(() => {
.map(format => formatApiFormat(format))
.join(' / ')
})
const SKIP_REASON_LABELS: Record<string, string> = {
auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
pool_key_lease_busy: '池内账号正被其他请求占用',
provider_concurrency_limit_reached: '上游提供商并发已达上限',
provider_key_concurrency_limit_reached: '上游账号并发已达上限',
provider_request_body_build_failed: '上游请求体转换失败',
provider_request_body_missing: '无法构建上游请求体',
}
const currentAttemptSkipReasonDisplay = computed(() => {
const attempt = currentAttempt.value
if (!attempt?.skip_reason) return ''
const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason]
if (skipReasonLabel) {
return skipReasonLabel
// transport_unsupported 这类"泛化原因"优先展示后端采集到的具体细节,便于排查。
if (attempt.skip_reason === 'transport_unsupported') {
const transportDiagnostics = resolveTransportDiagnostics(attempt)
const requestPair = extractObject(transportDiagnostics?.request_pair)
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
? requestPair.transport_unsupported_reason.trim()
: ''
return detailedReason || formatCandidateSkipReason(attempt.skip_reason)
}
if (attempt.skip_reason !== 'transport_unsupported') {
return attempt.skip_reason
}
return formatCandidateSkipReason(attempt.skip_reason)
})
const transportDiagnostics = resolveTransportDiagnostics(attempt)
const requestPair = extractObject(transportDiagnostics?.request_pair)
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
? requestPair.transport_unsupported_reason.trim()
: ''
return detailedReason || attempt.skip_reason
/**
* 区分"跳过"与"尝试失败":被跳过的候选从未发到上游,
* 不写清楚容易被误读成"上游返回了错误"。
*/
const currentAttemptSkipReasonLabel = computed(() => {
const status = currentAttempt.value?.status
return status === 'skipped' ? '跳过原因(未向上游发起请求)' : '跳过原因'
})
const currentAttemptFailureDiagnostic = computed<{
@@ -2212,7 +2245,9 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true })
// 始终拉取全部候选:被跳过的候选是"为什么没用某个提供商"的关键证据,
// 是否显示由前端开关控制,避免再发一次请求。
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) {
internalTrace.value = null
@@ -2798,6 +2833,30 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
cursor: not-allowed;
}
/* 「显示/隐藏被跳过候选」开关:低调的描边小按钮,不抢主状态徽标的视觉重心 */
.skipped-toggle {
padding: 0.125rem 0.5rem;
border: 1px dashed hsl(var(--border));
border-radius: 9999px;
background: transparent;
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.5;
cursor: pointer;
transition: all 0.15s ease;
}
.skipped-toggle:hover {
color: hsl(var(--foreground));
border-color: hsl(var(--muted-foreground) / 0.5);
}
.skipped-toggle.active {
border-style: solid;
border-color: hsl(var(--primary) / 0.5);
color: hsl(var(--primary));
}
.nav-info {
font-size: 0.8rem;
font-weight: 500;
@@ -1016,6 +1016,7 @@ const emit = defineEmits<{
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1158,6 +1159,7 @@ type HeaderModelTextField =
| 'model'
| 'target_model'
| 'model_version'
| 'response_model'
| 'request_type'
| 'requested_reasoning_effort'
| 'reasoning_effort'
@@ -1166,6 +1168,7 @@ type HeaderModelTextField =
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
'target_model',
'response_model',
'reasoning_effort',
'service_tier',
'actual_service_tier',
@@ -1252,6 +1255,7 @@ watch(
props.summaryRecord?.model,
props.summaryRecord?.target_model,
props.summaryRecord?.model_version,
props.summaryRecord?.response_model,
props.summaryRecord?.request_type,
props.summaryRecord?.requested_reasoning_effort,
props.summaryRecord?.reasoning_effort,
@@ -1298,6 +1302,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
const responseModel = resolveHeaderModelTextField('response_model', nextDetail)
emit('requestState', {
id,
@@ -1334,6 +1339,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
...(reasoningEffort ? { reasoningEffort } : {}),
...(serviceTier ? { serviceTier } : {}),
...(actualServiceTier ? { actualServiceTier } : {}),
...(responseModel ? { responseModel } : {}),
errorMessage: nextDetail.error_message ?? undefined,
updatedAt: nextDetail.updated_at ?? undefined,
})
@@ -1610,7 +1616,7 @@ const headerModelRecord = computed(() => {
return {
model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
target_model: resolveHeaderModelTextField('target_model', currentDetail),
model_version: resolveHeaderModelTextField('model_version', currentDetail),
response_model: resolveHeaderModelTextField('response_model', currentDetail),
request_type: resolveHeaderModelTextField('request_type', currentDetail),
requested_reasoning_effort: resolveHeaderModelTextField(
'requested_reasoning_effort',
@@ -8,19 +8,30 @@
: undefined"
>
<div
class="flex min-w-0 max-w-full items-center gap-1"
:class="[modelRowClass, actualModel ? 'flex-wrap' : '']"
class="flex min-w-0 max-w-full flex-wrap items-center gap-1"
:class="modelRowClass"
>
<span
class="min-w-0 truncate"
:class="modelClass"
data-usage-model-source
>{{ record.model }}</span>
<template v-if="actualModel">
<span
class="order-last basis-full min-w-0 break-all whitespace-normal text-muted-foreground"
data-usage-model-target
><span class="mr-1">-&gt;</span>{{ actualModel }}</span>
<template v-if="hasModelFacts">
<div
class="order-last basis-full flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-muted-foreground"
data-usage-model-facts
>
<span
v-if="mappingModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-mapping
><span class="mr-1 text-[10px] text-muted-foreground/70">映射模型</span>{{ mappingModel }}</span>
<span
v-if="responseModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-response
><span class="mr-1 text-[10px] text-muted-foreground/70">响应模型</span>{{ responseModel }}</span>
</div>
</template>
<template v-if="!shouldStackBadges">
<Badge
@@ -29,7 +40,7 @@
:data-usage-model-badge="badge.key"
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
:variant="badge.variant"
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
class="h-4 max-w-full shrink-0 truncate rounded-full px-1.5 text-[10px] leading-4"
:class="badge.className"
:title="badge.title"
:aria-label="badge.ariaLabel"
@@ -51,7 +62,7 @@
:data-usage-model-badge="badge.key"
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
:variant="badge.variant"
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
class="h-4 max-w-full shrink-0 truncate rounded-full px-1.5 text-[10px] leading-4"
:class="badge.className"
:title="badge.title"
:aria-label="badge.ariaLabel"
@@ -69,7 +80,7 @@ import { Badge } from '@/components/ui'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatServiceTierFact } from '../utils/service-tier'
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'service-tier' | 'cyber' | 'reasoning_tokens'
interface ModelBadgePresentation {
key: ModelBadgeKey
@@ -83,7 +94,7 @@ interface ModelBadgePresentation {
interface UsageModelDisplayRecord {
model: string
target_model?: string | null
model_version?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -113,15 +124,20 @@ const props = withDefaults(defineProps<{
showReasoningBadge: true,
})
const actualModel = computed(() => {
const mappingModel = computed(() => {
const targetModel = normalizeText(props.record.target_model)
if (targetModel && targetModel !== props.record.model) return targetModel
const modelVersion = normalizeText(props.record.model_version)
if (modelVersion && modelVersion !== props.record.model) return modelVersion
if (targetModel && targetModel !== normalizeText(props.record.model)) return targetModel
return null
})
const responseModel = computed(() => {
const response = normalizeText(props.record.response_model)
if (response && response !== normalizeText(props.record.model)) return response
return null
})
const hasModelFacts = computed(() => mappingModel.value !== null || responseModel.value !== null)
const reasoningLabel = computed(() => {
const requested = normalizeText(props.record.requested_reasoning_effort)
const actual = normalizeText(props.record.reasoning_effort)
@@ -154,14 +170,16 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
})
}
if (props.showServiceTierBadge && formatServiceTierFact(props.record.service_tier) === 'Fast') {
const serviceTier = formatServiceTierFact(props.record.service_tier)
if (props.showServiceTierBadge && serviceTier
&& !['auto', 'default', 'standard'].includes(serviceTier.toLowerCase())) {
badges.push({
key: 'fast',
label: 'Fast',
key: serviceTier === 'Fast' ? 'fast' : 'service-tier',
label: serviceTier,
variant: 'outline-transparent',
className: 'text-blue-500 dark:text-blue-300',
title: '上游请求档位:Fast\n计费档位:Fast',
ariaLabel: '上游请求档位:Fast,计费档位:Fast',
title: `上游请求档位:${serviceTier}\n计费档位:${serviceTier}`,
ariaLabel: `上游请求档位:${serviceTier},计费档位:${serviceTier}`,
})
}
@@ -189,7 +207,7 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
})
const shouldStackBadges = computed(() => (
actualModel.value === null && modelBadges.value.length >= 3
!hasModelFacts.value && modelBadges.value.length >= 3
))
function normalizeText(value: string | null | undefined): string | null {
@@ -149,32 +149,14 @@
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部类型
</SelectItem>
<SelectItem value="stream">
HTTP 流式
</SelectItem>
<SelectItem value="standard">
HTTP 标准
</SelectItem>
<SelectItem value="websocket">
WebSocket (WS)
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
<!-- 手机与桌面共用状态选项,避免新增筛选时漏掉手机入口。 -->
<SelectItem
v-for="option in statusFilterOptions"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
@@ -362,6 +344,28 @@
</span>
<span class="shrink-0 text-muted-foreground/40">·</span>
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
<!-- 手机与桌面保持相同的标记优先级:发生故障转移时优先显示转移标记。 -->
<Shuffle
v-if="record.has_fallback"
data-usage-attempt-marker="fallback"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
aria-label="发生 Provider 故障转移"
/>
<Ban
v-if="record.has_skipped_candidate && !record.has_fallback"
data-usage-attempt-marker="skipped-candidate"
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
:title="skippedCandidateTooltip(record)"
:aria-label="skippedCandidateAriaLabel(record)"
/>
<RefreshCcw
v-if="record.has_retry"
data-usage-attempt-marker="retry"
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
title="此请求发生了重试"
aria-label="发生重试"
/>
</div>
<!-- 第四行:性能指标 -->
@@ -791,6 +795,18 @@
title="此请求发生了 Provider 故障转移"
aria-label="发生 Provider 故障转移"
/>
<!--
被跳过的更高优先级候选:与"故障转移"区分开。
它表示候选在调度阶段就被排除、请求从未发出,因此不会有任何上游报错,
是"无报错却换了提供商"这一现象的直接解释。
-->
<Ban
v-if="record.has_skipped_candidate && !record.has_fallback"
data-usage-attempt-marker="skipped-candidate"
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
:title="skippedCandidateTooltip(record)"
:aria-label="skippedCandidateAriaLabel(record)"
/>
<RefreshCcw
v-if="record.has_retry"
data-usage-attempt-marker="retry"
@@ -1093,7 +1109,7 @@ import {
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { Ban, EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
@@ -1115,6 +1131,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
import { formatServiceTierFact } from '../utils/service-tier'
import { formatCandidateSkipReason } from '../utils/skipReason'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatUsageWebSocketTransportTitle as getWebSocketTransportTitle } from '../utils/websocketTransport'
import type { DateRangeParams, UsageRecord } from '../types'
@@ -1370,6 +1387,8 @@ const statusFilterOptions: FilterOption[] = [
{ value: 'cancelled', label: '已取消' },
{ value: 'has_retry', label: '发生重试' },
{ value: 'has_fallback', label: '发生转移' },
// 与"发生转移"区分:候选在调度阶段就被排除,请求从未发出(无上游报错)
{ value: 'has_skipped_candidate', label: '有候选被调度跳过' },
]
const timeRangeModel = computed({
@@ -1602,6 +1621,23 @@ function shouldShowFormatConversion(record: UsageRecord): boolean {
return record.api_format.trim().toLowerCase() !== record.endpoint_api_format.trim().toLowerCase()
}
// 被跳过候选的提示文案:把"调度阶段排除"和"上游失败"讲清楚,
// 避免用户把"无报错换提供商"误判成调度 bug 或提供商静默出错。
function skippedCandidateTooltip(record: UsageRecord): string {
const reasons = (record.skipped_candidate_reasons ?? [])
.map(formatCandidateSkipReason)
.filter((reason, index, all) => reason && all.indexOf(reason) === index)
const header = '本次有候选在调度阶段被跳过,请求未发往该候选(因此不会有上游报错)'
if (!reasons.length) return header
return `${header}\n跳过原因:${reasons.join(';')}`
}
function skippedCandidateAriaLabel(record: UsageRecord): string {
const reasons = (record.skipped_candidate_reasons ?? []).map(formatCandidateSkipReason)
return reasons.length ? `有候选被调度跳过:${reasons.join(';')}` : '有候选被调度跳过'
}
// 获取 API 格式的 tooltip(包含转换信息)
function getApiFormatTooltip(record: UsageRecord): string {
if (!record.api_format) {
@@ -1620,20 +1656,6 @@ function getApiFormatTooltip(record: UsageRecord): string {
return displayFormat
}
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
function getActualModel(record: UsageRecord): string | null {
// 优先显示模型映射
if (record.target_model && record.target_model !== record.model) {
return record.target_model
}
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
if (record.model_version && record.model_version !== record.model) {
return record.model_version
}
return null
}
function getReasoningEffort(record: UsageRecord): string | null {
const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim()
@@ -1708,15 +1730,18 @@ function getServiceTierTitle(record: UsageRecord): string {
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
}
return `${record.model}${suffix}`
const requestModel = record.model.trim()
const mappingModel = record.target_model?.trim()
const responseModel = record.response_model?.trim()
return [
requestModel,
mappingModel && mappingModel !== requestModel ? `映射模型: ${mappingModel}` : null,
responseModel && responseModel !== requestModel ? `响应模型: ${responseModel}` : null,
].filter((line): line is string => Boolean(line)).join('\n') + suffix
}
</script>
@@ -425,6 +425,56 @@ describe('HorizontalRequestTimeline', () => {
expect(nodeDots[2].classList.contains('status-pending')).toBe(true)
})
it('shows a skipped candidate with a Chinese reason and can collapse it', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-skipped-rpm',
provider_id: 'provider-1',
provider_name: 'Provider 1',
key_id: 'key-1',
key_name: 'Key 1',
candidate_index: 0,
status: 'skipped',
skip_reason: 'key_rpm_exhausted',
started_at: undefined,
finished_at: undefined,
}),
buildCandidate({
id: 'cand-success-2',
provider_id: 'provider-2',
provider_name: 'Provider 2',
key_id: 'key-2',
key_name: 'Key 2',
candidate_index: 1,
status: 'success',
}),
])
const root = mountTimeline(trace)
await nextTick()
// 被跳过的候选默认可见:它是"为什么没用这个提供商"的答案
expect([...root.querySelectorAll<HTMLElement>('.node-label')]
.map(label => label.textContent?.trim()))
.toEqual(['Provider 1', 'Provider 2'])
// 点击第一个节点查看详情,应看到中文跳过原因
root.querySelector<HTMLElement>('.minimal-node-group')?.click()
await nextTick()
expect(root.textContent).toContain('密钥本分钟请求数已达上限')
// 必须点明"没有向上游发起请求",否则会被误读成上游报错
expect(root.textContent).toContain('未向上游发起请求')
// 开关可把跳过候选整体收起
const toggle = root.querySelector<HTMLButtonElement>('.skipped-toggle')
expect(toggle).not.toBeNull()
toggle?.click()
await nextTick()
expect([...root.querySelectorAll<HTMLElement>('.node-label')]
.map(label => label.textContent?.trim()))
.toEqual(['Provider 2'])
})
it('keeps successful runtime pool key visible when only pool_key_index is recorded', async () => {
const trace = buildTrace([
buildCandidate({
@@ -309,26 +309,31 @@ describe('RequestDetailDrawer settlement pricing', () => {
})
})
it('shows mapping, reasoning, Fast, and Cyber together in the model header', async () => {
it.each([
{ tier: 'priority', label: 'Fast', badgeKey: 'fast' },
{ tier: 'ultrafast', label: 'ultrafast', badgeKey: 'service-tier' },
{ tier: 'future-tier', label: 'future-tier', badgeKey: 'service-tier' },
])('shows mapping, response model, reasoning, $tier, and Cyber together in the model header', async ({ tier, label, badgeKey }) => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-cyber-risk-demo',
request_id: 'req_usage-cyber-risk-demo',
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
request_body: {
model: 'gpt-5',
reasoning: { effort: 'xhigh' },
},
service_tier: 'priority',
service_tier: tier,
// A response-side tier must not be used for the Fast badge or billing.
actual_service_tier: 'default',
provider_request_body: {
model: 'gpt-5.1',
reasoning: { effort: 'max' },
service_tier: 'priority',
service_tier: tier,
},
status: 'failed',
status_code: 400,
@@ -369,10 +374,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
.toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
.toContain('Fast')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent?.trim())
.toBe('Fast')
expect(document.body.querySelector(`[data-request-detail-model-badge="${badgeKey}"]`)?.textContent?.trim())
.toBe(label)
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
const modelLayout = document.body.querySelector(
@@ -380,14 +383,15 @@ describe('RequestDetailDrawer settlement pricing', () => {
)
const modelRow = modelLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
.toBe(true)
expect(modelRow?.classList.contains('flex-wrap')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent)
.toContain('gpt-5.2')
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
.toContain('Fast')
expect(modelRow?.querySelector(`[data-request-detail-model-badge="${badgeKey}"]`)?.textContent)
.toContain(label)
expect(modelRow?.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(modelLayout?.querySelector('[data-request-detail-model-badges-row]')).toBeNull()
@@ -395,7 +399,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
.toEqual(['上游请求层级', '计费层级'])
expect([...serviceTierFacts?.querySelectorAll('dd') ?? []].map(node => node.textContent?.trim()))
.toEqual(['Fast', 'Fast'])
.toEqual([label, label])
})
})
@@ -460,7 +464,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(document.body.querySelector('[data-usage-model-target]')?.textContent)
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
@@ -571,8 +575,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1')
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent?.trim())
.toBe('映射模型gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -583,7 +587,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
})
})
it('uses detail model_version when the lightweight summary has null model facts', async () => {
it('does not use legacy model_version as a response-model fallback', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-version-summary',
@@ -626,8 +630,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1-2026-07-17')
expect(document.body.querySelector('[data-usage-model-response]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
})
})
@@ -687,7 +691,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
}
await nextTick()
expect(document.body.querySelector('[data-usage-model-target]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -92,6 +92,7 @@ vi.mock('lucide-vue-next', async () => {
EyeOff: Icon,
Search: Icon,
Shuffle: Icon,
Ban: Icon,
ChevronDown: Icon,
Check: Icon,
}
@@ -460,10 +461,11 @@ describe('UsageRecordsTable', () => {
.toBe('会话压缩')
})
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => {
it('shows mapping and response models as separately labelled facts', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
@@ -476,6 +478,8 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('gpt-5.1')
expect(root.textContent).toContain('映射模型')
expect(root.textContent).toContain('响应模型')
expect(root.textContent).toContain('xhigh -> max')
expect(root.textContent).toContain('Fast')
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
@@ -509,15 +513,35 @@ describe('UsageRecordsTable', () => {
expect(inlineLayout).not.toBeNull()
const modelRow = inlineLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('order-last')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent).toContain('映射模型')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(modelRow?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent).toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-usage-model-badge="fast"]')?.textContent).toContain('Fast')
expect(modelRow?.querySelector('[data-usage-model-badge="cyber"]')?.textContent).toContain('Cyber')
})
it('shows a response model without inventing a mapping arrow or using model_version', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5.1',
model_version: 'legacy-version',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(root.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(root.textContent).not.toContain('legacy-version')
expect(root.textContent).not.toContain('->')
})
it('hides a response model when it matches the request model', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-response]')).toBeNull()
})
it('stacks three model badges even without a model mapping', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
@@ -559,6 +583,37 @@ describe('UsageRecordsTable', () => {
},
)
it.each(['ultrafast', 'flex', 'future-tier', ' UltraFast '])(
'shows the final provider request tier %s without a fixed badge allowlist',
(requested) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: requested,
actual_service_tier: 'default',
})])
const badges = [...root.querySelectorAll<HTMLElement>('[data-usage-model-badge="service-tier"]')]
expect(badges.length).toBeGreaterThan(0)
for (const badge of badges) {
expect(badge.textContent?.trim()).toBe(requested.trim())
expect(badge.title).toBe(`上游请求档位:${requested.trim()}\n计费档位:${requested.trim()}`)
expect(badge.getAttribute('aria-label')).toBe(
`上游请求档位:${requested.trim()},计费档位:${requested.trim()}`,
)
}
},
)
it.each(['auto', 'default', 'standard', ' DEFAULT ', '', ' ', null])(
'does not show a badge for the default or absent request tier %s',
(requested) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: requested,
actual_service_tier: 'ultrafast',
})])
expect(root.querySelector('[data-usage-model-badge="service-tier"]')).toBeNull()
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
},
)
it.each(['default', 'flex', null])(
'ignores the response-side tier %s when the request tier is Fast',
(actualServiceTier) => {
@@ -679,4 +734,37 @@ describe('UsageRecordsTable', () => {
expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull()
expect(root.querySelector('[data-usage-attempt-marker="retry"]')).not.toBeNull()
})
it('shows the skipped-candidate marker when a candidate was skipped by scheduling', () => {
const root = mountUsageRecordsTable([buildRecord({
has_skipped_candidate: true,
skipped_candidate_reasons: ['key_rpm_exhausted'],
})])
const marker = root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')
expect(marker).not.toBeNull()
// tooltip 必须说明"请求未发往该候选",否则用户会以为上游报了错
const title = marker?.getAttribute('title') ?? ''
expect(title).toContain('调度阶段被跳过')
expect(title).toContain('不会有上游报错')
expect(title).toContain('密钥本分钟请求数已达上限')
})
it('prefers the fallback marker over the skipped-candidate marker', () => {
// 真正发生过故障转移时,琥珀色转移图标信息量更大,不再叠加灰色角标
const root = mountUsageRecordsTable([buildRecord({
has_fallback: true,
has_skipped_candidate: true,
skipped_candidate_reasons: ['key_rpm_exhausted'],
})])
expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull()
expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull()
})
it('hides the skipped-candidate marker when no candidate was skipped', () => {
const root = mountUsageRecordsTable([buildRecord({ has_skipped_candidate: false })])
expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull()
})
})
@@ -731,7 +731,13 @@ export function useUsageData(options: UseUsageDataOptions) {
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
? record.actual_service_tier
: null)
: existing.actual_service_tier
: existing.actual_service_tier,
// 终态列表快照是最终候选的权威事实;空值必须清除旧候选的响应模型。
response_model: statusProgressed
? (typeof record.response_model === 'string' && record.response_model.trim()
? record.response_model
: null)
: existing.response_model
}
})
}
@@ -3,6 +3,7 @@ import type { UsageRecord, FilterStatusValue } from '../types'
import {
hasUsageFallback,
hasUsageRetry,
hasUsageSkippedCandidate,
isUsageRecordFailed,
isUsageUpstreamStream,
isUsageWebSocket,
@@ -97,6 +98,8 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
records = records.filter(record => hasUsageFallback(record))
} else if (filterStatus.value === 'has_retry') {
records = records.filter(record => hasUsageRetry(record))
} else if (filterStatus.value === 'has_skipped_candidate') {
records = records.filter(record => hasUsageSkippedCandidate(record))
}
}
+2 -1
View File
@@ -102,7 +102,8 @@ export type FilterStatusValue =
'failed' |
'cancelled' |
'has_fallback' |
'has_retry'
'has_retry' |
'has_skipped_candidate'
// 默认统计状态
export function createDefaultStats(): UsageStatsState {
@@ -1,12 +1,31 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { gzipSync } from 'node:zlib'
import { BodyDocumentEngine, decodeBody } from '../body-document-engine'
import { JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from '../json-viewer'
import type { BodyWorkerRequest } from '../body-document-protocol'
function bytes(value: string) { return new TextEncoder().encode(value).buffer }
function gzip(value: string) { return Uint8Array.from(gzipSync(value)).buffer }
describe('body document decoding', () => {
it('loads and copies complete captured bodies through the worker entry point', async () => {
const postMessage = vi.fn()
vi.stubGlobal('postMessage', postMessage)
vi.stubGlobal('onmessage', undefined)
try {
await import('../body-document.worker')
const dispatch = globalThis.onmessage as unknown as (event: { data: BodyWorkerRequest }) => Promise<void>
const value = { messages: [{ role: 'user', content: `${'x'.repeat(100_000)}BODY-END` }] }
const text = JSON.stringify(value)
await dispatch({ data: { id: 1, action: 'load', bytes: gzip(text), encoding: 'gzip' } })
expect(postMessage).toHaveBeenLastCalledWith({ id: 1, ok: true, result: { byteLength: bytes(text).byteLength } })
await dispatch({ data: { id: 2, action: 'copy' } })
expect(postMessage).toHaveBeenLastCalledWith({ id: 2, ok: true, result: JSON.stringify(value, null, 2) })
} finally {
vi.unstubAllGlobals()
}
})
it.each(['gzip', 'json'] as const)('decodes %s off the UI protocol with a byte count', async encoding => {
const text = JSON.stringify({ text: '你好🙂', count: 0, enabled: false })
const decoded = await decodeBody(encoding === 'gzip' ? gzip(text) : bytes(text), encoding)
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
CANDIDATE_SKIP_REASON_LABELS,
formatCandidateSkipReason,
isNonAttemptedCandidateStatus,
isSkippedCandidateStatus,
} from '../skipReason'
describe('candidate skip reason formatting', () => {
it('translates known skip reasons into Chinese labels', () => {
expect(formatCandidateSkipReason('key_rpm_exhausted')).toBe('密钥本分钟请求数已达上限')
expect(formatCandidateSkipReason('provider_concurrency_limit_reached')).toBe('上游提供商并发已达上限')
expect(formatCandidateSkipReason('key_circuit_open')).toBe('密钥熔断中(连续失败后暂停)')
})
it('trims surrounding whitespace before lookup', () => {
expect(formatCandidateSkipReason(' key_rpm_exhausted ')).toBe('密钥本分钟请求数已达上限')
})
it('falls back to the raw reason so new backend reasons stay visible', () => {
// 后端新增白名单原因、前端还没补翻译时,不能丢信息。
expect(formatCandidateSkipReason('brand_new_reason')).toBe('brand_new_reason')
})
it('returns an empty string for missing or blank reasons', () => {
expect(formatCandidateSkipReason(undefined)).toBe('')
expect(formatCandidateSkipReason(null)).toBe('')
expect(formatCandidateSkipReason(' ')).toBe('')
})
it('labels every backend allowlisted reason', () => {
// 与后端 REQUEST_CANDIDATE_SKIP_REASONS 白名单保持同步的关键集合抽查。
const criticalReasons = [
'account_quota_exhausted',
'api_key_concurrency_limit_reached',
'auth_api_key_concurrency_limit_reached',
'key_circuit_open',
'key_health_score_zero',
'key_rpm_exhausted',
'pool_key_lease_busy',
'provider_concurrency_limit_reached',
'provider_key_concurrency_limit_reached',
'provider_quota_blocked',
'transport_unsupported',
]
for (const reason of criticalReasons) {
expect(CANDIDATE_SKIP_REASON_LABELS[reason], `missing label for ${reason}`).toBeTruthy()
}
})
it('distinguishes skipped candidates from attempted ones', () => {
expect(isSkippedCandidateStatus('skipped')).toBe(true)
expect(isSkippedCandidateStatus('failed')).toBe(false)
expect(isSkippedCandidateStatus(undefined)).toBe(false)
// available/unused 同样是"从未尝试",用于时间线是否展示的判定。
expect(isNonAttemptedCandidateStatus('skipped')).toBe(true)
expect(isNonAttemptedCandidateStatus('available')).toBe(true)
expect(isNonAttemptedCandidateStatus('unused')).toBe(true)
expect(isNonAttemptedCandidateStatus('success')).toBe(false)
expect(isNonAttemptedCandidateStatus('failed')).toBe(false)
})
})
@@ -4,6 +4,7 @@ import {
formatUsageStreamLabel,
hasUsageFallback,
hasUsageRetry,
hasUsageSkippedCandidate,
isUsageRecordFailed,
isUsageRecordSuccessful,
isUsageWebSocket,
@@ -178,6 +179,19 @@ describe('usage status helpers', () => {
expect(hasUsageRetry(buildUsageRecord({ has_retry: undefined }))).toBe(false)
})
it('uses explicit has_skipped_candidate flag for scheduling-skip filtering', () => {
expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: true }))).toBe(true)
expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: false }))).toBe(false)
expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: undefined }))).toBe(false)
})
it('keeps skipped-candidate signal independent from fallback signal', () => {
// 被调度跳过 ≠ 故障转移:前者请求从未发出,因此不应被 hasUsageFallback 认领
const skippedOnly = buildUsageRecord({ has_skipped_candidate: true, has_fallback: false })
expect(hasUsageSkippedCandidate(skippedOnly)).toBe(true)
expect(hasUsageFallback(skippedOnly)).toBe(false)
})
it('recognizes persisted WebSocket usage records', () => {
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true)
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false)
@@ -1,7 +1,11 @@
import type { FilterStatusValue } from '../types'
export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean {
return status === 'has_retry' || status === 'has_fallback'
// 这三个标记都由后端在列表响应里直接给出,但用户侧接口不支持作为服务端筛选条件,
// 因此统一走前端本地过滤。
return status === 'has_retry'
|| status === 'has_fallback'
|| status === 'has_skipped_candidate'
}
export function shouldUseServerUserRecordFilters(input: {
@@ -0,0 +1,98 @@
/**
* 候选"被跳过"原因的中文标签。
*
* 这些字符串来自后端 `StoredRequestCandidate.skip_reason`,取值受
* `crates/aether-data/contracts/src/repository/candidates/types.rs` 里的
* `REQUEST_CANDIDATE_SKIP_REASONS` 白名单约束(未知原因会被后端统一清洗成
* `unclassified_candidate_skip_reason`)。
*
* 维护约定:后端新增白名单项时,这里补一条中文说明;缺失时前端会原样显示英文,
* 不会丢信息,所以可以安全地"先上线原因、后补翻译"。
*/
export const CANDIDATE_SKIP_REASON_LABELS: Record<string, string> = {
// —— 调度期运行时可选择性(不满足条件,从未真正发起请求)——
account_quota_exhausted: '账号额度已耗尽',
oauth_invalid: 'OAuth 凭证已失效',
provider_quota_blocked: '上游提供商额度已用尽',
provider_concurrency_limit_reached: '上游提供商并发已达上限',
provider_key_concurrency_limit_reached: '上游账号并发已达上限',
provider_inactive: '提供商已停用',
key_inactive: '密钥已停用',
key_circuit_open: '密钥熔断中(连续失败后暂停)',
key_health_score_zero: '密钥健康分为 0',
key_rpm_exhausted: '密钥本分钟请求数已达上限',
key_model_disabled: '该密钥已停用此模型',
key_model_not_allowed: '该密钥不允许使用此模型',
key_api_format_disabled: '该密钥已停用此 API 格式',
api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
// —— 传输与路由策略门(在真正发请求前就被拦下)——
auth_channel_mismatch: '鉴权通道不匹配',
auth_snapshot_missing: '缺少该密钥的鉴权快照',
endpoint_api_format_changed: '端点 API 格式已变更',
endpoint_inactive: '端点已停用',
format_conversion_disabled: '该提供商未开启格式转换',
mapped_model_missing: '缺少映射后的上游模型',
routing_profile_disallowed_key: '路由策略未允许该密钥',
routing_profile_disallowed_provider: '路由策略未允许该提供商',
upstream_url_missing: '缺少上游地址',
gemini_file_mapping_mismatch: 'Gemini 文件映射不匹配',
provider_request_body_build_failed: '上游请求体转换失败',
provider_request_body_missing: '无法构建上游请求体',
// —— transport_* 系列:该提供商/端点不支持当前这种转发方式 ——
transport_unsupported: '该传输方式不受支持',
transport_api_format_mismatch: 'API 格式与端点不匹配',
transport_api_format_unsupported: '不支持该 API 格式',
transport_auth_unavailable: '无法获取可用鉴权',
transport_body_rules_apply_failed: '请求体改写规则执行失败',
transport_body_rules_unsupported: '不支持请求体改写规则',
transport_body_rules_unsupported_for_binary_upload: '二进制上传不支持请求体改写规则',
transport_custom_path_unsupported: '不支持自定义路径',
transport_endpoint_kind_unsupported: '不支持该端点类型',
transport_header_rules_apply_failed: '请求头改写规则执行失败',
transport_header_rules_unsupported: '不支持请求头改写规则',
transport_oauth_resolution_unsupported: '不支持该 OAuth 解析方式',
transport_operation_unsupported: '不支持该操作类型',
transport_profile_unsupported: '不支持该传输配置',
transport_provider_type_unsupported: '不支持该提供商类型',
transport_proxy_or_profile_unsupported: '不支持代理或传输配置',
transport_proxy_unsupported: '不支持该代理',
transport_snapshot_missing: '缺少传输快照',
// —— 号池(pool)相关 ——
pool_group_exhausted: '号池已无可用账号',
pool_account_blocked: '号池账号已被封禁',
pool_account_exhausted: '号池账号额度已耗尽',
pool_active_probe_sealed: '号池探测中,暂不分配',
pool_cooldown: '号池账号冷却中',
pool_cost_limit_reached: '号池费用已达上限',
pool_key_lease_busy: '池内账号正被其他请求占用',
pool_score_member_missing: '号池评分成员缺失',
}
/** 后端无法归类时使用的占位原因。 */
export const UNCLASSIFIED_CANDIDATE_SKIP_REASON = 'unclassified_candidate_skip_reason'
/**
* 把候选跳过原因转成中文展示文案。
*
* 命中已知标签时返回中文;否则原样返回后端字符串(便于排查新原因),
* 空值返回空字符串,调用方据此判断是否展示。
*/
export function formatCandidateSkipReason(reason?: string | null): string {
const normalized = typeof reason === 'string' ? reason.trim() : ''
if (!normalized) return ''
return CANDIDATE_SKIP_REASON_LABELS[normalized] ?? normalized
}
/** 是否为"根本没有向上游发起请求"的跳过状态。 */
export function isSkippedCandidateStatus(status?: string | null): boolean {
return status === 'skipped'
}
/** 是否为"被枚举出来但从未尝试"的候选状态(含跳过与未使用)。 */
export function isNonAttemptedCandidateStatus(status?: string | null): boolean {
return status === 'skipped' || status === 'available' || status === 'unused'
}
@@ -49,6 +49,16 @@ export function hasUsageRetry(
return record.has_retry === true
}
/**
* 是否有候选在调度阶段被跳过(请求从未发往该候选)。
* 与 hasUsageFallback 互补:后者要求"更靠前的候选确实尝试并失败"。
*/
export function hasUsageSkippedCandidate(
record: Pick<UsageRecord, 'has_skipped_candidate'>
): boolean {
return record.has_skipped_candidate === true
}
export function isUsageWebSocket(
record: Pick<UsageRecord, 'is_websocket'>
): boolean {