merge(main): 解决 usage 展示与生命周期同步冲突

This commit is contained in:
MMEXA
2026-07-18 05:38:38 +08:00
111 changed files with 8984 additions and 1715 deletions
@@ -239,6 +239,20 @@ describe('resolveModelsDevTieredPricing', () => {
})
})
it('uses the standard catalog when an imported tier has a zero default ratio', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
input: 5,
output: 30,
}, {
fast: {
cost: { input: 0, output: 0 },
provider: { body: { service_tier: 'priority' } },
},
})?.processing_tiers).toEqual({
priority: { price_multiplier: 1 },
})
})
it('prefers Anthropic speed=fast when the mode body also carries a standard service tier', () => {
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-fast', {
input: 5,
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
},
}))
import { getProviderKeysPage } from '@/api/endpoints/keys'
describe('getProviderKeysPage', () => {
beforeEach(() => {
getMock.mockReset()
})
it('normalizes a legacy array response for the provider drawer', async () => {
getMock.mockResolvedValue({
data: [{ id: 'key-1' }, { id: 'key-2' }],
})
const result = await getProviderKeysPage('provider-demo', { page: 1, page_size: 1 })
expect(result).toMatchObject({ total: 2, page: 1, page_size: 1 })
expect(result.keys).toEqual([{ id: 'key-1' }])
})
it('normalizes a malformed object without exposing a non-array keys field', async () => {
getMock.mockResolvedValue({
data: { total: null, page: null, page_size: null, keys: {} },
})
const result = await getProviderKeysPage('provider-demo', { page: 2, page_size: 3 })
expect(result).toEqual({ total: 0, page: 2, page_size: 3, keys: [] })
})
})
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
},
}))
import { getProviderMappingPreview, getProvidersSummary } from '@/api/endpoints/providers'
const provider = {
id: 'provider-1',
name: 'Provider 1',
provider_type: 'openai',
is_active: true,
endpoints: [],
}
describe('getProvidersSummary', () => {
beforeEach(() => {
getMock.mockReset()
})
it('normalizes the paginated summary response', async () => {
getMock.mockResolvedValue({
data: { total: 1, page: 1, page_size: 20, items: [provider] },
})
const result = await getProvidersSummary({ page: 1, page_size: 20, search: 'paged' })
expect(result.total).toBe(1)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.kiro_simulated_cache_enabled).toBe(false)
})
it('supports the legacy array response without reading an undefined items field', async () => {
getMock.mockResolvedValue({ data: [provider] })
const result = await getProvidersSummary({ page: 2, page_size: 20, search: 'legacy' })
expect(result).toMatchObject({ total: 1, page: 2, page_size: 20 })
expect(result.items).toHaveLength(1)
})
})
describe('getProviderMappingPreview', () => {
beforeEach(() => {
getMock.mockReset()
})
it('normalizes a non-contract response instead of exposing missing arrays to the UI', async () => {
getMock.mockResolvedValue({
data: { message: '演示模式:该接口暂未模拟', demo_mode: true },
})
const result = await getProviderMappingPreview('provider-demo')
expect(result).toEqual({
provider_id: 'provider-demo',
provider_name: '',
keys: [],
total_keys: 0,
total_matches: 0,
truncated: false,
truncated_keys: 0,
truncated_models: 0,
})
})
it('normalizes missing nested mapping arrays', async () => {
getMock.mockResolvedValue({
data: {
provider_id: 'provider-nested',
provider_name: 'Nested Provider',
keys: [{
key_id: 'key-1',
key_name: 'Primary',
masked_key: 'sk-***',
is_active: true,
allowed_models: null,
matching_global_models: [{
global_model_id: 'model-1',
global_model_name: 'gpt-5',
display_name: 'GPT-5',
is_active: true,
matched_models: null,
}],
}],
},
})
const result = await getProviderMappingPreview('provider-nested')
expect(result.keys[0]?.allowed_models).toEqual([])
expect(result.keys[0]?.matching_global_models[0]?.matched_models).toEqual([])
expect(result.total_keys).toBe(1)
expect(result.total_matches).toBe(1)
})
})
+55 -1
View File
@@ -1,13 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, cachedRequestMock } = vi.hoisted(() => ({
const { getMock, postMock, cachedRequestMock } = vi.hoisted(() => ({
getMock: vi.fn(),
postMock: vi.fn(),
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
}))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
post: postMock,
},
}))
@@ -20,6 +22,7 @@ import { usersApi } from '@/api/users'
describe('usersApi admin list query', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
cachedRequestMock.mockClear()
getMock.mockResolvedValue({
data: {
@@ -49,4 +52,55 @@ describe('usersApi admin list query', () => {
},
})
})
it('keeps user management renderable when the group response has no items array', async () => {
getMock.mockResolvedValueOnce({
data: {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
},
})
await expect(usersApi.listUserGroups()).resolves.toEqual({
message: '演示模式:该接口暂未模拟',
demo_mode: true,
items: [],
})
})
it('creates a managed key through the selected target user route', async () => {
postMock.mockResolvedValueOnce({
data: {
id: 'target-key',
key: 'sk-target',
},
})
const payload = {
name: 'target key',
feature_settings: {
chat_pii_redaction: { enabled: true },
},
}
await usersApi.createApiKey('target-user', payload)
expect(postMock).toHaveBeenCalledWith(
'/api/admin/users/target-user/api-keys',
payload,
)
})
it('reads managed keys from the production api_keys envelope', async () => {
getMock.mockResolvedValueOnce({
data: {
api_keys: [{ id: 'target-key', created_at: '2026-07-17T00:00:00Z' }],
total: 1,
},
})
await expect(usersApi.getUserApiKeys('target-user')).resolves.toEqual([
{ id: 'target-key', created_at: '2026-07-17T00:00:00Z' },
])
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
})
})
+2
View File
@@ -209,6 +209,7 @@ export interface RequestDetail {
has_format_conversion?: boolean | null
model: string
target_model?: string | null // 映射后的目标模型名
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
@@ -266,6 +267,7 @@ export interface RequestDetail {
response_time_ms: number
first_byte_time_ms?: number | null
created_at: string
updated_at?: string | null
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
+35 -2
View File
@@ -121,17 +121,50 @@ export interface ProviderKeysPageQuery {
page_size?: number
}
type ProviderKeysPagePayload = ProviderKeysPageResponse | EndpointAPIKey[]
function normalizeProviderKeysPage(
value: ProviderKeysPagePayload,
page: number,
pageSize: number,
): ProviderKeysPageResponse {
if (Array.isArray(value)) {
const start = value.length > pageSize ? (page - 1) * pageSize : 0
const keys = value.slice(start, start + pageSize)
return {
total: value.length,
page,
page_size: pageSize,
keys,
}
}
const keys = Array.isArray(value.keys) ? value.keys : []
return {
total: typeof value.total === 'number' && Number.isFinite(value.total)
? value.total
: keys.length,
page: typeof value.page === 'number' && Number.isFinite(value.page)
? value.page
: page,
page_size: typeof value.page_size === 'number' && Number.isFinite(value.page_size)
? value.page_size
: pageSize,
keys,
}
}
export async function getProviderKeysPage(
providerId: string,
params: ProviderKeysPageQuery = {},
): Promise<ProviderKeysPageResponse> {
const page = params.page ?? 1
const pageSize = params.page_size ?? 20
const response = await client.get<ProviderKeysPageResponse>(
const response = await client.get<ProviderKeysPagePayload>(
`/api/admin/endpoints/providers/${providerId}/keys`,
{ params: { page, page_size: pageSize } },
)
return response.data
return normalizeProviderKeysPage(response.data, page, pageSize)
}
export async function getProviderKeys(providerId: string): Promise<EndpointAPIKey[]> {
+94 -4
View File
@@ -42,6 +42,8 @@ export interface ProviderSummaryPageResponse {
items: ProviderWithEndpointsSummary[]
}
type ProviderSummaryResponse = ProviderSummaryPageResponse | ProviderWithEndpointsSummary[]
function normalizeProviderSummary(
provider: ProviderWithEndpointsSummary,
): ProviderWithEndpointsSummary {
@@ -62,16 +64,26 @@ export async function getProvidersSummary(
return cachedRequest(
cacheKey,
async () => {
const response = await client.get<ProviderSummaryPageResponse>(
const response = await client.get<ProviderSummaryResponse>(
'/api/admin/providers/summary',
{
params,
timeout: options.timeout,
},
)
const data = response.data
if (Array.isArray(data)) {
return {
total: data.length,
page: params.page ?? 1,
page_size: params.page_size ?? data.length,
items: data.map(normalizeProviderSummary),
}
}
return {
...response.data,
items: response.data.items.map(normalizeProviderSummary),
...data,
items: (data.items ?? []).map(normalizeProviderSummary),
}
},
cacheTtlMs,
@@ -371,6 +383,84 @@ export interface ProviderMappingPreviewResponse {
truncated_models: number
}
function mappingPreviewRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
function mappingPreviewString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
function mappingPreviewCount(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? value
: fallback
}
function normalizeProviderMappingPreview(
value: unknown,
providerId: string,
): ProviderMappingPreviewResponse {
const source = mappingPreviewRecord(value)
const rawKeys = Array.isArray(source.keys) ? source.keys : []
const keys = rawKeys.map((rawKey) => {
const key = mappingPreviewRecord(rawKey)
const rawGlobalModels = Array.isArray(key.matching_global_models)
? key.matching_global_models
: []
return {
key_id: mappingPreviewString(key.key_id),
key_name: mappingPreviewString(key.key_name),
masked_key: mappingPreviewString(key.masked_key, '***'),
is_active: key.is_active === true,
allowed_models: Array.isArray(key.allowed_models)
? key.allowed_models.filter((item): item is string => typeof item === 'string')
: [],
matching_global_models: rawGlobalModels.map((rawGlobalModel) => {
const globalModel = mappingPreviewRecord(rawGlobalModel)
const rawMatchedModels = Array.isArray(globalModel.matched_models)
? globalModel.matched_models
: []
return {
global_model_id: mappingPreviewString(globalModel.global_model_id),
global_model_name: mappingPreviewString(globalModel.global_model_name),
display_name: mappingPreviewString(
globalModel.display_name,
mappingPreviewString(globalModel.global_model_name),
),
is_active: globalModel.is_active === true,
matched_models: rawMatchedModels.map((rawMatchedModel) => {
const matchedModel = mappingPreviewRecord(rawMatchedModel)
return {
allowed_model: mappingPreviewString(matchedModel.allowed_model),
mapping_pattern: mappingPreviewString(matchedModel.mapping_pattern),
}
}),
}
}),
}
})
const inferredMatches = keys.reduce(
(total, key) => total + key.matching_global_models.length,
0,
)
return {
provider_id: mappingPreviewString(source.provider_id, providerId),
provider_name: mappingPreviewString(source.provider_name),
keys,
total_keys: mappingPreviewCount(source.total_keys, keys.length),
total_matches: mappingPreviewCount(source.total_matches, inferredMatches),
truncated: source.truncated === true,
truncated_keys: mappingPreviewCount(source.truncated_keys, 0),
truncated_models: mappingPreviewCount(source.truncated_models, 0),
}
}
/**
* 获取 Provider 映射预览
*/
@@ -379,6 +469,6 @@ export async function getProviderMappingPreview(
): Promise<ProviderMappingPreviewResponse> {
return dedupedRequest(`providers:mapping-preview:${providerId}`, async () => {
const response = await client.get<ProviderMappingPreviewResponse>(`/api/admin/providers/${providerId}/mapping-preview`)
return response.data
return normalizeProviderMappingPreview(response.data, providerId)
})
}
+2
View File
@@ -55,6 +55,7 @@ export interface UsageRecordDetail {
provider?: string // 仅管理员可见
model: string
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
@@ -370,6 +371,7 @@ export const meApi = {
has_fallback?: boolean | null
target_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
+4 -1
View File
@@ -28,6 +28,7 @@ const TOKEN_PRICE_FIELDS = [
'cache_read_price_per_1m',
] as const
const PROCESSING_MODE_FALLBACK_KEYS = new Set(['fast', 'priority', 'flex', 'batch'])
const DEFAULT_PROCESSING_TIER_MULTIPLIER = 1
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -128,7 +129,9 @@ function uniformPriceMultiplier(
if (Math.abs(processingPrice - standardPrice * candidate) > 1e-9) return null
}
}
return candidate
// A zero ratio from an imported experimental mode is a missing/default price marker,
// not a free processing tier. Keep the tier on the Standard catalog so it remains billable.
return candidate === 0 ? DEFAULT_PROCESSING_TIER_MULTIPLIER : candidate
}
export function resolveModelsDevTieredPricing(
+2
View File
@@ -15,6 +15,7 @@ export interface UsageRecord {
provider_name?: string
model: string
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
@@ -570,6 +571,7 @@ export const usageApi = {
has_fallback?: boolean | null
target_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
+7 -3
View File
@@ -374,7 +374,10 @@ export const usersApi = {
async listUserGroups(): Promise<ListUserGroupsResponse> {
const response = await apiClient.get<ListUserGroupsResponse>('/api/admin/user-groups')
return response.data
return {
...response.data,
items: Array.isArray(response.data?.items) ? response.data.items : [],
}
},
async createUserGroup(payload: UpsertUserGroupRequest): Promise<UserGroup> {
@@ -417,8 +420,9 @@ export const usersApi = {
},
async getUserApiKeys(userId: string): Promise<ApiKey[]> {
const response = await apiClient.get<{ api_keys: ApiKey[] }>(`/api/admin/users/${userId}/api-keys`)
return response.data.api_keys
const response = await apiClient.get<{ api_keys?: ApiKey[] } | ApiKey[]>(`/api/admin/users/${userId}/api-keys`)
if (Array.isArray(response.data)) return response.data
return Array.isArray(response.data?.api_keys) ? response.data.api_keys : []
},
async getUserSessions(userId: string): Promise<SessionRecord[]> {
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, type App } from 'vue'
import Badge from '../badge.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('Badge', () => {
it('renders the transparent outline variant without the card background', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(Badge, { variant: 'outline-transparent' }, () => 'Fast'),
})
app.mount(root)
mountedApps.push({ app, root })
const badge = root.firstElementChild
expect(badge?.classList.contains('border-border')).toBe(true)
expect(badge?.classList.contains('bg-transparent')).toBe(true)
expect(badge?.classList.contains('bg-card/50')).toBe(false)
})
})
+2 -1
View File
@@ -20,6 +20,7 @@ const badgeVariants = cva(
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground border-border bg-card/50',
'outline-transparent': 'text-foreground border-border bg-transparent',
success:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
warning:
@@ -35,7 +36,7 @@ const badgeVariants = cva(
)
interface Props {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'outline-transparent' | 'success' | 'warning' | 'dark'
class?: string
}
@@ -135,10 +135,10 @@
:title="row.statusBadgeTitle"
>{{ row.statusBadgeLabel }}</Badge>
<Badge
v-if="row.key.oauth_plan_type"
v-if="row.planLabel"
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ row.key.oauth_plan_type }}</Badge>
>{{ row.planLabel }}</Badge>
<Badge
v-if="row.oauthOrgBadge"
variant="secondary"
@@ -499,6 +499,7 @@ import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { formatOAuthPlanType } from '@/utils/oauthPlanType'
import {
canExportOAuthCredential,
canRefreshOAuthCredential,
@@ -552,6 +553,7 @@ type BatchActionOption = {
type PageKeyRow = {
key: PoolKeyDetail
planLabel: string
authTypeLabel: string
statusBadgeLabel: string | null
statusBadgeTitle: string
@@ -650,6 +652,7 @@ const pageKeyRows = computed<PageKeyRow[]>(() => pageKeys.value.map((key) => {
return {
key,
planLabel: formatOAuthPlanType(key.oauth_plan_type),
authTypeLabel: normalizeAuthTypeLabel(key),
statusBadgeLabel,
statusBadgeTitle: statusBadgeLabel ? getStatusBadgeTitle(key) : '',
@@ -109,7 +109,10 @@ const QuotaProgressRows = defineComponent({
: 'flex flex-col gap-1 min-w-[140px] max-w-[208px]',
}, [
h('div', { class: 'flex items-center justify-between text-[10px] leading-none' }, [
h('span', { class: 'text-muted-foreground font-medium shrink-0' }, item.label),
h('span', {
'data-testid': 'pool-quota-period-label',
class: 'text-muted-foreground font-medium shrink-0',
}, item.label),
item.resetText
? h('span', {
'data-testid': 'pool-quota-reset-text',
@@ -1,41 +1,40 @@
<template>
<div
v-if="cycle"
v-if="cycle && cycleMetricRows.length > 0"
:class="cycleContainerClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-groups' : undefined"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-text' : 'pool-mobile-stats-cycle-text'"
>
<div
:class="cycleGridClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-grid' : 'pool-mobile-stats-cycle-grid'"
v-for="row in cycleMetricRows"
:key="`${row.key}-${variant}-cycle-row`"
class="flex items-baseline justify-between gap-3"
:title="`${row.label} ${row.valueText}`"
>
<span aria-hidden="true" />
<span class="shrink-0 text-muted-foreground">
{{ row.label }}
</span>
<span
:class="cycleGroupLabelClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-5h' : 'pool-mobile-stats-cycle-group-5h'"
>5H</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
:class="cycleGroupLabelClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-weekly' : 'pool-mobile-stats-cycle-group-weekly'"
>{{ legacyT('周') }}</span>
<template
v-for="row in cycleRows"
:key="`${row.key}-${variant}-cycle-row`"
class="grid w-[112px] shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-baseline gap-x-1 font-medium text-foreground"
:data-testid="variant === 'desktop' ? `pool-stats-cycle-${row.key}` : undefined"
>
<span class="text-muted-foreground truncate">{{ row.label }}</span>
<span class="min-w-0 truncate text-right">{{ row.hasComparison ? row.smallValue : '-' }}</span>
<span
:class="[cycleValueClass, row.fiveH.missing ? 'text-muted-foreground/80' : '']"
:data-testid="variant === 'desktop' ? `pool-stats-5h-${row.key}` : undefined"
:title="row.fiveH.value"
>{{ row.fiveH.value }}</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
:class="[cycleValueClass, row.weekly.missing ? 'text-muted-foreground/80' : '']"
:data-testid="variant === 'desktop' ? `pool-stats-weekly-${row.key}` : undefined"
:title="row.weekly.value"
>{{ row.weekly.value }}</span>
</template>
class="w-1.5 text-center text-muted-foreground/60"
data-cycle-stat-part="divider"
aria-hidden="true"
>/</span>
<span class="min-w-0 truncate text-left">{{ row.largeValue }}</span>
</span>
</div>
</div>
<div
v-else-if="cycle"
:class="cycleContainerClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-empty' : 'pool-mobile-stats-cycle-empty'"
>
<div class="flex min-h-16 items-center justify-center text-muted-foreground">
</div>
</div>
@@ -68,47 +67,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/i18n'
import type { PoolStatsMetric } from '@/features/pool/utils/poolStatsDisplay'
export interface PoolKeyCycleStatsRow {
key: PoolStatsMetric['key']
label: string
fiveH: PoolStatsMetric
weekly: PoolStatsMetric
}
import type {
PoolCodexCycleStatsGroup,
PoolStatsMetric,
PoolStatsMetricKey,
} from '@/features/pool/utils/poolStatsDisplay'
const props = withDefaults(defineProps<{
cycle: boolean
cycleRows: PoolKeyCycleStatsRow[]
cycleGroups: PoolCodexCycleStatsGroup[]
accountMetrics: PoolStatsMetric[]
variant?: 'desktop' | 'mobile'
}>(), {
variant: 'desktop',
})
const { legacyT } = useI18n()
const CYCLE_METRIC_KEYS: PoolStatsMetricKey[] = ['request_count', 'total_tokens', 'total_cost_usd']
const CYCLE_METRIC_LABELS: Record<PoolStatsMetricKey, string> = {
request_count: '请求',
total_tokens: 'Token',
total_cost_usd: '费用',
}
const cycleContainerClass = computed(() => props.variant === 'desktop'
? 'mx-auto w-[188px] text-[10px] leading-4'
: ''
)
function missingMetric(key: PoolStatsMetricKey): PoolStatsMetric {
return {
key,
label: CYCLE_METRIC_LABELS[key],
value: '-',
missing: true,
numericValue: null,
}
}
const cycleGridClass = computed(() => [
'grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1',
props.variant === 'mobile' ? 'text-left' : '',
function metricForGroup(
group: PoolCodexCycleStatsGroup | undefined,
key: PoolStatsMetricKey,
): PoolStatsMetric {
return group?.metrics.find(metric => metric.key === key) ?? missingMetric(key)
}
const cycleMetricRows = computed(() => {
const smallGroup = props.cycleGroups.length > 1 ? props.cycleGroups[0] : undefined
const largeGroup = props.cycleGroups.at(-1)
if (!largeGroup) return []
return CYCLE_METRIC_KEYS.map((key) => {
const smallMetric = metricForGroup(smallGroup, key)
const largeMetric = metricForGroup(largeGroup, key)
const hasComparison = Boolean(smallGroup)
return {
key,
label: CYCLE_METRIC_LABELS[key],
hasComparison,
smallValue: smallMetric.value,
largeValue: largeMetric.value,
valueText: hasComparison ? `${smallMetric.value}/${largeMetric.value}` : largeMetric.value,
}
})
})
const cycleContainerClass = computed(() => [
'w-full space-y-1 text-[11px] leading-4 tabular-nums',
props.variant === 'desktop' ? 'mx-auto max-w-[168px]' : 'py-0.5',
].filter(Boolean).join(' '))
const cycleGroupLabelClass = computed(() => props.variant === 'desktop'
? 'text-center text-[9px] font-semibold text-muted-foreground/80'
: 'text-center text-[10px] font-semibold text-foreground'
)
const cycleValueClass = computed(() => [
'min-w-0 truncate text-center text-foreground/90',
props.variant === 'desktop' ? 'tabular-nums' : 'font-medium tabular-nums',
].join(' '))
const accountContainerClass = computed(() => props.variant === 'desktop'
? 'grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4'
: ''
@@ -11,20 +11,41 @@ describe('pool key display panels', () => {
document.body.appendChild(root)
const app = createApp(PoolKeyStatsPanel, {
cycle: true,
cycleRows: [{
key: 'request_count',
label: '请求',
fiveH: { key: 'request_count', label: '请求', value: '12', missing: false },
weekly: { key: 'request_count', label: '请求', value: '88', missing: false },
}],
cycleGroups: [
{
code: '5h',
label: '5H',
metrics: [{ key: 'request_count', label: '请求', value: '12', missing: false, numericValue: 12 }],
},
{
code: 'weekly',
label: '周',
metrics: [{ key: 'request_count', label: '请求', value: '88', missing: false, numericValue: 88 }],
},
],
accountMetrics: [],
})
app.use(createI18n())
app.mount(root)
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')).toBeTruthy()
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.textContent).toBe('12')
expect(root.querySelector('[data-testid="pool-stats-weekly-request_count"]')?.textContent).toBe('88')
const stats = root.querySelector('[data-testid="pool-stats-cycle-text"]')
const requestValue = root.querySelector('[data-testid="pool-stats-cycle-request_count"]')
expect(stats).toBeTruthy()
expect(stats?.className).toContain('w-full')
expect(stats?.className).toContain('max-w-[168px]')
expect(requestValue?.textContent?.trim()).toBe('12/88')
expect(requestValue?.previousElementSibling?.textContent?.trim()).toBe('请求')
expect(requestValue?.parentElement?.className).toContain('justify-between')
expect(requestValue?.className).toContain('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
expect(requestValue?.className).toContain('w-[112px]')
expect(requestValue?.children[0]?.className).toContain('text-right')
expect(requestValue?.children[1]?.textContent).toBe('/')
expect(requestValue?.children[2]?.className).toContain('text-left')
expect(root.querySelectorAll('[data-cycle-stat-part="divider"]')).toHaveLength(3)
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-large-base"]')).toBeNull()
expect(root.textContent).not.toContain('5H')
expect(root.textContent).not.toContain('周')
app.unmount()
root.remove()
@@ -68,4 +89,40 @@ describe('pool key display panels', () => {
app.unmount()
root.remove()
})
it('renders single-cycle stats as plain text', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(PoolKeyStatsPanel, {
cycle: true,
cycleGroups: [{
code: 'monthly',
label: '月',
metrics: [
{ key: 'request_count', label: '请求', value: '31', missing: false, numericValue: 31 },
{ key: 'total_tokens', label: 'Token', value: '38.8K', missing: false, numericValue: 38_800 },
{ key: 'total_cost_usd', label: '费用', value: '$0.077', missing: false, numericValue: 0.077 },
],
}],
accountMetrics: [],
})
app.use(createI18n())
app.mount(root)
const requestValue = root.querySelector('[data-testid="pool-stats-cycle-request_count"]')
expect(requestValue?.textContent?.trim()).toBe('-/31')
expect(requestValue?.previousElementSibling?.textContent?.trim()).toBe('请求')
expect(requestValue?.className).toContain('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
expect(requestValue?.children[0]?.textContent).toBe('-')
expect(requestValue?.children[1]?.textContent).toBe('/')
expect(requestValue?.children[1]?.className).toContain('w-1.5')
expect(requestValue?.children[2]?.textContent).toBe('31')
expect(requestValue?.children[2]?.className).toContain('text-left')
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-bar-request_count"]')).toBeNull()
expect(root.textContent).not.toContain('月')
app.unmount()
root.remove()
})
})
@@ -19,6 +19,7 @@ function createCodexKey(overrides: Partial<PoolStatsKeyInput> = {}): PoolStatsKe
windows: [
{
code: '5h',
window_minutes: 300,
usage: {
request_count: 5,
total_tokens: 2500,
@@ -27,10 +28,11 @@ function createCodexKey(overrides: Partial<PoolStatsKeyInput> = {}): PoolStatsKe
},
{
code: 'weekly',
window_minutes: 10_080,
usage: {
request_count: 0,
total_tokens: 0,
total_cost_usd: '0.00000000',
request_count: 8,
total_tokens: 5000,
total_cost_usd: '0.012',
},
},
],
@@ -54,9 +56,9 @@ describe('poolStatsDisplay', () => {
total_cost_usd: '$0.0045',
})
expect(metricValues(display.groups[1].metrics)).toEqual({
request_count: '0',
total_tokens: '0',
total_cost_usd: '0',
request_count: '8',
total_tokens: '5K',
total_cost_usd: '$0.012',
})
})
@@ -65,7 +67,7 @@ describe('poolStatsDisplay', () => {
createCodexKey({
status_snapshot: {
quota: {
windows: [{ code: '5h', usage: null }],
windows: [{ code: '5h', window_minutes: 300, usage: null }],
},
},
}),
@@ -81,10 +83,48 @@ describe('poolStatsDisplay', () => {
total_tokens: '—',
total_cost_usd: '—',
})
expect(metricValues(display.groups[1].metrics)).toEqual({
request_count: '—',
total_tokens: '—',
total_cost_usd: '—',
expect(display.groups).toHaveLength(1)
})
it('builds monthly stats from the actual quota window and ignores zero placeholders', () => {
const display = buildPoolStatsDisplay(
createCodexKey({
status_snapshot: {
quota: {
windows: [
{
code: 'monthly',
label: '月',
window_minutes: 43_800,
usage: {
request_count: 12,
total_tokens: 3456,
total_cost_usd: '0.125',
},
},
{
code: 'weekly',
label: '周',
window_minutes: 0,
usage: {
request_count: 99,
},
},
],
},
},
}),
'codex',
'current_cycle',
)
expect(display.kind).toBe('codex_cycle')
if (display.kind !== 'codex_cycle') throw new Error('expected codex cycle display')
expect(display.groups.map(group => group.label)).toEqual(['月'])
expect(metricValues(display.groups[0].metrics)).toEqual({
request_count: '12',
total_tokens: '3.5K',
total_cost_usd: '$0.125',
})
})
@@ -1,10 +1,11 @@
import type { QuotaWindowUsageSnapshot } from '@/api/endpoints/types/statusSnapshot'
import type { PoolManagementStatsMode } from '@/features/pool/utils/poolManagementState'
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
import { formatCompactNumber } from '@/utils/format'
export type PoolStatsMetricKey = 'request_count' | 'total_tokens' | 'total_cost_usd'
export type PoolStatsDisplayKind = 'account_total' | 'codex_cycle'
export type PoolCodexCycleWindowCode = '5h' | 'weekly'
export type PoolCodexCycleWindowCode = string
export interface PoolStatsKeyInput {
request_count?: number | null
@@ -14,6 +15,9 @@ export interface PoolStatsKeyInput {
quota?: {
windows?: Array<{
code?: string | null
label?: string | null
scope?: string | null
window_minutes?: number | null
usage?: QuotaWindowUsageSnapshot | null
} | null> | null
} | null
@@ -25,6 +29,7 @@ export interface PoolStatsMetric {
label: string
value: string
missing: boolean
numericValue?: number | null
}
export interface PoolAccountTotalStatsDisplay {
@@ -46,10 +51,6 @@ export interface PoolCodexCycleStatsDisplay {
export type PoolStatsDisplay = PoolAccountTotalStatsDisplay | PoolCodexCycleStatsDisplay
const MISSING_STAT_VALUE = '—'
const CODEX_CYCLE_WINDOWS: Array<{ code: PoolCodexCycleWindowCode, label: string }> = [
{ code: '5h', label: '5H' },
{ code: 'weekly', label: '周' },
]
export function isCodexProviderType(providerType: string | null | undefined): boolean {
return String(providerType || '').trim().toLowerCase() === 'codex'
@@ -103,12 +104,14 @@ function createMetric(
key: PoolStatsMetricKey,
label: string,
value: string | null,
numericValue?: number | null,
): PoolStatsMetric {
return {
key,
label,
value: value ?? MISSING_STAT_VALUE,
missing: value == null,
numericValue: numericValue ?? null,
}
}
@@ -116,17 +119,40 @@ function normalizeWindowCode(value: unknown): string {
return String(value || '').trim().toLowerCase()
}
function getQuotaWindowUsage(
key: PoolStatsKeyInput,
code: PoolCodexCycleWindowCode,
): QuotaWindowUsageSnapshot | null {
function getCodexCycleStatsGroups(key: PoolStatsKeyInput): PoolCodexCycleStatsGroup[] {
const windows = key.status_snapshot?.quota?.windows
if (!Array.isArray(windows)) return null
if (!Array.isArray(windows)) return []
const window = windows.find(item => normalizeWindowCode(item?.code) === code)
return window?.usage ?? null
const seenCodes = new Set<string>()
return windows
.map((window) => {
if (!window) return null
const code = normalizeWindowCode(window.code)
const scope = String(window.scope || 'account').trim().toLowerCase()
if (!code || scope !== 'account' || code.startsWith('spark_') || seenCodes.has(code)) {
return null
}
const presentation = getCodexQuotaWindowPresentation({
code,
label: window.label,
scope,
window_minutes: window.window_minutes,
})
if (!presentation) return null
seenCodes.add(code)
return {
code,
label: presentation.label,
sortOrder: presentation.sortOrder,
metrics: buildCycleMetrics(window.usage ?? null),
}
})
.filter((group): group is PoolCodexCycleStatsGroup & { sortOrder: number } => group != null)
.sort((left, right) => left.sortOrder - right.sortOrder)
.map(({ sortOrder: _sortOrder, ...group }) => group)
}
function buildAccountTotalMetrics(key: PoolStatsKeyInput): PoolStatsMetric[] {
return [
createMetric('request_count', '请求', formatPoolStatInteger(key.request_count)),
@@ -136,10 +162,28 @@ function buildAccountTotalMetrics(key: PoolStatsKeyInput): PoolStatsMetric[] {
}
function buildCycleMetrics(usage: QuotaWindowUsageSnapshot | null): PoolStatsMetric[] {
const requestCount = usage?.request_count == null ? null : Number(usage.request_count)
const totalTokens = usage?.total_tokens == null ? null : Number(usage.total_tokens)
const totalCostUsd = usage?.total_cost_usd == null ? null : Number(usage.total_cost_usd)
return [
createMetric('request_count', '请求', formatCycleInteger(usage?.request_count)),
createMetric('total_tokens', 'Token', formatCycleTokenCount(usage?.total_tokens)),
createMetric('total_cost_usd', '费用', formatCycleUsd(usage?.total_cost_usd)),
createMetric(
'request_count',
'请求',
formatCycleInteger(usage?.request_count),
Number.isFinite(requestCount) ? Math.max(requestCount ?? 0, 0) : null,
),
createMetric(
'total_tokens',
'Token',
formatCycleTokenCount(usage?.total_tokens),
Number.isFinite(totalTokens) ? Math.max(totalTokens ?? 0, 0) : null,
),
createMetric(
'total_cost_usd',
'费用',
formatCycleUsd(usage?.total_cost_usd),
Number.isFinite(totalCostUsd) ? Math.max(totalCostUsd ?? 0, 0) : null,
),
]
}
@@ -157,10 +201,7 @@ export function buildCodexCycleStatsDisplay(
): PoolCodexCycleStatsDisplay {
return {
kind: 'codex_cycle',
groups: CODEX_CYCLE_WINDOWS.map(window => ({
...window,
metrics: buildCycleMetrics(getQuotaWindowUsage(key, window.code)),
})),
groups: getCodexCycleStatsGroups(key),
}
}
@@ -782,7 +782,7 @@
:models="providerModels"
:endpoints="endpoints"
:provider-keys="providerKeys"
:loading="loadingProviderModels || loadingProviderKeys"
:loading="loadingProviderModels"
@edit-model="handleEditModel"
@batch-assign="handleBatchAssign"
@refresh="loadEndpoints"
@@ -798,7 +798,7 @@
:provider-keys="providerKeys"
:models="providerModels"
:mapping-preview="providerMappingPreview"
:loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"
:loading="loadingProviderMappingPreview"
@refresh="handleModelMappingChanged"
/>
</div>
@@ -1303,8 +1303,6 @@ watch(
loading.value = false
}
void loadSystemFormatConversionConfig()
// mapping-preview 较慢,不阻塞首屏渲染
void loadMappingPreview()
if (!hasInitialProvider) {
await loadProvider()
}
@@ -1313,7 +1311,13 @@ watch(
if (newOpen && !oldOpen) {
startCountdownTimer()
}
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
// 优先完成端点、密钥和模型的首屏数据,再请求计算量较大的映射预览。
// 同时校验抽屉状态,避免关闭或切换 Provider 后启动无用请求。
void endpointsPromise.then(() => {
if (!props.open || props.providerId !== newId) return
void loadMappingPreview()
void autoRefreshQuotaInBackground()
})
} else if (!newOpen && oldOpen) {
// 使在途请求失效,避免关闭后旧响应回写
providerLoadRequestId += 1
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { createSSRApp, h } from 'vue'
import { renderToString } from '@vue/server-renderer'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue'
const provider = {
id: 'provider-demo',
name: 'Demo Provider',
provider_type: 'custom',
is_active: true,
active_keys: 0,
api_formats: [],
} as ProviderWithEndpointsSummary
describe('ModelMappingTab response contracts', () => {
it('keeps the module visible when a legacy or malformed preview reaches the component', async () => {
const app = createSSRApp({
render: () => h(ModelMappingTab, {
provider,
models: [],
endpoints: [],
providerKeys: [],
mappingPreview: {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
},
loading: false,
}),
})
const html = await renderToString(app)
expect(html).toContain('模型映射')
expect(html).toContain('暂无模型映射')
})
})
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/features/providers/components/ProviderDetailDrawer.vue'),
'utf8',
)
describe('ProviderDetailDrawer loading priorities', () => {
it('loads mapping preview after first-screen provider data', () => {
const openWatcher = source
.split('// 合并监听 providerId 和 open')[1]
?.split('} else if (!newOpen && oldOpen)')[0]
expect(openWatcher).toBeTruthy()
expect(openWatcher).toContain('const endpointsPromise = loadEndpoints()')
expect(openWatcher).toContain('endpointsPromise.then(() => {')
expect(openWatcher).toContain('if (!props.open || props.providerId !== newId) return')
expect(openWatcher).toContain('void loadMappingPreview()')
const beforeEndpoints = openWatcher?.split('const endpointsPromise = loadEndpoints()')[0]
expect(beforeEndpoints).not.toContain('loadMappingPreview()')
})
it('keeps model and mapping loading states independent', () => {
expect(source).toContain(':loading="loadingProviderModels"')
expect(source).toContain(':loading="loadingProviderMappingPreview"')
expect(source).not.toContain(':loading="loadingProviderModels || loadingProviderKeys"')
expect(source).not.toContain(':loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"')
})
})
@@ -599,13 +599,20 @@ const regexMappings = computed<CombinedMapping[]>(() => {
const result: CombinedMapping[] = []
const modelMap = new Map<string, CombinedMapping>()
for (const keyInfo of aliasMappingPreview.value.keys) {
for (const gm of keyInfo.matching_global_models) {
const previewKeys = Array.isArray(aliasMappingPreview.value.keys)
? aliasMappingPreview.value.keys
: []
for (const keyInfo of previewKeys) {
const matchingGlobalModels = Array.isArray(keyInfo.matching_global_models)
? keyInfo.matching_global_models
: []
for (const gm of matchingGlobalModels) {
const matchedModels = Array.isArray(gm.matched_models) ? gm.matched_models : []
if (!modelMap.has(gm.global_model_id)) {
modelMap.set(gm.global_model_id, {
key: `regex-${gm.global_model_id}`,
type: 'regex',
targetModelName: gm.display_name,
targetModelName: gm.display_name || gm.global_model_name || gm.global_model_id,
targetModelId: gm.global_model_id,
globalModelName: gm.global_model_name,
mappings: [],
@@ -618,7 +625,7 @@ const regexMappings = computed<CombinedMapping[]>(() => {
if (!mapping) continue
// 添加 Key 信息
const keyMatches: MappingItem[] = gm.matched_models.map(m => ({
const keyMatches: MappingItem[] = matchedModels.map(m => ({
name: m.allowed_model,
pattern: m.mapping_pattern
}))
@@ -631,7 +638,7 @@ const regexMappings = computed<CombinedMapping[]>(() => {
})
// 收集所有映射(去重)
for (const match of gm.matched_models) {
for (const match of matchedModels) {
if (!mapping.mappings.some(m => m.name === match.allowed_model)) {
mapping.mappings.push({
name: match.allowed_model,
@@ -22,6 +22,9 @@ const props = withDefaults(defineProps<{
const now = ref(Date.now())
const precision = computed(() => Math.max(0, props.precision))
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
// Usage timestamps have second precision while durations have millisecond precision.
// Switching anchors can therefore introduce a sub-second phase shift at first byte.
const ACTIVE_CLOCK_TIMESTAMP_PRECISION_MS = 1000
let rafId: number | null = null
@@ -72,19 +75,29 @@ const displayText = computed(() => {
return `${(responseTimeMs / 1000).toFixed(precision.value)}s`
}
const createdAtMs = parseCreatedAtMs(props.createdAt)
const createdAtElapsedMs = Number.isNaN(createdAtMs)
? null
: Math.max(0, now.value - createdAtMs)
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
const updatedAtMs = parseCreatedAtMs(props.responseTimeUpdatedAt)
if (responseTimeMs != null && !Number.isNaN(updatedAtMs)) {
const elapsedSinceUpdateMs = Math.max(0, now.value - updatedAtMs)
return `${((responseTimeMs + elapsedSinceUpdateMs) / 1000).toFixed(precision.value)}s`
const responseElapsedMs = responseTimeMs + elapsedSinceUpdateMs
// When both clocks differ only by timestamp truncation, keep the original
// created-at clock so the first-byte snapshot cannot make total time pause
// or move backwards. A larger difference is a real calibration signal
// (for example an audit row created before execution) and remains authoritative.
if (createdAtElapsedMs != null &&
Math.abs(responseElapsedMs - createdAtElapsedMs) <= ACTIVE_CLOCK_TIMESTAMP_PRECISION_MS) {
return `${(createdAtElapsedMs / 1000).toFixed(precision.value)}s`
}
return `${(responseElapsedMs / 1000).toFixed(precision.value)}s`
}
if (!props.createdAt) return '-'
const createdAtMs = parseCreatedAtMs(props.createdAt)
if (Number.isNaN(createdAtMs)) return '-'
const elapsedMs = Math.max(0, now.value - createdAtMs)
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
if (createdAtElapsedMs == null) return '-'
return `${(createdAtElapsedMs / 1000).toFixed(precision.value)}s`
})
</script>
@@ -462,7 +462,7 @@
</span>
</div>
<!-- 错误信息真实上游响应合并在此处展示 -->
<!-- 错误信息将实际上游响应头和响应体作为同一个对象展示 -->
<div
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
class="error-block"
@@ -485,12 +485,24 @@
</div>
<div
v-if="currentAttemptRequestError.upstreamResponse"
class="error-json"
class="error-json error-upstream-response-json"
>
<JsonContentPanel
:data="currentAttemptRequestError.upstreamResponse"
:is-dark="isDark"
empty-message="无上游响应信息"
title="上游响应"
empty-message="无上游响应"
/>
</div>
<div
v-if="currentAttemptRequestError.diagnostic"
class="error-json error-diagnostic-json"
>
<JsonContentPanel
:data="currentAttemptRequestError.diagnostic"
:is-dark="isDark"
title="失败诊断"
empty-message="无失败诊断信息"
/>
</div>
</div>
@@ -1227,7 +1239,7 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
const raw = extractObject(value)
if (!raw) return null
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
const headers = raw.headers
const headers = raw.headers ?? raw.header
const body = raw.body
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
@@ -1627,6 +1639,7 @@ const currentAttemptRequestError = computed<{
message: string
statusCode?: number
upstreamResponse: Record<string, unknown> | null
diagnostic: Record<string, unknown> | null
} | null>(() => {
const attempt = currentAttempt.value
if (!attempt || attempt.status !== 'failed') return null
@@ -1667,10 +1680,20 @@ const currentAttemptRequestError = computed<{
rawMessage,
)
: null
const upstreamResponseWithDiagnostic = diagnostic
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
: upstreamResponseDisplay
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
const upstreamResponseData: Record<string, unknown> = {}
const responseHeader = upstreamResponseDisplay?.headers
const responseBody = upstreamResponseDisplay?.body
if (hasRenderableValue(responseHeader)) upstreamResponseData.header = responseHeader
if (hasRenderableValue(responseBody)) upstreamResponseData.body = responseBody
const response = Object.keys(upstreamResponseData).length > 0
? upstreamResponseData
: null
if (
!message
&& statusCode == null
&& !response
&& !diagnostic
) return null
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
rawMessage || fallbackType,
upstreamResponseDisplay,
@@ -1679,7 +1702,8 @@ const currentAttemptRequestError = computed<{
return {
message: showMessage ? (message || '未知错误') : '',
statusCode,
upstreamResponse: upstreamResponseWithDiagnostic,
upstreamResponse: response,
diagnostic,
}
})
@@ -23,23 +23,21 @@
<h3 class="text-lg font-semibold">
请求详情
</h3>
<div class="flex min-w-0 max-w-[10rem] items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded sm:max-w-none">
<span class="truncate">{{ detail?.model || '-' }}</span>
<template v-if="detail?.target_model && detail.target_model !== detail.model">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg>
<span class="truncate">{{ detail.target_model }}</span>
</template>
<UsageModelDisplay
v-if="headerModelRecord"
:record="headerModelRecord"
:cyber="detailCyberPolicyError"
context="detail"
data-request-detail-model-display
class="min-w-0 max-w-[18rem] text-sm font-mono text-muted-foreground sm:max-w-none"
model-row-class="rounded bg-muted px-2 py-0.5"
/>
<div
v-else
data-request-detail-model-display
class="rounded bg-muted px-2 py-0.5 text-sm font-mono text-muted-foreground"
>
-
</div>
<Badge
v-if="detail?.status_code === 200"
@@ -159,47 +157,6 @@
v-else-if="detail"
class="space-y-4"
>
<!-- 执行失败原因优先展示本地调度/运行时失败摘要 -->
<Card
v-if="failureNotice"
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
>
<div class="p-3 sm:p-4 flex gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
<AlertTriangle class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
{{ failureNotice.title }}
</h4>
<Badge
v-if="failureNotice.isSchedulingFailure"
variant="outline"
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
>
调度阶段
</Badge>
</div>
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
{{ failureNotice.message }}
</p>
<div
v-if="failureNotice.meta.length > 0"
class="flex flex-wrap gap-1.5"
>
<span
v-for="item in failureNotice.meta"
:key="item"
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
>
{{ item }}
</span>
</div>
</div>
</div>
</Card>
<!-- 费用与性能概览 -->
<Card>
<div class="p-3 sm:p-4">
@@ -253,8 +210,6 @@
v-if="hasServiceTierFacts || processingTierPriceMultiplier !== null"
class="mt-3"
:requested="serviceTierFacts.requested"
:actual="serviceTierFacts.actual"
:billing="serviceTierFacts.billing"
:price-multiplier="processingTierPriceMultiplier"
/>
</div>
@@ -886,6 +841,7 @@ import TabsContent from '@/components/ui/tabs-content.vue'
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import type { UsageRecord } from '../types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import {
formatByteSize,
@@ -906,7 +862,11 @@ import {
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments,
} from '../utils/status'
import { resolveRequestFailureNotice } from '../utils/errorNotice'
import { isCyberPolicyError } from '../utils/cyberError'
import {
mergeUsageRecordErrorMessage,
parseUsageTimestampMs,
} from '../utils/recordSync'
import {
formatPricePerMillion,
resolveProcessingTierPriceMultiplier,
@@ -922,7 +882,11 @@ import ConversationView from './RequestDetailDrawer/ConversationView.vue'
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
import ReplayDialog from './ReplayDialog.vue'
import ServiceTierFacts from './ServiceTierFacts.vue'
import { hasServiceTierFact, resolveServiceTierFacts } from '../utils/service-tier'
import UsageModelDisplay from './UsageModelDisplay.vue'
import {
hasServiceTierFact,
resolveServiceTierFacts,
} from '../utils/service-tier'
// 对话解析器
import {
@@ -937,6 +901,7 @@ type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'ca
const props = defineProps<{
isOpen: boolean
requestId: string | null
summaryRecord?: UsageRecord | null
}>()
const emit = defineEmits<{
@@ -966,11 +931,13 @@ const emit = defineEmits<{
endpointApiFormat?: string | null
hasFormatConversion?: boolean | null
targetModel?: string | null
requestedReasoningEffort?: string | null
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
}]
}>()
@@ -1098,6 +1065,114 @@ function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'st
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
}
type HeaderModelTextField =
| 'model'
| 'target_model'
| 'model_version'
| 'requested_reasoning_effort'
| 'reasoning_effort'
| 'service_tier'
| 'actual_service_tier'
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
'target_model',
'reasoning_effort',
'service_tier',
'actual_service_tier',
])
let modelSnapshotRevision = 0
const summaryModelRevision = ref(0)
const detailModelRevision = ref(0)
function usageSnapshotUpdatedAtMs(
source: UsageRecord | RequestDetail | null | undefined,
): number | null {
const value = source?.updated_at
if (typeof value !== 'string' || !value.trim()) return null
return parseUsageTimestampMs(value)
}
function summaryNullIsNewerForProviderField(
field: HeaderModelTextField,
nextDetail: RequestDetail | null | undefined,
): boolean {
if (!FINAL_PROVIDER_HEADER_FIELDS.has(field) || !props.summaryRecord) return false
const summaryUpdatedAt = usageSnapshotUpdatedAtMs(props.summaryRecord)
const detailUpdatedAt = usageSnapshotUpdatedAtMs(nextDetail)
if (summaryUpdatedAt != null && detailUpdatedAt != null && summaryUpdatedAt !== detailUpdatedAt) {
return summaryUpdatedAt > detailUpdatedAt
}
if (summaryModelRevision.value > detailModelRevision.value) return true
// A terminal list row is a complete final-provider snapshot. When no
// comparable timestamps exist, its explicit null must beat a cached detail
// from an earlier candidate. Non-terminal rows may still be filled by a
// detail request that completed after the lightweight list response.
return ['completed', 'failed', 'cancelled'].includes(props.summaryRecord.status ?? '')
}
function readHeaderModelTextField(
source: UsageRecord | RequestDetail | null | undefined,
field: HeaderModelTextField,
): { resolved: boolean, value: string | null } {
if (!source || !Object.prototype.hasOwnProperty.call(source, field)) {
return { resolved: false, value: null }
}
const value = (source as unknown as Record<string, unknown>)[field]
if (value === null) return { resolved: true, value: null }
if (typeof value !== 'string') return { resolved: false, value: null }
const normalized = value.trim()
return { resolved: true, value: normalized || null }
}
function resolveHeaderModelTextField(
field: HeaderModelTextField,
nextDetail: RequestDetail | null | undefined,
): string | null | undefined {
// Prefer a populated list/active fact so sparse detail cannot make the header
// flicker. A summary null is often only a lightweight-contract placeholder,
// though, so a later populated detail is still useful. Final-provider stale
// facts are cleared when full list/active snapshots merge into the summary.
const summaryValue = readHeaderModelTextField(props.summaryRecord, field)
if (summaryValue.value) return summaryValue.value
const detailValue = readHeaderModelTextField(nextDetail, field)
if (detailValue.value) {
if (
summaryValue.resolved
&& summaryValue.value === null
&& summaryNullIsNewerForProviderField(field, nextDetail)
) return null
return detailValue.value
}
return summaryValue.resolved || detailValue.resolved ? null : undefined
}
watch(
() => [
props.requestId,
props.summaryRecord?.status,
props.summaryRecord?.updated_at,
props.summaryRecord?.model,
props.summaryRecord?.target_model,
props.summaryRecord?.model_version,
props.summaryRecord?.requested_reasoning_effort,
props.summaryRecord?.reasoning_effort,
props.summaryRecord?.service_tier,
props.summaryRecord?.actual_service_tier,
],
() => {
summaryModelRevision.value = ++modelSnapshotRevision
},
{ immediate: true },
)
function detailTotalCost(nextDetail: RequestDetail): number | null {
const structuredCost = typeof nextDetail.cost === 'object' ? nextDetail.cost?.total : null
const totalCost = toNumber(nextDetail.total_cost)
@@ -1124,6 +1199,15 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
const id = props.requestId
if (!id) return
const targetModel = resolveHeaderModelTextField('target_model', nextDetail)
const requestedReasoningEffort = resolveHeaderModelTextField(
'requested_reasoning_effort',
nextDetail,
)
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
emit('requestState', {
id,
requestId: nextDetail.request_id || nextDetail.id || null,
@@ -1148,11 +1232,13 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
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,
actualServiceTier: nextDetail.actual_service_tier ?? null,
...(targetModel ? { targetModel } : {}),
...(requestedReasoningEffort ? { requestedReasoningEffort } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(serviceTier ? { serviceTier } : {}),
...(actualServiceTier ? { actualServiceTier } : {}),
errorMessage: nextDetail.error_message ?? undefined,
updatedAt: nextDetail.updated_at ?? undefined,
})
}
@@ -1358,10 +1444,106 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
: null
})
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
const detailForCurrentRequest = computed(() => (
detailMatchesRequestId(detail.value, props.requestId) ? detail.value : null
))
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
type AuthoritativeErrorSource = 'summary' | 'detail' | null
function isTerminalRequestState(status: RequestStateStatus | undefined): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function isSuccessfulTerminalRequestState(status: RequestStateStatus | undefined): boolean {
return status === 'completed' || status === 'cancelled'
}
const authoritativeErrorSource = computed<AuthoritativeErrorSource>(() => {
const summary = props.summaryRecord
const currentDetail = detailForCurrentRequest.value
if (!summary || !currentDetail) return null
const summaryStatus = resolveRequestStateStatus(
summary.status,
summary.status_code,
summary.error_message,
)
const detailStatus = resolveRequestStateStatusFromDetail(currentDetail)
const summaryUpdatedAtMs = usageSnapshotUpdatedAtMs(summary)
const detailUpdatedAtMs = usageSnapshotUpdatedAtMs(currentDetail)
if (summaryUpdatedAtMs != null && detailUpdatedAtMs != null &&
summaryUpdatedAtMs !== detailUpdatedAtMs) {
if (detailUpdatedAtMs > summaryUpdatedAtMs && isTerminalRequestState(detailStatus)) {
return 'detail'
}
if (summaryUpdatedAtMs > detailUpdatedAtMs && isTerminalRequestState(summaryStatus)) {
return 'summary'
}
}
// Without a comparable timestamp, a successful/cancelled terminal snapshot
// still has to clear a failure from the other source. Generic failed detail
// remains non-authoritative so opening the drawer cannot flash away a Cyber
// refusal already resolved by the list.
const detailSucceeded = isSuccessfulTerminalRequestState(detailStatus)
const summarySucceeded = isSuccessfulTerminalRequestState(summaryStatus)
if (detailSucceeded && !summarySucceeded) return 'detail'
if (summarySucceeded && !detailSucceeded) return 'summary'
if (detailSucceeded && summarySucceeded) {
return detailModelRevision.value >= summaryModelRevision.value ? 'detail' : 'summary'
}
return null
})
const headerModelRecord = computed(() => {
const summary = props.summaryRecord
const currentDetail = detailForCurrentRequest.value
if (!summary && !currentDetail) return null
const authoritativeSource = authoritativeErrorSource.value
const errorMessage = authoritativeSource === 'summary'
? mergeUsageRecordErrorMessage(undefined, summary?.error_message, { authoritative: true })
: mergeUsageRecordErrorMessage(
summary?.error_message,
currentDetail?.error_message,
{ authoritative: authoritativeSource === 'detail' },
)
return {
model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
target_model: resolveHeaderModelTextField('target_model', currentDetail),
model_version: resolveHeaderModelTextField('model_version', currentDetail),
requested_reasoning_effort: resolveHeaderModelTextField(
'requested_reasoning_effort',
currentDetail,
),
reasoning_effort: resolveHeaderModelTextField('reasoning_effort', currentDetail),
service_tier: resolveHeaderModelTextField('service_tier', currentDetail),
error_message: errorMessage,
}
})
const serviceTierFacts = computed(() => resolveServiceTierFacts(headerModelRecord.value))
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
const detailCyberPolicyError = computed(() => {
const summaryError = props.summaryRecord?.error_message
const currentDetail = detailForCurrentRequest.value
const detailErrors = [
currentDetail?.error_message,
currentDetail?.upstream_error,
currentDetail?.failure_summary,
currentDetail?.response_body,
]
if (authoritativeErrorSource.value === 'summary') {
return isCyberPolicyError(summaryError)
}
if (authoritativeErrorSource.value === 'detail') {
return isCyberPolicyError(detailErrors)
}
return isCyberPolicyError([summaryError, ...detailErrors])
})
const processingTierPriceMultiplier = computed(() => (
resolveProcessingTierPriceMultiplier(detail.value)
))
@@ -2257,15 +2439,12 @@ const visibleTabs = computed(() => {
})
})
watch(() => props.requestId, async (newId) => {
if (newId && props.isOpen) {
await loadDetail(newId)
}
})
watch(() => props.isOpen, async (isOpen) => {
if (isOpen && props.requestId) {
await loadDetail(props.requestId)
watch([() => props.isOpen, () => props.requestId], async ([isOpen, requestId]) => {
if (isOpen && requestId) {
if (!detailMatchesRequestId(detail.value, requestId)) {
detail.value = null
}
await loadDetail(requestId)
} else if (!isOpen) {
stopAutoRefresh()
showTimeline.value = false
@@ -2276,6 +2455,14 @@ watch(() => props.isOpen, async (isOpen) => {
}
})
function detailMatchesRequestId(
candidate: RequestDetail | null | undefined,
requestId: string | null | undefined,
): boolean {
if (!candidate || !requestId) return false
return candidate.id === requestId || candidate.request_id === requestId
}
async function ensureBodyContentLoaded() {
if (!props.requestId || !detail.value) return
@@ -2334,6 +2521,9 @@ async function loadDetail(id: string, silent = false) {
const requestId = ++loadDetailRequestId
loadDetailInFlight = true
if (!silent) {
if (!detailMatchesRequestId(detail.value, id)) {
detail.value = null
}
loading.value = true
historicalPricing.value = null
timelineLoaded.value = false
@@ -2371,6 +2561,7 @@ async function loadDetail(id: string, silent = false) {
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
detailModelRevision.value = ++modelSnapshotRevision
detail.value = nextDetail
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
emitDetailRequestState(nextDetail)
@@ -1,12 +1,12 @@
<template>
<dl
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs"
:class="hasPriceMultiplier ? 'sm:grid-cols-4' : 'sm:grid-cols-3'"
:class="hasPriceMultiplier ? 'sm:grid-cols-3' : 'sm:grid-cols-2'"
data-testid="service-tier-facts"
>
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
<dt class="text-muted-foreground">
请求层级
上游请求层级
</dt>
<dd
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
@@ -15,26 +15,15 @@
{{ formatServiceTierFact(requested) || '-' }}
</dd>
</div>
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
<dt class="text-muted-foreground">
实际层级
</dt>
<dd
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
:title="formatServiceTierFact(actual) || '-'"
>
{{ formatServiceTierFact(actual) || '-' }}
</dd>
</div>
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
<dt class="text-muted-foreground">
计费层级
</dt>
<dd
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
:title="formatServiceTierFact(billing) || '-'"
:title="formatServiceTierFact(requested) || '-'"
>
{{ formatServiceTierFact(billing) || '-' }}
{{ formatServiceTierFact(requested) || '-' }}
</dd>
</div>
<div
@@ -58,8 +47,6 @@ import { formatServiceTierFact } from '../utils/service-tier'
const props = defineProps<{
requested: string | null
actual: string | null
billing: string | null
priceMultiplier?: number | null
}>()
@@ -70,7 +57,7 @@ const hasPriceMultiplier = computed(() => (
))
const multiplierTierLabel = computed(() => (
formatServiceTierFact(props.billing ?? props.actual ?? props.requested) ?? '处理层级'
formatServiceTierFact(props.requested) ?? '处理层级'
))
const formattedPriceMultiplier = computed(() => (
@@ -0,0 +1,184 @@
<template>
<div
class="flex min-w-0 max-w-full flex-col gap-0.5"
:class="shouldStackBadges && stackFullWidth ? 'w-full items-start' : 'items-start'"
:data-usage-model-layout="shouldStackBadges ? 'stacked' : 'inline'"
:data-request-detail-model-layout="context === 'detail'
? (shouldStackBadges ? 'stacked' : 'inline')
: undefined"
>
<div
class="flex min-w-0 max-w-full 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="shrink-0 text-muted-foreground/70">-&gt;</span>
<span
class="min-w-0 truncate"
:class="modelClass"
data-usage-model-target
>{{ actualModel }}</span>
</template>
<template v-if="!shouldStackBadges">
<Badge
v-for="badge in modelBadges"
:key="badge.key"
: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="badge.className"
:title="badge.title"
:aria-label="badge.ariaLabel"
>
{{ badge.label }}
</Badge>
</template>
</div>
<div
v-if="shouldStackBadges && modelBadges.length > 0"
class="flex min-w-0 max-w-full flex-wrap items-center gap-1"
data-usage-model-badges-row
:data-request-detail-model-badges-row="context === 'detail' ? '' : undefined"
>
<Badge
v-for="badge in modelBadges"
:key="badge.key"
: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="badge.className"
:title="badge.title"
:aria-label="badge.ariaLabel"
>
{{ badge.label }}
</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '@/components/ui'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatServiceTierFact } from '../utils/service-tier'
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber'
interface ModelBadgePresentation {
key: ModelBadgeKey
label: string
variant: 'outline' | 'outline-transparent'
className: string
title: string
ariaLabel: string
}
interface UsageModelDisplayRecord {
model: string
target_model?: string | null
model_version?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
error_message?: string | null
}
const props = withDefaults(defineProps<{
record: UsageModelDisplayRecord
modelClass?: string
modelRowClass?: string
context?: 'usage' | 'detail'
cyber?: boolean | null
stackFullWidth?: boolean
}>(), {
modelClass: '',
modelRowClass: '',
context: 'usage',
cyber: null,
stackFullWidth: false,
})
const actualModel = 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
return null
})
const reasoningLabel = computed(() => {
const requested = normalizeText(props.record.requested_reasoning_effort)
const actual = normalizeText(props.record.reasoning_effort)
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
return `${requested} -> ${actual}`
}
return actual ?? requested
})
const modelBadges = computed<ModelBadgePresentation[]>(() => {
const badges: ModelBadgePresentation[] = []
if (normalizeText(props.record.request_type)?.toLowerCase() === 'compact') {
badges.push({
key: 'compact',
label: '会话压缩',
variant: 'outline',
className: 'border-sky-500/30 bg-sky-500/5 text-sky-700 dark:text-sky-300',
title: '会话压缩',
ariaLabel: '会话压缩',
})
}
if (reasoningLabel.value) {
badges.push({
key: 'reasoning',
label: reasoningLabel.value,
variant: 'outline',
className: 'border-primary/30 bg-primary/5 text-primary',
title: `Reasoning: ${reasoningLabel.value}`,
ariaLabel: `Reasoning: ${reasoningLabel.value}`,
})
}
if (formatServiceTierFact(props.record.service_tier) === 'Fast') {
badges.push({
key: 'fast',
label: 'Fast',
variant: 'outline-transparent',
className: 'text-amber-700 dark:text-amber-300',
title: '上游请求档位:Fast\n计费档位:Fast',
ariaLabel: '上游请求档位:Fast,计费档位:Fast',
})
}
if (props.cyber ?? isCyberPolicyError(props.record.error_message)) {
badges.push({
key: 'cyber',
label: 'Cyber',
variant: 'outline',
className: 'border-primary/30 bg-primary/5 text-rose-600 dark:text-rose-300',
title: '上游 Cyber Policy 拒绝',
ariaLabel: '上游 Cyber Policy 拒绝',
})
}
return badges
})
const shouldStackBadges = computed(() => (
actualModel.value !== null || modelBadges.value.length >= 3
))
function normalizeText(value: string | null | undefined): string | null {
const normalized = value?.trim()
return normalized || null
}
</script>
@@ -242,34 +242,12 @@
<!-- 第一行模型 + 费用 -->
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 truncate text-[15px] font-semibold leading-5">{{ record.model }}</span>
<Badge
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
</Badge>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getServiceTierBadge(record)"
variant="outline"
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
:class="getServiceTierBadge(record)?.className"
:title="getServiceTierBadge(record)?.title"
:aria-label="getServiceTierBadge(record)?.ariaLabel"
>
{{ getServiceTierBadge(record)?.label }}
</Badge>
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
<UsageModelDisplay
:record="record"
model-class="text-[15px] font-semibold leading-5"
stack-full-width
/>
<!-- 状态 Badge -->
<Badge
v-if="isUsageRecordFailed(record)"
@@ -320,10 +298,6 @@
{{ getStreamModeLabel(record) }}
</Badge>
</div>
<span
v-if="getActualModel(record)"
class="text-[11px] text-muted-foreground truncate block"
>-> {{ getActualModel(record) }}</span>
</div>
<div class="flex flex-col items-end flex-shrink-0">
<span class="text-sm text-primary font-semibold leading-5">{{ formatCurrency(record.cost || 0) }}</span>
@@ -745,85 +719,10 @@
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
:title="getModelTooltip(record)"
>
<div
v-if="getActualModel(record)"
class="flex flex-col text-xs gap-0.5"
>
<div class="flex min-w-0 items-center gap-1">
<span class="truncate">{{ record.model }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 text-muted-foreground flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg>
<Badge
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
</Badge>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getServiceTierBadge(record)"
variant="outline"
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
:class="getServiceTierBadge(record)?.className"
:title="getServiceTierBadge(record)?.title"
:aria-label="getServiceTierBadge(record)?.ariaLabel"
>
{{ getServiceTierBadge(record)?.label }}
</Badge>
</div>
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
</div>
<span
v-else
class="flex min-w-0 items-center gap-1"
>
<span class="truncate">{{ record.model }}</span>
<Badge
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
</Badge>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getServiceTierBadge(record)"
variant="outline"
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
:class="getServiceTierBadge(record)?.className"
:title="getServiceTierBadge(record)?.title"
:aria-label="getServiceTierBadge(record)?.ariaLabel"
>
{{ getServiceTierBadge(record)?.label }}
</Badge>
</span>
<UsageModelDisplay
:record="record"
class="text-xs"
/>
</TableCell>
<TableCell
v-if="isAdmin && isColumnVisible('provider')"
@@ -1130,11 +1029,13 @@ 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 { isCyberPolicyError } from '../utils/cyberError'
import type { DateRangeParams, UsageRecord } from '../types'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
import UsageModelDisplay from './UsageModelDisplay.vue'
export interface UserOption {
id: string
@@ -1632,24 +1533,20 @@ function getActualModel(record: UsageRecord): string | null {
}
function getReasoningEffort(record: UsageRecord): string | null {
const effort = record.reasoning_effort?.trim()
return effort || null
const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim()
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
return `${requested} -> ${actual}`
}
return actual || requested || null
}
function getRequestTypeLabel(record: UsageRecord): string | null {
return record.request_type?.trim().toLowerCase() === 'compact' ? '会话压缩' : null
function hasCyberPolicyError(record: UsageRecord): boolean {
return isCyberPolicyError(record.error_message)
}
function getReasoningEffortTitle(record: UsageRecord): string {
const effort = getReasoningEffort(record)
return effort ? `Reasoning: ${effort}` : ''
}
type ServiceTierBadgeState = 'confirmed' | 'downgraded' | 'upgraded' | 'pending' | 'unconfirmed'
interface ServiceTierBadgePresentation {
label: string
state: ServiceTierBadgeState
className: string
title: string
ariaLabel: string
@@ -1670,44 +1567,19 @@ function canonicalServiceTier(value: string | null): string | null {
return value
}
function serviceTierBadgeClass(state: ServiceTierBadgeState): string {
switch (state) {
case 'confirmed':
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
case 'downgraded':
return 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'
case 'upgraded':
return 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300'
case 'pending':
return 'border-dashed border-muted-foreground/30 bg-muted/30 text-muted-foreground'
case 'unconfirmed':
return 'border-dashed border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-300'
}
}
function buildServiceTierBadgePresentation(
label: string,
state: ServiceTierBadgeState,
requestedRaw: string | null,
actualRaw: string | null,
billingTier: string | null,
): ServiceTierBadgePresentation {
const titleLines: string[] = []
const requestedLabel = formatServiceTierFact(requestedRaw)
const actualLabel = formatServiceTierFact(actualRaw)
const billingLabel = formatServiceTierFact(billingTier)
if (requestedLabel) titleLines.push(`请求档位:${requestedLabel}`)
if (actualLabel) titleLines.push(`实际档位:${actualLabel}`)
if (billingLabel) {
titleLines.push(`计费档位:${billingLabel}`)
} else {
titleLines.push(`计费档位:${state === 'pending' ? '待上游确认' : '未确认'}`)
}
if (requestedLabel) titleLines.push(`上游请求档位:${requestedLabel}`)
// Billing is resolved from the same final provider request tier. Keep it
// explicit in the tooltip without consulting a response-side tier.
if (requestedLabel) titleLines.push(`计费档位:${requestedLabel}`)
const title = titleLines.join('\n')
return {
label,
state,
className: serviceTierBadgeClass(state),
label: 'Fast',
className: '!bg-transparent text-amber-700 dark:text-amber-300',
title,
ariaLabel: titleLines.join(''),
}
@@ -1715,54 +1587,10 @@ function buildServiceTierBadgePresentation(
function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation | null {
const requestedRaw = normalizeServiceTier(record.service_tier)
const actualRaw = normalizeServiceTier(record.actual_service_tier)
const requested = canonicalServiceTier(requestedRaw)
const actual = canonicalServiceTier(actualRaw)
const requestedFast = requested === 'priority'
const actualFast = actual === 'priority'
if (actual) {
if (requestedFast && !actualFast) {
return buildServiceTierBadgePresentation(
`Fast → ${actual}`,
'downgraded',
requestedRaw,
actualRaw,
actual,
)
}
if (!requestedFast && actualFast) {
const requestedLabel = requested ?? 'standard'
return buildServiceTierBadgePresentation(
requested ? `${requestedLabel} → Fast` : 'Fast',
requested ? 'upgraded' : 'confirmed',
requestedRaw,
actualRaw,
actual,
)
}
if (actualFast) {
return buildServiceTierBadgePresentation(
'Fast',
'confirmed',
requestedRaw,
actualRaw,
actual,
)
}
return null
}
if (!requestedFast) return null
const displayStatus = getDisplayStatus(record)
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
return buildServiceTierBadgePresentation(
isActive ? 'Fast · 待确认' : 'Fast · 未确认',
isActive ? 'pending' : 'unconfirmed',
requestedRaw,
null,
null,
)
return buildServiceTierBadgePresentation(requestedRaw)
}
function getServiceTierTitle(record: UsageRecord): string {
@@ -1770,10 +1598,9 @@ function getServiceTierTitle(record: UsageRecord): string {
if (badge) return badge.title
const requested = formatServiceTierFact(record.service_tier)
const actual = formatServiceTierFact(record.actual_service_tier)
return [
requested ? `请求档位:${requested}` : null,
actual ? `实际档位:${actual}` : null,
requested ? `上游请求档位:${requested}` : null,
requested ? `计费档位:${requested}` : null,
].filter((line): line is string => Boolean(line)).join('\n')
}
@@ -1781,10 +1608,10 @@ function getServiceTierTitle(record: UsageRecord): string {
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const requestType = getRequestTypeLabel(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const suffix = `${requestType ? `\n操作: ${requestType}` : ''}${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}`
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, type App } from 'vue'
import { createApp, h, nextTick, reactive, type App } from 'vue'
import ElapsedTimeText from '../ElapsedTimeText.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
@@ -15,6 +15,18 @@ function mountElapsedTimeText(props: Record<string, unknown>) {
return root
}
function mountReactiveElapsedTimeText(initialProps: Record<string, unknown>) {
const props = reactive(initialProps)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ElapsedTimeText, { ...props }),
})
app.mount(root)
mountedApps.push({ app, root })
return { props, root }
}
afterEach(() => {
vi.useRealTimers()
for (const { app, root } of mountedApps.splice(0)) {
@@ -53,4 +65,30 @@ describe('ElapsedTimeText', () => {
expect(root.textContent).toBe('4.00s')
})
it('does not pause or move total time backwards when the first-byte clock arrives', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-17T12:00:06.250Z'))
const { props, root } = mountReactiveElapsedTimeText({
status: 'pending',
createdAt: '2026-07-17T12:00:00Z',
responseTimeUpdatedAt: null,
responseTimeMs: null,
})
await nextTick()
expect(root.textContent).toBe('6.25s')
// The first-byte snapshot implies 5.85s at the same instant because its
// timestamp is truncated to seconds. The visible clock must stay continuous.
props.status = 'streaming'
props.responseTimeUpdatedAt = '2026-07-17T12:00:06Z'
props.responseTimeMs = 5600
await nextTick()
expect(root.textContent).toBe('6.25s')
vi.advanceTimersByTime(500)
await nextTick()
expect(Number.parseFloat(root.textContent ?? '')).toBeGreaterThanOrEqual(6.74)
})
})
@@ -58,9 +58,13 @@ vi.mock('../JsonContentPanel.vue', async () => {
type: null,
default: null,
},
title: {
type: String,
default: 'JSON',
},
},
setup(props) {
return () => h('pre', JSON.stringify(props.data))
return () => h('pre', { 'data-title': props.title }, JSON.stringify(props.data))
},
}),
}
@@ -517,7 +521,8 @@ describe('HorizontalRequestTimeline', () => {
expect(requestPathCode?.textContent).toContain('/v1/images/generations')
})
it('shows upstream response JSON inside the error block on trace nodes', async () => {
it('shows upstream response headers and body in one error envelope', async () => {
const upstreamErrorMessage = 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
const trace = buildTrace([
buildCandidate({
id: 'cand-upstream-response',
@@ -527,21 +532,33 @@ describe('HorizontalRequestTimeline', () => {
key_name: 'Upstream Key',
candidate_index: 0,
status: 'failed',
error_message: 'execution runtime stream returned non-success status 302',
error_message: 'execution runtime stream returned non-success status 400',
extra_data: {
upstream_response: {
status_code: 302,
headers: { location: '/' },
status_code: 400,
headers: {
'content-type': 'application/json',
'x-request-id': 'req_usage-cyber-risk-demo',
},
body: {
error: {
type: 'invalid_request',
message: upstreamErrorMessage,
code: 400,
},
},
body_ref: 'usage://request/req-1/response_body',
body_state: 'reference',
},
error_flow: {
source: 'upstream_response',
status_code: 302,
status_code: 400,
classification: 'use_default',
decision: 'use_default',
propagation: 'none',
retryable: false,
safe_to_expose: false,
message: 'execution runtime stream returned non-success status 302',
message: 'execution runtime stream returned non-success status 400',
},
client_response: {
status_code: 502,
@@ -555,15 +572,31 @@ describe('HorizontalRequestTimeline', () => {
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('HTTP 302')
expect(root.textContent).not.toContain('上游返回非成功状态 302')
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302')
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"')
expect(root.textContent).toContain('HTTP 400')
expect(root.textContent).not.toContain('上游返回非成功状态 400')
const upstreamResponse = root.querySelector<HTMLElement>('.error-upstream-response-json pre')
expect(upstreamResponse?.dataset.title).toBe('上游响应')
expect(JSON.parse(upstreamResponse?.textContent ?? '{}')).toEqual({
header: {
'content-type': 'application/json',
'x-request-id': 'req_usage-cyber-risk-demo',
},
body: {
error: {
type: 'invalid_request',
message: upstreamErrorMessage,
code: 400,
},
},
})
expect(upstreamResponse?.textContent).not.toContain('"status_code"')
expect(upstreamResponse?.textContent).not.toContain('"headers"')
expect(upstreamResponse?.textContent).not.toContain('"body_ref"')
expect(upstreamResponse?.textContent).not.toContain('"body_state"')
expect(root.textContent).not.toContain('上游真实响应')
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 302')
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 400')
expect(root.textContent).not.toContain('真实请求错误')
expect(root.textContent).not.toContain('返回客户端响应')
expect(root.textContent).not.toContain('上游响应')
expect(root.textContent).not.toContain('默认处理')
expect(root.textContent).not.toContain('none')
expect(root.textContent).not.toContain('不再重试')
@@ -601,12 +634,12 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).toContain('流式格式转换失败')
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"body_state":"disabled"')
expect(errorJsonText).toContain('"diagnostic"')
expect(errorJsonText).toContain('"breakpoint":"$.type"')
expect(errorJsonText).toContain('"analysis_hint"')
expect(errorJsonText).toContain('"raw"')
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
expect(diagnosticText).toContain('"breakpoint":"$.type"')
expect(diagnosticText).toContain('"analysis_hint"')
expect(diagnosticText).toContain('"raw"')
expect(diagnosticText).toContain('"body_state":"disabled"')
expect(root.querySelector('.error-upstream-response-json')?.textContent).toContain('"header":{"content-type":"application/json"}')
})
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
@@ -664,9 +697,8 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).toContain('流式格式转换失败')
expect(root.textContent).toContain('finish reason')
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"diagnostic"')
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
expect(diagnosticText).toContain('"breakpoint":"$.finish_reason"')
})
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
@@ -701,10 +733,10 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
expect(root.textContent).toContain('字段 $.n 会丢失信息')
expect(root.textContent).not.toContain('上游返回非成功状态 500')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"body_state":"disabled"')
expect(errorJsonText).toContain('"breakpoint":"$.n"')
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
expect(diagnosticText).toContain('"body_state":"disabled"')
expect(diagnosticText).toContain('"breakpoint":"$.n"')
expect(diagnosticText).toContain('断点在请求/响应格式转换器')
})
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
@@ -735,9 +767,8 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('格式转换失败')
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"diagnostic"')
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
expect(diagnosticText).toContain('"breakpoint":"$.temperature"')
})
it('keeps the failure message when upstream response only records an empty body state', async () => {
@@ -97,4 +97,388 @@ describe('RequestDetailDrawer settlement pricing', () => {
})
expect(document.body.textContent).not.toContain('输出 $0/M')
})
it('shows mapping, reasoning, Fast, and Cyber together in the model header', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-cyber-risk-demo',
request_id: 'req_usage-cyber-risk-demo',
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
request_body: {
model: 'gpt-5',
reasoning: { effort: 'xhigh' },
},
service_tier: 'priority',
// 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',
},
status: 'failed',
status_code: 400,
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
response_body: {
error: {
type: 'invalid_request',
message: 'This content was flagged for possible cybersecurity risk.',
code: 400,
},
},
})
let isOpen!: Ref<boolean>
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-cyber-risk-demo',
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
.toContain('gpt-5')
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
.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="cyber"]')?.textContent)
.toContain('Cyber')
const modelLayout = document.body.querySelector(
'[data-request-detail-model-layout="stacked"]',
)
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
.toBeNull()
const modelBadgesRow = modelLayout?.querySelector(
'[data-request-detail-model-badges-row]',
)
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
expect(modelBadgesRow?.textContent).toContain('Fast')
expect(modelBadgesRow?.textContent).toContain('Cyber')
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
.toEqual(['上游请求层级', '计费层级'])
expect([...serviceTierFacts?.querySelectorAll('dd') ?? []].map(node => node.textContent?.trim()))
.toEqual(['Fast', 'Fast'])
})
})
it('keeps the Cyber badge stable from the selected row while lightweight detail loads', async () => {
let resolveDetail!: (value: RequestDetail) => void
apiMocks.getRequestDetail.mockReturnValue(new Promise<RequestDetail>((resolve) => {
resolveDetail = resolve
}))
let isOpen!: Ref<boolean>
const requestId = ref('usage-cyber-summary')
const summaryRecord = ref<Record<string, unknown>>({
id: 'usage-cyber-summary',
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
})
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: requestId.value,
summaryRecord: summaryRecord.value as never,
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
.toContain('Fast')
resolveDetail({
...buildEmbeddingDetail(),
id: 'usage-cyber-summary',
request_id: 'usage-cyber-summary',
model: 'gpt-5',
// Lightweight detail can legitimately omit these final-provider facts.
target_model: null,
requested_reasoning_effort: null,
reasoning_effort: null,
service_tier: null,
status: 'failed',
status_code: 400,
error_message: 'execution runtime stream returned non-success status 400',
response_body: null,
})
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)
.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')
const tierValues = [
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
].map(node => node.textContent?.trim())
expect(tierValues).toEqual(['Fast', 'Fast'])
})
})
it('clears a stale summary Cyber badge when newer detail completed successfully', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-cyber-recovered',
request_id: 'usage-cyber-recovered',
model: 'gpt-5',
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
})
let isOpen!: Ref<boolean>
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-cyber-recovered',
summaryRecord: {
id: 'usage-cyber-recovered',
model: 'gpt-5',
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cost: 0,
is_stream: false,
status: 'failed',
status_code: 400,
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
created_at: '2026-07-17T00:00:00Z',
updated_at: '2026-07-17T00:00:01Z',
},
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]'))
.toBeNull()
})
})
it('uses populated detail fallbacks without overriding a populated summary tier', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-standard-summary',
request_id: 'usage-standard-summary',
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
// The non-empty summary tier remains authoritative over this stale fact.
service_tier: 'priority',
})
let isOpen!: Ref<boolean>
const summaryRecord = ref<Record<string, unknown>>({
id: 'usage-standard-summary',
model: 'gpt-5',
target_model: null,
model_version: null,
requested_reasoning_effort: 'xhigh',
reasoning_effort: null,
service_tier: 'default',
})
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-standard-summary',
summaryRecord: summaryRecord.value as never,
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
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-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
const tierValues = [
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
].map(node => node.textContent?.trim())
expect(tierValues).toEqual(['default', 'default'])
})
})
it('uses detail model_version when the lightweight summary has null model facts', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-version-summary',
request_id: 'usage-version-summary',
model: 'gpt-5',
target_model: null,
model_version: 'gpt-5.1-2026-07-17',
})
let isOpen!: Ref<boolean>
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-version-summary',
summaryRecord: {
id: 'usage-version-summary',
model: 'gpt-5',
target_model: null,
model_version: null,
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
cost: 0,
is_stream: false,
created_at: '2026-07-17T00:00:00Z',
},
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('gpt-5.1-2026-07-17')
})
})
it('lets a newer final-provider summary clear facts cached from an earlier candidate', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-final-candidate',
request_id: 'usage-final-candidate',
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
status: 'streaming',
updated_at: '2026-07-17T00:00:01Z',
})
let isOpen!: Ref<boolean>
const summaryRecord = ref<Record<string, unknown>>({
id: 'usage-final-candidate',
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
status: 'streaming',
updated_at: '2026-07-17T00:00:01Z',
})
const Host = defineComponent({
setup() {
isOpen = ref(false)
return () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-final-candidate',
summaryRecord: summaryRecord.value as never,
})
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).not.toBeNull()
})
summaryRecord.value = {
...summaryRecord.value,
target_model: null,
reasoning_effort: null,
service_tier: null,
updated_at: '2026-07-17T00:00:02Z',
}
await nextTick()
expect(document.body.querySelector('[data-usage-model-target]')).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()
expect([...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd')]
.some(node => node.textContent?.trim() === 'Fast')).toBe(false)
})
})
@@ -13,14 +13,12 @@ afterEach(() => {
})
describe('ServiceTierFacts', () => {
it('renders all three facts and marks a missing actual tier explicitly', () => {
it('renders request and billing facts from the same request tier', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: null,
billing: 'flex',
}),
})
app.mount(root)
@@ -28,30 +26,25 @@ describe('ServiceTierFacts', () => {
expect(root.querySelector('[data-testid="service-tier-facts"]')).not.toBeNull()
expect([...root.querySelectorAll('dt')].map(node => node.textContent?.trim())).toEqual([
'请求层级',
'实际层级',
'上游请求层级',
'计费层级',
])
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
'Fast',
'-',
'flex',
'Fast',
])
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
'Fast',
'-',
'flex',
'Fast',
])
})
it('uses the same Fast label for raw priority and fast facts', () => {
it('uses the Fast label for a raw fast request tier', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: 'fast',
billing: 'priority',
requested: 'fast',
}),
})
app.mount(root)
@@ -60,12 +53,10 @@ describe('ServiceTierFacts', () => {
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
'Fast',
'Fast',
'Fast',
])
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
'Fast',
'Fast',
'Fast',
])
})
@@ -75,8 +66,6 @@ describe('ServiceTierFacts', () => {
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: 'fast',
billing: 'fast',
priceMultiplier: 2.5,
}),
})
@@ -94,8 +83,6 @@ describe('ServiceTierFacts', () => {
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: null,
billing: null,
priceMultiplier: null,
}),
})
@@ -290,19 +290,31 @@ describe('UsageRecordsTable', () => {
})
it('shows reasoning effort next to the model name', () => {
const root = mountUsageRecordsTable([buildRecord({ reasoning_effort: 'xhigh' })])
const root = mountUsageRecordsTable([buildRecord({
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'xhigh',
service_tier: 'priority',
})])
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('xhigh')
const inlineLayout = root.querySelector('[data-usage-model-layout="inline"]')
expect(inlineLayout).not.toBeNull()
expect(inlineLayout?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh')
expect(inlineLayout?.querySelector('[data-usage-model-badge="fast"]')?.textContent?.trim())
.toBe('Fast')
})
it('shows request reasoning effort while the record is pending', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
reasoning_effort: 'max',
requested_reasoning_effort: 'max',
reasoning_effort: null,
})])
expect(root.textContent).toContain('max')
expect(root.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
.toBe('max')
})
it('marks Responses compaction while the record is pending', () => {
@@ -311,65 +323,145 @@ describe('UsageRecordsTable', () => {
request_type: 'compact',
})])
expect(root.textContent).toContain('会话压缩')
expect(root.querySelector('[data-usage-model-badge="compact"]')?.textContent?.trim())
.toBe('会话压缩')
})
it.each([
['priority', 'priority'],
['fast', 'fast'],
['priority', 'fast'],
['fast', 'priority'],
])('shows confirmed Fast for requested %s and actual %s', (requested, actual) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: requested,
actual_service_tier: actual,
})])
const badge = expectServiceTierBadge(root, 'Fast')
expect(badge.getAttribute('title')).toBe([
'请求档位:Fast',
'实际档位:Fast',
'计费档位:Fast',
].join('\n'))
expect(badge.getAttribute('aria-label')).toBe(
'请求档位:Fast,实际档位:Fast,计费档位:Fast',
)
})
it('shows fast to standard when the provider downgrades a priority request', () => {
it('shows mapping, reasoning, Fast, and Cyber together in the model area', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
// A conflicting response-side value must not affect the Fast badge.
actual_service_tier: 'default',
status: 'failed',
status_code: 400,
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
})])
const badge = expectServiceTierBadge(root, 'Fast → standard')
expect(badge.getAttribute('title')).toBe([
'请求档位:Fast',
'实际档位:default',
'计费档位:standard',
].join('\n'))
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('gpt-5.1')
expect(root.textContent).toContain('xhigh -> max')
expect(root.textContent).toContain('Fast')
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
const fastBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="fast"]')
const cyberBadges = root.querySelectorAll<HTMLElement>('[data-usage-model-badge="cyber"]')
const cyberBadge = cyberBadges[0]
for (const badge of [reasoningBadge, fastBadge, cyberBadge]) {
expect(badge).not.toBeNull()
expect(badge?.classList.contains('h-4')).toBe(true)
expect(badge?.classList.contains('rounded-full')).toBe(true)
expect(badge?.classList.contains('px-1.5')).toBe(true)
expect(badge?.classList.contains('text-[10px]')).toBe(true)
expect(badge?.classList.contains('leading-4')).toBe(true)
}
expect(reasoningBadge?.classList.contains('border-primary/30')).toBe(true)
expect(reasoningBadge?.classList.contains('bg-primary/5')).toBe(true)
expect(reasoningBadge?.classList.contains('text-primary')).toBe(true)
expect(fastBadge?.getAttribute('variant')).toBe('outline-transparent')
expect(fastBadge?.classList.contains('border-amber-400/50')).toBe(false)
expect(fastBadge?.classList.contains('!bg-transparent')).toBe(false)
expect(fastBadge?.classList.contains('bg-amber-400/10')).toBe(false)
expect(fastBadge?.classList.contains('text-amber-700')).toBe(true)
expect(cyberBadge?.classList.contains('border-primary/30')).toBe(true)
expect(cyberBadge?.classList.contains('bg-primary/5')).toBe(true)
expect(cyberBadge?.classList.contains('text-rose-600')).toBe(true)
expect(cyberBadges.length).toBeGreaterThan(0)
expect([...cyberBadges].every(badge => badge.textContent?.trim() === 'Cyber')).toBe(true)
expect([...cyberBadges].every(badge => badge.title === '上游 Cyber Policy 拒绝')).toBe(true)
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
expect(stackedLayout).not.toBeNull()
const modelRow = stackedLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-badge]')).toBeNull()
const badgesRow = stackedLayout?.querySelector('[data-usage-model-badges-row]')
expect(badgesRow?.textContent).toContain('xhigh -> max')
expect(badgesRow?.textContent).toContain('Fast')
expect(badgesRow?.textContent).toContain('Cyber')
})
it('shows fast to flex when the provider moves a priority request to flex', () => {
it('stacks three model badges even without a model mapping', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
target_model: null,
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'xhigh',
service_tier: 'priority',
actual_service_tier: 'flex',
status: 'failed',
status_code: 400,
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
})])
expectServiceTierBadge(root, 'Fast → flex')
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
expect(stackedLayout?.firstElementChild?.textContent?.trim()).toBe('gpt-5')
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
.toContain('xhigh')
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
.toContain('Fast')
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
.toContain('Cyber')
})
it('shows standard to fast when the provider upgrades a default request', () => {
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
'shows Fast from the final provider request tier %s',
(requested) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: requested,
actual_service_tier: 'default',
})])
const badge = expectServiceTierBadge(root, 'Fast')
expect(badge.getAttribute('title')).toBe([
'上游请求档位:Fast',
'计费档位:Fast',
].join('\n'))
expect(badge.getAttribute('aria-label')).toBe(
'上游请求档位:Fast,计费档位:Fast',
)
expect(badge.textContent).not.toContain('→')
expect(badge.textContent).not.toContain('待确认')
expect(badge.textContent).not.toContain('未确认')
},
)
it.each(['default', 'flex', null])(
'ignores the response-side tier %s when the request tier is Fast',
(actualServiceTier) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: 'priority',
actual_service_tier: actualServiceTier,
})])
expectServiceTierBadge(root, 'Fast')
expect(root.textContent).not.toContain('Fast →')
},
)
it('does not infer Fast from a response-side priority tier', () => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: 'default',
actual_service_tier: 'priority',
})])
expectServiceTierBadge(root, 'standard → Fast')
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
})
it('does not infer Fast when only the response has a tier', () => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: null,
actual_service_tier: 'priority',
})])
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
})
it.each(['pending', 'streaming'] as const)(
'shows fast as pending confirmation while a priority request is %s',
'keeps Fast stable while a priority request is %s',
(status) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: 'priority',
@@ -377,18 +469,18 @@ describe('UsageRecordsTable', () => {
status,
})])
expectServiceTierBadge(root, 'Fast · 待确认')
expectServiceTierBadge(root, 'Fast')
},
)
it('shows fast as unconfirmed when a completed priority request has no actual tier', () => {
it('keeps Fast stable for a completed request without a response tier', () => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: 'priority',
actual_service_tier: null,
status: 'completed',
})])
expectServiceTierBadge(root, 'Fast · 未确认')
expectServiceTierBadge(root, 'Fast')
})
it('offers embedding API formats in the usage record filter', () => {
@@ -162,7 +162,146 @@ describe('useUsageData', () => {
})
})
it('preserves detail-filled usage metrics when a later list refresh is still empty', async () => {
it('clears a failed candidate Cyber snapshot when the final candidate completes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 400,
error_message: cyberMessage,
updated_at: '2026-07-17T00:00:01Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
status_code: 200,
updated_at: '2026-07-17T00:00:02Z',
})
expect(currentRecords.value[0]?.error_message).toBeUndefined()
})
it('rejects an older same-rank terminal snapshot as a unit', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 400,
error_message: cyberMessage,
updated_at: '2026-07-17T00:00:01Z',
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
status_code: 200,
updated_at: '2026-07-17T00:00:02Z',
})
expect(currentRecords.value[0]?.error_message).toBeUndefined()
})
it('keeps live response duration and its anchor atomic across stale refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'streaming',
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
first_byte_time_ms: 2000,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'streaming',
response_time_ms: 5000,
response_time_updated_at: '2026-07-17T12:00:07Z',
first_byte_time_ms: 1500,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'streaming',
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
first_byte_time_ms: 2000,
})
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'completed',
response_time_ms: 5200,
response_time_updated_at: '2026-07-17T12:00:07Z',
first_byte_time_ms: 1800,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'completed',
response_time_ms: 5200,
response_time_updated_at: '2026-07-17T12:00:07Z',
first_byte_time_ms: 2000,
})
})
it('preserves client/detail metrics but clears stale final-provider facts from the next list snapshot', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
@@ -217,9 +356,10 @@ describe('useUsageData', () => {
has_retry: true,
target_model: 'gpt-5.5',
request_type: 'compact',
reasoning_effort: 'xhigh',
service_tier: 'auto',
actual_service_tier: 'priority',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
actual_service_tier: 'default',
})
getAllUsageRecordsMock.mockResolvedValueOnce({
@@ -245,11 +385,12 @@ describe('useUsageData', () => {
endpoint_api_format: undefined,
has_format_conversion: undefined,
has_retry: false,
target_model: null,
target_model: undefined,
request_type: null,
reasoning_effort: null,
service_tier: null,
actual_service_tier: null,
requested_reasoning_effort: null,
reasoning_effort: undefined,
service_tier: undefined,
actual_service_tier: undefined,
})],
total: 1,
limit: 20,
@@ -280,11 +421,12 @@ describe('useUsageData', () => {
endpoint_api_format: 'openai:responses',
has_format_conversion: false,
has_retry: true,
target_model: 'gpt-5.5',
target_model: null,
request_type: 'compact',
reasoning_effort: 'xhigh',
service_tier: 'auto',
actual_service_tier: 'priority',
requested_reasoning_effort: 'xhigh',
reasoning_effort: null,
service_tier: null,
actual_service_tier: null,
})
})
@@ -14,6 +14,12 @@ import { createDefaultStats } from '../types'
import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error'
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
import {
mergeUsageRecordErrorMessage,
mergeUsageRecordFirstByteTimeMs,
mergeUsageRecordResponseTiming,
parseUsageTimestampMs,
} from '../utils/recordSync'
export interface UseUsageDataOptions {
isAdminPage: Ref<boolean>
@@ -464,25 +470,6 @@ export function useUsageData(options: UseUsageDataOptions) {
}
}
function mergePositiveDurationMs(
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 (existingIsPositive && nextIsPositive) {
return Math.max(existingValue, nextValue)
}
if (existingIsPositive) {
return existingValue
}
if (nextIsPositive) {
return nextValue
}
return existingValue ?? nextValue
}
function mergeSparseRecordMetric(
existingValue: number | null | undefined,
nextValue: number | null | undefined
@@ -539,10 +526,18 @@ export function useUsageData(options: UseUsageDataOptions) {
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
const statusProgressed = hasNextStatus && (
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
const nextUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
const nextStatusSnapshotIsStale = existingUpdatedAtMs != null &&
nextUpdatedAtMs != null &&
nextUpdatedAtMs < existingUpdatedAtMs
const sameRankTerminalTransition = currentRank === 2 && nextRank === 2
const statusProgressed = hasNextStatus && !nextStatusSnapshotIsStale && (
!hasExistingStatus ||
nextRank > currentRank ||
(nextRank === currentRank && existing.status === record.status)
(nextRank === currentRank && (
existing.status === record.status || sameRankTerminalTransition
))
)
const mergedStatus = statusProgressed ? record.status : existing.status
@@ -586,12 +581,27 @@ export function useUsageData(options: UseUsageDataOptions) {
? existing.client_requested_stream
: undefined
const clientIsStream = mergeBooleanTrueWins(existingClientIsStream, recordClientIsStream) ?? clientRequestedStream
const nextTimingIsAuthoritative = statusProgressed &&
(record.status === 'completed' || record.status === 'failed' || record.status === 'cancelled')
const responseTiming = mergeUsageRecordResponseTiming(
{
response_time_ms: existing.response_time_ms,
response_time_updated_at: existing.response_time_updated_at,
},
{
response_time_ms: record.response_time_ms,
response_time_updated_at: record.response_time_updated_at,
},
{ preferNext: nextTimingIsAuthoritative },
)
return {
...record,
// 保留详情抽屉/活跃轮询已经拿到的完整指标,避免列表刷新用 0 或空值回退。
status: mergedStatus,
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
provider: statusProgressed
? (protectProvider ? existing.provider : (record.provider || existing.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,
@@ -610,13 +620,31 @@ export function useUsageData(options: UseUsageDataOptions) {
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,
response_time_ms: responseTiming.response_time_ms,
first_byte_time_ms: mergeUsageRecordFirstByteTimeMs(
existing.first_byte_time_ms,
record.first_byte_time_ms,
),
updated_at: statusProgressed
? (record.updated_at ?? existing.updated_at)
: existing.updated_at,
response_time_updated_at: responseTiming.response_time_updated_at,
// Status, code and error are one lifecycle snapshot. An accepted full
// list snapshot may clear an earlier candidate's 400/Cyber failure;
// a rejected stale status snapshot must not mutate either field.
status_code: statusProgressed
? (record.status_code ?? undefined)
: existing.status_code,
error_message: statusProgressed
? mergeUsageRecordErrorMessage(
existing.error_message,
record.error_message,
{ authoritative: true },
)
: existing.error_message,
image_progress: statusProgressed
? (record.image_progress ?? existing.image_progress)
: existing.image_progress,
is_stream: upstreamIsStream,
upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream,
@@ -627,13 +655,50 @@ export function useUsageData(options: UseUsageDataOptions) {
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,
request_type: record.request_type ?? existing.request_type,
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
service_tier: record.service_tier ?? existing.service_tier,
actual_service_tier: record.actual_service_tier ?? existing.actual_service_tier
provider_key_name: statusProgressed
? (record.provider_key_name || existing.provider_key_name)
: existing.provider_key_name,
rate_multiplier: statusProgressed
? (record.rate_multiplier ?? existing.rate_multiplier)
: existing.rate_multiplier,
// Full list snapshots describe the final provider candidate. Missing/null means the
// final request did not map the model and must clear an earlier candidate's arrow.
target_model: statusProgressed
? (typeof record.target_model === 'string' && record.target_model.trim()
? record.target_model
: null)
: existing.target_model,
// Request type is client-request identity, not a provider-candidate fact. Preserve a
// known compact operation when a later sparse snapshot omits it.
request_type:
typeof record.request_type === 'string' && record.request_type.trim()
? record.request_type
: existing.request_type,
requested_reasoning_effort:
typeof record.requested_reasoning_effort === 'string'
&& record.requested_reasoning_effort.trim()
? record.requested_reasoning_effort
: existing.requested_reasoning_effort,
// Provider reasoning belongs to the final candidate just like service_tier; do not
// retain a previous candidate's `max` when the final request has no reasoning field.
reasoning_effort: statusProgressed
? (typeof record.reasoning_effort === 'string' && record.reasoning_effort.trim()
? record.reasoning_effort
: null)
: existing.reasoning_effort,
// The list response is the authoritative snapshot of the final provider request. Do not
// carry a tier forward when this response has no tier; doing so can leave a stale Fast
// badge after the final upstream request falls back to Standard.
service_tier: statusProgressed
? (typeof record.service_tier === 'string' && record.service_tier.trim()
? record.service_tier
: null)
: existing.service_tier,
actual_service_tier: statusProgressed
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
? record.actual_service_tier
: null)
: existing.actual_service_tier
}
})
}
+2 -1
View File
@@ -97,9 +97,10 @@ export interface UsageRecord {
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
actual_service_tier?: string | null // Provider 响应确认的实际服务层级
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { isCyberPolicyError } from '../cyberError'
describe('isCyberPolicyError', () => {
it('recognizes the provider cybersecurity refusal message', () => {
expect(isCyberPolicyError({
error: {
type: 'invalid_request',
message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
code: 400,
},
})).toBe(true)
})
it('recognizes an explicit cyber_policy code', () => {
expect(isCyberPolicyError({ error: { code: 'CYBER_POLICY' } })).toBe(true)
})
it('recognizes explicit Cyber Policy types and reasons', () => {
expect(isCyberPolicyError({ error: { type: 'cyber_policy' } })).toBe(true)
expect(isCyberPolicyError({ error: { type: 'CYBER' } })).toBe(true)
expect(isCyberPolicyError({ error: { reason: 'cyber-policy' } })).toBe(true)
expect(isCyberPolicyError({ error: { category: 'cyber_policy_violation' } })).toBe(true)
expect(isCyberPolicyError({ error: { type: 'cybersecurity-risk' } })).toBe(true)
})
it('recognizes structured Cyber classifiers inside a serialized error', () => {
expect(isCyberPolicyError('{"error":{"type":"cyber"}}')).toBe(true)
})
it('does not classify ordinary invalid requests as Cyber Policy failures', () => {
expect(isCyberPolicyError({
error: {
type: 'invalid_request',
message: 'The request payload is malformed',
code: 400,
},
})).toBe(false)
})
it('does not classify a generic use of the word cyber', () => {
expect(isCyberPolicyError('The cyber security report was generated successfully')).toBe(false)
})
})
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import type { UsageRecord } from '../../types'
import {
mergeUsageRecordErrorMessage,
mergeUsageRecordFirstByteTimeMs,
mergeUsageRecordLifecycleSnapshot,
mergeUsageRecordResponseTiming,
syncUsageRecordStreamResolution,
} from '../recordSync'
@@ -75,3 +78,140 @@ describe('mergeUsageRecordFirstByteTimeMs', () => {
expect(mergeUsageRecordFirstByteTimeMs(-1, null)).toBeUndefined()
})
})
describe('mergeUsageRecordResponseTiming', () => {
it('keeps duration and update timestamp as one monotonic active snapshot', () => {
const existing = {
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
}
const stale = {
response_time_ms: 5000,
response_time_updated_at: '2026-07-17T12:00:07Z',
}
expect(mergeUsageRecordResponseTiming(existing, stale)).toBe(existing)
})
it('accepts a live snapshot whose projected elapsed time has advanced', () => {
const existing = {
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
}
const advanced = {
response_time_ms: 7000,
response_time_updated_at: '2026-07-17T12:00:07Z',
}
expect(mergeUsageRecordResponseTiming(existing, advanced)).toBe(advanced)
})
it('does not combine an unanchored detail estimate with an existing anchor', () => {
const existing = {
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
}
const detailEstimate = {
response_time_ms: 6000,
response_time_updated_at: null,
}
expect(mergeUsageRecordResponseTiming(existing, detailEstimate)).toBe(existing)
})
it('lets a terminal snapshot replace the active estimate', () => {
const terminal = {
response_time_ms: 5200,
response_time_updated_at: null,
}
expect(mergeUsageRecordResponseTiming({
response_time_ms: 5500,
response_time_updated_at: '2026-07-17T12:00:06Z',
}, terminal, { preferNext: true })).toBe(terminal)
})
})
describe('mergeUsageRecordErrorMessage', () => {
const cyberMessage = 'This content was flagged for possible cybersecurity risk. Join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
it('keeps an authoritative Cyber Policy message when trace reports a generic error', () => {
expect(mergeUsageRecordErrorMessage(
cyberMessage,
'execution runtime stream ended with a terminal error',
)).toBe(cyberMessage)
})
it('keeps an existing error when trace omits its error message', () => {
expect(mergeUsageRecordErrorMessage(cyberMessage, undefined)).toBe(cyberMessage)
expect(mergeUsageRecordErrorMessage(cyberMessage, null)).toBe(cyberMessage)
expect(mergeUsageRecordErrorMessage(cyberMessage, ' ')).toBe(cyberMessage)
})
it('accepts a Cyber Policy message discovered by trace', () => {
expect(mergeUsageRecordErrorMessage('Request failed', cyberMessage)).toBe(cyberMessage)
})
it('updates ordinary errors when the next snapshot has a more specific message', () => {
expect(mergeUsageRecordErrorMessage('Request failed', 'rate limit exceeded'))
.toBe('rate limit exceeded')
expect(mergeUsageRecordErrorMessage(undefined, 'Request failed')).toBe('Request failed')
})
it('lets an authoritative final-candidate snapshot replace or clear Cyber', () => {
expect(mergeUsageRecordErrorMessage(
cyberMessage,
'rate limit exceeded',
{ authoritative: true },
)).toBe('rate limit exceeded')
expect(mergeUsageRecordErrorMessage(
cyberMessage,
null,
{ authoritative: true },
)).toBeUndefined()
})
})
describe('mergeUsageRecordLifecycleSnapshot', () => {
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
it('rejects an older failed detail without changing status, code, or error', () => {
expect(mergeUsageRecordLifecycleSnapshot({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
}, {
status: 'failed',
statusCode: 400,
errorMessage: cyberMessage,
updatedAt: '2026-07-17T00:00:01Z',
})).toEqual({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
accepted: false,
})
})
it('accepts a newer completed detail and clears an earlier Cyber failure', () => {
expect(mergeUsageRecordLifecycleSnapshot({
status: 'failed',
status_code: 400,
error_message: cyberMessage,
updated_at: '2026-07-17T00:00:01Z',
}, {
status: 'completed',
statusCode: 200,
errorMessage: null,
updatedAt: '2026-07-17T00:00:02Z',
})).toEqual({
status: 'completed',
status_code: 200,
error_message: undefined,
updated_at: '2026-07-17T00:00:02Z',
accepted: true,
})
})
})
@@ -8,8 +8,8 @@ import {
} from '../service-tier'
describe('service tier facts', () => {
it('keeps requested, actual and billing tiers independent', () => {
const facts = resolveServiceTierFacts({
it('uses the final provider request tier for display and billing', () => {
const source = {
service_tier: 'priority',
actual_service_tier: 'default',
settlement: {
@@ -21,17 +21,28 @@ describe('service tier facts', () => {
},
},
},
})
}
const facts = resolveServiceTierFacts(source)
expect(facts).toEqual({ requested: 'priority', actual: 'default', billing: 'standard' })
expect(facts).toEqual({ requested: 'priority' })
expect(hasServiceTierFact(facts)).toBe(true)
})
it('does not infer billing from requested or actual tiers', () => {
expect(resolveServiceTierFacts({
service_tier: 'priority',
it('does not infer a tier from the provider response or settlement snapshot', () => {
const source = {
actual_service_tier: 'flex',
})).toEqual({ requested: 'priority', actual: 'flex', billing: null })
settlement: {
settlement_snapshot: {
pricing_snapshot: {
billing_processing_tier: 'priority',
},
},
},
}
const facts = resolveServiceTierFacts(source)
expect(facts).toEqual({ requested: null })
expect(hasServiceTierFact(facts)).toBe(false)
})
it('normalizes only non-empty string facts', () => {
@@ -0,0 +1,70 @@
const CYBER_POLICY_TEXT_MARKERS = [
'possible cybersecurity risk',
'trusted access for cyber',
'chatgpt.com/cyber',
]
const CYBER_POLICY_CLASSIFIER_FIELDS = [
'code',
'type',
'category',
'reason',
] as const
const CYBER_ERROR_OBJECT_FIELDS = [
'error',
'errors',
'message',
'error_message',
'detail',
'body',
'response_body',
'upstream_error',
'failure_summary',
] as const
function normalizeCyberClassifier(value: string): string {
return value.trim().toLowerCase().replace(/[\s-]+/g, '_')
}
function isCyberPolicyClassifier(value: unknown): boolean {
if (typeof value !== 'string') return false
const normalized = normalizeCyberClassifier(value)
if (normalized === 'cyber' || normalized === 'cyber_policy') return true
// Providers have used nearby classifier spellings while keeping the same
// structured error contract. Keep this deliberately narrower than a generic
// substring check so ordinary cybersecurity content is not badged.
return /^(?:cyber|cybersecurity)_(?:policy|safety|risk)(?:_(?:violation|error|refusal|blocked))?$/.test(normalized)
}
function isCyberPolicyText(value: string): boolean {
const normalized = value.trim().toLowerCase()
if (!normalized) return false
return CYBER_POLICY_TEXT_MARKERS.some(marker => normalized.includes(marker))
|| /["'](?:code|type|category|reason)["']\s*:\s*["'](?:cyber|cyber[-_ ]policy|cyber[-_ ]safety|cybersecurity[-_ ](?:policy|risk))["']/i.test(normalized)
}
function detectCyberPolicyError(value: unknown, seen: WeakSet<object>): boolean {
if (typeof value === 'string') return isCyberPolicyText(value)
if (value === null || typeof value !== 'object') return false
if (seen.has(value)) return false
seen.add(value)
if (Array.isArray(value)) return value.some(item => detectCyberPolicyError(item, seen))
const record = value as Record<string, unknown>
if (CYBER_POLICY_CLASSIFIER_FIELDS.some(field => isCyberPolicyClassifier(record[field]))) {
return true
}
return CYBER_ERROR_OBJECT_FIELDS.some(field => detectCyberPolicyError(record[field], seen))
}
/**
* Detects the provider's Cyber Policy refusal without treating generic HTTP 400,
* invalid_request, or ordinary uses of the word "cyber" as policy failures.
*/
export function isCyberPolicyError(value: unknown): boolean {
return detectCyberPolicyError(value, new WeakSet<object>())
}
+157 -1
View File
@@ -1,10 +1,63 @@
import type { UsageRecord } from '../types'
import type { RequestStatus, UsageRecord } from '../types'
import { isCyberPolicyError } from './cyberError'
export type UsageRecordStreamResolution = Pick<
UsageRecord,
'id' | 'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
>
export type UsageRecordResponseTiming = Pick<
UsageRecord,
'response_time_ms' | 'response_time_updated_at'
>
function finiteNonNegativeDurationMs(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
export function parseUsageTimestampMs(value: string | null | undefined): number | null {
if (!value) return null
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
const timestampMs = new Date(normalized).getTime()
return Number.isFinite(timestampMs) ? timestampMs : null
}
/**
* Merge a live response duration together with the timestamp that anchors it.
*
* These fields form one clock snapshot: active elapsed time is projected as
* `response_time_ms + (now - response_time_updated_at)`. Merging the larger
* duration with a newer timestamp can therefore manufacture a shorter clock
* that never existed. Keep the pair atomic and, while active, retain whichever
* snapshot projects the larger elapsed value.
*/
export function mergeUsageRecordResponseTiming(
existing: UsageRecordResponseTiming,
next: UsageRecordResponseTiming,
options: { preferNext?: boolean } = {},
): UsageRecordResponseTiming {
const existingDurationMs = finiteNonNegativeDurationMs(existing.response_time_ms)
const nextDurationMs = finiteNonNegativeDurationMs(next.response_time_ms)
if (nextDurationMs == null) return existing
if (options.preferNext || existingDurationMs == null) return next
const existingUpdatedAtMs = parseUsageTimestampMs(existing.response_time_updated_at)
const nextUpdatedAtMs = parseUsageTimestampMs(next.response_time_updated_at)
if (existingUpdatedAtMs != null && nextUpdatedAtMs != null) {
const existingStartedAtMs = existingUpdatedAtMs - existingDurationMs
const nextStartedAtMs = nextUpdatedAtMs - nextDurationMs
return nextStartedAtMs <= existingStartedAtMs ? next : existing
}
// An anchored snapshot is safer than an unanchored duration for a live clock.
if (existingUpdatedAtMs != null) return existing
if (nextUpdatedAtMs != null) return next
return nextDurationMs >= existingDurationMs ? next : existing
}
export function mergeUsageRecordFirstByteTimeMs(
existingValue: number | null | undefined,
nextValue: number | null | undefined
@@ -29,6 +82,109 @@ export function mergeUsageRecordFirstByteTimeMs(
return existingValue == null ? existingValue : undefined
}
export function mergeUsageRecordErrorMessage(
existingValue: string | null | undefined,
nextValue: string | null | undefined,
options: { authoritative?: boolean } = {},
): string | undefined {
const existing = typeof existingValue === 'string' && existingValue.trim()
? existingValue
: undefined
const next = typeof nextValue === 'string' && nextValue.trim()
? nextValue
: undefined
// Complete list/active snapshots describe the current final candidate. They
// must be able to replace *and clear* an error left by an earlier candidate.
if (options.authoritative) return next
if (!next) return existing
// Detail/trace snapshots may carry a generic runtime message. Do not let that
// downgrade a provider Cyber Policy refusal already resolved by the usage list.
if (isCyberPolicyError(existing) && !isCyberPolicyError(next)) return existing
return next
}
export type UsageRecordLifecycleSnapshot = Pick<
UsageRecord,
'status' | 'status_code' | 'error_message' | 'updated_at'
>
export type UsageRecordLifecycleUpdate = {
status?: RequestStatus
statusCode?: number | null
errorMessage?: string | null
updatedAt?: string | null
}
/**
* Merge the sparse lifecycle state emitted by the detail drawer.
*
* Status, status code and error belong to one snapshot. If a detail response
* is older (or its status would regress), none of those fields may leak into
* the newer row. A completed/cancelled or explicitly newer terminal snapshot
* is authoritative for errors; a same-snapshot generic failure still keeps a
* provider Cyber refusal already known by the list.
*/
export function mergeUsageRecordLifecycleSnapshot(
existing: UsageRecordLifecycleSnapshot,
update: UsageRecordLifecycleUpdate,
): UsageRecordLifecycleSnapshot & { accepted: boolean } {
const statusPriority: Record<RequestStatus, number> = {
pending: 0,
streaming: 1,
completed: 2,
failed: 2,
cancelled: 2,
}
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
const nextUpdatedAtMs = parseUsageTimestampMs(update.updatedAt)
const nextSnapshotIsOlder = existingUpdatedAtMs != null &&
nextUpdatedAtMs != null &&
nextUpdatedAtMs < existingUpdatedAtMs
const currentRank = existing.status ? statusPriority[existing.status] : -1
const nextRank = update.status ? statusPriority[update.status] : -1
const statusAccepted = update.status != null &&
!nextSnapshotIsOlder &&
nextRank >= currentRank
const accepted = !nextSnapshotIsOlder && (update.status == null || statusAccepted)
if (!accepted) {
return { ...existing, accepted: false }
}
const terminalSnapshotIsStrictlyNewer = statusAccepted &&
(update.status === 'completed' || update.status === 'failed' || update.status === 'cancelled') &&
existingUpdatedAtMs != null &&
nextUpdatedAtMs != null &&
nextUpdatedAtMs > existingUpdatedAtMs
const errorIsAuthoritative = statusAccepted && (
update.status === 'completed' ||
update.status === 'cancelled' ||
terminalSnapshotIsStrictlyNewer
)
const hasStatusCode = Object.prototype.hasOwnProperty.call(update, 'statusCode')
const hasErrorMessage = Object.prototype.hasOwnProperty.call(update, 'errorMessage')
return {
status: statusAccepted ? update.status : existing.status,
status_code: hasStatusCode ? (update.statusCode ?? undefined) : existing.status_code,
error_message: hasErrorMessage
? mergeUsageRecordErrorMessage(
existing.error_message,
update.errorMessage,
{ authoritative: errorIsAuthoritative },
)
: existing.error_message,
updated_at: typeof update.updatedAt === 'string'
? update.updatedAt
: existing.updated_at,
accepted: true,
}
}
export function syncUsageRecordStreamResolution(
records: UsageRecord[],
resolved: UsageRecordStreamResolution
@@ -1,30 +1,26 @@
export interface ServiceTierFacts {
requested: string | null
actual: string | null
billing: string | null
}
export interface ServiceTierFactSource {
service_tier?: unknown
actual_service_tier?: unknown
settlement?: unknown
}
export function resolveServiceTierFacts(
source: ServiceTierFactSource | null | undefined,
): ServiceTierFacts {
const settlement = asRecord(source?.settlement)
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
const pricingSnapshot = asRecord(settlementSnapshot?.pricing_snapshot)
// The processing tier is an input-side fact: it must come from the final
// request body sent to the provider. Response-advertised tiers and old
// settlement snapshots can describe a different/legacy value, so they are
// deliberately not consulted here. The billing display uses this same
// authoritative request tier.
return {
requested: normalizeServiceTierFact(source?.service_tier),
actual: normalizeServiceTierFact(source?.actual_service_tier),
billing: normalizeServiceTierFact(pricingSnapshot?.billing_processing_tier),
}
}
export function hasServiceTierFact(facts: ServiceTierFacts): boolean {
return facts.requested !== null || facts.actual !== null || facts.billing !== null
return facts.requested !== null
}
export function normalizeServiceTierFact(value: unknown): string | null {
@@ -47,9 +43,3 @@ export function formatServiceTierFact(value: unknown): string | null {
? 'Fast'
: normalized
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
buildApiKeyRedactionFeatureSettingsPatch,
resolveApiKeyRedactionFormState,
} from '../apiKeyFeatureSettings'
const redaction = {
enabled: true,
inject_model_instruction: false,
}
describe('managed API key feature setting inheritance', () => {
it('uses the target user value for an inherited key form', () => {
expect(resolveApiKeyRedactionFormState(null, {
chat_pii_redaction: redaction,
})).toEqual({
mode: 'inherit',
...redaction,
})
})
it('omits feature_settings when a created key keeps inheritance', () => {
expect(buildApiKeyRedactionFeatureSettingsPatch({
isEditing: false,
currentFeatureSettings: undefined,
mode: 'inherit',
value: redaction,
})).toEqual({})
})
it('writes an override only when custom mode is selected', () => {
expect(buildApiKeyRedactionFeatureSettingsPatch({
isEditing: false,
currentFeatureSettings: undefined,
mode: 'custom',
value: redaction,
})).toEqual({
feature_settings: {
chat_pii_redaction: redaction,
},
})
})
it('does not create an override when only another field of an inherited key changes', () => {
expect(buildApiKeyRedactionFeatureSettingsPatch({
isEditing: true,
currentFeatureSettings: null,
mode: 'inherit',
value: redaction,
})).toEqual({})
})
it('removes only the existing redaction override when inheritance is restored', () => {
expect(buildApiKeyRedactionFeatureSettingsPatch({
isEditing: true,
currentFeatureSettings: {
chat_pii_redaction: { enabled: false },
notification_push_service: { enabled: true },
},
mode: 'inherit',
value: redaction,
})).toEqual({
feature_settings: {
notification_push_service: { enabled: true },
},
})
})
})
@@ -0,0 +1,64 @@
import type { FeatureSettings } from '@/api/users'
import {
hasChatPiiRedactionFeatureSettings,
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
removeChatPiiRedactionFeatureSettings,
type ChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
export type ApiKeyRedactionMode = 'inherit' | 'custom'
export interface ApiKeyRedactionFormState extends ChatPiiRedactionFeatureSettings {
mode: ApiKeyRedactionMode
}
export function resolveApiKeyRedactionFormState(
apiKeyFeatureSettings: FeatureSettings | null | undefined,
inheritedUserFeatureSettings: FeatureSettings | null | undefined,
): ApiKeyRedactionFormState {
const hasCustomRedaction = hasChatPiiRedactionFeatureSettings(apiKeyFeatureSettings)
const value = readChatPiiRedactionFeatureSettings(
hasCustomRedaction ? apiKeyFeatureSettings : inheritedUserFeatureSettings,
)
return {
mode: hasCustomRedaction ? 'custom' : 'inherit',
...value,
}
}
/**
* Builds only the feature-settings portion of an API-key mutation.
*
* An omitted field preserves inheritance. `null` (or an object with the
* redaction key removed) is emitted only when an existing custom override is
* explicitly switched back to inheritance.
*/
export function buildApiKeyRedactionFeatureSettingsPatch(options: {
isEditing: boolean
currentFeatureSettings: FeatureSettings | null | undefined
mode: ApiKeyRedactionMode
value: ChatPiiRedactionFeatureSettings
}): { feature_settings?: FeatureSettings | null } {
if (options.mode === 'custom') {
return {
feature_settings: mergeChatPiiRedactionFeatureSettings(
options.isEditing ? options.currentFeatureSettings : null,
options.value,
),
}
}
if (
options.isEditing
&& hasChatPiiRedactionFeatureSettings(options.currentFeatureSettings)
) {
return {
feature_settings: removeChatPiiRedactionFeatureSettings(
options.currentFeatureSettings,
),
}
}
return {}
}
@@ -104,15 +104,47 @@
<div class="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
<div class="flex items-center justify-between gap-3">
<div>
<Label class="text-sm font-medium">
{{ legacyT('敏感信息保护') }}
</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ legacyT(form.chat_pii_redaction_mode === 'inherit' ? '跟随目标用户设置' : '仅覆盖此 API Key') }}
</p>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
:variant="form.chat_pii_redaction_mode === 'inherit' ? 'default' : 'outline'"
@click="updateField('chat_pii_redaction_mode', 'inherit')"
>
{{ legacyT('跟随用户') }}
</Button>
<Button
size="sm"
:variant="form.chat_pii_redaction_mode === 'custom' ? 'default' : 'outline'"
@click="updateField('chat_pii_redaction_mode', 'custom')"
>
{{ legacyT('单独配置') }}
</Button>
</div>
</div>
<div
v-if="form.chat_pii_redaction_mode === 'custom'"
class="flex items-center justify-between gap-3 border-t border-border/50 pt-3"
>
<Label class="text-sm font-medium">
{{ legacyT('敏感信息保护') }}
{{ legacyT('启用保护') }}
</Label>
<Switch
:model-value="form.chat_pii_redaction_enabled"
@update:model-value="updateField('chat_pii_redaction_enabled', $event)"
/>
</div>
<div class="flex items-center justify-between gap-3">
<div
v-if="form.chat_pii_redaction_mode === 'custom' && form.chat_pii_redaction_enabled"
class="flex items-center justify-between gap-3 border-t border-border/50 pt-3"
>
<Label class="text-sm font-medium">
{{ legacyT('占位符说明') }}
</Label>
@@ -156,6 +188,7 @@ export interface UserApiKeyFormState {
rate_limit?: number
concurrent_limit?: number
ip_rules_text: string
chat_pii_redaction_mode: 'inherit' | 'custom'
chat_pii_redaction_enabled: boolean
chat_pii_redaction_placeholder_notice: boolean
}
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config/demo', () => ({
isDemoMode: () => true,
DEMO_ACCOUNTS: {
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
user: { email: 'user@demo.aether.io', password: 'demo123' },
},
}))
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
import { handleMockRequest, setMockUserToken } from '../handler'
interface MockPoolKey {
key_id: string
oauth_plan_type: string
status_snapshot: {
quota: {
windows: QuotaWindowSnapshot[]
}
}
}
describe('pool quota demo contracts', () => {
beforeEach(() => {
setMockUserToken('demo-access-token-admin')
})
it('exposes a dedicated Codex pool in the overview and provider summary', async () => {
const overviewResponse = await handleMockRequest({
method: 'GET',
url: '/api/admin/pool/overview',
})
const overview = overviewResponse?.data as {
items: Array<{ provider_id: string; provider_type: string; total_keys: number }>
}
const provider = overview.items[0]
expect(provider).toMatchObject({
provider_id: 'provider-codex-pool-demo',
provider_type: 'codex',
total_keys: 4,
})
const summaryResponse = await handleMockRequest({
method: 'GET',
url: `/api/admin/providers/${provider.provider_id}/summary`,
})
expect(summaryResponse?.data).toMatchObject({
id: provider.provider_id,
provider_type: 'codex',
name: 'Codex 周期额度演示',
})
})
it('covers dual, weekly-only, monthly-only, and 5H-only quota windows', async () => {
const response = await handleMockRequest({
method: 'GET',
url: '/api/admin/pool/provider-codex-pool-demo/keys',
params: { page: 1, page_size: 50, status: 'all' },
})
const page = response?.data as { total: number; keys: MockPoolKey[] }
const keys = new Map(page.keys.map(key => [key.key_id, key]))
const labelsFor = (keyId: string) => keys.get(keyId)?.status_snapshot.quota.windows
.map(getCodexQuotaWindowPresentation)
.filter((item): item is NonNullable<typeof item> => item != null)
.sort((left, right) => left.sortOrder - right.sortOrder)
.map(item => item.label)
expect(page.total).toBe(4)
expect(labelsFor('codex-pool-plus-dual')).toEqual(['5H', '周'])
expect(labelsFor('codex-pool-team-weekly')).toEqual(['周'])
expect(labelsFor('codex-pool-business-monthly')).toEqual(['月'])
expect(labelsFor('codex-pool-free-five-hour')).toEqual(['5H'])
expect(keys.get('codex-pool-business-monthly')?.oauth_plan_type)
.toBe('self_serve_business_usage_based')
})
})
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config/demo', () => ({
isDemoMode: () => true,
DEMO_ACCOUNTS: {
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
user: { email: 'user@demo.aether.io', password: 'demo123' },
},
}))
import { handleMockRequest, setMockUserToken } from '../handler'
describe('provider detail demo contracts', () => {
beforeEach(() => {
setMockUserToken('demo-access-token-admin')
})
it('returns the paginated key contract used by the provider drawer', async () => {
const firstPageResponse = await handleMockRequest({
method: 'GET',
url: '/api/admin/endpoints/providers/provider-004/keys',
params: { page: 1, page_size: 1 },
})
const secondPageResponse = await handleMockRequest({
method: 'GET',
url: '/api/admin/endpoints/providers/provider-004/keys',
params: { page: 2, page_size: 1 },
})
const firstPage = firstPageResponse?.data as {
total: number
page: number
page_size: number
keys: Array<{ id: string }>
}
const secondPage = secondPageResponse?.data as typeof firstPage
expect(firstPage).toMatchObject({ total: 2, page: 1, page_size: 1 })
expect(firstPage.keys).toHaveLength(1)
expect(secondPage).toMatchObject({ total: 2, page: 2, page_size: 1 })
expect(secondPage.keys).toHaveLength(1)
expect(secondPage.keys[0]?.id).not.toBe(firstPage.keys[0]?.id)
})
it('preserves the legacy array contract for skip/limit callers', async () => {
const response = await handleMockRequest({
method: 'GET',
url: '/api/admin/endpoints/providers/provider-004/keys',
params: { skip: 1, limit: 1 },
})
expect(Array.isArray(response?.data)).toBe(true)
expect(response?.data).toHaveLength(1)
})
it('returns a complete mapping-preview envelope', async () => {
const response = await handleMockRequest({
method: 'GET',
url: '/api/admin/providers/provider-004/mapping-preview',
})
expect(response?.data).toEqual({
provider_id: 'provider-004',
provider_name: 'IKunCode',
keys: [],
total_keys: 0,
total_matches: 0,
truncated: false,
truncated_keys: 0,
truncated_models: 0,
})
})
})
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config/demo', () => ({
isDemoMode: () => true,
DEMO_ACCOUNTS: {
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
user: { email: 'user@demo.aether.io', password: 'demo123' },
},
}))
import { handleMockRequest, setMockUserToken } from '../handler'
describe('usage detail demo contracts', () => {
beforeEach(() => {
setMockUserToken('demo-access-token-admin')
})
it('keeps body availability while omitting bodies from lightweight detail', async () => {
const response = await handleMockRequest({
method: 'GET',
url: '/api/admin/usage/usage-cyber-risk-demo',
params: { include_bodies: false },
})
expect(response?.data).toMatchObject({
has_request_body: true,
has_provider_request_body: true,
has_response_body: true,
request_body: null,
provider_request_body: null,
response_body: null,
client_response_body: null,
body_load_errors: null,
})
})
it('returns the exact Cyber error body when bodies are requested', async () => {
const response = await handleMockRequest({
method: 'GET',
url: '/api/admin/usage/usage-cyber-risk-demo',
params: { include_bodies: true },
})
expect(response?.data?.response_body).toEqual({
error: {
type: 'invalid_request',
message: 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
code: 400,
},
})
})
})
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config/demo', () => ({
isDemoMode: () => true,
DEMO_ACCOUNTS: {
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
user: { email: 'user@demo.aether.io', password: 'demo123' },
},
}))
import { handleMockRequest, setMockUserToken } from '../handler'
describe('user management demo contracts', () => {
beforeEach(() => {
setMockUserToken('demo-access-token-admin')
})
it('returns a list-shaped user group response', async () => {
const response = await handleMockRequest({
method: 'GET',
url: '/api/admin/user-groups',
})
expect(response?.data).toEqual({
items: [],
default_group_id: null,
})
})
it('creates and lists managed keys only for the selected target user', async () => {
const aliceId = 'demo-user-uuid-0003'
const bobId = 'demo-user-uuid-0004'
const created = await handleMockRequest({
method: 'POST',
url: `/api/admin/users/${aliceId}/api-keys`,
data: JSON.stringify({ name: 'Alice inherited key' }),
})
expect(created?.data).toMatchObject({
name: 'Alice inherited key',
feature_settings: null,
is_standalone: false,
})
expect(created?.data?.key).toMatch(/^sk-ae-demo-/)
const aliceKeys = await handleMockRequest({
method: 'GET',
url: `/api/admin/users/${aliceId}/api-keys`,
})
const bobKeys = await handleMockRequest({
method: 'GET',
url: `/api/admin/users/${bobId}/api-keys`,
})
expect(aliceKeys?.data).toMatchObject({ total: 1 })
expect(aliceKeys?.data?.api_keys).toHaveLength(1)
expect(aliceKeys?.data?.api_keys[0]).toMatchObject({ name: 'Alice inherited key' })
expect(aliceKeys?.data?.api_keys[0]).not.toHaveProperty('key')
expect(aliceKeys?.data?.api_keys[0]).not.toHaveProperty('fullKey')
expect(bobKeys?.data).toEqual({ api_keys: [], total: 0 })
})
})
+656 -11
View File
@@ -705,6 +705,77 @@ function getActivityHeatmap() {
return cachedHeatmap
}
const MOCK_CYBER_POLICY_USAGE_ID = 'usage-cyber-risk-demo'
const MOCK_CYBER_POLICY_ERROR_MESSAGE = 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
const MOCK_CYBER_POLICY_ERROR_BODY = {
error: {
type: 'invalid_request',
message: MOCK_CYBER_POLICY_ERROR_MESSAGE,
code: 400
}
}
interface MockManagedUserApiKey {
id: string
fullKey: string
key_display: string
name: string
created_at: string
last_used_at?: string
is_active: boolean
is_locked: boolean
is_standalone: false
feature_settings?: Record<string, unknown> | null
rate_limit?: number | null
concurrent_limit?: number | null
ip_rules?: string[] | null
total_requests: number
total_cost_usd: number
force_capabilities?: Record<string, unknown> | null
}
const mockManagedUserApiKeysByUserId = new Map<string, MockManagedUserApiKey[]>([
[MOCK_NORMAL_USER.id ?? '', MOCK_USER_API_KEYS.map((key, index) => ({
...key,
fullKey: `sk-ae-demo-user-${index + 1}`,
is_locked: false,
is_standalone: false as const,
}))],
])
let mockManagedUserApiKeySequence = 0
function mockManagedUserApiKeys(userId: string): MockManagedUserApiKey[] {
if (!MOCK_ALL_USERS.some(user => user.id === userId)) {
throw { response: createMockResponse({ detail: '用户不存在' }, 404) }
}
let keys = mockManagedUserApiKeysByUserId.get(userId)
if (!keys) {
keys = []
mockManagedUserApiKeysByUserId.set(userId, keys)
}
return keys
}
function publicMockManagedUserApiKey(key: MockManagedUserApiKey) {
const { fullKey: _fullKey, ...publicKey } = key
void _fullKey
return publicKey
}
function mockRequestObject(config: AxiosRequestConfig): Record<string, unknown> {
if (typeof config.data === 'string') {
try {
const parsed = JSON.parse(config.data)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
return config.data && typeof config.data === 'object' && !Array.isArray(config.data)
? config.data as Record<string, unknown>
: {}
}
// 生成更真实的使用记录
function generateMockUsageRecords(count: number = 100) {
const records = []
@@ -798,6 +869,47 @@ function generateMockUsageRecords(count: number = 100) {
})
}
// 固定在首屏的失败记录,用于预览候选链路中的实际上游错误响应。
records.unshift({
id: MOCK_CYBER_POLICY_USAGE_ID,
user_id: 'demo-admin-uuid-0001',
username: 'Demo Admin',
user_email: 'admin@demo.aether.ai',
api_key: {
id: 'key-demo-cyber-risk',
name: 'OpenAI Cyber Risk Demo',
display: 'sk-ae...demo'
},
provider: 'openai',
api_key_name: 'openai-cyber-risk-demo',
rate_multiplier: 1.0,
model: 'gpt-5',
target_model: 'gpt-5.1',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
// Deliberately conflicts with the final provider request. UI and billing
// must use the request-side `service_tier`, never this response fact.
actual_service_tier: 'default',
api_format: 'openai:responses',
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_tokens: 0,
cost: 0,
actual_cost: 0,
response_time_ms: 428,
is_stream: true,
status_code: 400,
error_message: MOCK_CYBER_POLICY_ERROR_MESSAGE,
status: 'failed',
created_at: new Date(now).toISOString(),
updated_at: new Date(now).toISOString(),
has_fallback: false,
model_version: undefined
})
return records
}
@@ -979,6 +1091,204 @@ const MOCK_CAPABILITIES = [
{ name: 'context_1m', display_name: '1M上下文', description: '支持1M上下文窗口', match_mode: 'compatible', short_name: '1M' }
]
const MOCK_CODEX_POOL_PROVIDER_ID = 'provider-codex-pool-demo'
const MOCK_CODEX_POOL_PROVIDER = {
id: MOCK_CODEX_POOL_PROVIDER_ID,
name: 'Codex 周期额度演示',
provider_type: 'codex',
description: '展示 5H、周、月及组合额度窗口',
website: 'https://openai.com/codex',
provider_priority: 0,
billing_type: 'free_tier',
monthly_used_usd: 0,
is_active: true,
total_endpoints: 1,
active_endpoints: 1,
total_keys: 4,
active_keys: 4,
total_models: 3,
active_models: 3,
avg_health_score: 0.97,
unhealthy_endpoints: 0,
api_formats: ['openai:responses'],
endpoint_health_details: [
{ api_format: 'openai:responses', health_score: 0.97, is_active: true, active_keys: 4 }
],
pool_advanced: {
enabled: true,
probing_enabled: true,
},
claude_code_advanced: null,
proxy: null,
created_at: '2026-07-01T00:00:00Z',
updated_at: new Date().toISOString(),
}
function createMockCodexQuotaWindow(
code: string,
label: string,
windowMinutes: number,
remainingRatio: number,
resetSeconds: number,
observedAt: number,
requestCount: number,
) {
return {
code,
label,
scope: 'account',
unit: 'percent',
used_ratio: 1 - remainingRatio,
remaining_ratio: remainingRatio,
reset_at: resetSeconds > 0 ? observedAt + resetSeconds : null,
reset_seconds: resetSeconds,
window_minutes: windowMinutes,
usage: {
request_count: requestCount,
total_tokens: requestCount * 1250,
total_cost_usd: (requestCount * 0.0025).toFixed(8),
},
}
}
function createMockCodexPoolKeys() {
const nowSeconds = Math.floor(Date.now() / 1000)
const common = {
provider_type: 'codex',
is_active: true,
auth_type: 'oauth',
credential_kind: 'oauth_session',
runtime_auth_kind: 'bearer',
oauth_managed: true,
oauth_header_auth: true,
can_refresh_oauth: true,
can_export_oauth: true,
can_edit_oauth: true,
oauth_expires_at: nowSeconds + 14 * 24 * 3600,
api_formats: ['openai:responses'],
rate_multipliers: null,
internal_priority: 50,
rpm_limit: null,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
health_score: 0.97,
circuit_breaker_open: false,
proxy: null,
cooldown_reason: null,
cooldown_ttl_seconds: null,
cost_window_usage: 0,
cost_limit: null,
sticky_sessions: 0,
lru_score: null,
created_at: '2026-07-01T00:00:00Z',
imported_at: '2026-07-01T00:00:00Z',
last_used_at: new Date(nowSeconds * 1000 - 10 * 60 * 1000).toISOString(),
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可调度',
scheduling_reasons: [],
}
const buildKey = (
keyId: string,
keyName: string,
planType: string,
accountQuota: string,
windows: ReturnType<typeof createMockCodexQuotaWindow>[],
requestCount: number,
) => ({
...common,
key_id: keyId,
key_name: keyName,
oauth_plan_type: planType,
oauth_account_id: `acct-${keyId}`,
oauth_account_name: keyName,
quota_updated_at: nowSeconds - 10 * 60,
account_quota: accountQuota,
request_count: requestCount,
total_tokens: requestCount * 2400,
total_cost_usd: (requestCount * 0.004).toFixed(8),
status_snapshot: {
oauth: {
code: 'valid',
label: '有效',
expires_at: nowSeconds + 14 * 24 * 3600,
requires_reauth: false,
expiring_soon: false,
},
account: {
code: 'ok',
label: null,
reason: null,
blocked: false,
source: null,
recoverable: false,
},
quota: {
version: 2,
provider_type: 'codex',
code: 'ok',
label: null,
reason: null,
freshness: 'fresh',
source: 'response_headers',
observed_at: nowSeconds,
updated_at: nowSeconds,
exhausted: false,
usage_ratio: windows.reduce((max, window) => Math.max(max, window.used_ratio), 0),
plan_type: planType,
credits: { has_credits: false, unlimited: false },
windows,
},
},
})
return [
buildKey(
'codex-pool-plus-dual',
'Plus · 5H + 周',
'plus',
'5H剩余 62.0% | 周剩余 84.0%',
[
createMockCodexQuotaWindow('5h', '5H', 300, 0.62, 3 * 3600, nowSeconds, 18),
createMockCodexQuotaWindow('weekly', '周', 10_080, 0.84, 5 * 24 * 3600, nowSeconds, 42),
],
128,
),
buildKey(
'codex-pool-team-weekly',
'Team · 仅周',
'team',
'周剩余 71.0%',
[
createMockCodexQuotaWindow('weekly', '周', 10_080, 0.71, 4 * 24 * 3600, nowSeconds, 31),
],
96,
),
buildKey(
'codex-pool-business-monthly',
'Codex · 仅月(含空占位)',
'self_serve_business_usage_based',
'月剩余 86.0%',
[
createMockCodexQuotaWindow('monthly', '月', 43_800, 0.86, 2_627_672, nowSeconds, 54),
createMockCodexQuotaWindow('weekly', '周', 0, 1, 0, nowSeconds, 0),
],
214,
),
buildKey(
'codex-pool-free-five-hour',
'Free · 仅5H',
'free',
'5H剩余 93.0%',
[
createMockCodexQuotaWindow('5h', '5H', 300, 0.93, 4 * 3600, nowSeconds, 7),
],
37,
),
]
}
/**
* Mock API
*/
@@ -1330,6 +1640,15 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse(MOCK_ALL_USERS)
},
'GET /api/admin/user-groups': async () => {
await delay()
requireAdmin()
return createMockResponse({
items: [],
default_group_id: null,
})
},
'POST /api/admin/users': async (config) => {
await delay()
requireAdmin()
@@ -1378,7 +1697,12 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'GET /api/admin/providers/summary': async () => {
await delay()
requireAdmin()
return createMockResponse(MOCK_PROVIDERS)
return createMockResponse({
total: MOCK_PROVIDERS.length,
page: 1,
page_size: MOCK_PROVIDERS.length,
items: MOCK_PROVIDERS,
})
},
'GET /api/admin/providers': async () => {
@@ -1387,6 +1711,33 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse(MOCK_PROVIDERS)
},
'GET /api/admin/pool/overview': async () => {
await delay()
requireAdmin()
return createMockResponse({
items: [{
provider_id: MOCK_CODEX_POOL_PROVIDER_ID,
provider_name: MOCK_CODEX_POOL_PROVIDER.name,
provider_type: 'codex',
total_keys: 4,
active_keys: 4,
cooldown_count: 0,
pool_enabled: true,
provider_hot_count: 2,
provider_desired_hot: 3,
provider_in_flight: 1,
provider_ema_in_flight: 0.8,
provider_burst_pending: false,
}]
})
},
'GET /api/admin/pool/scheduling-presets': async () => {
await delay()
requireAdmin()
return createMockResponse([])
},
'POST /api/admin/providers': async (config) => {
await delay()
requireAdmin()
@@ -2404,6 +2755,9 @@ registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', asy
registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => {
await delay()
requireAdmin()
if (params.providerId === MOCK_CODEX_POOL_PROVIDER_ID) {
return createMockResponse(MOCK_CODEX_POOL_PROVIDER)
}
const provider = MOCK_PROVIDERS.find(p => p.id === params.providerId)
if (!provider) {
throw { response: createMockResponse({ detail: '提供商不存在' }, 404) }
@@ -2411,6 +2765,76 @@ registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_
return createMockResponse(provider)
})
registerDynamicRoute('GET', '/api/admin/pool/:providerId/keys', async (config, params) => {
await delay()
requireAdmin()
if (params.providerId !== MOCK_CODEX_POOL_PROVIDER_ID) {
return createMockResponse({ total: 0, page: 1, page_size: 50, keys: [] })
}
const query = (config.params || {}) as Record<string, unknown>
const search = String(query.search || '').trim().toLowerCase()
const status = String(query.status || 'all').trim().toLowerCase()
const sortBy = String(query.sort_by || 'imported_at').trim()
const sortOrder = String(query.sort_order || 'desc').trim().toLowerCase()
let keys = createMockCodexPoolKeys()
if (search) {
keys = keys.filter(key => [
key.key_name,
key.oauth_plan_type,
key.oauth_account_id,
key.account_quota,
].some(value => String(value || '').toLowerCase().includes(search)))
}
if (status === 'enabled') {
keys = keys.filter(key => key.is_active)
} else if (status === 'disabled') {
keys = keys.filter(key => !key.is_active)
} else if (status !== 'all') {
keys = keys.filter(key => key.scheduling_status === status || key.scheduling_reason === status)
}
keys.sort((left, right) => {
const leftValue = String((left as Record<string, unknown>)[sortBy] ?? left.imported_at ?? '')
const rightValue = String((right as Record<string, unknown>)[sortBy] ?? right.imported_at ?? '')
const comparison = leftValue.localeCompare(rightValue)
return sortOrder === 'asc' ? comparison : -comparison
})
const rawPage = Number(query.page)
const rawPageSize = Number(query.page_size)
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = Number.isFinite(rawPageSize) && rawPageSize >= 1 ? Math.floor(rawPageSize) : 50
const start = (page - 1) * pageSize
return createMockResponse({
total: keys.length,
page,
page_size: pageSize,
keys: keys.slice(start, start + pageSize),
})
})
// Provider 模型映射预览
registerDynamicRoute('GET', '/api/admin/providers/:providerId/mapping-preview', async (_config, params) => {
await delay()
requireAdmin()
const provider = MOCK_PROVIDERS.find(p => p.id === params.providerId)
if (!provider) {
throw { response: createMockResponse({ detail: '提供商不存在' }, 404) }
}
return createMockResponse({
provider_id: provider.id,
provider_name: provider.name,
keys: [],
total_keys: 0,
total_matches: 0,
truncated: false,
truncated_keys: 0,
truncated_models: 0,
})
})
// Provider 更新
registerDynamicRoute('PATCH', '/api/admin/providers/:providerId', async (config, params) => {
await delay()
@@ -2486,13 +2910,37 @@ registerDynamicRoute('DELETE', '/api/admin/endpoints/:endpointId', async (_confi
})
// Provider Keys 列表
registerDynamicRoute('GET', '/api/admin/endpoints/providers/:providerId/keys', async (_config, params) => {
registerDynamicRoute('GET', '/api/admin/endpoints/providers/:providerId/keys', async (config, params) => {
await delay()
requireAdmin()
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
}
return createMockResponse(PROVIDER_KEYS_CACHE[params.providerId])
const keys = PROVIDER_KEYS_CACHE[params.providerId]
const query = config.params || {}
// 当前详情抽屉使用 page/page_size 分页;其他调用仍使用 skip/limit 并期望裸数组。
if (query.page !== undefined || query.page_size !== undefined) {
const rawPage = Number(query.page)
const rawPageSize = Number(query.page_size)
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = Number.isFinite(rawPageSize) && rawPageSize >= 1
? Math.floor(rawPageSize)
: 20
const start = (page - 1) * pageSize
return createMockResponse({
total: keys.length,
page,
page_size: pageSize,
keys: keys.slice(start, start + pageSize),
})
}
const rawSkip = Number(query.skip)
const rawLimit = Number(query.limit)
const skip = Number.isFinite(rawSkip) && rawSkip >= 0 ? Math.floor(rawSkip) : 0
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.floor(rawLimit) : keys.length
return createMockResponse(keys.slice(skip, skip + limit))
})
// 为 Provider 创建 Key
@@ -2545,6 +2993,25 @@ registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys',
registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/refresh-quota', async (config, params) => {
await delay()
requireAdmin()
if (params.providerId === MOCK_CODEX_POOL_PROVIDER_ID) {
const body = JSON.parse(config.data || '{}')
const requestedKeyIds = Array.isArray(body.key_ids)
? body.key_ids.map((id: unknown) => String(id).trim()).filter(Boolean)
: createMockCodexPoolKeys().map(key => key.key_id)
const keyNames = new Map(createMockCodexPoolKeys().map(key => [key.key_id, key.key_name]))
const results = requestedKeyIds.map((keyId: string) => ({
key_id: keyId,
key_name: keyNames.get(keyId) || keyId,
status: 'success',
metadata: { updated_at: new Date().toISOString() },
}))
return createMockResponse({
success: results.length,
failed: 0,
total: results.length,
results,
})
}
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
}
@@ -2684,6 +3151,20 @@ registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/clear-oauth-inval
return createMockResponse({ message: 'OAuth invalid cleared (demo)', key_id: params.keyId })
})
registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/reset-cycle-stats', async (_config, params) => {
await delay()
requireAdmin()
const key = createMockCodexPoolKeys().find(item => item.key_id === params.keyId)
const windows = key?.status_snapshot.quota.windows.filter(window => (
window.window_minutes > 0 && !window.code.startsWith('spark_')
)).length ?? 0
return createMockResponse({
message: '已重置周期统计(演示模式)',
reset_at: Math.floor(Date.now() / 1000),
windows,
})
})
// Keys grouped by format
mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
@@ -3206,10 +3687,122 @@ registerDynamicRoute('DELETE', '/api/admin/users/:userId', async (_config, param
})
// 用户 API Keys
registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys', async (_config, _params) => {
registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys', async (_config, params) => {
await delay()
requireAdmin()
return createMockResponse(MOCK_USER_API_KEYS)
const apiKeys = mockManagedUserApiKeys(params.userId).map(publicMockManagedUserApiKey)
return createMockResponse({
api_keys: apiKeys,
total: apiKeys.length,
})
})
registerDynamicRoute('POST', '/api/admin/users/:userId/api-keys', async (config, params) => {
await delay()
requireAdmin()
const keys = mockManagedUserApiKeys(params.userId)
const body = mockRequestObject(config)
const sequence = ++mockManagedUserApiKeySequence
const fullKey = `sk-ae-demo-${params.userId.slice(0, 8)}-${sequence}`
const key: MockManagedUserApiKey = {
id: `managed-key-${params.userId}-${sequence}`,
fullKey,
key_display: `${fullKey.slice(0, 10)}...${fullKey.slice(-4)}`,
name: typeof body.name === 'string' && body.name.trim()
? body.name.trim()
: `Key-${sequence}`,
created_at: new Date().toISOString(),
is_active: true,
is_locked: false,
is_standalone: false,
feature_settings: body.feature_settings && typeof body.feature_settings === 'object'
? body.feature_settings as Record<string, unknown>
: null,
rate_limit: typeof body.rate_limit === 'number' ? body.rate_limit : 0,
concurrent_limit: typeof body.concurrent_limit === 'number'
? body.concurrent_limit
: null,
ip_rules: Array.isArray(body.ip_rules)
? body.ip_rules.filter((value): value is string => typeof value === 'string')
: null,
total_requests: 0,
total_cost_usd: 0,
force_capabilities: null,
}
keys.unshift(key)
return createMockResponse({
...publicMockManagedUserApiKey(key),
key: fullKey,
message: 'API Key创建成功,请妥善保存完整密钥',
})
})
registerDynamicRoute('PUT', '/api/admin/users/:userId/api-keys/:keyId', async (config, params) => {
await delay()
requireAdmin()
const keys = mockManagedUserApiKeys(params.userId)
const index = keys.findIndex(key => key.id === params.keyId)
if (index < 0) {
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
}
const body = mockRequestObject(config)
const existing = keys[index]
const updated: MockManagedUserApiKey = {
...existing,
...(typeof body.name === 'string' ? { name: body.name.trim() } : {}),
...(typeof body.rate_limit === 'number' ? { rate_limit: body.rate_limit } : {}),
...(typeof body.concurrent_limit === 'number' || body.concurrent_limit === null
? { concurrent_limit: body.concurrent_limit }
: {}),
...(Array.isArray(body.ip_rules) || body.ip_rules === null
? { ip_rules: body.ip_rules as string[] | null }
: {}),
...('feature_settings' in body
? { feature_settings: body.feature_settings as Record<string, unknown> | null }
: {}),
}
keys[index] = updated
return createMockResponse({
...publicMockManagedUserApiKey(updated),
message: 'API Key更新成功',
})
})
registerDynamicRoute('DELETE', '/api/admin/users/:userId/api-keys/:keyId', async (_config, params) => {
await delay()
requireAdmin()
const keys = mockManagedUserApiKeys(params.userId)
const index = keys.findIndex(key => key.id === params.keyId)
if (index < 0) {
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
}
keys.splice(index, 1)
return createMockResponse({ message: 'API Key删除成功' })
})
registerDynamicRoute('PATCH', '/api/admin/users/:userId/api-keys/:keyId/lock', async (_config, params) => {
await delay()
requireAdmin()
const key = mockManagedUserApiKeys(params.userId).find(key => key.id === params.keyId)
if (!key) {
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
}
key.is_locked = !key.is_locked
return createMockResponse({
id: key.id,
is_locked: key.is_locked,
message: key.is_locked ? 'API Key已锁定' : 'API Key已解锁',
})
})
registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys/:keyId/full-key', async (_config, params) => {
await delay()
requireAdmin()
const key = mockManagedUserApiKeys(params.userId).find(key => key.id === params.keyId)
if (!key) {
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
}
return createMockResponse({ key: key.fullKey })
})
// 管理员 - 用户会话列表
@@ -3295,10 +3888,13 @@ registerDynamicRoute('DELETE', '/api/users/me/api-keys/:keyId', async (_config,
})
// 使用记录详情 - /api/admin/usage/:requestId
registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, params) => {
registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (config, params) => {
await delay()
requireAdmin()
const includeBodies = config.params?.include_bodies !== false
&& config.params?.include_bodies !== 'false'
const records = getUsageRecords()
const record = records.find(r => r.id === params.requestId)
@@ -3318,6 +3914,9 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
// 生成模拟的请求/响应数据
const mockRequestBody = {
model: record.model,
...(record.requested_reasoning_effort
? { reasoning: { effort: record.requested_reasoning_effort } }
: {}),
max_tokens: 4096,
messages: [
{
@@ -3328,7 +3927,27 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
stream: record.is_stream
}
const mockResponseBody = record.status === 'failed' ? {
const mockProviderRequestBody = record.id === MOCK_CYBER_POLICY_USAGE_ID
? {
model: record.target_model || record.model,
reasoning: { effort: record.reasoning_effort },
service_tier: record.service_tier,
input: [
{
role: 'user',
content: 'Help me with an authorized cybersecurity research task.'
}
],
stream: record.is_stream
}
: {
...mockRequestBody,
model: record.target_model || record.model
}
const mockResponseBody = record.id === MOCK_CYBER_POLICY_USAGE_ID
? MOCK_CYBER_POLICY_ERROR_BODY
: record.status === 'failed' ? {
error: {
type: 'api_error',
message: record.error_message || 'An error occurred'
@@ -3376,6 +3995,10 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
api_format: record.api_format,
model: record.model,
target_model: record.target_model,
requested_reasoning_effort: record.requested_reasoning_effort,
reasoning_effort: record.reasoning_effort,
service_tier: record.service_tier,
actual_service_tier: record.actual_service_tier,
tokens: {
input: record.input_tokens,
output: record.output_tokens,
@@ -3406,6 +4029,7 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
error_message: record.error_message,
response_time_ms: record.response_time_ms,
created_at: record.created_at,
updated_at: record.updated_at ?? record.created_at,
request_headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk-aether-***',
@@ -3414,7 +4038,12 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
'Accept': 'application/json',
'X-Request-ID': `req_${record.id}`
},
request_body: mockRequestBody,
has_request_body: true,
has_provider_request_body: true,
has_response_body: true,
has_client_response_body: false,
request_body: includeBodies ? mockRequestBody : null,
provider_request_body: includeBodies ? mockProviderRequestBody : null,
provider_request_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer sk-${record.provider}-***`,
@@ -3428,7 +4057,9 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
'X-RateLimit-Remaining': '999',
'X-RateLimit-Reset': new Date(Date.now() + 60000).toISOString()
},
response_body: mockResponseBody,
response_body: includeBodies ? mockResponseBody : null,
client_response_body: null,
body_load_errors: null,
metadata: {
client_ip: '192.168.1.100',
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
@@ -3576,7 +4207,8 @@ registerDynamicRoute('GET', '/api/admin/monitoring/trace/:requestId', async (_co
})
} else if (record.status === 'failed') {
// 失败请求:多个候选都失败
const attemptCount = 2 + Math.floor(Math.random() * 2)
const isCyberPolicyDemo = record.id === MOCK_CYBER_POLICY_USAGE_ID
const attemptCount = isCyberPolicyDemo ? 1 : 2 + Math.floor(Math.random() * 2)
for (let i = 0; i < attemptCount; i++) {
const attemptStarted = new Date(now.getTime() + i * 200)
@@ -3611,7 +4243,20 @@ registerDynamicRoute('GET', '/api/admin/monitoring/trace/:requestId', async (_co
ranking_mode: 'FixedOrder',
priority_mode: 'Provider',
ranking_index: i,
priority_slot: i + 1
priority_slot: i + 1,
...(isCyberPolicyDemo ? {
upstream_response: {
source: 'upstream_response',
status_code: 400,
headers: {
'content-type': 'application/json',
'x-request-id': `req_${MOCK_CYBER_POLICY_USAGE_ID}`
},
body: MOCK_CYBER_POLICY_ERROR_BODY,
body_ref: `usage://request/req_${MOCK_CYBER_POLICY_USAGE_ID}/response_body`,
body_state: 'reference'
}
} : {})
},
latency_ms: attemptLatency,
created_at: attemptStarted.toISOString(),
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getCodexQuotaWindowPresentation } from '../codexQuotaWindow'
describe('getCodexQuotaWindowPresentation', () => {
it.each([
[300, '5H'],
[10_080, '周'],
[43_200, '月'],
[43_800, '月'],
[44_640, '月'],
])('labels a %i-minute window as %s', (windowMinutes, expectedLabel) => {
expect(getCodexQuotaWindowPresentation({
code: 'primary',
window_minutes: windowMinutes,
})?.label).toBe(expectedLabel)
})
it('supports simultaneous 5H and weekly windows', () => {
const windows = [
getCodexQuotaWindowPresentation({ code: 'secondary', window_minutes: 10_080 }),
getCodexQuotaWindowPresentation({ code: 'primary', window_minutes: 300 }),
].filter((item): item is NonNullable<typeof item> => item != null)
expect(windows.sort((a, b) => a.sortOrder - b.sortOrder).map(item => item.label)).toEqual(['5H', '周'])
})
it('drops zero-minute placeholder windows', () => {
expect(getCodexQuotaWindowPresentation({
code: 'weekly',
label: '周',
window_minutes: 0,
})).toBeNull()
})
it('keeps legacy labels when old snapshots have no window duration', () => {
expect(getCodexQuotaWindowPresentation({ code: '5h' })?.label).toBe('5H')
expect(getCodexQuotaWindowPresentation({ code: 'weekly' })?.label).toBe('周')
})
})
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { formatOAuthPlanType } from '../oauthPlanType'
describe('formatOAuthPlanType', () => {
it('uses the compact Codex label for the usage-based business plan', () => {
expect(formatOAuthPlanType('self_serve_business_usage_based')).toBe('Codex')
expect(formatOAuthPlanType(' SELF_SERVE_BUSINESS_USAGE_BASED ')).toBe('Codex')
})
it('keeps existing known plan labels intact', () => {
expect(formatOAuthPlanType('plus')).toBe('Plus')
expect(formatOAuthPlanType('team')).toBe('Team')
})
})
@@ -43,6 +43,34 @@ describe('providerKeyQuota', () => {
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
})
it('uses actual Codex window durations and ignores zero placeholders', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
provider_type: 'codex',
code: 'ok',
exhausted: false,
windows: [
{
code: 'weekly',
label: '周',
window_minutes: 0,
remaining_ratio: 1,
},
{
code: '5h',
label: '5H',
window_minutes: 43_800,
remaining_ratio: 0.86,
},
],
},
},
}, 'codex')).toBe('月剩余 86.0%')
})
it('formats Grok account quota from structured quota windows', () => {
expect(getQuotaDisplayText({
status_snapshot: {
+62
View File
@@ -0,0 +1,62 @@
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
const MINUTES_PER_HOUR = 60
const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY
const MIN_MONTH_MINUTES = 28 * MINUTES_PER_DAY
const MAX_MONTH_MINUTES = 31 * MINUTES_PER_DAY
export interface CodexQuotaWindowPresentation {
label: string
sortOrder: number
}
function formatCodexQuotaPeriod(windowMinutes: number): string {
if (windowMinutes === 5 * MINUTES_PER_HOUR) return '5H'
if (windowMinutes === MINUTES_PER_WEEK) return '周'
if (windowMinutes >= MIN_MONTH_MINUTES && windowMinutes <= MAX_MONTH_MINUTES) return '月'
if (windowMinutes % MINUTES_PER_WEEK === 0) {
return `${windowMinutes / MINUTES_PER_WEEK}`
}
if (windowMinutes % MINUTES_PER_DAY === 0) {
return `${windowMinutes / MINUTES_PER_DAY}`
}
if (windowMinutes % MINUTES_PER_HOUR === 0) {
return `${windowMinutes / MINUTES_PER_HOUR}H`
}
return `${windowMinutes}分钟`
}
function getLegacyCodexQuotaPeriod(code: string, label: string): string | null {
if (code === '5h') return '5H'
if (code === 'weekly') return '周'
if (code === 'monthly') return '月'
return label || null
}
export function getCodexQuotaWindowPresentation(
window: QuotaWindowSnapshot,
): CodexQuotaWindowPresentation | null {
const code = String(window.code || '').trim().toLowerCase()
const isSpark = code.startsWith('spark_')
const baseCode = isSpark ? code.slice('spark_'.length) : code
const rawLabel = String(window.label || '').trim().replace(/^Spark\s*/i, '')
const hasExplicitWindowMinutes = window.window_minutes != null
const windowMinutes = Number(window.window_minutes)
if (hasExplicitWindowMinutes && (!Number.isFinite(windowMinutes) || windowMinutes <= 0)) {
return null
}
const period = hasExplicitWindowMinutes
? formatCodexQuotaPeriod(windowMinutes)
: getLegacyCodexQuotaPeriod(baseCode, rawLabel)
if (!period) return null
const fallbackOrder = baseCode === '5h' ? 300 : baseCode === 'weekly' ? 10_080 : 1_000_000
return {
label: isSpark ? `Spark${period}` : period,
sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder),
}
}
+9
View File
@@ -58,6 +58,15 @@ export function mergeChatPiiRedactionFeatureSettings(
return Object.keys(settings).length > 0 ? settings : null
}
export function removeChatPiiRedactionFeatureSettings(
featureSettings: unknown,
): FeatureSettingsMap | null {
if (!isRecord(featureSettings)) return null
const settings: FeatureSettingsMap = { ...featureSettings }
delete settings.chat_pii_redaction
return Object.keys(settings).length > 0 ? settings : null
}
export function readNotificationPushServiceFeatureSettings(
featureSettings: unknown,
): NotificationPushServiceFeatureSettings {
+1
View File
@@ -1,4 +1,5 @@
const PLAN_TYPE_LABELS: Record<string, string> = {
self_serve_business_usage_based: 'Codex',
free: 'Free',
plus: 'Plus',
team: 'Team',
+6 -9
View File
@@ -4,6 +4,7 @@ import type {
QuotaWindowSnapshot,
} from '@/api/endpoints/types/statusSnapshot'
import type { UpstreamMetadata } from '@/api/endpoints/types/provider'
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
export interface ProviderKeyQuotaCarrier {
account_quota?: string | null
@@ -222,15 +223,11 @@ function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
const parts: string[] = []
for (const [label, code] of [
['周', 'weekly'],
['5H', '5h'],
['Spark5H', 'spark_5h'],
['Spark周', 'spark_weekly'],
] as const) {
const remainingPercent = getQuotaWindowRemainingPercent(getQuotaWindow(quota, code))
if (remainingPercent == null) continue
parts.push(`${label}剩余 ${formatPercent(remainingPercent)}`)
for (const window of getQuotaWindows(quota)) {
const presentation = getCodexQuotaWindowPresentation(window)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (!presentation || remainingPercent == null) continue
parts.push(`${presentation.label}剩余 ${formatPercent(remainingPercent)}`)
}
if (parts.length > 0) return parts.join(' | ')
@@ -10,17 +10,24 @@
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
手动刷新
</Badge>
<span class="text-xs text-muted-foreground">
更新 {{ lastUpdatedLabel }}
</span>
<RefreshButton
:loading="refreshing"
title="刷新运维总览"
@click="refreshAll()"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
:aria-label="autoRefresh ? '关闭自动刷新' : '开启自动刷新'"
:aria-pressed="autoRefresh"
@click="toggleAutoRefresh"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="autoRefresh || refreshing ? 'animate-spin' : ''"
/>
</Button>
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
@@ -1012,7 +1019,7 @@
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onUnmounted, ref, watch, type Component } from 'vue'
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
import { RouterLink } from 'vue-router'
import type { ChartData, ChartOptions } from 'chart.js'
import {
@@ -1028,7 +1035,7 @@ import {
Timer,
Zap,
} from 'lucide-vue-next'
import { Badge, Card, RefreshButton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import { Badge, Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import { LoadingState, TimeRangePicker } from '@/components/common'
import LineChart from '@/components/charts/LineChart.vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
@@ -1094,10 +1101,13 @@ const loadWarning = computed(() =>
[analyticsWarning.value, realtimeWarning.value].filter(Boolean).join('') || null
)
const refreshing = ref(false)
const autoRefresh = ref(false)
const trendLoading = ref(false)
const percentileLoading = ref(false)
const AUTO_REFRESH_INTERVAL = 10_000
let requestId = 0
let refreshPromise: Promise<void> | null = null
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let analyticsGeneration = 0
const timeRangeParams = computed(() => ({
@@ -1482,6 +1492,26 @@ function refreshAll(): Promise<void> {
return request
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (!autoRefresh.value) {
stopAutoRefresh()
return
}
void refreshAll()
autoRefreshTimer = setInterval(() => {
void refreshAll()
}, AUTO_REFRESH_INTERVAL)
}
const lastUpdatedLabel = computed(() => formatShortDate(lastUpdatedAt.value))
const totalRequests = computed(() => sumSeries('total_requests'))
@@ -2166,7 +2196,6 @@ const opsLinks = [
]
watch(timeRange, () => {
//
analyticsGeneration += 1
timeSeries.value = []
percentiles.value = []
@@ -2177,9 +2206,17 @@ watch(timeRange, () => {
lastUpdatedAt.value = null
trendLoading.value = false
percentileLoading.value = false
if (autoRefresh.value) {
void refreshAll()
}
}, { deep: true })
onMounted(() => {
void refreshAll()
})
onUnmounted(() => {
stopAutoRefresh()
requestId += 1
analyticsGeneration += 1
})
+42 -160
View File
@@ -110,21 +110,7 @@
class="px-2 font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.stats }"
>
<div class="flex items-center justify-center gap-1.5">
<button
v-if="showCodexStatsModeToggle"
type="button"
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
:title="poolStatsMode === 'current_cycle' ? '切换为总计统计' : '切换为周期统计'"
:aria-label="poolStatsMode === 'current_cycle' ? '切换为总计统计' : '切换为周期统计'"
:aria-pressed="poolStatsMode === 'current_cycle'"
data-testid="pool-stats-mode-control"
@click.stop="togglePoolStatsMode"
>
<Repeat2 class="h-3.5 w-3.5" />
</button>
<span>统计</span>
</div>
<span>统计</span>
</TableHead>
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
@@ -314,7 +300,7 @@
<TableCell class="py-3 px-2 align-middle">
<PoolKeyStatsPanel
:cycle="isPoolKeyCycleStatsDisplay(key)"
:cycle-rows="getPoolKeyCycleStatsRows(key)"
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
/>
</TableCell>
@@ -592,7 +578,7 @@
<div class="space-y-1 text-center">
<PoolKeyStatsPanel
:cycle="isPoolKeyCycleStatsDisplay(key)"
:cycle-rows="getPoolKeyCycleStatsRows(key)"
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
variant="mobile"
/>
@@ -977,7 +963,6 @@ import {
Copy,
Shield,
Globe,
Repeat2,
RotateCcw,
SquarePen,
Trash2,
@@ -1065,7 +1050,6 @@ import {
resolvePoolManagementPageAfterLoad,
type PoolManagementSortBy,
type PoolManagementSortOrder,
type PoolManagementStatsMode,
type PoolManagementViewState,
writePoolManagementViewState,
} from '@/features/pool/utils/poolManagementState'
@@ -1075,7 +1059,9 @@ import {
type PoolStatsDisplay,
type PoolStatsMetric,
} from '@/features/pool/utils/poolStatsDisplay'
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
canEditOAuthCredential,
@@ -1119,7 +1105,6 @@ const restoredViewState = readPoolManagementViewState(
pageSize: getQueryValue('pageSize'),
sortBy: getQueryValue('sortBy'),
sortOrder: getQueryValue('sortOrder'),
statsMode: getQueryValue('statsMode'),
},
poolManagementViewStorage,
)
@@ -1487,8 +1472,6 @@ const selectedProviderType = computed(() => {
return String(fromOverview || '').trim().toLowerCase()
})
const showCodexStatsModeToggle = computed(() => selectedProviderType.value === 'codex')
const selectedProviderStatusText = computed(() => {
if (!selectedProviderId.value) return ''
const providerActive = selectedProviderData.value?.is_active
@@ -1571,9 +1554,9 @@ const showAccountQuotaColumn = computed(() => {
const desktopColumnWidths = computed(() => {
if (showAccountQuotaColumn.value) {
return {
name: '21%',
name: '19%',
quota: '18%',
stats: '13%',
stats: '15%',
imported: '10%',
lastUsed: '8%',
score: '9%',
@@ -1673,7 +1656,6 @@ const currentPage = ref(restoredViewState.page)
const pageSize = ref(restoredViewState.pageSize)
const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy)
const sortOrder = ref<PoolManagementSortOrder>(restoredViewState.sortOrder)
const poolStatsMode = ref<PoolManagementStatsMode>(restoredViewState.statsMode)
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
const refreshingOAuthKeyId = ref<string | null>(null)
@@ -1694,12 +1676,6 @@ const keyFormDialogOpen = ref(false)
const oauthKeyEditDialogOpen = ref(false)
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
function togglePoolStatsMode() {
poolStatsMode.value = poolStatsMode.value === 'current_cycle'
? 'account_total'
: 'current_cycle'
}
function clearPoolKeyFilters() {
if (!hasPoolKeyFilters.value) return
suppressFiltersWatch = true
@@ -1764,18 +1740,6 @@ watch(
{ immediate: true },
)
watch(
() => readPoolManagementViewState(
{ statsMode: getQueryValue('statsMode') },
poolManagementViewStorage,
).statsMode,
(value) => {
if (poolStatsMode.value === value) return
poolStatsMode.value = value
},
{ immediate: true },
)
watch(
() => getQueryValue('providerId'),
(value) => {
@@ -1792,8 +1756,8 @@ watch(
)
watch(
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder, poolStatsMode],
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue, statsMode]) => {
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder],
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue]) => {
const nextState: PoolManagementViewState = {
providerId,
search,
@@ -1802,7 +1766,7 @@ watch(
pageSize: pageSizeValue,
sortBy: sortByValue,
sortOrder: sortOrderValue,
statsMode: statsMode as PoolManagementStatsMode,
statsMode: 'current_cycle',
}
patchQuery(buildPoolManagementQueryPatch(nextState))
writePoolManagementViewState(nextState, poolManagementViewStorage)
@@ -1812,6 +1776,7 @@ watch(
interface QuotaProgressItem {
label: string
remainingPercent: number
sortOrder?: number
detail?: string
resetAtSeconds?: number | null
resetSeconds?: number | null
@@ -1828,20 +1793,6 @@ interface QuotaProgressDisplayItem {
meterClass: string
}
interface PoolCodexCycleStatsRow {
key: PoolStatsMetric['key']
label: string
fiveH: PoolStatsMetric
weekly: PoolStatsMetric
}
const CODEX_CYCLE_STAT_KEYS: Array<PoolStatsMetric['key']> = ['request_count', 'total_tokens', 'total_cost_usd']
const CODEX_CYCLE_STAT_LABELS: Record<PoolStatsMetric['key'], string> = {
request_count: '请求',
total_tokens: 'Token',
total_cost_usd: '费用',
}
type PoolKeyUiState = {
rowClass: string
schedulingBadgeLabel: string
@@ -1920,7 +1871,7 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
: '',
importedAtRelative: formatPoolKeyImportedAt(key),
lastUsedRelative: key.last_used_at ? formatRelativeTime(key.last_used_at) : '-',
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, poolStatsMode.value),
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle'),
mobileTagItems: getMobileTagItems(key),
mobileActionIds: splitPoolMobileActions({
canDownloadOrCopy: true,
@@ -1937,7 +1888,7 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
function getPoolKeyStatsDisplay(key: PoolKeyDetail): PoolStatsDisplay {
return keyUiStateMap.value[key.key_id]?.statsDisplay
?? buildPoolStatsDisplay(key, selectedProviderType.value, poolStatsMode.value)
?? buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle')
}
function isPoolKeyCycleStatsDisplay(key: PoolKeyDetail): boolean {
@@ -1949,39 +1900,6 @@ function getPoolKeyCycleStatsGroups(key: PoolKeyDetail): PoolCodexCycleStatsGrou
return display.kind === 'codex_cycle' ? display.groups : []
}
function createMissingCycleMetric(key: PoolStatsMetric['key']): PoolStatsMetric {
return {
key,
label: CODEX_CYCLE_STAT_LABELS[key],
value: '—',
missing: true,
}
}
function findCycleMetric(
group: PoolCodexCycleStatsGroup | undefined,
key: PoolStatsMetric['key'],
): PoolStatsMetric {
return group?.metrics.find(metric => metric.key === key) ?? createMissingCycleMetric(key)
}
function getPoolKeyCycleStatsRows(key: PoolKeyDetail): PoolCodexCycleStatsRow[] {
const groups = getPoolKeyCycleStatsGroups(key)
const fiveHGroup = groups.find(group => group.code === '5h')
const weeklyGroup = groups.find(group => group.code === 'weekly')
return CODEX_CYCLE_STAT_KEYS.map((metricKey) => {
const fiveH = findCycleMetric(fiveHGroup, metricKey)
const weekly = findCycleMetric(weeklyGroup, metricKey)
return {
key: metricKey,
label: CODEX_CYCLE_STAT_LABELS[metricKey],
fiveH,
weekly,
}
})
}
function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const display = getPoolKeyStatsDisplay(key)
return display.kind === 'account_total'
@@ -3110,42 +3028,6 @@ function getMobileTagClass(item: PoolMobileTagItem): string {
return 'border-border/60 bg-background/80 text-foreground/80'
}
function formatOAuthPlanType(planType: string): string {
const labelMap: Record<string, string> = {
plus: 'Plus',
pro: 'Pro',
free: 'Free',
paid: 'Paid',
team: 'Team',
enterprise: 'Enterprise',
ultra: 'Ultra',
'pro+': 'Pro+',
power: 'Power',
basic: 'Basic',
super: 'Super',
heavy: 'Heavy',
}
return labelMap[planType.toLowerCase()] || planType
}
function getOAuthPlanTypeClass(planType: string): string {
const classes: Record<string, string> = {
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
free: 'border-primary/50 text-primary',
paid: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
basic: 'border-primary/50 text-primary',
super: 'border-green-500/50 text-green-600 dark:text-green-400',
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType.toLowerCase()] || ''
}
function getVisibleOAuthState(key: PoolKeyDetail) {
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
}
@@ -3233,6 +3115,7 @@ function getQuotaProgressLabel(label: string): string {
if (label === '日') return '日'
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === '月') return '月'
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
@@ -3241,7 +3124,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
const staticResetLabels = ['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
@@ -3303,15 +3186,17 @@ function getQuotaLabelOrder(label: string): number {
if (label === '日') return 5
if (label === '5H') return 6
if (label === '周') return 7
if (label === 'Spark5H') return 8
if (label === 'Spark') return 9
if (label === 'Prompt') return 10
if (label === 'Flex') return 11
if (label === '剩余') return 12
if (label === '最低') return 13
if (label === '生图') return 14
if (label === '速率') return 15
if (label === '模型') return 16
if (label === '') return 8
if (label === 'Spark5H') return 9
if (label === 'Spark周') return 10
if (label === 'Spark月') return 11
if (label === 'Prompt') return 12
if (label === 'Flex') return 13
if (label === '剩余') return 14
if (label === '最低') return 15
if (label === '生图') return 16
if (label === '速率') return 17
if (label === '模型') return 18
return 20
}
@@ -3479,27 +3364,24 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
const providerType = getQuotaSnapshotProviderType(key)
if (providerType === 'codex') {
const items: QuotaProgressItem[] = []
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
for (const [label, code] of [
['5H', '5h'],
['周', 'weekly'],
['Spark5H', 'spark_5h'],
['Spark周', 'spark_weekly'],
] as const) {
const window = getQuotaSnapshotWindow(quota, code)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) continue
items.push({
label,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
return (quota.windows ?? [])
.map((window): QuotaProgressItem | null => {
const presentation = getCodexQuotaWindowPresentation(window)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (!presentation || remainingPercent == null) return null
return {
label: presentation.label,
sortOrder: presentation.sortOrder,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
}
return items
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'kiro') {
@@ -3739,7 +3621,7 @@ function parseQuotaProgressItems(key: PoolKeyDetail): QuotaProgressItem[] {
const snapshotItems = buildQuotaProgressItemsFromSnapshot(key)
if (snapshotItems.length > 0) {
return snapshotItems.sort((a, b) => {
const orderDiff = getQuotaLabelOrder(a.label) - getQuotaLabelOrder(b.label)
const orderDiff = (a.sortOrder ?? getQuotaLabelOrder(a.label)) - (b.sortOrder ?? getQuotaLabelOrder(b.label))
if (orderDiff !== 0) return orderDiff
return a.label.localeCompare(b.label, 'zh-Hans-CN')
})
+90 -29
View File
@@ -138,7 +138,7 @@
:format-rate-limit="formatRateLimitSimple"
:format-concurrent-limit="formatConcurrentLimitSimple"
:format-ip-rules="formatIpRules"
@close="showApiKeysDialog = false"
@close="closeApiKeysDialog"
@create-key="openCreateUserApiKeyDialog"
@edit-key="openEditUserApiKeyDialog"
@toggle-lock="toggleLockApiKey"
@@ -231,6 +231,10 @@ import UserManagementList from '@/features/users/components/UserManagementList.v
import UserPlanDialog from '@/features/users/components/UserPlanDialog.vue'
import UserSelectionToolbar from '@/features/users/components/UserSelectionToolbar.vue'
import UserSessionsDialog from '@/features/users/components/UserSessionsDialog.vue'
import {
buildApiKeyRedactionFeatureSettingsPatch,
resolveApiKeyRedactionFormState,
} from '@/features/users/apiKeyFeatureSettings'
import type { UserManagementRow } from '@/features/users/components/user-management-types'
import {
USER_ROLE_FILTER_OPTIONS,
@@ -242,10 +246,6 @@ import {
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import {
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
import { log } from '@/utils/logger'
import { useBatchSelection } from '@/composables/useBatchSelection'
import { useI18n } from '@/i18n'
@@ -292,6 +292,7 @@ const userApiKeyForm = ref<UserApiKeyFormState>({
rate_limit: undefined,
concurrent_limit: undefined,
ip_rules_text: '',
chat_pii_redaction_mode: 'inherit',
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
})
@@ -327,6 +328,8 @@ const USERS_PAGE_CACHE_TTL_MS = 10 * 1000
const USER_WALLETS_CACHE_TTL_MS = 10 * 1000
const USERS_SEARCH_DEBOUNCE_MS = 300
let userWalletsRequestId = 0
let userApiKeysRequestId = 0
let userApiKeyMutationRequestId = 0
let usersSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const filteredUsers = computed(() => usersStore.users)
@@ -445,6 +448,8 @@ onMounted(() => {
onBeforeUnmount(() => {
clearUsersSearchDebounce()
userWalletsRequestId += 1
userApiKeysRequestId += 1
userApiKeyMutationRequestId += 1
})
async function refreshUsers(options: { preferCache?: boolean } = {}) {
@@ -783,11 +788,22 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
}
async function manageApiKeys(user: User) {
userApiKeyMutationRequestId += 1
creatingApiKey.value = false
selectedUser.value = user
userApiKeys.value = []
showApiKeysDialog.value = true
await loadUserApiKeys(user.id)
}
function closeApiKeysDialog() {
userApiKeyMutationRequestId += 1
creatingApiKey.value = false
showApiKeysDialog.value = false
userApiKeys.value = []
userApiKeysRequestId += 1
}
async function manageUserSessions(user: User) {
selectedUser.value = user
showUserSessionsDialog.value = true
@@ -866,21 +882,37 @@ async function grantPlanToSelectedUser() {
}
async function loadUserApiKeys(userId: string) {
const requestId = ++userApiKeysRequestId
try {
userApiKeys.value = await usersStore.getUserApiKeys(userId)
const apiKeys = await usersStore.getUserApiKeys(userId)
if (
requestId !== userApiKeysRequestId
|| selectedUser.value?.id !== userId
|| !showApiKeysDialog.value
) return
userApiKeys.value = apiKeys
} catch (err) {
if (
requestId !== userApiKeysRequestId
|| selectedUser.value?.id !== userId
|| !showApiKeysDialog.value
) return
log.error('加载API Keys失败:', err)
userApiKeys.value = []
}
}
function openCreateUserApiKeyDialog() {
const redactionFeature = readChatPiiRedactionFeatureSettings(null)
const redactionFeature = resolveApiKeyRedactionFormState(
null,
selectedUser.value?.feature_settings,
)
userApiKeyForm.value = {
name: `Key-${new Date().toISOString().split('T')[0]}`,
rate_limit: undefined,
concurrent_limit: undefined,
ip_rules_text: '',
chat_pii_redaction_mode: redactionFeature.mode,
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
}
@@ -889,13 +921,17 @@ function openCreateUserApiKeyDialog() {
}
function openEditUserApiKeyDialog(apiKey: ApiKey) {
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
const redactionFeature = resolveApiKeyRedactionFormState(
apiKey.feature_settings,
selectedUser.value?.feature_settings,
)
editingUserApiKey.value = apiKey
userApiKeyForm.value = {
name: apiKey.name || '',
rate_limit: apiKey.rate_limit ?? undefined,
concurrent_limit: apiKey.concurrent_limit ?? undefined,
ip_rules_text: apiKey.ip_rules?.join(', ') ?? '',
chat_pii_redaction_mode: redactionFeature.mode,
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
}
@@ -903,6 +939,10 @@ function openEditUserApiKeyDialog(apiKey: ApiKey) {
}
function closeUserApiKeyFormDialog() {
if (creatingApiKey.value) {
userApiKeyMutationRequestId += 1
creatingApiKey.value = false
}
showUserApiKeyFormDialog.value = false
editingUserApiKey.value = null
userApiKeyForm.value = {
@@ -910,6 +950,7 @@ function closeUserApiKeyFormDialog() {
rate_limit: undefined,
concurrent_limit: undefined,
ip_rules_text: '',
chat_pii_redaction_mode: 'inherit',
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
}
@@ -922,42 +963,62 @@ async function submitUserApiKeyForm() {
return
}
const targetUserId = selectedUser.value.id
const editingApiKey = editingUserApiKey.value
const form = { ...userApiKeyForm.value }
const mutationRequestId = ++userApiKeyMutationRequestId
const mutationIsCurrent = () => (
mutationRequestId === userApiKeyMutationRequestId
&& selectedUser.value?.id === targetUserId
&& showApiKeysDialog.value
)
creatingApiKey.value = true
try {
const ipRules = parseIpRulesInput(userApiKeyForm.value.ip_rules_text)
if (editingUserApiKey.value) {
await usersStore.updateApiKey(selectedUser.value.id, editingUserApiKey.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
const ipRules = parseIpRulesInput(form.ip_rules_text)
const featureSettingsPatch = buildApiKeyRedactionFeatureSettingsPatch({
isEditing: Boolean(editingApiKey),
currentFeatureSettings: editingApiKey?.feature_settings,
mode: form.chat_pii_redaction_mode,
value: {
enabled: form.chat_pii_redaction_enabled,
inject_model_instruction: form.chat_pii_redaction_placeholder_notice,
},
})
if (editingApiKey) {
await usersStore.updateApiKey(targetUserId, editingApiKey.id, {
name: form.name,
rate_limit: form.rate_limit ?? 0,
concurrent_limit: form.concurrent_limit,
ip_rules: ipRules,
feature_settings: mergeChatPiiRedactionFeatureSettings(editingUserApiKey.value.feature_settings, {
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
}),
...featureSettingsPatch,
})
if (!mutationIsCurrent()) return
success(legacyT('API Key已更新'))
} else {
const response = await usersStore.createApiKey(selectedUser.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
const response = await usersStore.createApiKey(targetUserId, {
name: form.name,
rate_limit: form.rate_limit ?? 0,
concurrent_limit: form.concurrent_limit,
ip_rules: ipRules,
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
}),
...featureSettingsPatch,
})
if (!mutationIsCurrent()) return
newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true
success(legacyT('API Key创建成功'))
}
await loadUserApiKeys(selectedUser.value.id)
await loadUserApiKeys(targetUserId)
if (!mutationIsCurrent()) return
closeUserApiKeyFormDialog()
} catch (err: unknown) {
error(localizedApiError(err, '未知错误'), legacyT(editingUserApiKey.value ? '更新 API Key 失败' : '创建 API Key 失败'))
if (mutationIsCurrent()) {
error(localizedApiError(err, '未知错误'), legacyT(editingApiKey ? '更新 API Key 失败' : '创建 API Key 失败'))
}
} finally {
creatingApiKey.value = false
if (mutationRequestId === userApiKeyMutationRequestId) {
creatingApiKey.value = false
}
}
}
@@ -7,18 +7,23 @@ const source = readFileSync(
'utf8',
)
describe('AdminOperationsDashboard manual refresh', () => {
it('only starts a refresh from the explicit refresh button', () => {
expect(source).toContain('@click="refreshAll()"')
expect(source).not.toContain('onMounted(')
expect(source).not.toContain('setInterval(')
expect(source).not.toContain('visibilitychange')
describe('AdminOperationsDashboard refresh behavior', () => {
it('refreshes on entry and supports toggling automatic refresh', () => {
expect(source).not.toContain('手动刷新')
expect(source).toContain('@click="toggleAutoRefresh"')
expect(source).toContain("autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'")
expect(source).toContain('onMounted(() => {')
expect(source).toContain('void refreshAll()')
expect(source).toContain('const AUTO_REFRESH_INTERVAL = 10_000')
expect(source).toContain('autoRefreshTimer = setInterval(')
expect(source).toContain('clearInterval(autoRefreshTimer)')
const rangeWatcher = source
.split('watch(timeRange, () => {')[1]
?.split('}, { deep: true })')[0]
expect(rangeWatcher).toBeTruthy()
expect(rangeWatcher).not.toContain('refreshAll(')
expect(rangeWatcher).toContain('if (autoRefresh.value)')
expect(rangeWatcher).toContain('refreshAll()')
})
it('does not request the heavyweight system-status fallback', () => {
@@ -133,7 +133,6 @@ vi.mock('lucide-vue-next', async () => {
Copy: Icon,
Shield: Icon,
Globe: Icon,
Repeat2: Icon,
RotateCcw: Icon,
SquarePen: Icon,
Trash2: Icon,
@@ -492,7 +491,7 @@ function createPoolKey(providerType = 'codex', overrides: Partial<PoolKeyDetail>
{
code: 'weekly',
remaining_ratio: 0.5,
usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' },
usage: { request_count: 12, total_tokens: 5000, total_cost_usd: '0.012' },
},
]
: [],
@@ -533,13 +532,6 @@ async function settle() {
}
}
function seedStoredStatsMode(statsMode: 'current_cycle' | 'account_total') {
window.sessionStorage.setItem(
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
JSON.stringify({ statsMode }),
)
}
beforeEach(() => {
resetQuery()
window.sessionStorage.clear()
@@ -574,7 +566,7 @@ afterEach(() => {
})
describe('PoolManagement Codex cycle stats mode', () => {
it('renders Codex current-cycle stats by default with a header icon toggle', async () => {
it('renders current-cycle comparison text without a mode toggle', async () => {
const codexKey = createPoolKey('codex')
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
@@ -583,19 +575,12 @@ describe('PoolManagement Codex cycle stats mode', () => {
const root = mountPoolManagement()
await settle()
expect(root.querySelector('[data-testid="pool-stats-mode-switch"]')).toBeNull()
const modeButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-stats-mode-control"]')
expect(modeButton).not.toBeNull()
expect(modeButton?.getAttribute('title')).toBe('切换为总计统计')
expect(root.querySelectorAll('[data-testid="pool-stats-cycle-group-5h"]').length).toBeGreaterThan(0)
expect(root.querySelectorAll('[data-testid="pool-stats-cycle-group-weekly"]').length).toBeGreaterThan(0)
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.textContent?.trim()).toBe('7')
expect(root.querySelector('[data-testid="pool-stats-weekly-total_tokens"]')?.textContent?.trim()).toBe('0')
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')?.className).toContain('grid-cols-[38px_64px_10px_64px]')
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')?.className).toContain('w-[188px]')
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')?.className).toContain('min-h-16')
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.className).toContain('text-center')
expect(root.querySelector('[data-testid="pool-stats-weekly-total_tokens"]')?.className).toContain('text-center')
expect(root.querySelector('[data-testid="pool-stats-mode-control"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).not.toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('7/12')
expect(root.querySelector('[data-testid="pool-stats-cycle-total_tokens"]')?.textContent?.trim()).toBe('2.5K/5K')
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-large-base"]')).toBeNull()
expect(endpointMocks.listPoolKeys).toHaveBeenLastCalledWith(
'codex-provider',
expect.objectContaining({
@@ -684,6 +669,50 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.textContent).toContain('生图')
})
it('labels Codex quota by the actual refresh window duration', async () => {
const monthlyCodexKey = createPoolKey('codex', {
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'codex',
windows: [
{
code: 'weekly',
remaining_ratio: 0.86,
window_minutes: 43_800,
usage: { request_count: 23, total_tokens: 45_600, total_cost_usd: '0.1234' },
},
{
code: '5h',
remaining_ratio: 1,
window_minutes: 0,
},
],
},
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(monthlyCodexKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
const periodLabels = Array.from(root.querySelectorAll('[data-testid="pool-quota-period-label"]'))
.map((element) => element.textContent?.trim())
.filter(Boolean)
expect(periodLabels).toContain('月')
expect(periodLabels).not.toContain('5H')
expect(periodLabels).not.toContain('周')
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('-/23')
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-bar-request_count"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
})
it('opens only one score popover across desktop and mobile layouts', async () => {
const scoredKey = createPoolKey('codex', {
pool_score: {
@@ -768,32 +797,11 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(endpointMocks.refreshProviderQuota).not.toHaveBeenCalledWith('codex-provider')
})
it('toggles Codex stats to account totals and persists the choice', async () => {
const codexKey = createPoolKey('codex')
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
const modeButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-stats-mode-control"]')
expect(modeButton).not.toBeNull()
modeButton?.click()
await settle()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')?.className).toContain('w-[188px]')
expect(root.querySelector('[data-testid="pool-stats-account-total"]')?.className).toContain('grid-rows-4')
expect(root.querySelector('[data-testid="pool-stats-account-total"]')?.className).toContain('min-h-16')
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
expect(routeMocks.query.statsMode).toBe('account_total')
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"')
expect(modeButton?.getAttribute('title')).toBe('切换为周期统计')
})
it('restores stored and query account-total mode for Codex providers', async () => {
seedStoredStatsMode('account_total')
it('ignores legacy account-total mode and removes it from the route', async () => {
window.sessionStorage.setItem(
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
JSON.stringify({ statsMode: 'account_total' }),
)
routeMocks.query.statsMode = 'account_total'
const codexKey = createPoolKey('codex')
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
@@ -803,10 +811,10 @@ describe('PoolManagement Codex cycle stats mode', () => {
const root = mountPoolManagement()
await settle()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
expect(routeMocks.query.statsMode).toBe('account_total')
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"')
expect(root.querySelector('[data-testid="pool-stats-mode-control"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).not.toBeNull()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).toBeNull()
expect(routeMocks.query.statsMode).toBeUndefined()
})
it('resets Codex cycle stats from the action column', async () => {
@@ -841,10 +849,9 @@ describe('PoolManagement Codex cycle stats mode', () => {
const root = mountPoolManagement()
await settle()
expect(root.querySelector('[data-testid="pool-stats-mode-switch"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-mode-control"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-reset-cycle-stats"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
expect(root.textContent).toContain('12')
expect(root.textContent).toContain('3.5K')
@@ -53,4 +53,50 @@ describe('Users request loading', () => {
?.split('async function manageApiKeys')[0]
expect(formSubmit).toContain('Promise.all([refreshUsers(), loadUserWallets()])')
})
it('seeds a new managed key from the selected target user feature settings', () => {
const openCreateKey = source
.split('function openCreateUserApiKeyDialog()')[1]
?.split('function openEditUserApiKeyDialog')[0]
expect(openCreateKey).toBeTruthy()
expect(openCreateKey).toContain('selectedUser.value?.feature_settings')
expect(openCreateKey).not.toContain('authStore')
})
it('rejects a stale API key response after switching users or closing the dialog', () => {
const manageKeys = source
.split('async function manageApiKeys(user: User)')[1]
?.split('async function manageUserSessions')[0]
expect(manageKeys).toContain('userApiKeys.value = []')
expect(manageKeys).toContain('loadUserApiKeys(user.id)')
const loadKeys = source
.split('async function loadUserApiKeys(userId: string)')[1]
?.split('function openCreateUserApiKeyDialog')[0]
expect(loadKeys).toContain('const requestId = ++userApiKeysRequestId')
expect(loadKeys).toContain('requestId !== userApiKeysRequestId')
expect(loadKeys).toContain('selectedUser.value?.id !== userId')
expect(loadKeys).toContain('!showApiKeysDialog.value')
const closeKeys = source
.split('function closeApiKeysDialog()')[1]
?.split('async function manageUserSessions')[0]
expect(closeKeys).toContain('userApiKeysRequestId += 1')
expect(closeKeys).toContain('userApiKeys.value = []')
expect(source).toContain('@close="closeApiKeysDialog"')
})
it('keeps an in-flight key mutation bound to its original target user', () => {
const submitKey = source
.split('async function submitUserApiKeyForm()')[1]
?.split('async function revokeSelectedUserSession')[0]
expect(submitKey).toContain('const targetUserId = selectedUser.value.id')
expect(submitKey).toContain('const mutationRequestId = ++userApiKeyMutationRequestId')
expect(submitKey).toContain('selectedUser.value?.id === targetUserId')
expect(submitKey).toContain('usersStore.createApiKey(targetUserId')
expect(submitKey).toContain('usersStore.updateApiKey(targetUserId')
expect(submitKey).toContain('if (!mutationIsCurrent()) return')
})
})
+106 -59
View File
@@ -123,6 +123,7 @@
v-if="isAdminPage"
:is-open="detailModalOpen"
:request-id="selectedRequestId"
:summary-record="selectedRequestSummary"
@close="detailModalOpen = false"
@request-state="handleDetailRequestState"
/>
@@ -154,7 +155,13 @@ import {
getDateRangeFromPeriod
} from '@/features/usage/composables'
import { reconcileActiveRequestDiscovery } from '@/features/usage/utils/activeRequestDiscovery'
import { mergeUsageRecordFirstByteTimeMs } from '@/features/usage/utils/recordSync'
import {
mergeUsageRecordErrorMessage,
mergeUsageRecordFirstByteTimeMs,
mergeUsageRecordLifecycleSnapshot,
mergeUsageRecordResponseTiming,
parseUsageTimestampMs,
} from '@/features/usage/utils/recordSync'
import {
hasUsageFallback,
isUsageRecordFailed,
@@ -494,20 +501,27 @@ async function pollActiveRequests() {
}
const currentRank = record.status ? (statusPriority[record.status] ?? 0) : 0
const newRank = update.status ? (statusPriority[update.status] ?? 0) : 0
const shouldApply = newRank >= currentRank
const currentUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
const updateUpdatedAtMs = parseUsageTimestampMs(update.updated_at)
const updateSnapshotIsOlder = currentUpdatedAtMs != null &&
updateUpdatedAtMs != null &&
updateUpdatedAtMs < currentUpdatedAtMs
const shouldApply = !updateSnapshotIsOlder && newRank >= currentRank
const updateHasFailureSignal =
(typeof update.status_code === 'number' && update.status_code >= 400) ||
(typeof update.error_message === 'string' && update.error_message.trim().length > 0) ||
update.image_progress?.phase === 'failed'
const shouldApplyData = shouldApply || updateHasFailureSignal
const shouldApplyData = shouldApply || (
!updateSnapshotIsOlder && currentRank < 2 && updateHasFailureSignal
)
if (shouldApply && record.status !== update.status) {
record.status = update.status
}
if ('image_progress' in update) {
record.image_progress = update.image_progress ?? null
}
if (shouldApplyData) {
if ('image_progress' in update) {
record.image_progress = update.image_progress ?? null
}
// provider/key/TTFB streaming
record.input_tokens = update.input_tokens
record.effective_input_tokens = update.effective_input_tokens ?? record.effective_input_tokens
@@ -521,22 +535,38 @@ async function pollActiveRequests() {
record.cost = update.cost
record.actual_cost = update.actual_cost ?? undefined
record.rate_multiplier = update.rate_multiplier ?? undefined
record.response_time_ms = update.response_time_ms ?? undefined
const responseTiming = mergeUsageRecordResponseTiming(
{
response_time_ms: record.response_time_ms,
response_time_updated_at: record.response_time_updated_at,
},
{
response_time_ms: update.response_time_ms,
response_time_updated_at: update.response_time_updated_at,
},
{
preferNext: update.status === 'completed' ||
update.status === 'failed' ||
update.status === 'cancelled',
},
)
record.response_time_ms = responseTiming.response_time_ms
record.response_time_updated_at = responseTiming.response_time_updated_at
record.first_byte_time_ms = mergeUsageRecordFirstByteTimeMs(
record.first_byte_time_ms,
update.first_byte_time_ms
)
if ('updated_at' in update) {
record.updated_at = typeof update.updated_at === 'string' ? update.updated_at : null
}
if ('response_time_updated_at' in update) {
record.response_time_updated_at =
typeof update.response_time_updated_at === 'string'
? update.response_time_updated_at
: null
if (typeof update.updated_at === 'string') {
record.updated_at = update.updated_at
}
}
record.status_code = update.status_code ?? undefined
record.error_message = update.error_message ?? undefined
record.error_message = mergeUsageRecordErrorMessage(
record.error_message,
update.error_message,
{ authoritative: shouldApply },
)
if (typeof update.upstream_is_stream === 'boolean') {
record.upstream_is_stream = update.upstream_is_stream
record.is_stream = update.upstream_is_stream
@@ -558,30 +588,28 @@ async function pollActiveRequests() {
if (typeof update.has_fallback === 'boolean') {
record.has_fallback = record.has_fallback === true || update.has_fallback
}
// streaming
if ('target_model' in update && (typeof update.target_model === 'string' || update.target_model === null)) {
record.target_model = update.target_model
// Active responses are complete final-provider snapshots. Absence clears facts left by
// a previous candidate, while requested reasoning remains tied to the client request.
record.target_model = typeof update.target_model === 'string' && update.target_model.trim()
? update.target_model
: null
record.reasoning_effort = typeof update.reasoning_effort === 'string' && update.reasoning_effort.trim()
? update.reasoning_effort
: null
if (typeof update.request_type === 'string' && update.request_type.trim()) {
record.request_type = update.request_type
}
if ('request_type' in update) {
record.request_type = typeof update.request_type === 'string'
? update.request_type
: null
}
if ('reasoning_effort' in update) {
record.reasoning_effort = typeof update.reasoning_effort === 'string'
? update.reasoning_effort
: null
}
if ('service_tier' in update) {
record.service_tier = typeof update.service_tier === 'string'
? update.service_tier
: null
}
if ('actual_service_tier' in update) {
record.actual_service_tier = typeof update.actual_service_tier === 'string'
? update.actual_service_tier
: null
if (typeof update.requested_reasoning_effort === 'string' && update.requested_reasoning_effort.trim()) {
record.requested_reasoning_effort = update.requested_reasoning_effort
}
// Active responses describe the current final provider request. Clear an old Fast fact
// when the refreshed snapshot has no request-side tier instead of retaining it forever.
record.service_tier = typeof update.service_tier === 'string' && update.service_tier.trim()
? update.service_tier
: null
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
? update.actual_service_tier
: null
//
// provider pending/unknown/unknow
if ('provider' in update && typeof update.provider === 'string') {
@@ -816,6 +844,9 @@ const availableClientFamilies = computed(() => {
//
const detailModalOpen = ref(false)
const selectedRequestId = ref<string | null>(null)
const selectedRequestSummary = computed(() => (
currentRecords.value.find(record => record.id === selectedRequestId.value) ?? null
))
//
onMounted(async () => {
@@ -1051,34 +1082,30 @@ function handleDetailRequestState(update: {
endpointApiFormat?: string | null
hasFormatConversion?: boolean | null
targetModel?: string | null
requestedReasoningEffort?: string | null
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
}) {
const record = currentRecords.value.find(record => record.id === update.id)
if (!record) return
const nextStatus = resolveDetailUpdateStatus(update)
const lifecycle = mergeUsageRecordLifecycleSnapshot(record, {
...(nextStatus ? { status: nextStatus } : {}),
...('statusCode' in update ? { statusCode: update.statusCode } : {}),
...('errorMessage' in update ? { errorMessage: update.errorMessage } : {}),
...('updatedAt' in update ? { updatedAt: update.updatedAt } : {}),
})
record.status = lifecycle.status
record.status_code = lifecycle.status_code
record.error_message = lifecycle.error_message
record.updated_at = lifecycle.updated_at
if (!lifecycle.accepted) return
const statusPriority: Record<RequestStatus, number> = {
pending: 0,
streaming: 1,
completed: 2,
failed: 2,
cancelled: 2,
}
if (nextStatus) {
const currentRank = record.status ? statusPriority[record.status] : 0
const nextRank = statusPriority[nextStatus]
if (nextRank >= currentRank) {
record.status = nextStatus
}
}
if ('statusCode' in update) {
record.status_code = update.statusCode ?? undefined
}
if ('inputTokens' in update && update.inputTokens != null) {
record.input_tokens = update.inputTokens
}
@@ -1109,8 +1136,26 @@ function handleDetailRequestState(update: {
if ('actualCost' in update && update.actualCost != null) {
record.actual_cost = update.actualCost
}
if ('responseTimeMs' in update && update.responseTimeMs != null) {
record.response_time_ms = update.responseTimeMs
if ('responseTimeMs' in update) {
const responseTiming = mergeUsageRecordResponseTiming(
{
response_time_ms: record.response_time_ms,
response_time_updated_at: record.response_time_updated_at,
},
{
response_time_ms: update.responseTimeMs,
response_time_updated_at: null,
},
{
preferNext: lifecycle.accepted && (
nextStatus === 'completed' ||
nextStatus === 'failed' ||
nextStatus === 'cancelled'
),
},
)
record.response_time_ms = responseTiming.response_time_ms
record.response_time_updated_at = responseTiming.response_time_updated_at
}
if ('firstByteTimeMs' in update) {
record.first_byte_time_ms = mergeUsageRecordFirstByteTimeMs(
@@ -1145,6 +1190,11 @@ function handleDetailRequestState(update: {
if ('reasoningEffort' in update) {
record.reasoning_effort = typeof update.reasoningEffort === 'string' ? update.reasoningEffort : null
}
if ('requestedReasoningEffort' in update) {
record.requested_reasoning_effort = typeof update.requestedReasoningEffort === 'string'
? update.requestedReasoningEffort
: null
}
if ('serviceTier' in update) {
record.service_tier = typeof update.serviceTier === 'string' ? update.serviceTier : null
}
@@ -1159,9 +1209,6 @@ function handleDetailRequestState(update: {
record.image_progress = nextProgress
}
}
if ('errorMessage' in update) {
record.error_message = update.errorMessage ?? undefined
}
}
function resolveDetailUpdateStatus(update: {
@@ -20,4 +20,24 @@ describe('admin usage initial loading', () => {
.toBeLessThan(mountedBlock?.indexOf('await loadRecords(') ?? -1)
expect(mountedBlock).not.toContain('await loadAdminUsers()')
})
it('uses authoritative active snapshots for errors and final-provider facts', () => {
const pollBlock = source
.split('async function pollActiveRequests()')[1]
?.split('async function discoverActiveRequests()')[0]
expect(pollBlock).toBeTruthy()
expect(pollBlock).toContain('const shouldApply = !updateSnapshotIsOlder && newRank >= currentRank')
expect(pollBlock).toContain('!updateSnapshotIsOlder && currentRank < 2 && updateHasFailureSignal')
expect(pollBlock).toContain('record.error_message = mergeUsageRecordErrorMessage(')
expect(pollBlock).toContain('{ authoritative: shouldApply }')
expect(pollBlock).toContain('record.target_model = typeof update.target_model')
expect(pollBlock).toContain('record.reasoning_effort = typeof update.reasoning_effort')
expect(pollBlock).toContain('record.service_tier = typeof update.service_tier')
expect(pollBlock).not.toContain("if ('target_model' in update)")
expect(pollBlock).not.toContain("if ('reasoning_effort' in update)")
expect(pollBlock).toContain(
"if (typeof update.requested_reasoning_effort === 'string' && update.requested_reasoning_effort.trim())",
)
})
})