mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts: # frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
This commit is contained in:
@@ -250,11 +250,12 @@
|
||||
</span>
|
||||
</div>
|
||||
<ServiceTierFacts
|
||||
v-if="hasServiceTierFacts"
|
||||
v-if="hasServiceTierFacts || processingTierPriceMultiplier !== null"
|
||||
class="mt-3"
|
||||
:requested="serviceTierFacts.requested"
|
||||
:actual="serviceTierFacts.actual"
|
||||
:billing="serviceTierFacts.billing"
|
||||
:price-multiplier="processingTierPriceMultiplier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -313,13 +314,13 @@
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground sm:hidden">
|
||||
<div class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1">
|
||||
<span>输入</span>
|
||||
<span class="text-right">${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span class="text-right">{{ formatPricePerMillion(tier.input_price_per_1m) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1"
|
||||
>
|
||||
<span>输出</span>
|
||||
<span class="text-right">${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<span class="text-right">{{ formatPricePerMillion(tier.output_price_per_1m) }}</span>
|
||||
</div>
|
||||
<template v-if="getTierActiveCacheCreationDisplay(tier) || shouldShowCacheReadPrice(tier)">
|
||||
<div
|
||||
@@ -341,8 +342,8 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-muted-foreground hidden items-center gap-2 flex-wrap sm:flex">
|
||||
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<span>输入 {{ formatPricePerMillion(tier.input_price_per_1m) }}</span>
|
||||
<span>输出 {{ formatPricePerMillion(tier.output_price_per_1m) }}</span>
|
||||
<span v-if="getTierActiveCacheCreationDisplay(tier)">
|
||||
{{ getTierActiveCacheCreationDisplay(tier)?.label }}
|
||||
${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
|
||||
@@ -906,6 +907,12 @@ import {
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
resolveProcessingTierPriceMultiplier,
|
||||
resolveSettlementPricingSourceLabel,
|
||||
resolveSettlementPricingTiers,
|
||||
} from '../utils/settlement-pricing'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
@@ -1355,6 +1362,9 @@ const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
|
||||
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
|
||||
const processingTierPriceMultiplier = computed(() => (
|
||||
resolveProcessingTierPriceMultiplier(detail.value)
|
||||
))
|
||||
|
||||
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||
asRecord(detail.value?.settlement ?? null),
|
||||
@@ -1643,20 +1653,10 @@ const hasValidConversation = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
// 价格来源标签
|
||||
// tiered_pricing.source 表示定价来源: 'provider' 或 'global'
|
||||
// 价格来源优先使用 v3 结算快照,旧 tiered_pricing.source 仅作回退。
|
||||
const priceSourceLabel = computed(() => {
|
||||
if (!detail.value) return '历史定价'
|
||||
|
||||
const source = detail.value.tiered_pricing?.source
|
||||
if (source === 'provider') {
|
||||
return '提供商定价'
|
||||
} else if (source === 'global') {
|
||||
return '全局定价'
|
||||
}
|
||||
|
||||
// 没有 tiered_pricing 时,使用历史价格
|
||||
return '历史定价'
|
||||
return resolveSettlementPricingSourceLabel(detail.value) ?? '历史定价'
|
||||
})
|
||||
|
||||
const cacheCreationInputTokens5m = computed(() => {
|
||||
@@ -1896,10 +1896,9 @@ const activeCacheTtlMinutes = computed(() => {
|
||||
const displayTiers = computed(() => {
|
||||
if (!detail.value) return []
|
||||
|
||||
// 如果有阶梯定价数据,直接使用
|
||||
if (detail.value.tiered_pricing?.tiers && detail.value.tiered_pricing.tiers.length > 0) {
|
||||
return detail.value.tiered_pricing.tiers
|
||||
}
|
||||
// 优先展示结算快照中已解析的价格目录,旧字段仅作回退。
|
||||
const resolvedTiers = resolveSettlementPricingTiers(detail.value)
|
||||
if (resolvedTiers) return resolvedTiers as PricingTierLike[]
|
||||
|
||||
// 否则用历史价格构建单阶梯(无上限)
|
||||
return [{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs"
|
||||
:class="hasPriceMultiplier ? 'sm:grid-cols-4' : 'sm:grid-cols-3'"
|
||||
data-testid="service-tier-facts"
|
||||
>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
@@ -9,9 +10,9 @@
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="requested || '-'"
|
||||
:title="formatServiceTierFact(requested) || '-'"
|
||||
>
|
||||
{{ requested || '-' }}
|
||||
{{ formatServiceTierFact(requested) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
@@ -20,9 +21,9 @@
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="actual || '-'"
|
||||
:title="formatServiceTierFact(actual) || '-'"
|
||||
>
|
||||
{{ actual || '-' }}
|
||||
{{ formatServiceTierFact(actual) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
@@ -31,18 +32,48 @@
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="billing || '-'"
|
||||
:title="formatServiceTierFact(billing) || '-'"
|
||||
>
|
||||
{{ billing || '-' }}
|
||||
{{ formatServiceTierFact(billing) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasPriceMultiplier"
|
||||
class="flex min-w-0 items-baseline justify-between gap-3 sm:block"
|
||||
data-testid="service-tier-price-multiplier"
|
||||
>
|
||||
<dt class="text-muted-foreground">
|
||||
{{ multiplierTierLabel }} 倍率
|
||||
</dt>
|
||||
<dd class="truncate font-mono font-medium text-foreground sm:mt-0.5">
|
||||
{{ formattedPriceMultiplier }}×
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
const props = defineProps<{
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
priceMultiplier?: number | null
|
||||
}>()
|
||||
|
||||
const hasPriceMultiplier = computed(() => (
|
||||
typeof props.priceMultiplier === 'number'
|
||||
&& Number.isFinite(props.priceMultiplier)
|
||||
&& props.priceMultiplier >= 0
|
||||
))
|
||||
|
||||
const multiplierTierLabel = computed(() => (
|
||||
formatServiceTierFact(props.billing ?? props.actual ?? props.requested) ?? '处理层级'
|
||||
))
|
||||
|
||||
const formattedPriceMultiplier = computed(() => (
|
||||
hasPriceMultiplier.value ? String(props.priceMultiplier) : ''
|
||||
))
|
||||
</script>
|
||||
|
||||
@@ -1129,6 +1129,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
||||
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 type { DateRangeParams, UsageRecord } from '../types'
|
||||
import { MultiSelect, TimeRangePicker } from '@/components/common'
|
||||
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||
@@ -1663,6 +1664,9 @@ function canonicalServiceTier(value: string | null): string | null {
|
||||
if (value === 'auto' || value === 'default' || value === 'standard') {
|
||||
return 'standard'
|
||||
}
|
||||
if (value === 'fast') {
|
||||
return 'priority'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -1689,10 +1693,13 @@ function buildServiceTierBadgePresentation(
|
||||
billingTier: string | null,
|
||||
): ServiceTierBadgePresentation {
|
||||
const titleLines: string[] = []
|
||||
if (requestedRaw) titleLines.push(`请求档位:${requestedRaw}`)
|
||||
if (actualRaw) titleLines.push(`实际档位:${actualRaw}`)
|
||||
if (billingTier) {
|
||||
titleLines.push(`计费档位:${billingTier}`)
|
||||
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' ? '待上游确认' : '未确认'}`)
|
||||
}
|
||||
@@ -1717,7 +1724,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
if (actual) {
|
||||
if (requestedFast && !actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
`fast → ${actual}`,
|
||||
`Fast → ${actual}`,
|
||||
'downgraded',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
@@ -1727,7 +1734,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
if (!requestedFast && actualFast) {
|
||||
const requestedLabel = requested ?? 'standard'
|
||||
return buildServiceTierBadgePresentation(
|
||||
requested ? `${requestedLabel} → fast` : 'fast',
|
||||
requested ? `${requestedLabel} → Fast` : 'Fast',
|
||||
requested ? 'upgraded' : 'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
@@ -1736,7 +1743,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
}
|
||||
if (actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
'fast',
|
||||
'Fast',
|
||||
'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
@@ -1750,7 +1757,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
const displayStatus = getDisplayStatus(record)
|
||||
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
|
||||
return buildServiceTierBadgePresentation(
|
||||
isActive ? 'fast · 待确认' : 'fast · 未确认',
|
||||
isActive ? 'Fast · 待确认' : 'Fast · 未确认',
|
||||
isActive ? 'pending' : 'unconfirmed',
|
||||
requestedRaw,
|
||||
null,
|
||||
@@ -1762,8 +1769,8 @@ function getServiceTierTitle(record: UsageRecord): string {
|
||||
const badge = getServiceTierBadge(record)
|
||||
if (badge) return badge.title
|
||||
|
||||
const requested = normalizeServiceTier(record.service_tier)
|
||||
const actual = normalizeServiceTier(record.actual_service_tier)
|
||||
const requested = formatServiceTierFact(record.service_tier)
|
||||
const actual = formatServiceTierFact(record.actual_service_tier)
|
||||
return [
|
||||
requested ? `请求档位:${requested}` : null,
|
||||
actual ? `实际档位:${actual}` : null,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
|
||||
|
||||
import type { RequestDetail } from '@/api/dashboard'
|
||||
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getRequestDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/dashboard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/api/dashboard')>()
|
||||
return {
|
||||
...actual,
|
||||
dashboardApi: {
|
||||
...actual.dashboardApi,
|
||||
getRequestDetail: apiMocks.getRequestDetail,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
apiMocks.getRequestDetail.mockReset()
|
||||
})
|
||||
|
||||
function buildEmbeddingDetail(): RequestDetail {
|
||||
return {
|
||||
id: 'usage-embedding-1',
|
||||
request_id: 'req-embedding-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'embedding-user',
|
||||
email: '[email protected]',
|
||||
},
|
||||
api_key: {
|
||||
id: 'key-1',
|
||||
name: 'test-key',
|
||||
display: 'test-key',
|
||||
},
|
||||
provider: 'embedding-provider',
|
||||
model: 'embedding-model',
|
||||
tokens: { input: 100, output: 0, total: 100 },
|
||||
cost: { input: 0.00001, output: 0, total: 0.00001 },
|
||||
request_type: 'embedding',
|
||||
is_stream: false,
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
response_time_ms: 10,
|
||||
created_at: '2026-07-16T00:00:00Z',
|
||||
request_headers: { 'content-type': 'application/json' },
|
||||
settlement: {
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
pricing_source: 'global_default',
|
||||
tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 0.1 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('RequestDetailDrawer settlement pricing', () => {
|
||||
it('renders an input-only embedding tier without treating the missing output price as zero', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue(buildEmbeddingDetail())
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-embedding-1',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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.textContent).toContain('输入 $0.1/M')
|
||||
expect(document.body.textContent).toContain('输出 -')
|
||||
})
|
||||
expect(document.body.textContent).not.toContain('输出 $0/M')
|
||||
})
|
||||
})
|
||||
@@ -33,9 +33,75 @@ describe('ServiceTierFacts', () => {
|
||||
'计费层级',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'priority',
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the same Fast label for raw priority and fast facts', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'priority',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
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',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the processing-tier multiplier with the billing tier label', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'fast',
|
||||
priceMultiplier: 2.5,
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
const multiplier = root.querySelector('[data-testid="service-tier-price-multiplier"]')
|
||||
expect(multiplier?.textContent).toContain('Fast 倍率')
|
||||
expect(multiplier?.textContent).toContain('2.5×')
|
||||
})
|
||||
|
||||
it('does not render an empty or invalid processing-tier multiplier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: null,
|
||||
priceMultiplier: null,
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
expect(root.querySelector('[data-testid="service-tier-price-multiplier"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,11 +162,12 @@ function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string
|
||||
return root
|
||||
}
|
||||
|
||||
function expectServiceTierBadge(root: HTMLElement, label: string) {
|
||||
const labels = [...root.querySelectorAll('span')]
|
||||
.map((element) => element.textContent?.trim())
|
||||
function expectServiceTierBadge(root: HTMLElement, label: string): HTMLElement {
|
||||
const badge = [...root.querySelectorAll<HTMLElement>('span')]
|
||||
.find(element => element.textContent?.trim() === label)
|
||||
|
||||
expect(labels).toContain(label)
|
||||
expect(badge).toBeDefined()
|
||||
return badge as HTMLElement
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -313,13 +314,26 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.textContent).toContain('会话压缩')
|
||||
})
|
||||
|
||||
it('shows confirmed fast when requested and actual service tiers are priority', () => {
|
||||
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: 'priority',
|
||||
actual_service_tier: 'priority',
|
||||
service_tier: requested,
|
||||
actual_service_tier: actual,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast')
|
||||
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', () => {
|
||||
@@ -328,7 +342,12 @@ describe('UsageRecordsTable', () => {
|
||||
actual_service_tier: 'default',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast → standard')
|
||||
const badge = expectServiceTierBadge(root, 'Fast → standard')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:default',
|
||||
'计费档位:standard',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('shows fast to flex when the provider moves a priority request to flex', () => {
|
||||
@@ -337,7 +356,7 @@ describe('UsageRecordsTable', () => {
|
||||
actual_service_tier: 'flex',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast → flex')
|
||||
expectServiceTierBadge(root, 'Fast → flex')
|
||||
})
|
||||
|
||||
it('shows standard to fast when the provider upgrades a default request', () => {
|
||||
@@ -346,7 +365,7 @@ describe('UsageRecordsTable', () => {
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'standard → fast')
|
||||
expectServiceTierBadge(root, 'standard → Fast')
|
||||
})
|
||||
|
||||
it.each(['pending', 'streaming'] as const)(
|
||||
@@ -358,7 +377,7 @@ describe('UsageRecordsTable', () => {
|
||||
status,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast · 待确认')
|
||||
expectServiceTierBadge(root, 'Fast · 待确认')
|
||||
},
|
||||
)
|
||||
|
||||
@@ -369,7 +388,7 @@ describe('UsageRecordsTable', () => {
|
||||
status: 'completed',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast · 未确认')
|
||||
expectServiceTierBadge(root, 'Fast · 未确认')
|
||||
})
|
||||
|
||||
it('offers embedding API formats in the usage record filter', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatServiceTierFact,
|
||||
hasServiceTierFact,
|
||||
normalizeServiceTierFact,
|
||||
resolveServiceTierFacts,
|
||||
@@ -38,4 +39,16 @@ describe('service tier facts', () => {
|
||||
expect(normalizeServiceTierFact(' ')).toBeNull()
|
||||
expect(normalizeServiceTierFact(0)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
|
||||
'displays the raw %s tier as Fast',
|
||||
(tier) => {
|
||||
expect(formatServiceTierFact(tier)).toBe('Fast')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps non-fast tier labels unchanged', () => {
|
||||
expect(formatServiceTierFact(' Batch ')).toBe('Batch')
|
||||
expect(formatServiceTierFact(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
resolveProcessingTierPriceMultiplier,
|
||||
resolveSettlementPricingSnapshot,
|
||||
resolveSettlementPricingSourceLabel,
|
||||
resolveSettlementPricingTiers,
|
||||
} from '../settlement-pricing'
|
||||
|
||||
function buildSource(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
settlement: {
|
||||
rate_multiplier: 9,
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
billing_processing_tier: 'priority',
|
||||
pricing_source: 'provider_override',
|
||||
tiered_pricing_source: 'global_default',
|
||||
processing_tier_price_multiplier: 2.5,
|
||||
tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
},
|
||||
tiered_pricing: {
|
||||
source: 'global',
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1 }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('settlement pricing presentation', () => {
|
||||
it('reads the resolved pricing snapshot and prefers its catalog over legacy tiers', () => {
|
||||
const source = buildSource()
|
||||
|
||||
expect(resolveSettlementPricingSnapshot(source)?.billing_processing_tier).toBe('priority')
|
||||
expect(resolveSettlementPricingTiers(source)).toEqual([
|
||||
{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 },
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['provider_override', '提供商定价'],
|
||||
['global_default', '全局定价'],
|
||||
['mixed', '混合定价'],
|
||||
])('maps the resolved %s pricing source', (pricingSource, label) => {
|
||||
expect(resolveSettlementPricingSourceLabel(buildSource({
|
||||
pricing_source: pricingSource,
|
||||
}))).toBe(label)
|
||||
})
|
||||
|
||||
it('falls back from pricing_source to tiered_pricing_source and then legacy source', () => {
|
||||
expect(resolveSettlementPricingSourceLabel(buildSource({
|
||||
pricing_source: null,
|
||||
tiered_pricing_source: 'global_default',
|
||||
}))).toBe('全局定价')
|
||||
|
||||
expect(resolveSettlementPricingSourceLabel({
|
||||
tiered_pricing: { source: 'provider' },
|
||||
})).toBe('提供商定价')
|
||||
})
|
||||
|
||||
it('uses only processing_tier_price_multiplier, never settlement.rate_multiplier', () => {
|
||||
expect(resolveProcessingTierPriceMultiplier(buildSource())).toBe(2.5)
|
||||
expect(resolveProcessingTierPriceMultiplier(buildSource({
|
||||
processing_tier_price_multiplier: null,
|
||||
}))).toBeNull()
|
||||
expect(resolveProcessingTierPriceMultiplier({
|
||||
settlement: { rate_multiplier: 9 },
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to legacy tiers when the resolved snapshot has no catalog', () => {
|
||||
expect(resolveSettlementPricingTiers(buildSource({ tiered_pricing: null }))).toEqual([
|
||||
{ up_to: null, input_price_per_1m: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('formats an input-only embedding tier without inventing an output price', () => {
|
||||
const tiers = resolveSettlementPricingTiers(buildSource({
|
||||
tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 0.1 }],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(formatPricePerMillion(tiers?.[0]?.input_price_per_1m)).toBe('$0.1/M')
|
||||
expect(formatPricePerMillion(tiers?.[0]?.output_price_per_1m)).toBe('-')
|
||||
expect(formatPricePerMillion(null)).toBe('-')
|
||||
expect(formatPricePerMillion(0)).toBe('$0/M')
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,21 @@ export function normalizeServiceTierFact(value: unknown): string | null {
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider contracts use both `priority` (OpenAI) and `fast` (Claude) for the
|
||||
* same user-facing processing mode. Keep the raw fact in data structures and
|
||||
* normalize only at the presentation boundary.
|
||||
*/
|
||||
export function formatServiceTierFact(value: unknown): string | null {
|
||||
const normalized = normalizeServiceTierFact(value)
|
||||
if (normalized === null) return null
|
||||
|
||||
const canonical = normalized.toLowerCase()
|
||||
return canonical === 'priority' || canonical === 'fast'
|
||||
? 'Fast'
|
||||
: normalized
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
interface SettlementPricingSource {
|
||||
settlement?: unknown
|
||||
tiered_pricing?: unknown
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const PRICING_SOURCE_LABELS: Record<string, string> = {
|
||||
provider_override: '提供商定价',
|
||||
global_default: '全局定价',
|
||||
mixed: '混合定价',
|
||||
provider: '提供商定价',
|
||||
global: '全局定价',
|
||||
unpriced: '未定价',
|
||||
}
|
||||
|
||||
export function resolveSettlementPricingSnapshot(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): JsonRecord | null {
|
||||
const settlement = asRecord(source?.settlement)
|
||||
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
|
||||
return asRecord(settlementSnapshot?.pricing_snapshot)
|
||||
}
|
||||
|
||||
export function resolveSettlementPricingSourceLabel(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): string | null {
|
||||
const snapshot = resolveSettlementPricingSnapshot(source)
|
||||
const legacyPricing = asRecord(source?.tiered_pricing)
|
||||
for (const value of [
|
||||
snapshot?.pricing_source,
|
||||
snapshot?.tiered_pricing_source,
|
||||
legacyPricing?.source,
|
||||
]) {
|
||||
const key = normalizeString(value)?.toLowerCase()
|
||||
if (key && PRICING_SOURCE_LABELS[key]) return PRICING_SOURCE_LABELS[key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveSettlementPricingTiers(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): JsonRecord[] | null {
|
||||
const snapshot = resolveSettlementPricingSnapshot(source)
|
||||
const snapshotPricing = asRecord(snapshot?.tiered_pricing)
|
||||
const snapshotTiers = nonEmptyRecordArray(snapshotPricing?.tiers)
|
||||
if (snapshotTiers) return snapshotTiers
|
||||
|
||||
const legacyPricing = asRecord(source?.tiered_pricing)
|
||||
return nonEmptyRecordArray(legacyPricing?.tiers)
|
||||
}
|
||||
|
||||
export function resolveProcessingTierPriceMultiplier(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): number | null {
|
||||
const value = resolveSettlementPricingSnapshot(source)?.processing_tier_price_multiplier
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
export function formatPricePerMillion(value: unknown): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '-'
|
||||
|
||||
const fixed = value.toFixed(4)
|
||||
return `$${Number.parseFloat(fixed).toString()}/M`
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function nonEmptyRecordArray(value: unknown): JsonRecord[] | null {
|
||||
if (!Array.isArray(value) || value.length === 0) return null
|
||||
const records = value.filter((item): item is JsonRecord => asRecord(item) !== null)
|
||||
return records.length > 0 ? records : null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonRecord
|
||||
: null
|
||||
}
|
||||
Reference in New Issue
Block a user