mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
图片编辑/图生图请求的图片输入 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
217 lines
5.8 KiB
Go
217 lines
5.8 KiB
Go
package service
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
BillingTypeBalance int8 = 0 // 钱包余额
|
||
BillingTypeSubscription int8 = 1 // 订阅套餐
|
||
)
|
||
|
||
type RequestType int16
|
||
|
||
const (
|
||
RequestTypeUnknown RequestType = 0
|
||
RequestTypeSync RequestType = 1
|
||
RequestTypeStream RequestType = 2
|
||
RequestTypeWSV2 RequestType = 3
|
||
RequestTypeCyberBlocked RequestType = 4 // cyber_policy 命中(透传但被上游安全策略拒绝)
|
||
)
|
||
|
||
func (t RequestType) IsValid() bool {
|
||
switch t {
|
||
case RequestTypeUnknown, RequestTypeSync, RequestTypeStream, RequestTypeWSV2, RequestTypeCyberBlocked:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (t RequestType) Normalize() RequestType {
|
||
if t.IsValid() {
|
||
return t
|
||
}
|
||
return RequestTypeUnknown
|
||
}
|
||
|
||
func (t RequestType) String() string {
|
||
switch t.Normalize() {
|
||
case RequestTypeSync:
|
||
return "sync"
|
||
case RequestTypeStream:
|
||
return "stream"
|
||
case RequestTypeWSV2:
|
||
return "ws_v2"
|
||
case RequestTypeCyberBlocked:
|
||
return "cyber"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
func RequestTypeFromInt16(v int16) RequestType {
|
||
return RequestType(v).Normalize()
|
||
}
|
||
|
||
func ParseUsageRequestType(value string) (RequestType, error) {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "unknown":
|
||
return RequestTypeUnknown, nil
|
||
case "sync":
|
||
return RequestTypeSync, nil
|
||
case "stream":
|
||
return RequestTypeStream, nil
|
||
case "ws_v2":
|
||
return RequestTypeWSV2, nil
|
||
case "cyber":
|
||
return RequestTypeCyberBlocked, nil
|
||
default:
|
||
return RequestTypeUnknown, fmt.Errorf("invalid request_type, allowed values: unknown, sync, stream, ws_v2, cyber")
|
||
}
|
||
}
|
||
|
||
func RequestTypeFromLegacy(stream bool, openAIWSMode bool) RequestType {
|
||
if openAIWSMode {
|
||
return RequestTypeWSV2
|
||
}
|
||
if stream {
|
||
return RequestTypeStream
|
||
}
|
||
return RequestTypeSync
|
||
}
|
||
|
||
func ApplyLegacyRequestFields(requestType RequestType, fallbackStream bool, fallbackOpenAIWSMode bool) (stream bool, openAIWSMode bool) {
|
||
switch requestType.Normalize() {
|
||
case RequestTypeSync:
|
||
return false, false
|
||
case RequestTypeStream:
|
||
return true, false
|
||
case RequestTypeWSV2:
|
||
return true, true
|
||
default:
|
||
return fallbackStream, fallbackOpenAIWSMode
|
||
}
|
||
}
|
||
|
||
type UsageLog struct {
|
||
ID int64
|
||
UserID int64
|
||
APIKeyID int64
|
||
AccountID int64
|
||
RequestID string
|
||
Model string
|
||
// RequestedModel is the client-requested model name recorded for stable user/admin display.
|
||
// Empty should be treated as Model for backward compatibility with historical rows.
|
||
RequestedModel string
|
||
// UpstreamModel is the actual model sent to the upstream provider after mapping.
|
||
// Nil means no mapping was applied (requested model was used as-is).
|
||
UpstreamModel *string
|
||
// ChannelID 渠道 ID
|
||
ChannelID *int64
|
||
// ModelMappingChain 模型映射链,如 "a→b→c"
|
||
ModelMappingChain *string
|
||
// BillingTier 计费层级标签(per_request/image 模式)
|
||
BillingTier *string
|
||
// BillingMode 计费模式:token/image
|
||
BillingMode *string
|
||
// ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex".
|
||
ServiceTier *string
|
||
// ReasoningEffort is the request's reasoning effort level.
|
||
// OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max".
|
||
// Nil means not provided / not applicable.
|
||
ReasoningEffort *string
|
||
// InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions.
|
||
InboundEndpoint *string
|
||
// UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses.
|
||
UpstreamEndpoint *string
|
||
|
||
GroupID *int64
|
||
SubscriptionID *int64
|
||
|
||
InputTokens int
|
||
OutputTokens int
|
||
CacheCreationTokens int
|
||
CacheReadTokens int
|
||
|
||
CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"`
|
||
CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"`
|
||
|
||
ImageInputTokens int
|
||
ImageInputCost float64
|
||
ImageOutputTokens int
|
||
ImageOutputCost float64
|
||
|
||
InputCost float64
|
||
OutputCost float64
|
||
CacheCreationCost float64
|
||
CacheReadCost float64
|
||
TotalCost float64
|
||
ActualCost float64
|
||
RateMultiplier float64
|
||
LongContextBillingApplied bool
|
||
// AccountRateMultiplier 账号计费倍率快照(nil 表示历史数据,按 1.0 处理)
|
||
AccountRateMultiplier *float64
|
||
// AccountStatsCost 账号统计定价预计算费用(nil = 使用默认公式 total_cost × account_rate_multiplier)
|
||
AccountStatsCost *float64
|
||
|
||
BillingType int8
|
||
RequestType RequestType
|
||
Stream bool
|
||
OpenAIWSMode bool
|
||
DurationMs *int
|
||
FirstTokenMs *int
|
||
UserAgent *string
|
||
IPAddress *string
|
||
|
||
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
|
||
CacheTTLOverridden bool
|
||
|
||
// 图片生成字段
|
||
ImageCount int
|
||
ImageSize *string
|
||
ImageInputSize *string
|
||
ImageOutputSize *string
|
||
ImageSizeSource *string
|
||
ImageSizeBreakdown map[string]int
|
||
MediaType *string
|
||
|
||
// 视频生成字段(Grok 视频按秒计费;video_count>0 的行不要求 image_size)
|
||
VideoCount int
|
||
VideoResolution *string
|
||
VideoDurationSeconds *int
|
||
|
||
CreatedAt time.Time
|
||
|
||
User *User
|
||
APIKey *APIKey
|
||
Account *Account
|
||
Group *Group
|
||
Subscription *UserSubscription
|
||
}
|
||
|
||
func (u *UsageLog) TotalTokens() int {
|
||
return u.InputTokens + u.OutputTokens + u.CacheCreationTokens + u.CacheReadTokens
|
||
}
|
||
|
||
func (u *UsageLog) EffectiveRequestType() RequestType {
|
||
if u == nil {
|
||
return RequestTypeUnknown
|
||
}
|
||
if normalized := u.RequestType.Normalize(); normalized != RequestTypeUnknown {
|
||
return normalized
|
||
}
|
||
return RequestTypeFromLegacy(u.Stream, u.OpenAIWSMode)
|
||
}
|
||
|
||
func (u *UsageLog) SyncRequestTypeAndLegacyFields() {
|
||
if u == nil {
|
||
return
|
||
}
|
||
requestType := u.EffectiveRequestType()
|
||
u.RequestType = requestType
|
||
u.Stream, u.OpenAIWSMode = ApplyLegacyRequestFields(requestType, u.Stream, u.OpenAIWSMode)
|
||
}
|