| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146 |
- 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 地址
- SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
- RequestPayloadHash string
- APIKeyService APIKeyQuotaUpdater
- QuotaPlatform string // user×platform quota platform resolved by the handler before async billing.
- // PricingAt 是请求级定价时刻(请求开始捕获,与利润门的 D 同源):高峰因子
- // 按该时刻计算,保证同一请求从准入到扣费不中途变价。零值回退记录时刻
- //(既有行为),供未装配的路径(图片/异步/cyber 等)沿用。
- PricingAt time.Time
- // CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。
- CyberBlocked bool
- // NativeCompactionV2 is an orthogonal semantic flag captured by the
- // Responses handler from stream=true + compaction_trigger. It never stores
- // the request payload and does not replace the transport request type.
- NativeCompactionV2 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
- SessionID string
- RequestPayloadHash string
- APIKeyService APIKeyQuotaUpdater
- NativeCompactionV2 bool
- 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,
- SessionID: in.SessionID,
- RequestPayloadHash: in.RequestPayloadHash,
- APIKeyService: in.APIKeyService,
- ChannelUsageFields: in.ChannelUsageFields,
- CyberBlocked: true,
- NativeCompactionV2: in.NativeCompactionV2,
- }); 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)
- }
- // openAIUsagePricingAt 返回本次用量记录使用的定价时刻:优先请求级 PricingAt
- // (与利润门 D 同源同刻),未装配时回退记录时刻(既有行为)。
- func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time {
- if input != nil && !input.PricingAt.IsZero() {
- return input.PricingAt
- }
- return timezone.Now()
- }
- func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool {
- if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast {
- return false
- }
- if account == nil || !account.IsOpenAI() {
- return false
- }
- if !groupSupportsOpenAIFast(apiKey.Group.Platform) {
- return false
- }
- switch normalizeBillingServiceTier(serviceTier) {
- case "priority", "fast":
- return true
- default:
- return false
- }
- }
- // 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
- billingAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
- if err != nil {
- return err
- }
- if !isGrokVideoUsageResult(result, nil) {
- ApplyOpenAIImageBillingResolution(result)
- }
- logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(billingAccount, 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,图片按次倍率不受影响)。
- // 高峰因子按请求级 PricingAt 现算(与利润门 D 同源同刻,跨峰谷请求不中途
- // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的
- // Resolve,以免污染 user:group 倍率缓存。
- baseMultiplier := multiplier
- pricingAt := openAIUsagePricingAt(input)
- multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt)
- videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier)
- var cost *CostBreakdown
- 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,
- )
- billingModels = s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, billingModels)
- serviceTier := ""
- if result.ServiceTier != nil {
- serviceTier = strings.TrimSpace(*result.ServiceTier)
- }
- longContextBillingGate := openAILongContextBillingGate(billingAccount)
- cost, err = s.calculateOpenAIRecordUsageCost(
- ctx,
- result,
- apiKey,
- billingModels,
- multiplier,
- imageMultiplier,
- videoMultiplier,
- baseMultiplier,
- tokens,
- serviceTier,
- longContextBillingGate,
- pricingAt,
- )
- 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)}
- }
- // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
- // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing
- // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
- // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。
- baselineBillingModel := firstUsageBillingModel(billingModels)
- if responseModel := responseModelBillingDeclaration(
- input.BillingModelSource,
- result.UpstreamResponseModel,
- result.UpstreamResponseModelConflict,
- result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0 ||
- result.AudioUsage != nil || result.SearchCount > 0,
- ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) {
- if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified {
- responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel))
- responseCost, responseErr := s.calculateOpenAIRecordUsageCost(
- ctx, result, apiKey, responseModels, multiplier, imageMultiplier,
- videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt,
- )
- // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost
- // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际
- // 定价基准(有渠道价就一定能算出价,循环不会落到后续候选)。
- baselineChannelPriced := s.resolveOpenAIChannelPricing(ctx, baselineBillingModel, apiKey) != nil
- if responseErr == nil && responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
- logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID,
- baselineBillingModel, responseModel, cost, responseCost)
- billingModels = responseModels
- cost = responseCost
- }
- }
- }
- // Free Fast changes only the customer charge. Keep priority TotalCost and
- // service_tier for upstream accounting, but evaluate ActualCost once more at
- // the Standard tier using the same channel, peak, and long-context policy.
- if groupBillsOpenAIFastAtStandard(apiKey, billingAccount, serviceTier) {
- standardCost, standardErr := s.calculateOpenAIRecordUsageCost(
- ctx,
- result,
- apiKey,
- billingModels,
- multiplier,
- imageMultiplier,
- videoMultiplier,
- baseMultiplier,
- tokens,
- "",
- longContextBillingGate,
- pricingAt,
- )
- if standardErr != nil {
- return standardErr
- }
- if cost != nil && standardCost != nil {
- cost.ActualCost = standardCost.ActualCost
- }
- }
- // 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
- }
- }
- // Async Grok video: always use the stable task id for dedup (status + content polls
- // share one bill). Context-local client/local IDs would otherwise create a new row
- // per poll if Redis claim is lost.
- if result.VideoCount > 0 {
- if stable := StableGrokVideoBillingRequestID(firstNonEmpty(
- strings.TrimPrefix(strings.TrimSpace(result.RequestID), "grok-video:"),
- strings.TrimSpace(result.ResponseID),
- strings.TrimPrefix(strings.TrimSpace(requestID), "grok-video:"),
- )); stable != "" {
- requestID = stable
- }
- }
- // 确定 RequestedModel(渠道映射前的原始模型)
- requestedModel := result.Model
- if input.OriginalModel != "" {
- requestedModel = input.OriginalModel
- }
- sentModel := upstreamSentModel(result.Model, result.UpstreamModel)
- if result.UpstreamResponseModelConflict {
- logger.L().Warn("upstream_response_model_conflict",
- zap.String("platform", account.Platform),
- zap.Int64("account_id", account.ID),
- zap.String("request_id", requestID),
- zap.String("sent_model", sentModel),
- zap.String("selected_response_model", strings.TrimSpace(result.UpstreamResponseModel)),
- )
- }
- usageLog := &UsageLog{
- UserID: user.ID,
- APIKeyID: apiKey.ID,
- AccountID: account.ID,
- RequestID: requestID,
- UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, result.OpenAIWSMode),
- Model: result.Model,
- RequestedModel: requestedModel,
- UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
- UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
- UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
- ServiceTier: result.ServiceTier,
- ReasoningEffort: result.ReasoningEffort,
- RequestedReasoningEffort: coalesceRequestedReasoningEffort(result.RequestedReasoningEffort, 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,
- NativeCompactionV2: input.NativeCompactionV2,
- }
- 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
- }
- // 添加 SessionID(客户端显式会话标识;缺失/无效时保持 nil)
- usageLog.SessionID = optionalTrimmedStringPtr(input.SessionID)
- 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, pricingAt,
- )
- }
- 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 {
- usageLog.ActualCost = 0
- writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
- return billingErr
- }
- writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
- return nil
- }
- // hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准,
- // 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用,
- // 避免为此再解析一次)。
- // 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目;
- // 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把
- // 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。
- func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) {
- model = strings.TrimSpace(model)
- if model == "" {
- return false, false
- }
- if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil {
- return true, true
- }
- return s.billingService.HasIdentifiedTokenPricing(model), false
- }
- // openAILongContextBillingGate returns the per-account long-context opt-in.
- // The flag is an OpenAI-only account setting, so other platforms (Grok) return
- // nil — "no per-account gate" — and are governed by the group toggle alone.
- // Returning a hardcoded false for them would veto the official model ladders
- // (e.g. the Grok >=200k 2x card) that no account setting can ever re-enable.
- func openAILongContextBillingGate(account *Account) *bool {
- if account == nil || !account.IsOpenAI() {
- return nil
- }
- enabled := account.IsOpenAILongContextBillingEnabled()
- return &enabled
- }
- 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,
- longContextBillingGate *bool,
- pricingAt time.Time,
- ) (*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.AudioUsage != nil {
- if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
- (resolved.Mode == BillingModePerRequest) {
- gid := apiKey.Group.ID
- return s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
- UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode,
- RateMultiplier: webSearchMultiplier, Resolver: s.resolver, Resolved: resolved,
- })
- }
- cfg := groupAudioPriceConfigFromAPIKey(apiKey)
- return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, webSearchMultiplier), 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
- }
- }
- // Token path (optional search surcharge is additive — never replaces token cost).
- var tokenCost *CostBreakdown
- var lastErr error
- if len(billingModels) > 0 && billingModel != "" {
- for _, candidate := range billingModels {
- candidate = strings.TrimSpace(candidate)
- if candidate == "" {
- continue
- }
- cost, err := s.calculateOpenAIRecordUsageTokenCost(
- ctx,
- apiKey,
- candidate,
- multiplier,
- pricingAt,
- tokens,
- serviceTier,
- optionalStringValue(result.ReasoningEffort),
- longContextBillingGate,
- )
- if err == nil {
- tokenCost = cost
- break
- }
- lastErr = err
- }
- }
- // Search surcharge is additive. Never let a zero/default search cost mask a
- // real token-pricing failure for requests that attempted token billing.
- searchCost := (*CostBreakdown)(nil)
- if result != nil && result.SearchCount > 0 {
- price := groupSearchPricePer1kFromAPIKey(apiKey)
- if price != nil && *price == 0 {
- logger.L().Info("openai_usage.search_price_per_1k_explicit_free",
- zap.Int("search_count", result.SearchCount),
- zap.String("model", billingModel),
- zap.Int64("api_key_id", apiKey.ID),
- zap.Any("group_id", apiKey.GroupID),
- )
- }
- searchCost = s.billingService.CalculateSearchCost(result.SearchCount, price, webSearchMultiplier)
- }
- tokenBillingAttempted := len(billingModels) > 0 && billingModel != ""
- if tokenCost == nil {
- if tokenBillingAttempted {
- if lastErr == nil {
- lastErr = fmt.Errorf("%w: no non-empty billing model candidates", ErrModelPricingUnavailable)
- }
- return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
- }
- // Search-only (no model / pure tool path): allow search billing alone.
- if searchCost != nil {
- return searchCost, nil
- }
- // 空候选按「无价可循」处理并携带 ErrModelPricingUnavailable:上层据此走
- // 零成本+告警落账,而不是丢弃整条 usage 记录。CN 账号的 claude-* 候选被
- // filterCNProviderBillingModelCandidates 全数过滤后即落到这里。
- if lastErr == nil {
- lastErr = fmt.Errorf("%w: openai usage billing model is empty", ErrModelPricingUnavailable)
- }
- return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
- }
- if searchCost == nil || (searchCost.TotalCost == 0 && searchCost.ActualCost == 0) {
- return tokenCost, nil
- }
- // Additive: tokens + search surcharge.
- tokenCost.TotalCost += searchCost.TotalCost
- tokenCost.ActualCost += searchCost.ActualCost
- return tokenCost, nil
- }
- 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
- }
- // VideoCount alone is authoritative for async video completion billing.
- // Prefer model-family match when present; never drop video mode on rename/mapping.
- candidates := append([]string{}, billingModels...)
- candidates = append(candidates, result.BillingModel, result.Model, result.UpstreamModel)
- for _, candidate := range candidates {
- if isGrokVideoBillingModel(candidate) {
- return true
- }
- }
- return true
- }
- 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,
- pricingAt time.Time,
- tokens UsageTokens,
- serviceTier string,
- reasoningEffort string,
- longContextBillingGate *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, Group: apiKey.Group,
- Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt,
- ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver,
- LongContextBillingEnabled: longContextBillingGate,
- })
- }
- breakdown, err := s.billingService.calculateCostWithServiceTierPolicy(
- billingModel,
- tokens,
- multiplier,
- serviceTier,
- longContextBillingGate == nil || *longContextBillingGate,
- )
- if err == nil {
- applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil))
- }
- return breakdown, err
- }
- func (s *OpenAIGatewayService) calculateOpenAIImageCost(
- ctx context.Context,
- billingModel string,
- apiKey *APIKey,
- result *OpenAIForwardResult,
- multiplier float64,
- ) *CostBreakdown {
- sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
- resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey)
- if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup) &&
- (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
- gid := apiKey.Group.ID
- cost, err := s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
- RequestCount: result.ImageCount, SizeTier: sizeTier,
- RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
- })
- if err == nil {
- return cost
- }
- }
- 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 != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceChannel) &&
- (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
- gid := apiKey.Group.ID
- cost, err := s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx,
- Model: billingModel,
- GroupID: &gid,
- Group: apiKey.Group,
- 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)
- resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey)
- if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup) && resolved.Mode == BillingModeVideo {
- gid := apiKey.Group.ID
- cost, err := s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
- UsageUnits: float64(videoCount * durationSeconds), SizeTier: resolution,
- RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
- })
- if err == nil {
- return cost
- }
- }
- groupConfig := videoPriceConfigFromAPIKey(apiKey)
- if apiKeyHasConfiguredVideoPrice(apiKey, billingModel, 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, billingModel, resolution) {
- return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
- }
- }
- if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceChannel) &&
- (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage || resolved.Mode == BillingModeVideo) {
- // 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。
- gid := apiKey.Group.ID
- units := float64(videoCount)
- if resolved.Mode == BillingModeVideo {
- units = float64(videoCount * durationSeconds)
- }
- cost, err := s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx,
- Model: billingModel,
- GroupID: &gid,
- Group: apiKey.Group,
- RequestCount: videoCount,
- UsageUnits: units,
- 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 查询。
- //
- // 注意:apiKeyAuthSnapshotVersion 升级会强制刷新存量快照;本函数是热路径上的二次兜底,
- // 不能仅凭 legacy video_price_* 判定完整而跳过 VideoModelPrices/search/audio 的回源。
- 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
- }
- // Any first-class pricing field present means the projection is not a blank shell.
- if len(group.VideoModelPrices) > 0 {
- return false
- }
- if len(group.ModelPricing) > 0 || group.LongContextPricingEnabled {
- return false
- }
- if group.SearchPricePer1k != nil ||
- group.AudioRealtimePricePerMin != nil ||
- group.AudioTTSPricePerMillionChars != nil ||
- group.AudioSTTPricePerHour != nil ||
- group.WebSearchPricePerCall != nil {
- return false
- }
- return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil &&
- group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil
- }
- // filterCNProviderBillingModelCandidates 过滤国产供应商(kimi/zhipu/deepseek)
- // 账号的计费候选模型名:claude-* 候选仅在运营者显式配置了分组/渠道定价时保留。
- //
- // 背景:候选链的兜底候选含客户端请求的原始模型名。CN 上游的 Anthropic 兼容端点
- // 接受 claude-* 模型名但从不真正服务 Claude 模型;若放行,目录里的 Claude 价卡
- // 与 getFallbackPricing 的 "claude"→Sonnet 统一兜底会把 CN 流量按 Claude 原价
- // (数倍~数十倍)静默误计,且 usage 日志显示的正是 claude-* 名,无从察觉。
- // 候选全部落空时走既有的零成本+告警路径(openai_usage.pricing_missing_record_
- // zero_cost),与定价层「未知型号不回退以避免误计价」的既有设计意图一致;
- // 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
- // 分组/渠道显式定价。
- func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
- if account == nil || !account.IsCNProvider() {
- return candidates
- }
- out := make([]string, 0, len(candidates))
- for _, candidate := range candidates {
- trimmed := strings.TrimSpace(candidate)
- if trimmed == "" {
- continue
- }
- if strings.Contains(strings.ToLower(trimmed), "claude") &&
- s.resolveOpenAIChannelPricing(ctx, trimmed, apiKey) == nil {
- continue
- }
- out = append(out, candidate)
- }
- return out
- }
- 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, Group: apiKey.Group})
- if resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup || 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()
- if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil {
- notifyOpenAIAutoReset(accountID)
- }
- }()
- }
- 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)
- }
- }
|