Merge pull request #2930 from touwaeriol/feat/image-token-billing

fix(billing): channel pricing override for image generation + display image_output_tokens
This commit is contained in:
Wesley Liddick
2026-06-06 09:28:46 +08:00
committed by GitHub
17 changed files with 351 additions and 75 deletions
+2
View File
@@ -610,6 +610,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSize: l.ImageSize,
ImageInputSize: l.ImageInputSize,
ImageOutputSize: l.ImageOutputSize,
ImageOutputTokens: l.ImageOutputTokens,
ImageOutputCost: l.ImageOutputCost,
ImageSizeSource: l.ImageSizeSource,
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType,
+2
View File
@@ -465,6 +465,8 @@ type UsageLog struct {
ImageSize *string `json:"image_size"`
ImageInputSize *string `json:"image_input_size"`
ImageOutputSize *string `json:"image_output_size"`
ImageOutputTokens int `json:"image_output_tokens"`
ImageOutputCost float64 `json:"image_output_cost"`
ImageSizeSource *string `json:"image_size_source"`
ImageSizeBreakdown map[string]int `json:"image_size_breakdown"`
MediaType *string `json:"media_type"`
@@ -581,6 +581,8 @@ func TestAPIContracts(t *testing.T) {
"image_size": null,
"image_input_size": null,
"image_output_size": null,
"image_output_tokens": 0,
"image_output_cost": 0,
"image_size_source": null,
"image_size_breakdown": null,
"media_type": null,
+7 -3
View File
@@ -103,6 +103,7 @@ type ModelPricing struct {
LongContextInputMultiplier float64 // 长上下文整次会话输入倍率
LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率
ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD)
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
}
const (
@@ -409,7 +410,7 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
}
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值
// 仅覆盖渠道中非 nil 的价格字段,nil 字段使用默认定价
// 渠道存在时,未配置的图片输出价格归零(不回退到 LiteLLM)
func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) {
pricing, err := s.GetModelPricing(model)
if err != nil {
@@ -437,7 +438,10 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
}
if channelPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
} else {
pricing.ImageOutputPricePerToken = 0
}
pricing.ImageOutputPriceExplicit = true
return pricing, nil
}
@@ -570,8 +574,8 @@ func (s *BillingService) computeTokenBreakdown(
// 图片输出 token 费用(独立费率)
if tokens.ImageOutputTokens > 0 {
imgPrice := pricing.ImageOutputPricePerToken
if imgPrice == 0 {
imgPrice = outputPrice // 回退到常规输出价格
if imgPrice == 0 && !pricing.ImageOutputPriceExplicit {
imgPrice = outputPrice
}
bd.ImageOutputCost = float64(tokens.ImageOutputTokens) * imgPrice
}
@@ -950,3 +950,62 @@ func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) {
require.Nil(t, pricing)
require.Contains(t, err.Error(), "pricing not found")
}
func TestGetModelPricingWithChannel_NilImageOutputPriceZerosAndMarksExplicit(t *testing.T) {
svc := newTestBillingService()
chPricing := &ChannelModelPricing{
InputPrice: testPtrFloat64(10e-6),
OutputPrice: testPtrFloat64(20e-6),
// ImageOutputPrice intentionally nil
}
pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
require.NoError(t, err)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
require.True(t, pricing.ImageOutputPriceExplicit)
}
func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) {
svc := newTestBillingService()
pricing := &ModelPricing{
InputPricePerToken: 3e-6,
OutputPricePerToken: 15e-6,
ImageOutputPricePerToken: 0,
ImageOutputPriceExplicit: true,
}
tokens := UsageTokens{
InputTokens: 100,
OutputTokens: 200,
ImageOutputTokens: 50,
}
bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
// ImageOutputTokens should NOT fall back to outputPrice
require.Equal(t, 0.0, bd.ImageOutputCost)
// textOutputTokens = 200 - 50 = 150
require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12)
}
func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *testing.T) {
svc := newTestBillingService()
pricing := &ModelPricing{
InputPricePerToken: 3e-6,
OutputPricePerToken: 15e-6,
ImageOutputPricePerToken: 0,
ImageOutputPriceExplicit: false,
}
tokens := UsageTokens{
InputTokens: 100,
OutputTokens: 200,
ImageOutputTokens: 50,
}
bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
// Should fall back to outputPrice since not explicit
require.InDelta(t, 50*15e-6, bd.ImageOutputCost, 1e-12)
// textOutputTokens = 200 - 50 = 150
require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12)
}
+5 -2
View File
@@ -9002,8 +9002,11 @@ func (s *GatewayService) calculateRecordUsageCost(
imageMultiplier float64,
opts *recordUsageOpts,
) *CostBreakdown {
// 图片生成计费
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
if result.ImageCount > 0 {
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts)
}
return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
}
@@ -9181,7 +9184,7 @@ func (s *GatewayService) buildRecordUsageLog(
SubscriptionID: optionalSubscriptionID(subscription),
CreatedAt: time.Now(),
}
if result.ImageCount > 0 {
if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
usageLog.RateMultiplier = imageMultiplier
}
if cost != nil {
@@ -34,6 +34,9 @@ type ResolvedPricing struct {
// 是否支持缓存细分
SupportsCacheBreakdown bool
// 渠道定价原始配置(用于区间模式下获取 ImageOutputPrice)
channelPricing *ChannelModelPricing
}
// ModelPricingResolver 统一模型定价解析器。
@@ -71,8 +74,9 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
}
if mode == BillingModePerRequest || mode == BillingModeImage {
resolved := &ResolvedPricing{
Mode: mode,
Source: PricingSourceChannel,
Mode: mode,
Source: PricingSourceChannel,
channelPricing: chPricing,
}
r.applyRequestTierOverrides(chPricing, resolved)
return resolved
@@ -93,6 +97,7 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
// 2. 如果有 GroupID,尝试渠道覆盖
if chPricing != nil {
resolved.Source = PricingSourceChannel
resolved.channelPricing = chPricing
r.applyTokenOverrides(chPricing, resolved)
} else if input.GroupID != nil {
r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved)
@@ -120,6 +125,7 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI
}
resolved.Source = PricingSourceChannel
resolved.channelPricing = chPricing
resolved.Mode = chPricing.BillingMode
if resolved.Mode == "" {
resolved.Mode = BillingModeToken
@@ -141,6 +147,16 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
// 如果有有效的区间定价,使用区间
if len(validIntervals) > 0 {
resolved.Intervals = validIntervals
// 区间不匹配时回退到 BasePricing,也需要覆盖图片价格
if resolved.BasePricing == nil {
resolved.BasePricing = &ModelPricing{}
}
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
} else {
resolved.BasePricing.ImageOutputPricePerToken = 0
}
resolved.BasePricing.ImageOutputPriceExplicit = true
return
}
@@ -166,9 +182,13 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
resolved.BasePricing.CacheReadPricePerToken = *chPricing.CacheReadPrice
resolved.BasePricing.CacheReadPricePerTokenPriority = *chPricing.CacheReadPrice
}
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
} else {
resolved.BasePricing.ImageOutputPricePerToken = 0
}
resolved.BasePricing.ImageOutputPriceExplicit = true
}
// applyRequestTierOverrides 应用按次/图片模式的渠道覆盖
@@ -205,11 +225,11 @@ func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, tot
return resolved.BasePricing
}
return intervalToModelPricing(iv, resolved.SupportsCacheBreakdown)
return intervalToModelPricing(iv, resolved.SupportsCacheBreakdown, resolved.channelPricing)
}
// intervalToModelPricing 将区间定价转换为 ModelPricing
func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool) *ModelPricing {
func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, chPricing *ChannelModelPricing) *ModelPricing {
pricing := &ModelPricing{
SupportsCacheBreakdown: supportsCacheBreakdown,
}
@@ -230,6 +250,13 @@ func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool) *M
pricing.CacheReadPricePerToken = *iv.CacheReadPrice
pricing.CacheReadPricePerTokenPriority = *iv.CacheReadPrice
}
// 渠道定价存在时,ImageOutputPrice 显式覆盖
if chPricing != nil {
pricing.ImageOutputPriceExplicit = true
if chPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
}
}
return pricing
}
@@ -661,3 +661,69 @@ func TestFilterValidIntervals(t *testing.T) {
})
}
}
// ===========================================================================
// 9. ImageOutputPriceExplicit tests
// ===========================================================================
func TestApplyTokenOverrides_FlatSetsImageOutputPriceExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(3e-6),
OutputPrice: testPtrFloat64(15e-6),
// ImageOutputPrice intentionally nil
}})
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.Equal(t, PricingSourceChannel, resolved.Source)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
}
func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(3e-6),
OutputPrice: testPtrFloat64(15e-6),
ImageOutputPrice: testPtrFloat64(50e-6),
}})
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.InDelta(t, 50e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
// No ImageOutputPrice
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
},
}})
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
// BasePricing should have explicit mark (for interval fallback)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
// intervalToModelPricing should also have explicit mark
pricing := r.GetIntervalPricing(resolved, 50000)
require.True(t, pricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
}
@@ -5842,7 +5842,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
usageLog.TotalCost = cost.TotalCost
usageLog.ActualCost = cost.ActualCost
}
if result.ImageCount > 0 {
if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
usageLog.RateMultiplier = imageMultiplier
} else {
usageLog.RateMultiplier = multiplier
@@ -5936,7 +5936,10 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
) (*CostBreakdown, error) {
billingModel := firstUsageBillingModel(billingModels)
if result != nil && result.ImageCount > 0 {
return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, imageMultiplier), nil
// 渠道定价为 token 计费时走 token 路径,否则走图片计费
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, imageMultiplier), nil
}
}
if len(billingModels) == 0 || billingModel == "" {
return nil, errors.New("openai usage billing model is empty")
@@ -128,6 +128,12 @@
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span class="font-medium text-pink-600 dark:text-pink-400">{{ row.image_output_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
@@ -209,10 +215,18 @@
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData) && textOutputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ textOutputTokens(tokenTooltipData).toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokens') }}</span>
<span class="font-medium text-pink-300">{{ tokenTooltipData.image_output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 有 5m/1h 明细时,展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
@@ -282,8 +296,26 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && hasImageOutputCost(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputCost') }}</span>
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="tooltipData && isImageUsage(tooltipData)">
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, textOutputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && hasImageOutputTokens(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokenPrice') }}</span>
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<template v-else-if="isImageUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
@@ -317,16 +349,6 @@
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</template>
<template v-else-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@@ -387,13 +409,23 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode'
import {
BILLING_MODE_TOKEN,
getBillingModeLabel,
getBillingModeBadgeClass,
isImageUsage,
getDisplayBillingMode,
imageUnitPrice,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
formatImageInputSize,
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
hasImageOutputTokens,
textOutputTokens,
hasImageOutputCost,
} from '@/utils/imageUsage'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
@@ -403,23 +435,6 @@ function accountBilled(row: { total_cost?: number | null; account_stats_cost?: n
return Number.isNaN(result) ? 0 : result
}
function imageUnitPrice(row: AdminUsageLog | null): number {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
function isImageUsage(row: Pick<AdminUsageLog, 'image_count'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0
}
function getDisplayBillingMode(row: Pick<AdminUsageLog, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
if (isImageUsage(row)) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
+3
View File
@@ -906,6 +906,9 @@ export default {
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
imageOutputSize: 'Output size',
imageOutputTokens: 'Image Output Tokens',
imageOutputTokenPrice: 'Image Output Price',
imageOutputCost: 'Image Output Cost',
imageSizeSource: 'Size source',
imageSizeBreakdown: 'Size breakdown',
imageSizeSourceOutput: 'Upstream output',
+3
View File
@@ -910,6 +910,9 @@ export default {
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
imageOutputSize: '输出尺寸',
imageOutputTokens: '图片输出 Token',
imageOutputTokenPrice: '图片输出单价',
imageOutputCost: '图片输出成本',
imageSizeSource: '尺寸来源',
imageSizeBreakdown: '尺寸明细',
imageSizeSourceOutput: '上游输出',
+1
View File
@@ -358,6 +358,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
risk_control_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
allow_user_view_error_requests: false,
}
+5
View File
@@ -233,6 +233,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
}
@@ -1236,6 +1237,8 @@ export interface UsageLog {
image_output_size: string | null
image_size_source: ImageSizeSource | null
image_size_breakdown: ImageSizeBreakdown | null
image_output_tokens: number
image_output_cost: number
// User-Agent
user_agent: string | null
@@ -1410,6 +1413,8 @@ export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
total_cache_tokens: number
total_cache_read_tokens: number
total_cache_creation_tokens: number
total_tokens: number
total_cost: number // 标准计费
total_actual_cost: number // 实际扣除
+21
View File
@@ -17,3 +17,24 @@ export function getBillingModeBadgeClass(mode: string | null | undefined): strin
default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
}
interface ImageBillingRow {
image_count: number
billing_mode?: string | null
total_cost: number
}
export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing_mode'> | null | undefined): boolean {
return (row?.image_count ?? 0) > 0 && row?.billing_mode !== BILLING_MODE_TOKEN
}
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
return row?.billing_mode
}
export function imageUnitPrice(row: Pick<ImageBillingRow, 'image_count' | 'total_cost'> | null): number {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
+22
View File
@@ -2,6 +2,28 @@ import type { UsageLog } from '@/types'
type Translate = (key: string) => string
// --- Image output token / cost helpers ---
type ImageOutputTokenRow = Pick<UsageLog, 'output_tokens' | 'image_output_tokens'>
type ImageOutputCostRow = Pick<UsageLog, 'image_output_cost'>
/** Whether the row contains any image-output tokens. */
export const hasImageOutputTokens = (row: ImageOutputTokenRow | null | undefined): boolean =>
(row?.image_output_tokens ?? 0) > 0
/**
* Text-only output tokens (total output minus image-output).
* Returns 0 when no text tokens exist.
*/
export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): number =>
Math.max(0, (row?.output_tokens ?? 0) - (row?.image_output_tokens ?? 0))
/** Whether the row has a non-zero image-output cost. */
export const hasImageOutputCost = (row: ImageOutputCostRow | null | undefined): boolean =>
(row?.image_output_cost ?? 0) > 0
// --- Image size / billing helpers ---
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
const knownImageBillingSizes = new Set(['1K', '2K', '4K', 'mixed'])
+72 -34
View File
@@ -29,7 +29,7 @@
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('usage.totalTokens') }}
</p>
@@ -37,8 +37,23 @@
{{ formatTokens(usageStats?.total_tokens || 0) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} /
{{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}
<span>{{ t('usage.in') }} {{ formatTokens(usageStats?.total_input_tokens || 0) }}</span>
<span> · </span>
<span>{{ t('usage.out') }} {{ formatTokens(usageStats?.total_output_tokens || 0) }}</span>
<span> · </span>
<span class="text-sky-600 dark:text-sky-400">{{ t('usage.cacheHit') }} {{ formatTokens(usageStats?.total_cache_read_tokens || 0) }}</span>
<span> · </span>
<span class="text-amber-600 dark:text-amber-400">{{ t('usage.cacheCreate') }} {{ formatTokens(usageStats?.total_cache_creation_tokens || 0) }}</span>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
{{ t('usage.cacheHitRate') }}:
<template v-if="cacheStats.totalInput > 0">
<span class="text-sky-600 dark:text-sky-400">{{ formatTokens(cacheStats.cacheRead) }}</span>
<span class="text-gray-400">/</span>
<span class="text-gray-600 dark:text-gray-300">{{ formatTokens(cacheStats.totalInput) }}</span>
<span class="ml-1">{{ cacheStats.ratePercent }}</span>
</template>
<template v-else>-</template>
</p>
</div>
</div>
@@ -273,6 +288,12 @@
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span class="font-medium text-pink-600 dark:text-pink-400">{{ row.image_output_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
@@ -400,10 +421,18 @@
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData) && textOutputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ textOutputTokens(tokenTooltipData).toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokens') }}</span>
<span class="font-medium text-pink-300">{{ tokenTooltipData.image_output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 有 5m/1h 明细时,展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
@@ -479,8 +508,27 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && hasImageOutputCost(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputCost') }}</span>
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, textOutputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && hasImageOutputTokens(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageOutputTokenPrice') }}</span>
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-image billing: show image metadata and unit price -->
<template v-if="tooltipData && isImageUsage(tooltipData)">
<template v-else-if="tooltipData && isImageUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
@@ -514,17 +562,6 @@
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</template>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-else-if="!getDisplayBillingMode(tooltipData) || getDisplayBillingMode(tooltipData) === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@@ -592,10 +629,12 @@ import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import {
BILLING_MODE_IMAGE,
BILLING_MODE_TOKEN,
getBillingModeBadgeClass,
getBillingModeLabel,
isImageUsage,
getDisplayBillingMode,
imageUnitPrice,
} from '@/utils/billingMode'
import {
formatImageBillingSize,
@@ -603,6 +642,9 @@ import {
formatImageOutputSize,
formatImageSizeBreakdown,
formatImageSizeSource,
hasImageOutputTokens,
textOutputTokens,
hasImageOutputCost,
} from '@/utils/imageUsage'
const { t } = useI18n()
@@ -623,6 +665,19 @@ const tokenTooltipData = ref<UsageLog | null>(null)
// Usage stats from API
const usageStats = ref<UsageStatsResponse | null>(null)
// 缓存命中率 = cache_read / (input + cache_read)
// 分母为 0(无任何输入)时显示 '-'
const cacheStats = computed(() => {
// 总输入 token = 普通输入 + 缓存写入 + 缓存读取(命中)
// 缓存命中率 = 缓存读取 / 总输入;总输入为 0 时返回零值,模板按 '-' 渲染。
const cacheRead = usageStats.value?.total_cache_read_tokens || 0
const cacheCreate = usageStats.value?.total_cache_creation_tokens || 0
const input = usageStats.value?.total_input_tokens || 0
const totalInput = input + cacheCreate + cacheRead
const ratePercent = totalInput > 0 ? `${((cacheRead / totalInput) * 100).toFixed(1)}%` : '-'
return { cacheRead, totalInput, ratePercent }
})
const columns = computed<Column[]>(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
@@ -711,23 +766,6 @@ const formatDuration = (ms: number | null | undefined): string => {
return `${(ms / 1000).toFixed(2)}s`
}
const imageUnitPrice = (row: UsageLog | null): number => {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
const isImageUsage = (row: Pick<UsageLog, 'image_count'> | null | undefined): boolean => {
return (row?.image_count ?? 0) > 0
}
const getDisplayBillingMode = (row: Pick<UsageLog, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined => {
if (isImageUsage(row)) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
const formatUserAgent = (ua: string): string => {
return ua