feat(usage): 修复缓存命中率计算并新增用户端 API 格式统计

- 新增 input_context_expr() 按 api_format 区分 input_tokens 语义
  (OpenAI/Gemini input_tokens 已含 cache_read,Claude 需额外加上)
- 缓存命中率统一改为基于归一化后的 total_input_context 计算
- 用户 /me/usage 接口新增 summary_by_api_format 后端聚合字段
- 前端 API 格式统计改用后端聚合数据,移除前端逐条记录手动统计
- 提取 formatHitRate 到 utils/format.ts 消除三处重复定义
- 移除 PoolManager 中未使用的 select_key 方法

Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-03-14 00:28:28 +08:00
parent e9678ea899
commit bdfe4adc98
14 changed files with 281 additions and 247 deletions

View File

@@ -79,6 +79,8 @@ export interface ModelSummary {
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
total_tokens: number total_tokens: number
cache_read_tokens?: number
cache_hit_rate?: number
total_cost_usd: number total_cost_usd: number
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见) actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
} }
@@ -88,11 +90,24 @@ export interface ProviderSummary {
provider: string provider: string
requests: number requests: number
total_tokens: number total_tokens: number
cache_read_tokens?: number
cache_hit_rate?: number
total_cost_usd: number total_cost_usd: number
success_rate: number | null success_rate: number | null
avg_response_time_ms: number | null avg_response_time_ms: number | null
} }
// API 格式统计接口
export interface ApiFormatSummary {
api_format: string
request_count: number
total_tokens: number
cache_read_tokens: number
cache_hit_rate: number
total_cost_usd: number
avg_response_time_ms: number
}
// 使用统计响应接口 // 使用统计响应接口
export interface UsageResponse { export interface UsageResponse {
total_requests: number total_requests: number
@@ -105,6 +120,7 @@ export interface UsageResponse {
billing: BillingSummary billing: BillingSummary
summary_by_model: ModelSummary[] summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[] summary_by_provider?: ProviderSummary[]
summary_by_api_format?: ApiFormatSummary[]
pagination?: { pagination?: {
total: number total: number
limit: number limit: number

View File

@@ -40,6 +40,8 @@ export interface UsageByModel {
total_tokens: number total_tokens: number
total_cost: number total_cost: number
avg_response_time?: number avg_response_time?: number
cache_read_tokens?: number
cache_hit_rate?: number
} }
export interface UsageByUser { export interface UsageByUser {
@@ -61,6 +63,8 @@ export interface UsageByProvider {
avg_response_time_ms: number avg_response_time_ms: number
success_rate: number success_rate: number
error_count: number error_count: number
cache_read_tokens?: number
cache_hit_rate?: number
} }
export interface UsageByApiFormat { export interface UsageByApiFormat {
@@ -70,6 +74,8 @@ export interface UsageByApiFormat {
total_cost: number total_cost: number
actual_cost: number actual_cost: number
avg_response_time_ms: number avg_response_time_ms: number
cache_read_tokens?: number
cache_hit_rate?: number
} }
export interface UsageFilters { export interface UsageFilters {

View File

@@ -21,6 +21,12 @@
<TableHead class="h-8 px-2 text-right"> <TableHead class="h-8 px-2 text-right">
费用 费用
</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">
缓存Token
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
</TableHead>
<TableHead class="h-8 px-2 text-right"> <TableHead class="h-8 px-2 text-right">
平均响应 平均响应
</TableHead> </TableHead>
@@ -29,7 +35,7 @@
<TableBody> <TableBody>
<TableRow v-if="data.length === 0"> <TableRow v-if="data.length === 0">
<TableCell <TableCell
:colspan="5" :colspan="7"
class="text-center py-6 text-muted-foreground px-2" class="text-center py-6 text-muted-foreground px-2"
> >
暂无API格式统计数据 暂无API格式统计数据
@@ -59,6 +65,12 @@
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2">
{{ formatTokens(item.cache_read_tokens || 0) }}
</TableCell>
<TableCell class="text-right py-2 px-2 text-muted-foreground">
{{ formatHitRate(item.cache_hit_rate) }}
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2"> <TableCell class="text-right text-muted-foreground py-2 px-2">
{{ item.avgResponseTime }} {{ item.avgResponseTime }}
</TableCell> </TableCell>
@@ -77,7 +89,7 @@ import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue' import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue' import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue' import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
import { formatApiFormat } from '@/api/endpoints/types/api-format' import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ApiFormatStatsItem } from '../types' import type { ApiFormatStatsItem } from '../types'
@@ -85,5 +97,4 @@ defineProps<{
data: ApiFormatStatsItem[] data: ApiFormatStatsItem[]
isAdmin: boolean isAdmin: boolean
}>() }>()
</script> </script>

View File

@@ -21,6 +21,12 @@
<TableHead class="h-8 px-2 text-right"> <TableHead class="h-8 px-2 text-right">
费用 费用
</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">
缓存Token
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
</TableHead>
<TableHead class="h-8 px-2 text-right"> <TableHead class="h-8 px-2 text-right">
效率 效率
</TableHead> </TableHead>
@@ -29,7 +35,7 @@
<TableBody> <TableBody>
<TableRow v-if="data.length === 0"> <TableRow v-if="data.length === 0">
<TableCell <TableCell
:colspan="5" :colspan="7"
class="text-center py-6 text-muted-foreground px-2" class="text-center py-6 text-muted-foreground px-2"
> >
暂无模型统计数据 暂无模型统计数据
@@ -59,6 +65,12 @@
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2">
{{ formatTokens(model.cache_read_tokens || 0) }}
</TableCell>
<TableCell class="text-right py-2 px-2 text-muted-foreground">
{{ formatHitRate(model.cache_hit_rate) }}
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2"> <TableCell class="text-right text-muted-foreground py-2 px-2">
{{ model.costPerToken }} {{ model.costPerToken }}
</TableCell> </TableCell>
@@ -77,7 +89,7 @@ import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue' import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue' import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue' import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
import type { EnhancedModelStatsItem } from '../types' import type { EnhancedModelStatsItem } from '../types'
defineProps<{ defineProps<{

View File

@@ -21,6 +21,12 @@
<TableHead class="h-8 px-2 text-right"> <TableHead class="h-8 px-2 text-right">
费用 费用
</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">
缓存Token
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
</TableHead>
<TableHead class="h-8 px-2 text-right"> <TableHead class="h-8 px-2 text-right">
成功率 成功率
</TableHead> </TableHead>
@@ -32,7 +38,7 @@
<TableBody> <TableBody>
<TableRow v-if="data.length === 0"> <TableRow v-if="data.length === 0">
<TableCell <TableCell
:colspan="6" :colspan="8"
class="text-center py-6 text-muted-foreground px-2" class="text-center py-6 text-muted-foreground px-2"
> >
暂无提供商统计数据 暂无提供商统计数据
@@ -62,6 +68,12 @@
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2">
{{ formatTokens(provider.cacheReadTokens || 0) }}
</TableCell>
<TableCell class="text-right py-2 px-2 text-muted-foreground">
{{ formatHitRate(provider.cacheHitRate) }}
</TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span> <span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
</TableCell> </TableCell>
@@ -83,7 +95,7 @@ import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue' import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue' import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue' import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
import type { ProviderStatsItem } from '../types' import type { ProviderStatsItem } from '../types'
defineProps<{ defineProps<{

View File

@@ -110,6 +110,8 @@ export function useUsageData(options: UseUsageDataOptions) {
model: item.model, model: item.model,
request_count: item.request_count || 0, request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0, total_tokens: item.total_tokens || 0,
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
cache_hit_rate: typeof raw.cache_hit_rate === 'number' ? raw.cache_hit_rate : 0,
total_cost: item.total_cost || 0, total_cost: item.total_cost || 0,
actual_cost: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined actual_cost: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined
} }
@@ -119,6 +121,8 @@ export function useUsageData(options: UseUsageDataOptions) {
provider: item.provider, provider: item.provider,
requests: item.request_count, requests: item.request_count,
totalTokens: item.total_tokens || 0, totalTokens: item.total_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost, totalCost: item.total_cost,
actualCost: item.actual_cost, actualCost: item.actual_cost,
successRate: item.success_rate, successRate: item.success_rate,
@@ -131,6 +135,8 @@ export function useUsageData(options: UseUsageDataOptions) {
api_format: item.api_format, api_format: item.api_format,
request_count: item.request_count || 0, request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0, total_tokens: item.total_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost || 0, total_cost: item.total_cost || 0,
actual_cost: item.actual_cost, actual_cost: item.actual_cost,
avgResponseTime: item.avg_response_time_ms > 0 avgResponseTime: item.avg_response_time_ms > 0
@@ -163,6 +169,8 @@ export function useUsageData(options: UseUsageDataOptions) {
model: item.model, model: item.model,
request_count: item.requests || 0, request_count: item.requests || 0,
total_tokens: item.total_tokens || 0, total_tokens: item.total_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost_usd || 0, total_cost: item.total_cost_usd || 0,
actual_cost: item.actual_total_cost_usd actual_cost: item.actual_total_cost_usd
})) }))
@@ -170,7 +178,9 @@ export function useUsageData(options: UseUsageDataOptions) {
providerStats.value = (userData.summary_by_provider || []).map((item) => ({ providerStats.value = (userData.summary_by_provider || []).map((item) => ({
provider: item.provider, provider: item.provider,
requests: item.requests || 0, requests: item.requests || 0,
totalTokens: 0, totalTokens: item.total_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost_usd || 0, totalCost: item.total_cost_usd || 0,
successRate: item.success_rate || 0, successRate: item.success_rate || 0,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0 avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
@@ -184,57 +194,28 @@ export function useUsageData(options: UseUsageDataOptions) {
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords) currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total ?? currentRecords.value.length totalRecords.value = userData.pagination?.total ?? currentRecords.value.length
// 从记录中提取筛选选项和 API 格式统计 // 从记录中提取筛选选项
const models = new Set<string>() const models = new Set<string>()
const providers = new Set<string>() const providers = new Set<string>()
const apiFormatMap = new Map<string, {
count: number
tokens: number
cost: number
totalResponseTime: number
responseTimeCount: number
}>()
currentRecords.value.forEach(record => { currentRecords.value.forEach(record => {
if (record.model) models.add(record.model) if (record.model) models.add(record.model)
if (record.provider) providers.add(record.provider) if (record.provider) providers.add(record.provider)
if (record.api_format) {
const existing = apiFormatMap.get(record.api_format) || {
count: 0,
tokens: 0,
cost: 0,
totalResponseTime: 0,
responseTimeCount: 0
}
existing.count++
existing.tokens += record.total_tokens || 0
existing.cost += record.cost || 0
if (record.response_time_ms) {
existing.totalResponseTime += record.response_time_ms
existing.responseTimeCount++
}
apiFormatMap.set(record.api_format, existing)
}
}) })
availableModels.value = Array.from(models).sort() availableModels.value = Array.from(models).sort()
availableProviders.value = Array.from(providers).sort() availableProviders.value = Array.from(providers).sort()
// 构建 API 格式统计数据 // API 格式统计直接使用后端聚合数据
apiFormatStats.value = Array.from(apiFormatMap.entries()) apiFormatStats.value = (userData.summary_by_api_format || []).map(item => ({
.map(([format, data]) => { api_format: item.api_format,
const avgMs = data.responseTimeCount > 0 request_count: item.request_count || 0,
? data.totalResponseTime / data.responseTimeCount total_tokens: item.total_tokens || 0,
: 0 cache_read_tokens: item.cache_read_tokens || 0,
return { cache_hit_rate: item.cache_hit_rate || 0,
api_format: format, total_cost: item.total_cost_usd || 0,
request_count: data.count, avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
total_tokens: data.tokens, ? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
total_cost: data.cost, : '-'
avgResponseTime: avgMs > 0 ? `${(avgMs / 1000).toFixed(2)}s` : '-' }))
}
})
.sort((a, b) => b.request_count - a.request_count)
} }
} catch (error: unknown) { } catch (error: unknown) {
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {

View File

@@ -22,6 +22,8 @@ export interface ModelStatsItem {
model: string model: string
request_count: number request_count: number
total_tokens: number total_tokens: number
cache_read_tokens?: number
cache_hit_rate?: number
total_cost: number total_cost: number
actual_cost?: number // 倍率消耗 actual_cost?: number // 倍率消耗
} }
@@ -36,6 +38,8 @@ export interface ProviderStatsItem {
provider: string provider: string
requests: number requests: number
totalTokens: number totalTokens: number
cacheReadTokens?: number
cacheHitRate?: number
totalCost: number totalCost: number
actualCost?: number actualCost?: number
successRate: number successRate: number
@@ -47,6 +51,8 @@ export interface ApiFormatStatsItem {
api_format: string api_format: string
request_count: number request_count: number
total_tokens: number total_tokens: number
cache_read_tokens?: number
cache_hit_rate?: number
total_cost: number total_cost: number
actual_cost?: number actual_cost?: number
avgResponseTime: string avgResponseTime: string

View File

@@ -90,8 +90,8 @@
:required="!isEditMode" :required="!isEditMode"
minlength="6" minlength="6"
:placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)" :placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
class="h-10"
:class="[ :class="[
'h-10',
passwordError ? 'border-destructive' : '', passwordError ? 'border-destructive' : '',
]" ]"
/> />

View File

@@ -152,4 +152,10 @@ export function formatRemainingTime(expireAt: number | undefined, currentTime: n
const minutes = Math.floor(remaining / 60) const minutes = Math.floor(remaining / 60)
const seconds = Math.floor(remaining % 60) const seconds = Math.floor(remaining % 60)
return `${minutes}${seconds}` return `${minutes}${seconds}`
} }
// Cache hit rate formatting
export function formatHitRate(rate: number | undefined): string {
if (typeof rate !== 'number' || Number.isNaN(rate)) return '-'
return `${rate.toFixed(2)}%`
}

View File

@@ -29,6 +29,7 @@ from src.models.database import (
) )
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.services.usage.query import input_context_expr
from src.services.usage.service import UsageService from src.services.usage.service import UsageService
from src.utils.cache_decorator import cache_result from src.utils.cache_decorator import cache_result
@@ -78,6 +79,25 @@ def _build_time_range_params(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
def _calculate_token_cache_hit_rate(
total_input_context: int | None,
cache_read_tokens: int | None,
) -> float:
"""计算缓存命中率。
Args:
total_input_context: 已归一化的总输入上下文 token 数。
Claude 格式: input_tokens + cache_read_input_tokens
OpenAI/Gemini 格式: input_tokens已包含 cache_read
cache_read_tokens: 缓存读取 token 数
"""
context = int(total_input_context or 0)
cached = int(cache_read_tokens or 0)
if context <= 0:
return 0.0
return round(cached / context * 100, 2)
# ==================== RESTful Routes ==================== # ==================== RESTful Routes ====================
@@ -107,10 +127,10 @@ async def get_usage_aggregation(
- `limit`: 返回数量限制,默认 20最大 100 - `limit`: 返回数量限制,默认 20最大 100
**返回字段**: **返回字段**:
- 按模型聚合时model, request_count, total_tokens, total_cost, actual_cost - 按模型聚合时model, request_count, total_tokens, total_cost, actual_cost, cache_read_tokens, cache_hit_rate
- 按用户聚合时user_id, email, username, request_count, total_tokens, total_cost - 按用户聚合时user_id, email, username, request_count, total_tokens, total_cost
- 按提供商聚合时provider_id, provider, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, success_rate, error_count - 按提供商聚合时provider_id, provider, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, success_rate, error_count, cache_read_tokens, cache_hit_rate
- 按 API 格式聚合时api_format, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms - 按 API 格式聚合时api_format, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, cache_read_tokens, cache_hit_rate
""" """
time_range = _apply_admin_default_range( time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes) _build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
@@ -524,6 +544,8 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
func.sum(Usage.total_tokens).label("total_tokens"), func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost"), func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("actual_cost"), func.sum(Usage.actual_total_cost_usd).label("actual_cost"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(input_context_expr()).label("total_input_context"),
) )
# 过滤掉 pending/streaming 状态的请求(尚未完成的请求不应计入统计) # 过滤掉 pending/streaming 状态的请求(尚未完成的请求不应计入统计)
query = query.filter(Usage.status.notin_(["pending", "streaming"])) query = query.filter(Usage.status.notin_(["pending", "streaming"]))
@@ -553,8 +575,13 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
"total_tokens": int(tokens or 0), "total_tokens": int(tokens or 0),
"total_cost": float(cost or 0), "total_cost": float(cost or 0),
"actual_cost": float(actual_cost or 0), "actual_cost": float(actual_cost or 0),
"cache_read_tokens": int(cache_read_tokens or 0),
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=total_input_context,
cache_read_tokens=cache_read_tokens,
),
} }
for model, count, tokens, cost, actual_cost in stats for model, count, tokens, cost, actual_cost, cache_read_tokens, total_input_context in stats
] ]
@@ -678,6 +705,8 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
func.sum(Usage.total_cost_usd).label("total_cost"), func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("actual_cost"), func.sum(Usage.actual_total_cost_usd).label("actual_cost"),
func.avg(Usage.response_time_ms).label("avg_response_time_ms"), func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(input_context_expr()).label("total_input_context"),
).filter( ).filter(
Usage.provider_id.isnot(None), Usage.provider_id.isnot(None),
# 过滤掉 pending/streaming 状态的请求 # 过滤掉 pending/streaming 状态的请求
@@ -742,6 +771,13 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
"avg_response_time_ms": float(stat.avg_latency_ms or 0), "avg_response_time_ms": float(stat.avg_latency_ms or 0),
"success_rate": round(success_rate, 2), "success_rate": round(success_rate, 2),
"error_count": failed_count, "error_count": failed_count,
"cache_read_tokens": (
int(usage_stat.cache_read_tokens or 0) if usage_stat else 0
),
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=(usage_stat.total_input_context if usage_stat else 0),
cache_read_tokens=(usage_stat.cache_read_tokens if usage_stat else 0),
),
} }
) )
@@ -773,6 +809,8 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
func.sum(Usage.total_cost_usd).label("total_cost"), func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("actual_cost"), func.sum(Usage.actual_total_cost_usd).label("actual_cost"),
func.avg(Usage.response_time_ms).label("avg_response_time_ms"), func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(input_context_expr()).label("total_input_context"),
) )
# 过滤掉 pending/streaming 状态的请求 # 过滤掉 pending/streaming 状态的请求
query = query.filter(Usage.status.notin_(["pending", "streaming"])) query = query.filter(Usage.status.notin_(["pending", "streaming"]))
@@ -808,8 +846,22 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
"total_cost": float(cost or 0), "total_cost": float(cost or 0),
"actual_cost": float(actual_cost or 0), "actual_cost": float(actual_cost or 0),
"avg_response_time_ms": float(avg_response_time or 0), "avg_response_time_ms": float(avg_response_time or 0),
"cache_read_tokens": int(cache_read_tokens or 0),
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=total_input_context,
cache_read_tokens=cache_read_tokens,
),
} }
for api_format, count, tokens, cost, actual_cost, avg_response_time in stats for (
api_format,
count,
tokens,
cost,
actual_cost,
avg_response_time,
cache_read_tokens,
total_input_context,
) in stats
] ]

View File

@@ -48,6 +48,7 @@ from src.models.database import (
from src.services.cache.user_cache import UserCacheService from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.services.usage.query import input_context_expr
from src.services.usage.service import UsageService from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.bulk_cleanup import pre_clean_api_key from src.services.user.bulk_cleanup import pre_clean_api_key
@@ -59,6 +60,20 @@ router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
def _calculate_token_cache_hit_rate(total_input_context: int, cache_read_tokens: int) -> float:
"""计算缓存命中率。
Args:
total_input_context: 已归一化的总输入上下文 token 数(由 query.py 按 API 格式精确计算)。
cache_read_tokens: 缓存读取 token 数。
"""
context = max(0, int(total_input_context))
cached = max(0, int(cache_read_tokens))
if context == 0:
return 0.0
return round(cached / context * 100, 2)
def _update_profile_sync( def _update_profile_sync(
user_id: str, user_id: str,
request: UpdateProfileRequest, request: UpdateProfileRequest,
@@ -508,8 +523,8 @@ async def get_my_usage(
- `total_requests`: 总请求数 - `total_requests`: 总请求数
- `total_tokens`: 总 Token 数 - `total_tokens`: 总 Token 数
- `total_cost`: 总成本USD - `total_cost`: 总成本USD
- `summary_by_model`: 按模型分组统计 - `summary_by_model`: 按模型分组统计(含 `cache_read_tokens`、`cache_hit_rate`
- `summary_by_provider`: 按提供商分组统计 - `summary_by_provider`: 按提供商分组统计(含 `cache_read_tokens`、`cache_hit_rate`
- `records`: 详细使用记录列表 - `records`: 详细使用记录列表
- `pagination`: 分页信息 - `pagination`: 分页信息
""" """
@@ -1045,6 +1060,9 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"input_tokens": 0, "input_tokens": 0,
"output_tokens": 0, "output_tokens": 0,
"total_tokens": 0, "total_tokens": 0,
"cache_read_tokens": 0,
"total_input_context": 0,
"cache_hit_rate": 0.0,
"total_cost_usd": 0.0, "total_cost_usd": 0.0,
} }
# 管理员可以看到真实成本 # 管理员可以看到真实成本
@@ -1056,6 +1074,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
stats["input_tokens"] += item["input_tokens"] stats["input_tokens"] += item["input_tokens"]
stats["output_tokens"] += item["output_tokens"] stats["output_tokens"] += item["output_tokens"]
stats["total_tokens"] += item["total_tokens"] stats["total_tokens"] += item["total_tokens"]
stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
stats["total_cost_usd"] += item["total_cost_usd"] stats["total_cost_usd"] += item["total_cost_usd"]
# 管理员可以看到真实成本 # 管理员可以看到真实成本
if user.role == UserRole.ADMIN: if user.role == UserRole.ADMIN:
@@ -1066,6 +1086,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"provider": provider_name, "provider": provider_name,
"requests": 0, "requests": 0,
"total_tokens": 0, "total_tokens": 0,
"cache_read_tokens": 0,
"total_input_context": 0,
"total_cost_usd": 0.0, "total_cost_usd": 0.0,
"success_count": 0, "success_count": 0,
"total_response_time_ms": 0.0, "total_response_time_ms": 0.0,
@@ -1074,6 +1096,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
provider_stats = provider_summary.setdefault(provider_name, provider_base_stats) provider_stats = provider_summary.setdefault(provider_name, provider_base_stats)
provider_stats["requests"] += item["requests"] provider_stats["requests"] += item["requests"]
provider_stats["total_tokens"] += item["total_tokens"] provider_stats["total_tokens"] += item["total_tokens"]
provider_stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
provider_stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
provider_stats["total_cost_usd"] += item["total_cost_usd"] provider_stats["total_cost_usd"] += item["total_cost_usd"]
provider_stats["success_count"] += int(item.get("success_count", 0) or 0) provider_stats["success_count"] += int(item.get("success_count", 0) or 0)
success_response_time_count = int(item.get("success_response_time_count", 0) or 0) success_response_time_count = int(item.get("success_response_time_count", 0) or 0)
@@ -1083,6 +1107,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
) )
provider_stats["response_time_count"] += success_response_time_count provider_stats["response_time_count"] += success_response_time_count
for model_stats in model_summary.values():
model_stats["cache_hit_rate"] = _calculate_token_cache_hit_rate(
total_input_context=int(model_stats.get("total_input_context", 0) or 0),
cache_read_tokens=int(model_stats.get("cache_read_tokens", 0) or 0),
)
model_stats.pop("total_input_context", None)
summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True) summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True)
summary_by_provider = [] summary_by_provider = []
for provider_stats in provider_summary.values(): for provider_stats in provider_summary.values():
@@ -1101,6 +1132,11 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"provider": provider_stats["provider"], "provider": provider_stats["provider"],
"requests": provider_stats["requests"], "requests": provider_stats["requests"],
"total_tokens": provider_stats["total_tokens"], "total_tokens": provider_stats["total_tokens"],
"cache_read_tokens": provider_stats["cache_read_tokens"],
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=int(provider_stats.get("total_input_context", 0) or 0),
cache_read_tokens=int(provider_stats.get("cache_read_tokens", 0) or 0),
),
"total_cost_usd": provider_stats["total_cost_usd"], "total_cost_usd": provider_stats["total_cost_usd"],
"success_rate": round(success_rate, 2), "success_rate": round(success_rate, 2),
"avg_response_time_ms": round(avg_response_time_ms, 2), "avg_response_time_ms": round(avg_response_time_ms, 2),
@@ -1108,6 +1144,52 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
) )
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True) summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
# 按 api_format 聚合统计(独立查询,因为 get_usage_summary 按 provider+model 分组无此维度)
api_format_query = db.query(
Usage.api_format,
func.count(Usage.id).label("request_count"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(input_context_expr()).label("total_input_context"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
).filter(
Usage.user_id == user.id,
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
Usage.api_format.isnot(None),
)
if start_utc and end_utc:
api_format_query = api_format_query.filter(
Usage.created_at >= start_utc, Usage.created_at < end_utc
)
api_format_stats = (
api_format_query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).all()
)
summary_by_api_format = [
{
"api_format": api_format or "unknown",
"request_count": count,
"total_tokens": int(total_tokens or 0),
"cache_read_tokens": int(cache_read_tokens or 0),
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=total_input_context,
cache_read_tokens=cache_read_tokens,
),
"total_cost_usd": float(total_cost_usd or 0),
"avg_response_time_ms": float(avg_response_time_ms or 0),
}
for (
api_format,
count,
total_tokens,
cache_read_tokens,
total_input_context,
total_cost_usd,
avg_response_time_ms,
) in api_format_stats
]
query = ( query = (
db.query(Usage, ApiKey, ProviderEndpoint) db.query(Usage, ApiKey, ProviderEndpoint)
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id) .outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
@@ -1199,6 +1281,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"billing": WalletService.serialize_wallet_summary(wallet), "billing": WalletService.serialize_wallet_summary(wallet),
"summary_by_model": summary_by_model, "summary_by_model": summary_by_model,
"summary_by_provider": summary_by_provider, "summary_by_provider": summary_by_provider,
"summary_by_api_format": summary_by_api_format,
# 分页信息 # 分页信息
"pagination": { "pagination": {
"total": total_records, "total": total_records,

View File

@@ -471,186 +471,6 @@ class PoolManager:
return ordered_keys, trace return ordered_keys, trace
# ------------------------------------------------------------------
# Single-key selection (used by CandidateBuilder for pooled providers)
# ------------------------------------------------------------------
async def select_key(
self,
session_uuid: str | None,
keys: list[ProviderAPIKey],
) -> ProviderAPIKey | None:
"""Select the best key from *keys* according to pool rules.
Same logic as :meth:`reorder_candidates` but operates directly on
:class:`ProviderAPIKey` objects instead of candidates:
1. Sticky session hit (if bound and still healthy).
2. Filter out keys in account-blocked / cooldown / cost-exhausted.
3. LRU sort among remaining keys.
4. Random tiebreak for identical LRU scores.
5. Return the first available key, or ``None``.
"""
if not keys:
return None
pid = self.provider_id
# --- 1. Sticky session ------------------------------------------------
sticky_key_id: str | None = None
if session_uuid and self.config.sticky_session_ttl_seconds > 0:
sticky_key_id = await redis_ops.get_sticky_binding(
pid, session_uuid, self.config.sticky_session_ttl_seconds
)
# --- 2. Batch fetch pool state (parallel) -----------------------------
key_ids = [str(k.id) for k in keys]
_cooldown_coro = redis_ops.batch_get_cooldowns(pid, key_ids)
_cost_coro = (
redis_ops.batch_get_cost_totals(pid, key_ids, self.config.cost_window_seconds)
if (
self.config.cost_limit_per_key_tokens is not None
or self.config.scheduling_mode == "multi_score"
)
else None
)
_need_lru_sk = self.config.lru_enabled or self.config.scheduling_mode == "multi_score"
_lru_coro = redis_ops.get_lru_scores(pid, key_ids) if _need_lru_sk else None
_latency_coro = (
redis_ops.batch_get_latency_avgs(pid, key_ids, self.config.latency_window_seconds)
if self.config.scheduling_mode == "multi_score"
else None
)
coros_sk: list[Any] = [_cooldown_coro]
_cost_idx_sk = -1
_lru_idx_sk = -1
_latency_idx_sk = -1
if _cost_coro is not None:
_cost_idx_sk = len(coros_sk)
coros_sk.append(_cost_coro)
if _lru_coro is not None:
_lru_idx_sk = len(coros_sk)
coros_sk.append(_lru_coro)
if _latency_coro is not None:
_latency_idx_sk = len(coros_sk)
coros_sk.append(_latency_coro)
gathered_sk = await asyncio.gather(*coros_sk)
cooldowns = gathered_sk[0]
cost_exhausted: set[str] = set()
cost_totals: dict[str, int] = {}
if _cost_idx_sk >= 0:
cost_totals = gathered_sk[_cost_idx_sk]
if self.config.cost_limit_per_key_tokens is not None:
for kid, total in cost_totals.items():
if total >= self.config.cost_limit_per_key_tokens:
cost_exhausted.add(kid)
lru_scores: dict[str, float] = {}
if _lru_idx_sk >= 0:
lru_scores = gathered_sk[_lru_idx_sk]
latency_avgs: dict[str, float] = {}
if _latency_idx_sk >= 0:
latency_avgs = gathered_sk[_latency_idx_sk]
health_scores: dict[str, float] = {}
if self.config.scheduling_mode == "multi_score":
health_scores = get_health_scores(pid, keys)
provider_type = self.provider_type
if provider_type is None and keys:
first_provider = getattr(keys[0], "provider", None)
provider_type = str(getattr(first_provider, "provider_type", "") or "").strip().lower()
if not provider_type:
provider_type = None
# --- Strategy: compute_score ------------------------------------------
strategies = _get_active_strategies(self.config)
strategy_context: dict[str, Any] = {
"session_uuid": session_uuid,
"provider_type": provider_type,
"all_key_ids": key_ids,
"lru_scores": lru_scores,
"cost_totals": cost_totals if _cost_idx_sk >= 0 else {},
"cost_limit_per_key_tokens": self.config.cost_limit_per_key_tokens,
"latency_avgs": latency_avgs,
"health_scores": health_scores,
"keys_by_id": {str(k.id): k for k in keys},
}
for strategy in strategies:
if hasattr(strategy, "compute_score"):
for kid in key_ids:
try:
custom = strategy.compute_score(
key_id=kid,
config=self.config,
context=strategy_context,
)
if custom is not None:
lru_scores[kid] = float(custom)
except Exception:
pass
# --- 3. Classify keys -------------------------------------------------
# Use precomputed account states when available.
account_states_sk: dict[str, Any] = {}
for k in keys:
kid = str(k.id)
if kid not in account_states_sk:
precomputed = getattr(k, "_pool_account_state", None)
if precomputed is not None:
account_states_sk[kid] = precomputed
else:
account_states_sk[kid] = resolve_pool_account_state(
provider_type=self.provider_type,
upstream_metadata=getattr(k, "upstream_metadata", None),
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
)
sticky_key: ProviderAPIKey | None = None
available: list[ProviderAPIKey] = []
for k in keys:
kid = str(k.id)
if account_states_sk[kid].blocked:
continue
if cooldowns.get(kid) is not None:
continue
if kid in cost_exhausted:
continue
if sticky_key_id and kid == sticky_key_id:
sticky_key = k
continue
available.append(k)
# --- 4. Sort by LRU ---------------------------------------------------
if lru_scores and available:
available.sort(key=lambda k: lru_scores.get(str(k.id), 0.0))
# Random tiebreak within same-score groups
if len(available) > 1 and lru_scores:
_shuffle_same_score_keys(available, lru_scores)
# --- 5. Pick the winner -----------------------------------------------
if sticky_key is not None:
logger.debug(
"Pool[{}]: sticky select key={}",
pid[:8],
sticky_key_id and sticky_key_id[:8],
)
return sticky_key
return available[0] if available else None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Post-request hooks # Post-request hooks
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -799,10 +619,3 @@ def _shuffle_same_score_groups(
lru_scores: dict[str, float], lru_scores: dict[str, float],
) -> None: ) -> None:
_shuffle_same_score(candidates, lru_scores, lambda c: str(c.key.id)) _shuffle_same_score(candidates, lru_scores, lambda c: str(c.key.id))
def _shuffle_same_score_keys(
keys: list[ProviderAPIKey],
lru_scores: dict[str, float],
) -> None:
_shuffle_same_score(keys, lru_scores, lambda k: str(k.id))

View File

@@ -4,13 +4,28 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import case, func from sqlalchemy import Case, case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ApiKey, Usage, User from src.models.database import ApiKey, Usage, User
def input_context_expr() -> Case:
"""构造 SQL CASE 表达式,根据 api_format 精确计算每条记录的总输入上下文 token 数。
- OpenAI/Gemini: input_tokens 已包含 cache_read_input_tokens直接使用
- Claude/未知: input_tokens 不含 cache_read需要加上
"""
return case(
(
Usage.api_format.like("openai:%") | Usage.api_format.like("gemini:%"),
Usage.input_tokens,
),
else_=Usage.input_tokens + Usage.cache_read_input_tokens,
)
@dataclass(slots=True) @dataclass(slots=True)
class RequestBalanceCheckResult: class RequestBalanceCheckResult:
allowed: bool allowed: bool
@@ -245,6 +260,8 @@ class UsageQueryMixin:
func.sum(Usage.input_tokens).label("input_tokens"), func.sum(Usage.input_tokens).label("input_tokens"),
func.sum(Usage.output_tokens).label("output_tokens"), func.sum(Usage.output_tokens).label("output_tokens"),
func.sum(Usage.total_tokens).label("total_tokens"), func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(input_context_expr()).label("total_input_context"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"), func.sum(Usage.total_cost_usd).label("total_cost_usd"),
func.sum(Usage.actual_total_cost_usd).label("actual_total_cost_usd"), func.sum(Usage.actual_total_cost_usd).label("actual_total_cost_usd"),
func.sum(case((Usage.status_code == 200, 1), else_=0)).label("success_count"), func.sum(case((Usage.status_code == 200, 1), else_=0)).label("success_count"),
@@ -289,6 +306,8 @@ class UsageQueryMixin:
"input_tokens": row.input_tokens, "input_tokens": row.input_tokens,
"output_tokens": row.output_tokens, "output_tokens": row.output_tokens,
"total_tokens": row.total_tokens, "total_tokens": row.total_tokens,
"cache_read_tokens": int(row.cache_read_tokens or 0),
"total_input_context": int(row.total_input_context or 0),
"total_cost_usd": float(row.total_cost_usd or 0.0), "total_cost_usd": float(row.total_cost_usd or 0.0),
"actual_total_cost_usd": float(row.actual_total_cost_usd or 0.0), "actual_total_cost_usd": float(row.actual_total_cost_usd or 0.0),
"success_count": int(row.success_count or 0), "success_count": int(row.success_count or 0),

View File

@@ -19,6 +19,7 @@ async def test_get_usage_adapter_uses_coarse_summary_grouping(
count_query.scalar.return_value = 0 count_query.scalar.return_value = 0
query.outerjoin.return_value = query query.outerjoin.return_value = query
query.filter.return_value = query query.filter.return_value = query
query.group_by.return_value = query
query.with_entities.return_value = count_query query.with_entities.return_value = count_query
query.options.return_value = query query.options.return_value = query
query.order_by.return_value = query query.order_by.return_value = query
@@ -36,6 +37,8 @@ async def test_get_usage_adapter_uses_coarse_summary_grouping(
"input_tokens": 10, "input_tokens": 10,
"output_tokens": 5, "output_tokens": 5,
"total_tokens": 15, "total_tokens": 15,
"cache_read_tokens": 5,
"total_input_context": 15,
"total_cost_usd": 1.5, "total_cost_usd": 1.5,
"actual_total_cost_usd": 1.2, "actual_total_cost_usd": 1.2,
"success_count": 2, "success_count": 2,
@@ -49,6 +52,8 @@ async def test_get_usage_adapter_uses_coarse_summary_grouping(
"input_tokens": 999, "input_tokens": 999,
"output_tokens": 999, "output_tokens": 999,
"total_tokens": 1998, "total_tokens": 1998,
"cache_read_tokens": 50,
"total_input_context": 1049,
"total_cost_usd": 9.9, "total_cost_usd": 9.9,
"actual_total_cost_usd": 9.9, "actual_total_cost_usd": 9.9,
"success_count": 0, "success_count": 0,
@@ -82,15 +87,20 @@ async def test_get_usage_adapter_uses_coarse_summary_grouping(
"input_tokens": 10, "input_tokens": 10,
"output_tokens": 5, "output_tokens": 5,
"total_tokens": 15, "total_tokens": 15,
"cache_read_tokens": 5,
"cache_hit_rate": 33.33,
"total_cost_usd": 1.5, "total_cost_usd": 1.5,
} }
] ]
assert result["summary_by_api_format"] == []
assert "total_actual_cost" not in result assert "total_actual_cost" not in result
assert result["summary_by_provider"] == [ assert result["summary_by_provider"] == [
{ {
"provider": "provider-a", "provider": "provider-a",
"requests": 2, "requests": 2,
"total_tokens": 15, "total_tokens": 15,
"cache_read_tokens": 5,
"cache_hit_rate": 33.33,
"total_cost_usd": 1.5, "total_cost_usd": 1.5,
"success_rate": 100.0, "success_rate": 100.0,
"avg_response_time_ms": 500.0, "avg_response_time_ms": 500.0,
@@ -109,6 +119,7 @@ async def test_get_usage_adapter_provider_success_rate_uses_success_count(
count_query.scalar.return_value = 0 count_query.scalar.return_value = 0
query.outerjoin.return_value = query query.outerjoin.return_value = query
query.filter.return_value = query query.filter.return_value = query
query.group_by.return_value = query
query.with_entities.return_value = count_query query.with_entities.return_value = count_query
query.options.return_value = query query.options.return_value = query
query.order_by.return_value = query query.order_by.return_value = query
@@ -126,6 +137,8 @@ async def test_get_usage_adapter_provider_success_rate_uses_success_count(
"input_tokens": 30, "input_tokens": 30,
"output_tokens": 15, "output_tokens": 15,
"total_tokens": 45, "total_tokens": 45,
"cache_read_tokens": 15,
"total_input_context": 45,
"total_cost_usd": 4.5, "total_cost_usd": 4.5,
"actual_total_cost_usd": 4.5, "actual_total_cost_usd": 4.5,
"success_count": 2, "success_count": 2,
@@ -139,6 +152,8 @@ async def test_get_usage_adapter_provider_success_rate_uses_success_count(
"input_tokens": 10, "input_tokens": 10,
"output_tokens": 5, "output_tokens": 5,
"total_tokens": 15, "total_tokens": 15,
"cache_read_tokens": 5,
"total_input_context": 15,
"total_cost_usd": 1.5, "total_cost_usd": 1.5,
"actual_total_cost_usd": 1.5, "actual_total_cost_usd": 1.5,
"success_count": 0, "success_count": 0,
@@ -168,6 +183,8 @@ async def test_get_usage_adapter_provider_success_rate_uses_success_count(
"provider": "provider-a", "provider": "provider-a",
"requests": 4, "requests": 4,
"total_tokens": 60, "total_tokens": 60,
"cache_read_tokens": 20,
"cache_hit_rate": 33.33,
"total_cost_usd": 6.0, "total_cost_usd": 6.0,
"success_rate": 50.0, "success_rate": 50.0,
"avg_response_time_ms": 300.0, "avg_response_time_ms": 300.0,