mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -910,6 +910,9 @@ export default {
|
||||
imageBillingSize: '计费尺寸',
|
||||
imageInputSize: '输入尺寸',
|
||||
imageOutputSize: '输出尺寸',
|
||||
imageOutputTokens: '图片输出 Token',
|
||||
imageOutputTokenPrice: '图片输出单价',
|
||||
imageOutputCost: '图片输出成本',
|
||||
imageSizeSource: '尺寸来源',
|
||||
imageSizeBreakdown: '尺寸明细',
|
||||
imageSizeSourceOutput: '上游输出',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 // 实际扣除
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user