feat(billing): usage_logs 单独记录图片输入 token 与费用
图片编辑/图生图请求的图片输入 token 此前并入 input_tokens/input_cost, 无法对账。拆分上报口径,total_cost 保持不变。 后端: - CostBreakdown 新增 ImageInputCost;computeTokenBreakdown 将图片输入费用 从 InputCost 拆出(InputCost 从此仅含文本输入),并纳入 tier 倍率与总额; 长上下文合并路径同步携带 ImageInputCost - 迁移 179:usage_logs 新增 image_input_tokens / image_input_cost 列 - UsageLog、insert/query 仓储(含定位参数数组、CTE 列表、扫描顺序)、 DTO 与 mapper 补齐两列 - openai_gateway_usage 从 usage 与 cost 落库图片输入 token/费用 前端: - UsageLog 类型、imageUsage 工具(hasImageInputTokens/Cost、textInputTokens) - 用量表 token 徽标、Token/费用 tooltip 与单价行按图/文输入拆分展示 - zh/en usage.* i18n 测试: - 新增 gpt-image-2 图片编辑复现用例(复现 #4386 的 $0.016081 期望值) - 新增 usage 提取器图片输入 token 解析用例(input_tokens_details.image_tokens) - 更新 doubao 图文分价用例与仓储/契约测试以匹配新的 input/image 拆分口径 相关 #4386。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wcJTKDddxXSQrepSs3wrU
This commit is contained in:
1 parent
06e03f467a
commit
62d57c02d8
17 files changed
+195
-23
No files matched your search
@@ -634,6 +634,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
ImageSize: l.ImageSize,
|
||||
ImageInputSize: l.ImageInputSize,
|
||||
ImageOutputSize: l.ImageOutputSize,
|
||||
ImageInputTokens: l.ImageInputTokens,
|
||||
ImageInputCost: l.ImageInputCost,
|
||||
ImageOutputTokens: l.ImageOutputTokens,
|
||||
ImageOutputCost: l.ImageOutputCost,
|
||||
ImageSizeSource: l.ImageSizeSource,
|
||||
|
||||
@@ -503,6 +503,8 @@ type UsageLog struct {
|
||||
ImageSize *string `json:"image_size"`
|
||||
ImageInputSize *string `json:"image_input_size"`
|
||||
ImageOutputSize *string `json:"image_output_size"`
|
||||
ImageInputTokens int `json:"image_input_tokens"`
|
||||
ImageInputCost float64 `json:"image_input_cost"`
|
||||
ImageOutputTokens int `json:"image_output_tokens"`
|
||||
ImageOutputCost float64 `json:"image_output_cost"`
|
||||
ImageSizeSource *string `json:"image_size_source"`
|
||||
|
||||
@@ -41,6 +41,8 @@ var usageLogInsertArgTypes = [...]string{
|
||||
"integer", // cache_creation_1h_tokens
|
||||
"integer", // image_output_tokens
|
||||
"numeric", // image_output_cost
|
||||
"integer", // image_input_tokens
|
||||
"numeric", // image_input_cost
|
||||
"numeric", // input_cost
|
||||
"numeric", // output_cost
|
||||
"numeric", // cache_creation_cost
|
||||
@@ -234,6 +236,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -277,7 +281,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
RETURNING id, created_at
|
||||
@@ -686,6 +690,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -725,7 +731,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(keys)*54)
|
||||
args := make([]any, 0, len(keys)*56)
|
||||
argPos := 1
|
||||
for idx, key := range keys {
|
||||
if idx > 0 {
|
||||
@@ -771,6 +777,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -827,6 +835,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -923,6 +933,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -962,7 +974,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(preparedList)*54)
|
||||
args := make([]any, 0, len(preparedList)*56)
|
||||
argPos := 1
|
||||
for idx, prepared := range preparedList {
|
||||
if idx > 0 {
|
||||
@@ -1005,6 +1017,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -1061,6 +1075,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -1125,6 +1141,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -1168,7 +1186,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
`, prepared.args...)
|
||||
@@ -1243,6 +1261,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
log.CacheCreation1hTokens,
|
||||
log.ImageOutputTokens,
|
||||
log.ImageOutputCost,
|
||||
log.ImageInputTokens,
|
||||
log.ImageInputCost,
|
||||
log.InputCost,
|
||||
log.OutputCost,
|
||||
log.CacheCreationCost,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
|
||||
|
||||
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
|
||||
@@ -443,6 +443,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
cacheCreation1h int
|
||||
imageOutputTokens int
|
||||
imageOutputCost float64
|
||||
imageInputTokens int
|
||||
imageInputCost float64
|
||||
inputCost float64
|
||||
outputCost float64
|
||||
cacheCreationCost float64
|
||||
@@ -501,6 +503,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
&cacheCreation1h,
|
||||
&imageOutputTokens,
|
||||
&imageOutputCost,
|
||||
&imageInputTokens,
|
||||
&imageInputCost,
|
||||
&inputCost,
|
||||
&outputCost,
|
||||
&cacheCreationCost,
|
||||
@@ -557,6 +561,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
CacheCreation1hTokens: cacheCreation1h,
|
||||
ImageOutputTokens: imageOutputTokens,
|
||||
ImageOutputCost: imageOutputCost,
|
||||
ImageInputTokens: imageInputTokens,
|
||||
ImageInputCost: imageInputCost,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
CacheCreationCost: cacheCreationCost,
|
||||
|
||||
@@ -58,6 +58,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
|
||||
log.CacheCreation1hTokens,
|
||||
log.ImageOutputTokens,
|
||||
log.ImageOutputCost,
|
||||
log.ImageInputTokens,
|
||||
log.ImageInputCost,
|
||||
log.InputCost,
|
||||
log.OutputCost,
|
||||
log.CacheCreationCost,
|
||||
@@ -145,6 +147,8 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
|
||||
log.CacheCreation1hTokens,
|
||||
log.ImageOutputTokens,
|
||||
log.ImageOutputCost,
|
||||
log.ImageInputTokens,
|
||||
log.ImageInputCost,
|
||||
log.InputCost,
|
||||
log.OutputCost,
|
||||
log.CacheCreationCost,
|
||||
@@ -267,11 +271,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) {
|
||||
CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[34])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[35])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[36])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[37])
|
||||
breakdownJSON, ok := prepared.args[38].(string)
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39])
|
||||
breakdownJSON, ok := prepared.args[40].(string)
|
||||
require.True(t, ok)
|
||||
require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON)
|
||||
}
|
||||
@@ -790,6 +794,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullInt64{},
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0, 0.0, // image_output_tokens, image_output_cost
|
||||
0, 0.0, // image_input_tokens, image_input_cost
|
||||
0.0, 0.0, 0.0, 0.0, 0.8, 0.8,
|
||||
1.0,
|
||||
sql.NullFloat64{},
|
||||
@@ -857,6 +862,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
6, // cache_creation_1h_tokens
|
||||
0, // image_output_tokens
|
||||
0.0, // image_output_cost
|
||||
0, // image_input_tokens
|
||||
0.0, // image_input_cost
|
||||
0.1, // input_cost
|
||||
0.2, // output_cost
|
||||
0.3, // cache_creation_cost
|
||||
@@ -918,6 +925,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
0, 0.0, // image_output_tokens, image_output_cost
|
||||
0, 0.0, // image_input_tokens, image_input_cost
|
||||
0.1, 0.2, 0.3, 0.4, 1.0, 0.9,
|
||||
1.0,
|
||||
sql.NullFloat64{},
|
||||
@@ -974,6 +982,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
0, 0.0, // image_output_tokens, image_output_cost
|
||||
0, 0.0, // image_input_tokens, image_input_cost
|
||||
0.1, 0.2, 0.3, 0.4, 1.0, 0.9,
|
||||
1.0,
|
||||
sql.NullFloat64{},
|
||||
|
||||
@@ -603,6 +603,8 @@ func TestAPIContracts(t *testing.T) {
|
||||
"image_size": null,
|
||||
"image_input_size": null,
|
||||
"image_output_size": null,
|
||||
"image_input_tokens": 0,
|
||||
"image_input_cost": 0,
|
||||
"image_output_tokens": 0,
|
||||
"image_output_cost": 0,
|
||||
"image_size_source": null,
|
||||
|
||||
@@ -153,7 +153,8 @@ type UsageTokens struct {
|
||||
|
||||
// CostBreakdown 费用明细
|
||||
type CostBreakdown struct {
|
||||
InputCost float64
|
||||
InputCost float64 // 文本输入费用(不含图片输入,图片输入单独记入 ImageInputCost)
|
||||
ImageInputCost float64 // 图片输入 token 费用(如 gpt-image-2 图片编辑)
|
||||
OutputCost float64
|
||||
ImageOutputCost float64
|
||||
CacheCreationCost float64
|
||||
@@ -1003,7 +1004,8 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
}
|
||||
|
||||
bd := &CostBreakdown{}
|
||||
// 分离图片输入 token 与文本输入 token(多模态 embedding 等图文不同价场景)。
|
||||
// 分离图片输入 token 与文本输入 token(多模态 embedding、图片编辑等图文不同价场景)。
|
||||
// InputCost 仅计文本输入,图片输入费用单独记入 ImageInputCost,便于对账;总额不变。
|
||||
// ImageInputTokens 为 0 时(绝大多数 chat/vision 流量)走原始单价路径,行为不变。
|
||||
if tokens.ImageInputTokens > 0 {
|
||||
imageInputTokens := tokens.ImageInputTokens
|
||||
@@ -1017,7 +1019,8 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
// 未配置图片输入档时回退到文本 input 价(已含 priority / 长上下文调整)
|
||||
imageInputPrice = inputPrice
|
||||
}
|
||||
bd.InputCost = float64(textInputTokens)*inputPrice + float64(imageInputTokens)*imageInputPrice
|
||||
bd.InputCost = float64(textInputTokens) * inputPrice
|
||||
bd.ImageInputCost = float64(imageInputTokens) * imageInputPrice
|
||||
} else {
|
||||
bd.InputCost = float64(tokens.InputTokens) * inputPrice
|
||||
}
|
||||
@@ -1045,13 +1048,14 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
|
||||
if tierMultiplier != 1.0 {
|
||||
bd.InputCost *= tierMultiplier
|
||||
bd.ImageInputCost *= tierMultiplier
|
||||
bd.OutputCost *= tierMultiplier
|
||||
bd.ImageOutputCost *= tierMultiplier
|
||||
bd.CacheCreationCost *= tierMultiplier
|
||||
bd.CacheReadCost *= tierMultiplier
|
||||
}
|
||||
|
||||
bd.TotalCost = bd.InputCost + bd.OutputCost + bd.ImageOutputCost +
|
||||
bd.TotalCost = bd.InputCost + bd.ImageInputCost + bd.OutputCost + bd.ImageOutputCost +
|
||||
bd.CacheCreationCost + bd.CacheReadCost
|
||||
bd.ActualCost = bd.TotalCost * rateMultiplier
|
||||
bd.LongContextBillingApplied = baselineCost != nil && bd.ActualCost > baselineCost.ActualCost
|
||||
@@ -1279,6 +1283,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage
|
||||
// 合并成本
|
||||
return &CostBreakdown{
|
||||
InputCost: inRangeCost.InputCost + outRangeCost.InputCost,
|
||||
ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost,
|
||||
OutputCost: inRangeCost.OutputCost,
|
||||
ImageOutputCost: inRangeCost.ImageOutputCost,
|
||||
CacheCreationCost: inRangeCost.CacheCreationCost,
|
||||
|
||||
@@ -755,7 +755,7 @@ func TestGetModelPricing_DoubaoEmbeddingVisionImageInputRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证双档计费:InputCost = 文本token×文本价 + 图片token×图片价;
|
||||
// 验证双档计费:InputCost = 文本token×文本价(不含图片),ImageInputCost = 图片token×图片价;
|
||||
// 且 ImageInputTokens=0 时走原单价路径,ImageInputTokens>InputTokens 时不负计文本。
|
||||
func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
@@ -764,22 +764,60 @@ func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) {
|
||||
mixed := UsageTokens{InputTokens: 1340, ImageInputTokens: 28}
|
||||
cost, err := svc.CalculateCost("doubao-embedding-vision", mixed, 1.0)
|
||||
require.NoError(t, err)
|
||||
wantMixed := float64(1312)*0.098e-6 + float64(28)*0.252e-6
|
||||
require.InDelta(t, wantMixed, cost.InputCost, 1e-15)
|
||||
require.InDelta(t, wantMixed, cost.TotalCost, 1e-15)
|
||||
wantText := float64(1312) * 0.098e-6
|
||||
wantImage := float64(28) * 0.252e-6
|
||||
require.InDelta(t, wantText, cost.InputCost, 1e-15, "InputCost 仅计文本输入")
|
||||
require.InDelta(t, wantImage, cost.ImageInputCost, 1e-15, "ImageInputCost 单独计图片输入")
|
||||
require.InDelta(t, wantText+wantImage, cost.TotalCost, 1e-15, "TotalCost 口径不变")
|
||||
require.Zero(t, cost.OutputCost)
|
||||
|
||||
// 纯文本:全部按文本档计费,与原单价路径一致。
|
||||
// 纯文本:全部按文本档计费,与原单价路径一致,无图片输入费用。
|
||||
textOnly := UsageTokens{InputTokens: 1340}
|
||||
costText, err := svc.CalculateCost("doubao-embedding-vision", textOnly, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, float64(1340)*0.098e-6, costText.InputCost, 1e-15)
|
||||
require.Zero(t, costText.ImageInputCost)
|
||||
|
||||
// 健壮性:ImageInputTokens 超过 InputTokens 时,文本置 0、计费 token 不超过 InputTokens。
|
||||
weird := UsageTokens{InputTokens: 10, ImageInputTokens: 50}
|
||||
costWeird, err := svc.CalculateCost("doubao-embedding-vision", weird, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, float64(10)*0.252e-6, costWeird.InputCost, 1e-15)
|
||||
require.Zero(t, costWeird.InputCost, "全为图片输入时文本费用为 0")
|
||||
require.InDelta(t, float64(10)*0.252e-6, costWeird.ImageInputCost, 1e-15)
|
||||
require.InDelta(t, float64(10)*0.252e-6, costWeird.TotalCost, 1e-15)
|
||||
}
|
||||
|
||||
// 复现 issue #4386:gpt-image-2 /v1/images/edits 带 1 张输入图。
|
||||
// 上游 usage:input_tokens=371(image_tokens=352 + text_tokens=19),
|
||||
// output_tokens=439(全部图片输出)。官方定价:文本输入 $5/1M、图片输入 $8/1M、
|
||||
// 文本输出 $10/1M、图片输出 $30/1M。修复前图片输入被并入文本价,单次偏低 ~6.6%。
|
||||
func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
pricing := &ModelPricing{
|
||||
InputPricePerToken: 5e-6,
|
||||
ImageInputPricePerToken: 8e-6,
|
||||
OutputPricePerToken: 10e-6,
|
||||
ImageOutputPricePerToken: 30e-6,
|
||||
ImageOutputPriceExplicit: true,
|
||||
}
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 371,
|
||||
ImageInputTokens: 352,
|
||||
OutputTokens: 439,
|
||||
ImageOutputTokens: 439,
|
||||
}
|
||||
|
||||
cost := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
|
||||
|
||||
wantTextInput := float64(19) * 5e-6 // 0.000095
|
||||
wantImageInput := float64(352) * 8e-6 // 0.002816
|
||||
wantImageOutput := float64(439) * 30e-6 // 0.013170
|
||||
require.InDelta(t, wantTextInput, cost.InputCost, 1e-15, "InputCost 仅含文本输入")
|
||||
require.InDelta(t, wantImageInput, cost.ImageInputCost, 1e-15, "图片输入按 $8/1M 独立计费")
|
||||
require.Zero(t, cost.OutputCost, "输出全部为图片,文本输出费用为 0")
|
||||
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
|
||||
require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
|
||||
}
|
||||
func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -259,6 +259,31 @@ func TestExtractOpenAIResponseIDFromJSONBytes(t *testing.T) {
|
||||
require.Empty(t, extractOpenAIResponseIDFromJSONBytes([]byte(`not-json`)))
|
||||
}
|
||||
|
||||
// 复现 #4386:gpt-image-2 /v1/images/edits 的 usage 携带 input_tokens_details.image_tokens,
|
||||
// 提取器须将图片输入 token 单独填入 ImageInputTokens(此前被丢弃并入 InputTokens 按文本价计费)。
|
||||
func TestExtractOpenAIUsage_CapturesImageInputTokens(t *testing.T) {
|
||||
body := []byte(`{"usage":{"input_tokens":371,"input_tokens_details":{"image_tokens":352,"text_tokens":19},"output_tokens":439,"output_tokens_details":{"image_tokens":439,"text_tokens":0},"total_tokens":810}}`)
|
||||
usage, ok := extractOpenAIUsageFromJSONBytes(body)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 371, usage.InputTokens)
|
||||
require.Equal(t, 352, usage.ImageInputTokens)
|
||||
require.Equal(t, 439, usage.OutputTokens)
|
||||
require.Equal(t, 439, usage.ImageOutputTokens)
|
||||
|
||||
// prompt_tokens_details 回退路径(部分上游用 prompt_tokens 口径)。
|
||||
promptStyle := []byte(`{"usage":{"prompt_tokens":100,"prompt_tokens_details":{"image_tokens":80}}}`)
|
||||
pu, ok := extractOpenAIUsageFromJSONBytes(promptStyle)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 100, pu.InputTokens)
|
||||
require.Equal(t, 80, pu.ImageInputTokens)
|
||||
|
||||
// 纯文本请求:无 image_tokens 时 ImageInputTokens 为 0,行为不变。
|
||||
textOnly := []byte(`{"usage":{"input_tokens":50,"output_tokens":10}}`)
|
||||
tu, ok := extractOpenAIUsageFromJSONBytes(textOnly)
|
||||
require.True(t, ok)
|
||||
require.Zero(t, tu.ImageInputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -254,6 +254,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
ImageInputTokens: result.Usage.ImageInputTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
ImageCount: result.ImageCount,
|
||||
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
|
||||
@@ -271,6 +272,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
}
|
||||
if cost != nil {
|
||||
usageLog.InputCost = cost.InputCost
|
||||
usageLog.ImageInputCost = cost.ImageInputCost
|
||||
usageLog.OutputCost = cost.OutputCost
|
||||
usageLog.ImageOutputCost = cost.ImageOutputCost
|
||||
usageLog.CacheCreationCost = cost.CacheCreationCost
|
||||
|
||||
@@ -139,6 +139,8 @@ type UsageLog struct {
|
||||
CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"`
|
||||
CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"`
|
||||
|
||||
ImageInputTokens int
|
||||
ImageInputCost float64
|
||||
ImageOutputTokens int
|
||||
ImageOutputCost float64
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 179_usage_log_image_input_tokens.sql
|
||||
-- usage_logs 单独记录图片输入 token 数与费用,便于图片编辑/图生图场景对账。
|
||||
-- image_input_tokens 从 input_tokens 中拆出,image_input_cost 从 input_cost 中拆出,
|
||||
-- total_cost 口径不变。
|
||||
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_input_tokens INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_input_cost DECIMAL(20, 10) NOT NULL DEFAULT 0;
|
||||
@@ -144,6 +144,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="hasImageInputTokens(row)" class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-fuchsia-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-fuchsia-600 dark:text-fuchsia-400">{{ row.image_input_tokens.toLocaleString() }}</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>
|
||||
@@ -246,10 +252,18 @@
|
||||
<div class="space-y-1.5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0 && !hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<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 && hasImageInputTokens(tokenTooltipData) && textInputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ textInputTokens(tokenTooltipData).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputTokens') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">{{ tokenTooltipData.image_input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -327,6 +341,10 @@
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputCost(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputCost') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">${{ tooltipData.image_input_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
@@ -337,9 +355,13 @@
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!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">
|
||||
<div v-if="tooltipData && textInputTokens(tooltipData) > 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>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, textInputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputTokens(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputTokenPrice') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">{{ formatTokenPricePerMillion(tooltipData.image_input_cost ?? 0, tooltipData.image_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>
|
||||
@@ -471,6 +493,9 @@ import {
|
||||
hasImageOutputTokens,
|
||||
textOutputTokens,
|
||||
hasImageOutputCost,
|
||||
hasImageInputTokens,
|
||||
textInputTokens,
|
||||
hasImageInputCost,
|
||||
} from '@/utils/imageUsage'
|
||||
|
||||
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
||||
|
||||
@@ -325,6 +325,9 @@ export default {
|
||||
imageBillingSize: 'Billing size',
|
||||
imageInputSize: 'Input size',
|
||||
imageOutputSize: 'Output size',
|
||||
imageInputTokens: 'Image Input Tokens',
|
||||
imageInputTokenPrice: 'Image Input Price',
|
||||
imageInputCost: 'Image Input Cost',
|
||||
imageOutputTokens: 'Image Output Tokens',
|
||||
imageOutputTokenPrice: 'Image Output Price',
|
||||
imageOutputCost: 'Image Output Cost',
|
||||
|
||||
@@ -330,6 +330,9 @@ export default {
|
||||
imageBillingSize: '计费尺寸',
|
||||
imageInputSize: '输入尺寸',
|
||||
imageOutputSize: '输出尺寸',
|
||||
imageInputTokens: '图片输入 Token',
|
||||
imageInputTokenPrice: '图片输入单价',
|
||||
imageInputCost: '图片输入费用',
|
||||
imageOutputTokens: '图片输出 Token',
|
||||
imageOutputTokenPrice: '图片输出单价',
|
||||
imageOutputCost: '图片输出费用',
|
||||
|
||||
@@ -1398,6 +1398,8 @@ export interface UsageLog {
|
||||
image_output_size: string | null
|
||||
image_size_source: ImageSizeSource | null
|
||||
image_size_breakdown: ImageSizeBreakdown | null
|
||||
image_input_tokens: number
|
||||
image_input_cost: number
|
||||
image_output_tokens: number
|
||||
image_output_cost: number
|
||||
|
||||
|
||||
@@ -22,6 +22,26 @@ export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): n
|
||||
export const hasImageOutputCost = (row: ImageOutputCostRow | null | undefined): boolean =>
|
||||
(row?.image_output_cost ?? 0) > 0
|
||||
|
||||
// --- Image input token / cost helpers ---
|
||||
|
||||
type ImageInputTokenRow = Pick<UsageLog, 'input_tokens' | 'image_input_tokens'>
|
||||
type ImageInputCostRow = Pick<UsageLog, 'image_input_cost'>
|
||||
|
||||
/** Whether the row contains any image-input tokens (e.g. gpt-image-2 image edits). */
|
||||
export const hasImageInputTokens = (row: ImageInputTokenRow | null | undefined): boolean =>
|
||||
(row?.image_input_tokens ?? 0) > 0
|
||||
|
||||
/**
|
||||
* Text-only input tokens (total input minus image-input).
|
||||
* Returns 0 when no text tokens exist.
|
||||
*/
|
||||
export const textInputTokens = (row: ImageInputTokenRow | null | undefined): number =>
|
||||
Math.max(0, (row?.input_tokens ?? 0) - (row?.image_input_tokens ?? 0))
|
||||
|
||||
/** Whether the row has a non-zero image-input cost. */
|
||||
export const hasImageInputCost = (row: ImageInputCostRow | null | undefined): boolean =>
|
||||
(row?.image_input_cost ?? 0) > 0
|
||||
|
||||
// --- Image size / billing helpers ---
|
||||
|
||||
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
|
||||
|
||||
Reference in new issue
Block a user