843 lines
30 KiB
Go
843 lines
30 KiB
Go
package service
|
||
|
||
// 本文件由 openai_gateway_service.go 纯移动拆分而来:用量记录、计费成本计算与
|
||
// Codex 用量快照。仅做代码搬迁,无任何行为变更。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// OpenAIRecordUsageInput input for recording usage
|
||
type OpenAIRecordUsageInput struct {
|
||
Result *OpenAIForwardResult
|
||
APIKey *APIKey
|
||
User *User
|
||
Account *Account
|
||
Subscription *UserSubscription
|
||
InboundEndpoint string
|
||
UpstreamEndpoint string
|
||
UserAgent string // 请求的 User-Agent
|
||
IPAddress string // 请求的客户端 IP 地址
|
||
RequestPayloadHash string
|
||
APIKeyService APIKeyQuotaUpdater
|
||
QuotaPlatform string // user×platform quota platform resolved by the handler before async billing.
|
||
// CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。
|
||
CyberBlocked bool
|
||
ChannelUsageFields
|
||
}
|
||
|
||
// CyberPolicyUsageInput 是 cyber 拒绝、未走正常 RecordUsage 的请求记录用量的入参。
|
||
// 用量按上游真实 token 计费,与 WS cyber 及正常请求口径一致(InputTokens/OutputTokens
|
||
// 取自上游 response.failed 报告的 usage,即 mark.UpstreamInTok/OutTok)。
|
||
type CyberPolicyUsageInput struct {
|
||
APIKey *APIKey
|
||
Account *Account
|
||
Subscription *UserSubscription
|
||
RequestID string
|
||
Model string
|
||
Stream bool
|
||
InputTokens int
|
||
OutputTokens int
|
||
// 渠道归因与请求级 meta,使 cyber 计费行与正常 RecordUsage 行口径一致
|
||
// (否则 cyber 行 channel_id 等为空,渠道维度统计会遗漏 cyber 命中)。
|
||
InboundEndpoint string
|
||
UpstreamEndpoint string
|
||
UserAgent string
|
||
IPAddress string
|
||
RequestPayloadHash string
|
||
APIKeyService APIKeyQuotaUpdater
|
||
ChannelUsageFields
|
||
}
|
||
|
||
// RecordCyberPolicyUsageLog 为被上游 cyber_policy 拒绝、未走正常 RecordUsage 的请求
|
||
// (HTTP forward 返回错误路径)记录用量并按上游真实 token 计费,使其与 WS cyber 路径、
|
||
// 与正常请求的计费口径统一(不再是 tokens=0 免费行)。token 取自上游 response.failed
|
||
// 报告的 usage(非流式直接拒通常为 0,cost 随之为 0)。复用 RecordUsage 完成成本计算、
|
||
// 扣费与用量行写入(request_type=cyber 由 CyberBlocked 置位)。仅 forward 返回错误的
|
||
// 路径由 handler 调用,避免与成功路径的正常 RecordUsage 重复。
|
||
func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in CyberPolicyUsageInput) {
|
||
if s == nil || in.APIKey == nil || in.APIKey.User == nil || in.Account == nil || strings.TrimSpace(in.Model) == "" {
|
||
return
|
||
}
|
||
result := &OpenAIForwardResult{
|
||
RequestID: in.RequestID,
|
||
Model: in.Model,
|
||
Stream: in.Stream,
|
||
Usage: OpenAIUsage{
|
||
InputTokens: in.InputTokens,
|
||
OutputTokens: in.OutputTokens,
|
||
},
|
||
}
|
||
if err := s.RecordUsage(ctx, &OpenAIRecordUsageInput{
|
||
Result: result,
|
||
APIKey: in.APIKey,
|
||
User: in.APIKey.User,
|
||
Account: in.Account,
|
||
Subscription: in.Subscription,
|
||
InboundEndpoint: in.InboundEndpoint,
|
||
UpstreamEndpoint: in.UpstreamEndpoint,
|
||
UserAgent: in.UserAgent,
|
||
IPAddress: in.IPAddress,
|
||
RequestPayloadHash: in.RequestPayloadHash,
|
||
APIKeyService: in.APIKeyService,
|
||
ChannelUsageFields: in.ChannelUsageFields,
|
||
CyberBlocked: true,
|
||
}); err != nil {
|
||
logger.LegacyPrintf("service.openai_gateway", "cyber usage record failed: request_id=%s err=%v", in.RequestID, err)
|
||
}
|
||
}
|
||
|
||
// ResolveUserGroupRateMultiplier resolves the same cached multiplier used by OpenAI usage billing.
|
||
func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
|
||
if s == nil {
|
||
return groupDefaultMultiplier
|
||
}
|
||
resolver := s.userGroupRateResolver
|
||
if resolver == nil {
|
||
resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway")
|
||
}
|
||
return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
|
||
}
|
||
|
||
// RecordUsage records usage and deducts balance
|
||
func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error {
|
||
if input == nil {
|
||
return errors.New("openai usage input is nil")
|
||
}
|
||
result := input.Result
|
||
if result == nil {
|
||
return errors.New("openai usage result is nil")
|
||
}
|
||
if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI {
|
||
s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID)
|
||
}
|
||
|
||
apiKey := input.APIKey
|
||
user := input.User
|
||
account := input.Account
|
||
subscription := input.Subscription
|
||
if !isGrokVideoUsageResult(result, nil) {
|
||
ApplyOpenAIImageBillingResolution(result)
|
||
}
|
||
|
||
// OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。
|
||
// 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。
|
||
actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens - result.Usage.CacheCreationInputTokens
|
||
if actualInputTokens < 0 {
|
||
actualInputTokens = 0
|
||
}
|
||
|
||
// Calculate cost
|
||
tokens := UsageTokens{
|
||
InputTokens: actualInputTokens,
|
||
ImageInputTokens: result.Usage.ImageInputTokens,
|
||
OutputTokens: result.Usage.OutputTokens,
|
||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||
}
|
||
|
||
// Get rate multiplier
|
||
multiplier := 1.0
|
||
if s.cfg != nil {
|
||
multiplier = s.cfg.Default.RateMultiplier
|
||
}
|
||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||
multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier)
|
||
}
|
||
// token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算,
|
||
// 不并入上面的 Resolve,以免污染 user:group 倍率缓存。
|
||
baseMultiplier := multiplier
|
||
multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, timezone.Now())
|
||
videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier)
|
||
|
||
var cost *CostBreakdown
|
||
var err error
|
||
billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
|
||
if result.BillingModel != "" {
|
||
billingModel = strings.TrimSpace(result.BillingModel)
|
||
}
|
||
if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" && input.ChannelMappedModel != input.OriginalModel {
|
||
billingModel = input.ChannelMappedModel
|
||
}
|
||
if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" {
|
||
billingModel = input.OriginalModel
|
||
}
|
||
billingModels := usageBillingModelCandidates(
|
||
billingModel,
|
||
result.BillingModel,
|
||
input.ChannelMappedModel,
|
||
input.OriginalModel,
|
||
result.UpstreamModel,
|
||
result.Model,
|
||
)
|
||
serviceTier := ""
|
||
if result.ServiceTier != nil {
|
||
serviceTier = strings.TrimSpace(*result.ServiceTier)
|
||
}
|
||
billingAccount := account
|
||
if account.IsShadow() {
|
||
billingAccount, err = resolveCredentialAccount(ctx, s.accountRepo, account)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
longContextBillingEnabled := billingAccount.IsOpenAILongContextBillingEnabled()
|
||
cost, err = s.calculateOpenAIRecordUsageCost(
|
||
ctx,
|
||
result,
|
||
apiKey,
|
||
billingModels,
|
||
multiplier,
|
||
imageMultiplier,
|
||
videoMultiplier,
|
||
baseMultiplier,
|
||
tokens,
|
||
serviceTier,
|
||
longContextBillingEnabled,
|
||
)
|
||
if err != nil {
|
||
if !isUsagePricingUnavailableError(err) {
|
||
return err
|
||
}
|
||
logger.L().With(
|
||
zap.String("component", "service.openai_gateway"),
|
||
zap.Strings("billing_models", billingModels),
|
||
zap.String("requested_model", input.OriginalModel),
|
||
zap.String("mapped_model", input.ChannelMappedModel),
|
||
zap.String("upstream_model", result.UpstreamModel),
|
||
zap.Int64("api_key_id", apiKey.ID),
|
||
zap.Int64("account_id", account.ID),
|
||
).Warn("openai_usage.pricing_missing_record_zero_cost", zap.Error(err))
|
||
cost = &CostBreakdown{BillingMode: string(BillingModeToken)}
|
||
}
|
||
|
||
// Determine billing type
|
||
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||
billingType := BillingTypeBalance
|
||
if isSubscriptionBilling {
|
||
billingType = BillingTypeSubscription
|
||
}
|
||
|
||
// Create usage log
|
||
durationMs := int(result.Duration.Milliseconds())
|
||
accountRateMultiplier := account.BillingRateMultiplier()
|
||
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
|
||
if result.OpenAIWSMode {
|
||
if upstreamRequestID := strings.TrimSpace(result.RequestID); upstreamRequestID != "" {
|
||
requestID = upstreamRequestID
|
||
}
|
||
}
|
||
|
||
// 确定 RequestedModel(渠道映射前的原始模型)
|
||
requestedModel := result.Model
|
||
if input.OriginalModel != "" {
|
||
requestedModel = input.OriginalModel
|
||
}
|
||
|
||
usageLog := &UsageLog{
|
||
UserID: user.ID,
|
||
APIKeyID: apiKey.ID,
|
||
AccountID: account.ID,
|
||
RequestID: requestID,
|
||
Model: result.Model,
|
||
RequestedModel: requestedModel,
|
||
UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model),
|
||
ServiceTier: result.ServiceTier,
|
||
ReasoningEffort: result.ReasoningEffort,
|
||
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
|
||
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
|
||
InputTokens: actualInputTokens,
|
||
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),
|
||
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
|
||
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
|
||
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
|
||
ImageSizeBreakdown: result.ImageSizeBreakdown,
|
||
}
|
||
isVideoUsage := isGrokVideoUsageResult(result, billingModels)
|
||
if isVideoUsage {
|
||
usageLog.VideoCount = result.VideoCount
|
||
usageLog.VideoResolution = optionalTrimmedStringPtr(NormalizeVideoBillingResolutionOrDefault(result.VideoResolution))
|
||
videoDurationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds)
|
||
usageLog.VideoDurationSeconds = &videoDurationSeconds
|
||
}
|
||
if cost != nil {
|
||
usageLog.InputCost = cost.InputCost
|
||
usageLog.ImageInputCost = cost.ImageInputCost
|
||
usageLog.OutputCost = cost.OutputCost
|
||
usageLog.ImageOutputCost = cost.ImageOutputCost
|
||
usageLog.CacheCreationCost = cost.CacheCreationCost
|
||
usageLog.CacheReadCost = cost.CacheReadCost
|
||
usageLog.TotalCost = cost.TotalCost
|
||
usageLog.ActualCost = cost.ActualCost
|
||
usageLog.LongContextBillingApplied = cost.LongContextBillingApplied
|
||
}
|
||
if isVideoUsage && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
|
||
usageLog.RateMultiplier = videoMultiplier
|
||
} else if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
|
||
usageLog.RateMultiplier = imageMultiplier
|
||
} else {
|
||
usageLog.RateMultiplier = multiplier
|
||
}
|
||
usageLog.AccountRateMultiplier = &accountRateMultiplier
|
||
usageLog.BillingType = billingType
|
||
usageLog.Stream = result.Stream
|
||
if input.CyberBlocked {
|
||
usageLog.RequestType = RequestTypeCyberBlocked
|
||
}
|
||
usageLog.OpenAIWSMode = result.OpenAIWSMode
|
||
usageLog.DurationMs = &durationMs
|
||
usageLog.FirstTokenMs = result.FirstTokenMs
|
||
usageLog.CreatedAt = time.Now()
|
||
// 设置渠道信息
|
||
usageLog.ChannelID = optionalInt64Ptr(input.ChannelID)
|
||
usageLog.ModelMappingChain = optionalTrimmedStringPtr(input.ModelMappingChain)
|
||
// 设置计费模式
|
||
if cost != nil && cost.BillingMode != "" {
|
||
billingMode := cost.BillingMode
|
||
usageLog.BillingMode = &billingMode
|
||
} else if isVideoUsage {
|
||
billingMode := string(BillingModeVideo)
|
||
usageLog.BillingMode = &billingMode
|
||
} else if result.ImageCount > 0 {
|
||
billingMode := string(BillingModeImage)
|
||
usageLog.BillingMode = &billingMode
|
||
} else {
|
||
billingMode := string(BillingModeToken)
|
||
usageLog.BillingMode = &billingMode
|
||
}
|
||
// 添加 UserAgent
|
||
if input.UserAgent != "" {
|
||
usageLog.UserAgent = &input.UserAgent
|
||
}
|
||
|
||
// 添加 IPAddress
|
||
if input.IPAddress != "" {
|
||
usageLog.IPAddress = &input.IPAddress
|
||
}
|
||
|
||
if apiKey.GroupID != nil {
|
||
usageLog.GroupID = apiKey.GroupID
|
||
}
|
||
if subscription != nil {
|
||
usageLog.SubscriptionID = &subscription.ID
|
||
}
|
||
|
||
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
|
||
if apiKey.GroupID != nil {
|
||
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
|
||
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
|
||
tokens, cost.TotalCost,
|
||
)
|
||
}
|
||
|
||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
|
||
logger.LegacyPrintf("service.openai_gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||
return nil
|
||
}
|
||
|
||
// Async usage billing runs outside the original request context, so it
|
||
// cannot recover ForcePlatform there. Fall back for internal/test callers.
|
||
quotaPlatform := input.QuotaPlatform
|
||
if quotaPlatform == "" {
|
||
quotaPlatform = PlatformFromAPIKey(apiKey)
|
||
}
|
||
|
||
billingErr := func() error {
|
||
_, err := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{
|
||
Cost: cost,
|
||
User: user,
|
||
APIKey: apiKey,
|
||
Account: account,
|
||
Subscription: subscription,
|
||
RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
|
||
IsSubscriptionBill: isSubscriptionBilling,
|
||
AccountRateMultiplier: accountRateMultiplier,
|
||
APIKeyService: input.APIKeyService,
|
||
Platform: quotaPlatform,
|
||
}, s.billingDeps(), s.usageBillingRepo)
|
||
return err
|
||
}()
|
||
|
||
if billingErr != nil {
|
||
return billingErr
|
||
}
|
||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
|
||
ctx context.Context,
|
||
result *OpenAIForwardResult,
|
||
apiKey *APIKey,
|
||
billingModels []string,
|
||
multiplier float64,
|
||
imageMultiplier float64,
|
||
videoMultiplier float64,
|
||
webSearchMultiplier float64,
|
||
tokens UsageTokens,
|
||
serviceTier string,
|
||
longContextBillingEnabled bool,
|
||
) (*CostBreakdown, error) {
|
||
billingModel := firstUsageBillingModel(billingModels)
|
||
if result != nil && result.WebSearchCalls > 0 {
|
||
// Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,单价只取
|
||
// 分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),不参与渠道级模型定价。
|
||
// 倍率与 image/video 按次口径一致:使用不含高峰因子的基础倍率
|
||
//(用户专属 > 分组 rate_multiplier > 系统默认),与分组表单的价格预览承诺一致。
|
||
return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), webSearchMultiplier), nil
|
||
}
|
||
if isGrokVideoUsageResult(result, billingModels) {
|
||
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
|
||
return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil
|
||
}
|
||
}
|
||
if result != nil && result.ImageCount > 0 {
|
||
// 渠道定价为 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")
|
||
}
|
||
var lastErr error
|
||
for _, candidate := range billingModels {
|
||
candidate = strings.TrimSpace(candidate)
|
||
if candidate == "" {
|
||
continue
|
||
}
|
||
cost, err := s.calculateOpenAIRecordUsageTokenCost(
|
||
ctx,
|
||
apiKey,
|
||
candidate,
|
||
multiplier,
|
||
tokens,
|
||
serviceTier,
|
||
longContextBillingEnabled,
|
||
)
|
||
if err == nil {
|
||
return cost, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = errors.New("no non-empty billing model candidates")
|
||
}
|
||
return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
|
||
}
|
||
|
||
func isGrokVideoBillingModel(model string) bool {
|
||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-imagine-video")
|
||
}
|
||
|
||
func isGrokVideoUsageResult(result *OpenAIForwardResult, billingModels []string) bool {
|
||
if result == nil || result.VideoCount <= 0 {
|
||
return false
|
||
}
|
||
candidates := append([]string{}, billingModels...)
|
||
candidates = append(candidates, result.BillingModel, result.Model, result.UpstreamModel)
|
||
for _, candidate := range candidates {
|
||
if isGrokVideoBillingModel(candidate) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isUsagePricingUnavailableError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if errors.Is(err, ErrModelPricingUnavailable) {
|
||
return true
|
||
}
|
||
msg := strings.ToLower(err.Error())
|
||
return strings.Contains(msg, "no pricing available") || strings.Contains(msg, "pricing not found")
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost(
|
||
ctx context.Context,
|
||
apiKey *APIKey,
|
||
billingModel string,
|
||
multiplier float64,
|
||
tokens UsageTokens,
|
||
serviceTier string,
|
||
longContextBillingEnabled bool,
|
||
) (*CostBreakdown, error) {
|
||
if s.resolver != nil && apiKey.Group != nil {
|
||
gid := apiKey.Group.ID
|
||
return s.billingService.CalculateCostUnified(CostInput{
|
||
Ctx: ctx,
|
||
Model: billingModel,
|
||
GroupID: &gid,
|
||
Tokens: tokens,
|
||
RequestCount: 1,
|
||
RateMultiplier: multiplier,
|
||
ServiceTier: serviceTier,
|
||
Resolver: s.resolver,
|
||
LongContextBillingEnabled: &longContextBillingEnabled,
|
||
})
|
||
}
|
||
return s.billingService.calculateCostWithServiceTierPolicy(
|
||
billingModel,
|
||
tokens,
|
||
multiplier,
|
||
serviceTier,
|
||
longContextBillingEnabled,
|
||
)
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) calculateOpenAIImageCost(
|
||
ctx context.Context,
|
||
billingModel string,
|
||
apiKey *APIKey,
|
||
result *OpenAIForwardResult,
|
||
multiplier float64,
|
||
) *CostBreakdown {
|
||
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
|
||
groupConfig := imagePriceConfigFromAPIKey(apiKey)
|
||
if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
|
||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||
}
|
||
if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey {
|
||
apiKey = refreshed
|
||
groupConfig = imagePriceConfigFromAPIKey(apiKey)
|
||
if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
|
||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||
}
|
||
}
|
||
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
|
||
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
|
||
gid := apiKey.Group.ID
|
||
cost, err := s.billingService.CalculateCostUnified(CostInput{
|
||
Ctx: ctx,
|
||
Model: billingModel,
|
||
GroupID: &gid,
|
||
RequestCount: result.ImageCount,
|
||
SizeTier: sizeTier,
|
||
RateMultiplier: multiplier,
|
||
Resolver: s.resolver,
|
||
Resolved: resolved,
|
||
})
|
||
if err == nil {
|
||
return cost
|
||
}
|
||
logger.LegacyPrintf("service.openai_gateway", "Calculate image channel cost failed: %v", err)
|
||
}
|
||
|
||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) calculateOpenAIVideoCost(
|
||
ctx context.Context,
|
||
billingModel string,
|
||
apiKey *APIKey,
|
||
result *OpenAIForwardResult,
|
||
multiplier float64,
|
||
) *CostBreakdown {
|
||
videoCount := result.VideoCount
|
||
if videoCount <= 0 {
|
||
videoCount = 1
|
||
}
|
||
resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution)
|
||
durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds)
|
||
groupConfig := videoPriceConfigFromAPIKey(apiKey)
|
||
if apiKeyHasConfiguredVideoPrice(apiKey, resolution) {
|
||
return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
|
||
}
|
||
if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey {
|
||
apiKey = refreshed
|
||
groupConfig = videoPriceConfigFromAPIKey(apiKey)
|
||
if apiKeyHasConfiguredVideoPrice(apiKey, resolution) {
|
||
return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
|
||
}
|
||
}
|
||
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
|
||
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
|
||
// 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。
|
||
gid := apiKey.Group.ID
|
||
cost, err := s.billingService.CalculateCostUnified(CostInput{
|
||
Ctx: ctx,
|
||
Model: billingModel,
|
||
GroupID: &gid,
|
||
RequestCount: videoCount,
|
||
SizeTier: resolution,
|
||
RateMultiplier: multiplier,
|
||
Resolver: s.resolver,
|
||
Resolved: resolved,
|
||
})
|
||
if err == nil {
|
||
cost.BillingMode = string(BillingModeVideo)
|
||
return cost
|
||
}
|
||
logger.LegacyPrintf("service.openai_gateway", "Calculate video channel cost failed: %v", err)
|
||
}
|
||
|
||
return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Context, apiKey *APIKey) *APIKey {
|
||
if apiKey == nil || apiKey.GroupID == nil || *apiKey.GroupID <= 0 {
|
||
return apiKey
|
||
}
|
||
if !groupMediaPricingLooksIncomplete(apiKey.Group) {
|
||
return apiKey
|
||
}
|
||
if s == nil || s.channelService == nil || s.channelService.groupRepo == nil {
|
||
return apiKey
|
||
}
|
||
group, err := s.channelService.groupRepo.GetByIDLite(ctx, *apiKey.GroupID)
|
||
if err != nil || group == nil {
|
||
return apiKey
|
||
}
|
||
clone := *apiKey
|
||
clone.Group = group
|
||
return &clone
|
||
}
|
||
|
||
// groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体计费字段(例如由不含
|
||
// 这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在数据库中的
|
||
// 默认值均为 1.0,正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、全部媒体
|
||
// 价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条媒体用量都多打一次 DB 查询。
|
||
func groupMediaPricingLooksIncomplete(group *Group) bool {
|
||
if group == nil {
|
||
return true
|
||
}
|
||
if group.ImageRateIndependent || group.VideoRateIndependent {
|
||
return false
|
||
}
|
||
if group.ImageRateMultiplier != 0 || group.VideoRateMultiplier != 0 {
|
||
return false
|
||
}
|
||
return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil &&
|
||
group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
|
||
if s.resolver == nil || apiKey == nil || apiKey.Group == nil {
|
||
return nil
|
||
}
|
||
gid := apiKey.Group.ID
|
||
resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid})
|
||
if resolved.Source == PricingSourceChannel {
|
||
return resolved
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ParseCodexRateLimitHeaders extracts Codex usage limits from response headers.
|
||
// Exported for use in ratelimit_service when handling OpenAI 429 responses.
|
||
func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
|
||
snapshot := &OpenAICodexUsageSnapshot{}
|
||
hasData := false
|
||
|
||
// Helper to parse float64 from header
|
||
parseFloat := func(key string) *float64 {
|
||
if v := headers.Get(key); v != "" {
|
||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||
return &f
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Helper to parse int from header
|
||
parseInt := func(key string) *int {
|
||
if v := headers.Get(key); v != "" {
|
||
if i, err := strconv.Atoi(v); err == nil {
|
||
return &i
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Primary (weekly) limits
|
||
if v := parseFloat("x-codex-primary-used-percent"); v != nil {
|
||
snapshot.PrimaryUsedPercent = v
|
||
hasData = true
|
||
}
|
||
if v := parseInt("x-codex-primary-reset-after-seconds"); v != nil {
|
||
snapshot.PrimaryResetAfterSeconds = v
|
||
hasData = true
|
||
}
|
||
if v := parseInt("x-codex-primary-window-minutes"); v != nil {
|
||
snapshot.PrimaryWindowMinutes = v
|
||
hasData = true
|
||
}
|
||
|
||
// Secondary (5h) limits
|
||
if v := parseFloat("x-codex-secondary-used-percent"); v != nil {
|
||
snapshot.SecondaryUsedPercent = v
|
||
hasData = true
|
||
}
|
||
if v := parseInt("x-codex-secondary-reset-after-seconds"); v != nil {
|
||
snapshot.SecondaryResetAfterSeconds = v
|
||
hasData = true
|
||
}
|
||
if v := parseInt("x-codex-secondary-window-minutes"); v != nil {
|
||
snapshot.SecondaryWindowMinutes = v
|
||
hasData = true
|
||
}
|
||
|
||
// Overflow ratio
|
||
if v := parseFloat("x-codex-primary-over-secondary-limit-percent"); v != nil {
|
||
snapshot.PrimaryOverSecondaryPercent = v
|
||
hasData = true
|
||
}
|
||
|
||
if !hasData {
|
||
return nil
|
||
}
|
||
|
||
snapshot.UpdatedAt = time.Now().Format(time.RFC3339)
|
||
return snapshot
|
||
}
|
||
|
||
func codexSnapshotBaseTime(snapshot *OpenAICodexUsageSnapshot, fallback time.Time) time.Time {
|
||
if snapshot == nil {
|
||
return fallback
|
||
}
|
||
if snapshot.UpdatedAt == "" {
|
||
return fallback
|
||
}
|
||
base, err := time.Parse(time.RFC3339, snapshot.UpdatedAt)
|
||
if err != nil {
|
||
return fallback
|
||
}
|
||
return base
|
||
}
|
||
|
||
func codexResetAtRFC3339(base time.Time, resetAfterSeconds *int) *string {
|
||
if resetAfterSeconds == nil {
|
||
return nil
|
||
}
|
||
sec := *resetAfterSeconds
|
||
if sec < 0 {
|
||
sec = 0
|
||
}
|
||
resetAt := base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339)
|
||
return &resetAt
|
||
}
|
||
|
||
func buildCodexUsageExtraUpdates(snapshot *OpenAICodexUsageSnapshot, fallbackNow time.Time) map[string]any {
|
||
if snapshot == nil {
|
||
return nil
|
||
}
|
||
|
||
baseTime := codexSnapshotBaseTime(snapshot, fallbackNow)
|
||
updates := make(map[string]any)
|
||
|
||
// 保存原始 primary/secondary 字段,便于排查问题
|
||
if snapshot.PrimaryUsedPercent != nil {
|
||
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
|
||
}
|
||
if snapshot.PrimaryResetAfterSeconds != nil {
|
||
updates["codex_primary_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds
|
||
}
|
||
if snapshot.PrimaryWindowMinutes != nil {
|
||
updates["codex_primary_window_minutes"] = *snapshot.PrimaryWindowMinutes
|
||
}
|
||
if snapshot.SecondaryUsedPercent != nil {
|
||
updates["codex_secondary_used_percent"] = *snapshot.SecondaryUsedPercent
|
||
}
|
||
if snapshot.SecondaryResetAfterSeconds != nil {
|
||
updates["codex_secondary_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds
|
||
}
|
||
if snapshot.SecondaryWindowMinutes != nil {
|
||
updates["codex_secondary_window_minutes"] = *snapshot.SecondaryWindowMinutes
|
||
}
|
||
if snapshot.PrimaryOverSecondaryPercent != nil {
|
||
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
|
||
}
|
||
updates["codex_usage_updated_at"] = baseTime.Format(time.RFC3339)
|
||
|
||
// 归一化到 5h/7d 规范字段
|
||
if normalized := snapshot.Normalize(); normalized != nil {
|
||
if normalized.Used5hPercent != nil {
|
||
updates["codex_5h_used_percent"] = *normalized.Used5hPercent
|
||
}
|
||
if normalized.Reset5hSeconds != nil {
|
||
updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds
|
||
}
|
||
if normalized.Window5hMinutes != nil {
|
||
updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes
|
||
}
|
||
if normalized.Used7dPercent != nil {
|
||
updates["codex_7d_used_percent"] = *normalized.Used7dPercent
|
||
}
|
||
if normalized.Reset7dSeconds != nil {
|
||
updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds
|
||
}
|
||
if normalized.Window7dMinutes != nil {
|
||
updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
|
||
}
|
||
if reset5hAt := codexResetAtRFC3339(baseTime, normalized.Reset5hSeconds); reset5hAt != nil {
|
||
updates["codex_5h_reset_at"] = *reset5hAt
|
||
}
|
||
if reset7dAt := codexResetAtRFC3339(baseTime, normalized.Reset7dSeconds); reset7dAt != nil {
|
||
updates["codex_7d_reset_at"] = *reset7dAt
|
||
}
|
||
}
|
||
|
||
return updates
|
||
}
|
||
|
||
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
|
||
// updateCodexUsageSnapshot 把 /responses 的 x-codex-* 全局头快照写入账号 codex_* Extra。
|
||
// ⚠️ 调用方必须排除 spark 影子账号(account.IsShadow()):影子的 codex_* 仅由 QueryUsage
|
||
// (/wham/usage bengalfox 道)更新,不能被全局头口径污染(外审第7轮 P1)。本函数仅持 accountID,
|
||
// 无法在此自检影子,故守卫前置到各调用点。
|
||
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
|
||
if snapshot == nil {
|
||
return
|
||
}
|
||
if s == nil || s.accountRepo == nil {
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
updates := buildCodexUsageExtraUpdates(snapshot, now)
|
||
if len(updates) == 0 {
|
||
return
|
||
}
|
||
if !s.getCodexSnapshotThrottle().Allow(accountID, now) {
|
||
return
|
||
}
|
||
|
||
go func() {
|
||
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
|
||
}()
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) UpdateCodexUsageSnapshotFromHeaders(ctx context.Context, accountID int64, headers http.Header) {
|
||
if accountID <= 0 || headers == nil {
|
||
return
|
||
}
|
||
if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil {
|
||
s.updateCodexUsageSnapshot(ctx, accountID, snapshot)
|
||
}
|
||
}
|