feat(frontend): expose processing tier pricing

This commit is contained in:
MMEXA
2026-07-11 12:27:09 +08:00
parent 0b30cc6b0f
commit 8f1070a451
25 changed files with 2488 additions and 340 deletions
@@ -249,6 +249,13 @@
<span class="ml-1 font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}tps</span>
</span>
</div>
<ServiceTierFacts
v-if="hasServiceTierFacts"
class="mt-3"
:requested="serviceTierFacts.requested"
:actual="serviceTierFacts.actual"
:billing="serviceTierFacts.billing"
/>
</div>
<!-- 分隔线 -->
@@ -907,6 +914,8 @@ import JsonContentPanel from './JsonContentPanel.vue'
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 {
@@ -952,6 +961,7 @@ const emit = defineEmits<{
targetModel?: string | null
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
}]
@@ -1134,6 +1144,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
targetModel: nextDetail.target_model ?? null,
reasoningEffort: nextDetail.reasoning_effort ?? null,
serviceTier: nextDetail.service_tier ?? null,
actualServiceTier: nextDetail.actual_service_tier ?? null,
errorMessage: nextDetail.error_message ?? undefined,
})
}
@@ -1342,6 +1353,9 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null),
)
@@ -0,0 +1,48 @@
<template>
<dl
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"
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"
:title="requested || '-'"
>
{{ 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="actual || '-'"
>
{{ 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="billing || '-'"
>
{{ billing || '-' }}
</dd>
</div>
</dl>
</template>
<script setup lang="ts">
defineProps<{
requested: string | null
actual: string | null
billing: string | null
}>()
</script>
@@ -1610,26 +1610,42 @@ function getReasoningEffortTitle(record: UsageRecord): string {
return effort ? `Reasoning: ${effort}` : ''
}
function getServiceTier(record: UsageRecord): string | null {
const serviceTier = record.service_tier?.trim().toLowerCase()
function normalizeServiceTier(value: string | null | undefined): string | null {
const serviceTier = value?.trim().toLowerCase()
return serviceTier || null
}
function getRequestedServiceTier(record: UsageRecord): string | null {
return normalizeServiceTier(record.service_tier)
}
function getActualServiceTier(record: UsageRecord): string | null {
return normalizeServiceTier(record.actual_service_tier)
}
function getFastBadge(record: UsageRecord): boolean {
return getServiceTier(record) === 'priority'
return (getActualServiceTier(record) ?? getRequestedServiceTier(record)) === 'priority'
}
function getFastBadgeTitle(record: UsageRecord): string {
const serviceTier = getServiceTier(record)
return serviceTier ? `Service tier: ${serviceTier}` : ''
const requested = getRequestedServiceTier(record)
const actual = getActualServiceTier(record)
if (requested && actual) {
return requested === actual
? `Requested and actual service tier: ${actual}`
: `Requested service tier: ${requested}\nActual service tier: ${actual}`
}
if (actual) return `Actual service tier: ${actual}`
return requested ? `Requested service tier: ${requested}` : ''
}
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const fastSuffix = getFastBadge(record) ? '\nService tier: priority' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${fastSuffix}`
const serviceTierTitle = getFastBadgeTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, type App } from 'vue'
import ServiceTierFacts from '../ServiceTierFacts.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('ServiceTierFacts', () => {
it('renders all three facts and marks a missing actual tier explicitly', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ServiceTierFacts, {
requested: 'priority',
actual: null,
billing: 'flex',
}),
})
app.mount(root)
mountedApps.push({ app, root })
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([
'priority',
'-',
'flex',
])
})
})
@@ -295,6 +295,22 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('fast')
})
it('uses the actual service tier when the provider changes processing class', () => {
const downgraded = mountUsageRecordsTable([buildRecord({
service_tier: 'priority',
actual_service_tier: 'default',
})])
expect(downgraded.textContent).not.toContain('fast')
expect(downgraded.querySelector('[title*="Requested service tier: priority"]')).not.toBeNull()
expect(downgraded.querySelector('[title*="Actual service tier: default"]')).not.toBeNull()
const upgraded = mountUsageRecordsTable([buildRecord({
service_tier: 'flex',
actual_service_tier: 'priority',
})])
expect(upgraded.textContent).toContain('fast')
})
it('offers embedding API formats in the usage record filter', () => {
const root = mountUsageRecordsTable([buildRecord({ api_format: 'openai:chat' })])
@@ -218,6 +218,7 @@ describe('useUsageData', () => {
target_model: 'gpt-5.5',
reasoning_effort: 'xhigh',
service_tier: 'auto',
actual_service_tier: 'priority',
})
getAllUsageRecordsMock.mockResolvedValueOnce({
@@ -246,6 +247,7 @@ describe('useUsageData', () => {
target_model: null,
reasoning_effort: null,
service_tier: null,
actual_service_tier: null,
})],
total: 1,
limit: 20,
@@ -279,6 +281,7 @@ describe('useUsageData', () => {
target_model: 'gpt-5.5',
reasoning_effort: 'xhigh',
service_tier: 'auto',
actual_service_tier: 'priority',
})
})
@@ -631,7 +631,8 @@ export function useUsageData(options: UseUsageDataOptions) {
rate_multiplier: record.rate_multiplier ?? existing.rate_multiplier,
target_model: record.target_model ?? existing.target_model,
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
service_tier: record.service_tier ?? existing.service_tier
service_tier: record.service_tier ?? existing.service_tier,
actual_service_tier: record.actual_service_tier ?? existing.actual_service_tier
}
})
}
+1
View File
@@ -98,6 +98,7 @@ export interface UsageRecord {
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
actual_service_tier?: string | null // Provider 响应确认的实际服务层级
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
hasServiceTierFact,
normalizeServiceTierFact,
resolveServiceTierFacts,
} from '../service-tier'
describe('service tier facts', () => {
it('keeps requested, actual and billing tiers independent', () => {
const facts = resolveServiceTierFacts({
service_tier: 'priority',
actual_service_tier: 'default',
settlement: {
settlement_snapshot: {
pricing_snapshot: {
requested_processing_tier: 'ignored-requested-snapshot',
actual_processing_tier: 'ignored-actual-snapshot',
billing_processing_tier: 'standard',
},
},
},
})
expect(facts).toEqual({ requested: 'priority', actual: 'default', billing: 'standard' })
expect(hasServiceTierFact(facts)).toBe(true)
})
it('does not infer billing from requested or actual tiers', () => {
expect(resolveServiceTierFacts({
service_tier: 'priority',
actual_service_tier: 'flex',
})).toEqual({ requested: 'priority', actual: 'flex', billing: null })
})
it('normalizes only non-empty string facts', () => {
expect(normalizeServiceTierFact(' Batch ')).toBe('Batch')
expect(normalizeServiceTierFact(' ')).toBeNull()
expect(normalizeServiceTierFact(0)).toBeNull()
})
})
@@ -0,0 +1,40 @@
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)
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
}
export function normalizeServiceTierFact(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized || null
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}