mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' : '',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)}%`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user