mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑
- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token - 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口 - billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费 - usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性 - 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出 - executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义 - provider-transport auth 层新增 complete passthrough headers 构建逻辑 - 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
This commit is contained in:
@@ -128,6 +128,7 @@ export interface RequestDetail {
|
||||
}
|
||||
provider: string
|
||||
api_format?: string
|
||||
endpoint_api_format?: string
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
tokens: {
|
||||
@@ -142,6 +143,7 @@ export interface RequestDetail {
|
||||
}
|
||||
// Additional token fields
|
||||
input_tokens?: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UsageRecordDetail {
|
||||
provider: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cost: number // 官方费率
|
||||
@@ -80,9 +81,12 @@ export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_input_context?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||
@@ -92,8 +96,12 @@ export interface ModelSummary {
|
||||
export interface ProviderSummary {
|
||||
provider: string
|
||||
requests: number
|
||||
effective_input_tokens?: number
|
||||
total_tokens: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_input_context?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost_usd: number
|
||||
success_rate: number | null
|
||||
@@ -104,8 +112,12 @@ export interface ProviderSummary {
|
||||
export interface ApiFormatSummary {
|
||||
api_format: string
|
||||
request_count: number
|
||||
effective_input_tokens?: number
|
||||
total_tokens: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_tokens?: number
|
||||
total_input_context?: number
|
||||
cache_hit_rate: number
|
||||
total_cost_usd: number
|
||||
avg_response_time_ms: number
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface UsageRecord {
|
||||
provider_name?: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
@@ -38,6 +39,10 @@ export interface UsageByModel {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_cost: number
|
||||
avg_response_time?: number
|
||||
cache_read_tokens?: number
|
||||
@@ -58,6 +63,10 @@ export interface UsageByProvider {
|
||||
provider: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
@@ -71,6 +80,10 @@ export interface UsageByApiFormat {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
@@ -276,6 +289,7 @@ export const usageApi = {
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number | null
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
<!-- 阶梯标题 -->
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-foreground">Token 计费</span>
|
||||
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
||||
<span class="text-muted-foreground/60">(输入 {{ formatNumber(displayInputTokens) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
||||
<Badge
|
||||
v-if="displayTiers.length > 1"
|
||||
variant="outline"
|
||||
@@ -260,7 +260,7 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center flex-1">
|
||||
<span class="text-xs text-muted-foreground w-[56px]">输入</span>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.input || detail.input_tokens || 0 }}</span>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ displayInputTokens }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
|
||||
</div>
|
||||
<Separator
|
||||
@@ -701,6 +701,7 @@ import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatShortRequestId } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
@@ -774,6 +775,16 @@ let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
|
||||
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
|
||||
const displayInputTokens = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
return getEffectiveInputTokens({
|
||||
effective_input_tokens: detail.value.effective_input_tokens,
|
||||
input_tokens: detail.value.input_tokens ?? detail.value.tokens?.input,
|
||||
cache_read_input_tokens: detail.value.cache_read_input_tokens,
|
||||
api_format: detail.value.api_format,
|
||||
endpoint_api_format: detail.value.endpoint_api_format,
|
||||
})
|
||||
})
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(item.total_input_context || 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(item.effective_input_tokens ?? item.total_input_context ?? 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens((item.cache_read_tokens || 0) + (item.cache_creation_tokens || 0)) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(model.total_input_context || 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(model.effective_input_tokens ?? model.total_input_context ?? 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(provider.totalInputContext || 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
|
||||
<span>{{ formatTokens(provider.effectiveInputTokens ?? provider.totalInputContext ?? 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens((provider.cacheReadTokens || 0) + (provider.cacheCreationTokens || 0)) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
>-</span>
|
||||
<span class="text-muted-foreground/50">|</span>
|
||||
<!-- Tokens -->
|
||||
<span>{{ formatTokens(record.input_tokens || 0) }}/{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(getRecordEffectiveInputTokens(record)) }}/{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,7 +552,7 @@
|
||||
<TableCell class="text-right py-4 w-[140px]">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ formatTokens(record.input_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(getRecordEffectiveInputTokens(record)) }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
</div>
|
||||
@@ -677,6 +677,7 @@ import {
|
||||
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { DateRangeParams, UsageRecord } from '../types'
|
||||
@@ -777,6 +778,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
||||
emit('showDetail', id)
|
||||
}
|
||||
|
||||
function getRecordEffectiveInputTokens(record: UsageRecord): number {
|
||||
return getEffectiveInputTokens(record)
|
||||
}
|
||||
|
||||
// useDebounceFn 自动处理清理,无需 onUnmounted
|
||||
|
||||
// 判断是否应该显示格式转换信息
|
||||
|
||||
@@ -110,6 +110,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
model: item.model,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: typeof raw.effective_input_tokens === 'number' ? raw.effective_input_tokens : 0,
|
||||
total_input_context: typeof raw.total_input_context === 'number' ? raw.total_input_context : 0,
|
||||
output_tokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : 0,
|
||||
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
|
||||
@@ -129,6 +130,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
provider: item.provider,
|
||||
requests: item.request_count,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
effectiveInputTokens: item.effective_input_tokens || 0,
|
||||
totalInputContext: item.total_input_context || 0,
|
||||
outputTokens: item.output_tokens || 0,
|
||||
cacheReadTokens: item.cache_read_tokens || 0,
|
||||
@@ -151,6 +153,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: item.effective_input_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
@@ -188,6 +191,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
model: item.model,
|
||||
request_count: item.requests || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: item.effective_input_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
@@ -201,6 +205,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
provider: item.provider,
|
||||
requests: item.requests || 0,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
effectiveInputTokens: item.effective_input_tokens || 0,
|
||||
totalInputContext: item.total_input_context || 0,
|
||||
outputTokens: item.output_tokens || 0,
|
||||
cacheReadTokens: item.cache_read_tokens || 0,
|
||||
@@ -234,6 +239,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: item.effective_input_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
@@ -384,6 +390,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
input_tokens: existing.input_tokens || record.input_tokens,
|
||||
effective_input_tokens: existing.effective_input_tokens ?? record.effective_input_tokens,
|
||||
output_tokens: existing.output_tokens || record.output_tokens,
|
||||
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
||||
|
||||
40
frontend/src/features/usage/token-normalization.ts
Normal file
40
frontend/src/features/usage/token-normalization.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
type UsageTokenLike = {
|
||||
effective_input_tokens?: number | null
|
||||
input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
}
|
||||
|
||||
function toNonNegativeNumber(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(value, 0) : 0
|
||||
}
|
||||
|
||||
function apiFamily(apiFormat: string | null | undefined): string {
|
||||
return String(apiFormat || '')
|
||||
.split(':', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function getEffectiveInputTokens(usage: UsageTokenLike): number {
|
||||
const explicit = toNonNegativeNumber(usage.effective_input_tokens)
|
||||
if (explicit > 0) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const inputTokens = toNonNegativeNumber(usage.input_tokens)
|
||||
const cacheReadTokens = toNonNegativeNumber(usage.cache_read_input_tokens)
|
||||
if (inputTokens === 0 || cacheReadTokens === 0) {
|
||||
return inputTokens
|
||||
}
|
||||
|
||||
switch (apiFamily(usage.endpoint_api_format || usage.api_format)) {
|
||||
case 'openai':
|
||||
case 'gemini':
|
||||
case 'google':
|
||||
return Math.max(inputTokens - cacheReadTokens, 0)
|
||||
default:
|
||||
return inputTokens
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface ModelStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
@@ -41,6 +42,7 @@ export interface ProviderStatsItem {
|
||||
provider: string
|
||||
requests: number
|
||||
totalTokens: number
|
||||
effectiveInputTokens?: number
|
||||
totalInputContext?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
@@ -57,6 +59,7 @@ export interface ApiFormatStatsItem {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
@@ -92,6 +95,7 @@ export interface UsageRecord {
|
||||
endpoint_api_format?: string // 端点原生格式
|
||||
has_format_conversion?: boolean // 是否发生了格式转换
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
|
||||
@@ -391,6 +391,7 @@ async function pollActiveRequests() {
|
||||
if (shouldApply) {
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
record.effective_input_tokens = update.effective_input_tokens ?? record.effective_input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||||
|
||||
Reference in New Issue
Block a user