| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224 |
- package service
- import (
- "context"
- "log/slog"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- )
- func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
- if s == nil {
- return groupDefaultMultiplier
- }
- resolver := s.userGroupRateResolver
- if resolver == nil {
- resolver = newUserGroupRateResolver(
- s.userGroupRateRepo,
- s.userGroupRateCache,
- resolveUserGroupRateCacheTTL(s.cfg),
- &s.userGroupRateSF,
- "service.gateway",
- )
- }
- return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
- }
- // ResolveUserGroupRateMultiplier resolves the same cached multiplier used by usage billing.
- func (s *GatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
- return s.getUserGroupRateMultiplier(ctx, userID, groupID, groupDefaultMultiplier)
- }
- // RecordUsageInput 记录使用量的输入参数。
- // 异步 worker 只接收计费所需快照,不能持有 ParsedRequest/RequestBodyRef 这类大请求体引用。
- type RecordUsageInput struct {
- Result *ForwardResult
- APIKey *APIKey
- User *User
- Account *Account
- Subscription *UserSubscription // 可选:订阅信息
- PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义
- InboundEndpoint string // 入站端点(客户端请求路径)
- UpstreamEndpoint string // 上游端点(标准化后的上游路径)
- UserAgent string // 请求的 User-Agent
- IPAddress string // 请求的客户端 IP 地址
- SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
- RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
- ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
- APIKeyService APIKeyQuotaUpdater // 可选:用于更新API Key配额
- QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform)
- ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析)
- }
- // APIKeyQuotaUpdater defines the interface for updating API Key quota and rate limit usage
- type APIKeyQuotaUpdater interface {
- UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cost float64) error
- UpdateRateLimitUsage(ctx context.Context, apiKeyID int64, cost float64) error
- }
- type apiKeyAuthCacheInvalidator interface {
- InvalidateAuthCacheByKey(ctx context.Context, key string)
- }
- type usageLogBestEffortWriter interface {
- CreateBestEffort(ctx context.Context, log *UsageLog) error
- }
- // postUsageBillingParams 统一扣费所需的参数
- type postUsageBillingParams struct {
- Cost *CostBreakdown
- User *User
- APIKey *APIKey
- Account *Account
- Subscription *UserSubscription
- RequestPayloadHash string
- IsSubscriptionBill bool
- AccountRateMultiplier float64
- APIKeyService APIKeyQuotaUpdater
- Platform string // 来自 APIKey 关联 Group 的平台标识
- }
- // PlatformFromAPIKey 从 APIKey 关联的 Group 推导 platform 名称。
- // apiKey 为 nil 或 Group 信息缺失时返回空串(调用方据此 short-circuit quota 累加)。
- // 导出供 handler 层调用。
- func PlatformFromAPIKey(apiKey *APIKey) string {
- if apiKey == nil || apiKey.Group == nil {
- return ""
- }
- return apiKey.Group.Platform
- }
- // QuotaPlatform 返回 user×platform 配额计量使用的平台标识。
- // 强制平台路由(如 /antigravity)优先按 ctx 中的 ForcePlatform 计量,否则回退到
- // APIKey 关联 Group 的平台。
- //
- // 注意:必须用带 ForcePlatform 的请求 context 调用(如 handler 的 c.Request.Context())。
- // 后扣运行在 worker 池的 background ctx 上没有 ForcePlatform,因此后扣平台由 handler
- // 预先算定、经 RecordUsageInput.QuotaPlatform 传入,不要在后扣链路用 worker ctx 调用本函数。
- func QuotaPlatform(ctx context.Context, apiKey *APIKey) string {
- if ctx != nil {
- if fp, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok && fp != "" {
- return fp
- }
- }
- if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok {
- return platform
- }
- platform := PlatformFromAPIKey(apiKey)
- if platform == PlatformComposite {
- return ""
- }
- return platform
- }
- func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool {
- return p.Cost.ActualCost > 0 && p.APIKey.Quota > 0 && p.APIKeyService != nil
- }
- func (p *postUsageBillingParams) shouldUpdateRateLimits() bool {
- return p.Cost.ActualCost > 0 && p.APIKey.HasRateLimits() && p.APIKeyService != nil
- }
- func (p *postUsageBillingParams) shouldUpdateAccountQuota() bool {
- return p.Cost.TotalCost > 0 && p.Account.IsAPIKeyOrBedrock() && p.Account.HasAnyQuotaLimit()
- }
- // postUsageBilling is the legacy fallback billing path used when the unified
- // billing repo is unavailable (nil). Production uses applyUsageBilling → repo.Apply
- // for atomic billing. This path only runs in tests or degraded mode.
- func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps) {
- billingCtx, cancel := detachedBillingContext(ctx)
- defer cancel()
- cost := p.Cost
- if p.IsSubscriptionBill {
- // Subscription usage tracked by ActualCost so group rate multiplier
- // consumes the quota at the expected speed.
- if cost.ActualCost > 0 {
- if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil {
- slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err)
- }
- }
- } else {
- if cost.ActualCost > 0 {
- if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil {
- slog.Error("deduct balance failed", "user_id", p.User.ID, "error", err)
- } else if deps.billingCacheService != nil {
- if err := deps.billingCacheService.InvalidateUserBalance(billingCtx, p.User.ID); err != nil {
- slog.Warn("invalidate balance cache after legacy deduction failed", "user_id", p.User.ID, "error", err)
- }
- }
- }
- }
- if p.shouldDeductAPIKeyQuota() {
- if err := p.APIKeyService.UpdateQuotaUsed(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil {
- slog.Error("update api key quota failed", "api_key_id", p.APIKey.ID, "error", err)
- }
- }
- if p.shouldUpdateRateLimits() {
- if err := p.APIKeyService.UpdateRateLimitUsage(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil {
- slog.Error("update api key rate limit usage failed", "api_key_id", p.APIKey.ID, "error", err)
- }
- }
- if p.shouldUpdateAccountQuota() {
- accountCost := cost.TotalCost * p.AccountRateMultiplier
- if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil {
- slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err)
- }
- }
- // Platform quota 累加(legacy 兜底路径):仅对 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写
- // - HasUserPlatformQuotaLimit 守卫:与正常路径对齐,无 limit 公司跳过
- // - 新增 Redis 同步写:enforcement 走 Redis,legacy 路径也必须同步写,否则 preflight 看不到消费
- // - flusher_enabled=false(降级):保留原有同步直写 DB
- // - flusher_enabled=true:跳过直写 DB,由 flusher 异步批量刷(markDirty 在 IncrementUserPlatformQuotaUsage 内部完成)
- // - 失败仅记 ALERT log + counter,不阻断主扣费流程
- if !p.IsSubscriptionBill && p.Platform != "" && cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil {
- if deps.billingCacheService.HasUserPlatformQuotaLimit(billingCtx, p.User.ID, p.Platform) {
- deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, cost.ActualCost)
- if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled {
- // 降级路径:flusher 未启用时保留原有同步直写 DB
- if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(billingCtx, p.User.ID, p.Platform, cost.ActualCost, time.Now().UTC()); err != nil {
- userPlatformQuotaDBIncrLegacyErrorTotal.Add(1)
- logger.LegacyPrintf("service.gateway", "ALERT: legacy incr user platform quota DB failed user=%d platform=%s cost=%f: %v", p.User.ID, p.Platform, cost.ActualCost, err)
- }
- }
- // flusher_enabled=true:不直写 DB,flusher 异步批量刷
- }
- }
- // NOTE: finalizePostUsageBilling is NOT called here to avoid double-queuing
- // cache updates. The legacy path does DB writes directly; the finalize path
- // does cache queue + notifications. Notifications are dispatched separately
- // by the caller after recording the usage log.
- }
- func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string {
- // Forced durable money-event IDs must win over client/local context IDs so
- // standalone web_search / async video cannot collapse under a reused client id.
- if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" {
- if isForcedUsageBillingRequestID(requestID) {
- return requestID
- }
- }
- if ctx != nil {
- if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" {
- return "client:" + strings.TrimSpace(clientRequestID)
- }
- if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" {
- return "local:" + strings.TrimSpace(requestID)
- }
- }
- if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" {
- return requestID
- }
- return "generated:" + generateRequestID()
- }
- func isForcedUsageBillingRequestID(requestID string) bool {
- id := strings.TrimSpace(requestID)
- return strings.HasPrefix(id, "web_search:") ||
- strings.HasPrefix(id, "grok-video:") ||
- strings.HasPrefix(id, "grok_audio:") ||
- strings.HasPrefix(id, "grok_realtime:")
- }
- // StableGrokAudioBillingRequestID is the durable usage_logs / dedup key for one
- // voice HTTP call (TTS/STT). Prefer an upstream request id when present.
- func StableGrokAudioBillingRequestID(upstreamRequestID string) string {
- upstreamRequestID = strings.TrimSpace(upstreamRequestID)
- if strings.HasPrefix(upstreamRequestID, "grok_audio:") {
- return upstreamRequestID
- }
- if upstreamRequestID == "" {
- upstreamRequestID = generateRequestID()
- }
- return "grok_audio:" + upstreamRequestID
- }
- // StableGrokRealtimeBillingRequestID is the durable usage_logs / dedup key for
- // one realtime WebSocket session.
- func StableGrokRealtimeBillingRequestID(sessionID string) string {
- sessionID = strings.TrimSpace(sessionID)
- if strings.HasPrefix(sessionID, "grok_realtime:") {
- return sessionID
- }
- if sessionID == "" {
- sessionID = generateRequestID()
- }
- return "grok_realtime:" + sessionID
- }
- func resolveUsageBillingPayloadFingerprint(ctx context.Context, requestPayloadHash string) string {
- if payloadHash := strings.TrimSpace(requestPayloadHash); payloadHash != "" {
- return payloadHash
- }
- if ctx != nil {
- if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" {
- return "client:" + strings.TrimSpace(clientRequestID)
- }
- if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" {
- return "local:" + strings.TrimSpace(requestID)
- }
- }
- return ""
- }
- func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsageBillingParams) *UsageBillingCommand {
- if p == nil || p.Cost == nil || p.APIKey == nil || p.User == nil || p.Account == nil {
- return nil
- }
- cmd := &UsageBillingCommand{
- RequestID: requestID,
- APIKeyID: p.APIKey.ID,
- UserID: p.User.ID,
- AccountID: p.Account.ID,
- AccountType: p.Account.Type,
- RequestPayloadHash: strings.TrimSpace(p.RequestPayloadHash),
- }
- if usageLog != nil {
- cmd.Model = usageLog.Model
- cmd.BillingType = usageLog.BillingType
- cmd.InputTokens = usageLog.InputTokens
- cmd.OutputTokens = usageLog.OutputTokens
- cmd.CacheCreationTokens = usageLog.CacheCreationTokens
- cmd.CacheReadTokens = usageLog.CacheReadTokens
- cmd.ImageCount = usageLog.ImageCount
- if usageLog.ServiceTier != nil {
- cmd.ServiceTier = *usageLog.ServiceTier
- }
- if usageLog.ReasoningEffort != nil {
- cmd.ReasoningEffort = *usageLog.ReasoningEffort
- }
- if usageLog.SubscriptionID != nil {
- cmd.SubscriptionID = usageLog.SubscriptionID
- }
- }
- // Record subscription / balance cost using ActualCost so the group (and any
- // user-specific) rate multiplier consumes subscription quota at the expected
- // speed. TotalCost remains the raw (pre-multiplier) value; downstream guards
- // on "> 0" still correctly skip free subscriptions (RateMultiplier == 0).
- if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 {
- cmd.SubscriptionID = &p.Subscription.ID
- cmd.SubscriptionCost = p.Cost.ActualCost
- } else if p.Cost.ActualCost > 0 {
- cmd.BalanceCost = p.Cost.ActualCost
- }
- if p.shouldDeductAPIKeyQuota() {
- cmd.APIKeyQuotaCost = p.Cost.ActualCost
- }
- if p.shouldUpdateRateLimits() {
- cmd.APIKeyRateLimitCost = p.Cost.ActualCost
- }
- if p.shouldUpdateAccountQuota() {
- cmd.AccountQuotaCost = p.Cost.TotalCost * p.AccountRateMultiplier
- }
- cmd.Normalize()
- return cmd
- }
- func applyUsageBilling(ctx context.Context, requestID string, usageLog *UsageLog, p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository) (bool, error) {
- if p == nil || deps == nil {
- return false, nil
- }
- cmd := buildUsageBillingCommand(requestID, usageLog, p)
- if cmd == nil || cmd.RequestID == "" || repo == nil {
- postUsageBilling(ctx, p, deps)
- return true, nil
- }
- billingCtx, cancel := detachedBillingContext(ctx)
- defer cancel()
- result, err := repo.Apply(billingCtx, cmd)
- if err != nil {
- return false, err
- }
- if result == nil || !result.Applied {
- deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
- return false, nil
- }
- if result.APIKeyQuotaExhausted {
- if invalidator, ok := p.APIKeyService.(apiKeyAuthCacheInvalidator); ok && p.APIKey != nil && p.APIKey.Key != "" {
- invalidator.InvalidateAuthCacheByKey(billingCtx, p.APIKey.Key)
- }
- }
- finalizePostUsageBilling(billingCtx, p, deps, result)
- return true, nil
- }
- func finalizePostUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
- if p == nil || p.Cost == nil || deps == nil {
- return
- }
- if p.IsSubscriptionBill {
- if p.Cost.ActualCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil {
- deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.ActualCost)
- }
- } else if p.Cost.ActualCost > 0 && p.User != nil {
- syncBalanceCacheAfterDeduction(ctx, p, deps, result)
- }
- if p.Cost.ActualCost > 0 && p.APIKey != nil && p.APIKey.HasRateLimits() {
- deps.billingCacheService.QueueUpdateAPIKeyRateLimitUsage(p.APIKey.ID, p.Cost.ActualCost)
- }
- deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
- // Platform quota 累加:仅在 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写
- // Redis 同步写 + DB 异步持久化(flag=false 降级)或 flusher 异步刷(flag=true):
- // - HasUserPlatformQuotaLimit 守卫:无 limit 的公司跳过,避免无效写入 + 浪费 Redis 容量
- // - Redis 同步:确保下次 preflight 立即看到最新 usage,把 TOCTOU 超支窗口
- // 限制在并发 in-flight 请求数量内(旧实现的异步入队会让超支无限累积直到 worker 处理)
- // - DB 异步(flusher_enabled=false):在独立 goroutine 中走 detached context,失败用 ALERT log 触发 oncall 对账
- // - flusher_enabled=true:不直写 DB,由 flusher 异步批量刷(markDirty 已在 IncrementUserPlatformQuotaUsage 内部完成)
- if !p.IsSubscriptionBill && p.Platform != "" && p.Cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil {
- if deps.billingCacheService.HasUserPlatformQuotaLimit(ctx, p.User.ID, p.Platform) {
- deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, p.Cost.ActualCost)
- if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled {
- // 降级路径:flusher 未启用时保留原有异步直写 DB
- dbCtx, dbCancel := detachUpstreamContext(ctx)
- userID, platform, cost := p.User.ID, p.Platform, p.Cost.ActualCost
- go func() {
- defer func() {
- if r := recover(); r != nil {
- logger.LegacyPrintf("service.gateway", "ALERT: panic in user platform quota incr goroutine user=%d platform=%s: %v", userID, platform, r)
- }
- }()
- defer dbCancel()
- if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(dbCtx, userID, platform, cost, time.Now().UTC()); err != nil {
- // 失败计数器:暴露给 GatewayUserPlatformQuotaIncrStats(),由 ops 面板做斜率告警。
- userPlatformQuotaDBIncrErrorTotal.Add(1)
- // ALERT 级别:DB 持久化失败意味着 Redis cache 失效后该笔 cost 永久丢失,
- // 用户配额视图与实际消费会偏差,oncall 需要据此对账或人工补录。
- logger.LegacyPrintf("service.gateway", "ALERT: incr user platform quota DB failed user=%d platform=%s cost=%f: %v", userID, platform, cost, err)
- }
- }()
- }
- // flusher_enabled=true:不直写 DB,flusher 异步批量刷
- }
- }
- // Notification checks run async — all parameters are already captured,
- // no dependency on the request context or upstream connection.
- go notifyBalanceLow(p, deps, result)
- go notifyAccountQuota(p, deps, result)
- }
- func syncBalanceCacheAfterDeduction(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
- if p == nil || p.Cost == nil || p.User == nil || deps == nil || deps.billingCacheService == nil {
- return
- }
- if result != nil && result.NewBalance != nil && deps.billingCacheService.balanceBelowEligibilityThreshold(*result.NewBalance) {
- if err := deps.billingCacheService.InvalidateUserBalance(ctx, p.User.ID); err != nil {
- slog.Warn("invalidate balance cache after exhausted deduction failed",
- "user_id", p.User.ID,
- "new_balance", *result.NewBalance,
- "balance_overdrafted", result.BalanceOverdrafted,
- "error", err,
- )
- }
- return
- }
- deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost)
- }
- // notifyBalanceLow sends balance low notification after deduction.
- // When result.NewBalance is available (from DB transaction RETURNING), it is used directly
- // to reconstruct oldBalance, avoiding stale Redis reads and concurrent-deduction races.
- func notifyBalanceLow(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("panic in notifyBalanceLow", "recover", r)
- }
- }()
- if p.IsSubscriptionBill || p.Cost.ActualCost <= 0 || p.User == nil || deps.balanceNotifyService == nil {
- slog.Debug("notifyBalanceLow: skipped",
- "is_subscription", p.IsSubscriptionBill,
- "actual_cost", p.Cost.ActualCost,
- "user_nil", p.User == nil,
- "service_nil", deps.balanceNotifyService == nil,
- )
- return
- }
- oldBalance := resolveOldBalance(p, result)
- slog.Debug("notifyBalanceLow: calling CheckBalanceAfterDeduction",
- "user_id", p.User.ID,
- "old_balance", oldBalance,
- "cost", p.Cost.ActualCost,
- "notify_enabled", p.User.BalanceNotifyEnabled,
- "threshold", p.User.BalanceNotifyThreshold,
- "result_has_new_balance", result != nil && result.NewBalance != nil,
- )
- deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, oldBalance, p.Cost.ActualCost)
- }
- // resolveOldBalance returns the pre-deduction balance.
- // Prefers the DB transaction result (newBalance + cost) over snapshot.
- func resolveOldBalance(p *postUsageBillingParams, result *UsageBillingApplyResult) float64 {
- if result != nil && result.NewBalance != nil {
- return *result.NewBalance + p.Cost.ActualCost
- }
- // Legacy fallback: snapshot balance from request context
- return p.User.Balance
- }
- // notifyAccountQuota sends account quota threshold notification after increment.
- // When result.QuotaState is available (from DB transaction RETURNING), it is passed directly
- // to avoid a separate DB read that may see stale or concurrently-modified data.
- func notifyAccountQuota(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("panic in notifyAccountQuota", "recover", r)
- }
- }()
- if p.Cost.TotalCost <= 0 || p.Account == nil || !p.Account.IsAPIKeyOrBedrock() || deps.balanceNotifyService == nil {
- slog.Debug("notifyAccountQuota: skipped",
- "total_cost", p.Cost.TotalCost,
- "account_nil", p.Account == nil,
- "is_apikey_or_bedrock", p.Account != nil && p.Account.IsAPIKeyOrBedrock(),
- "service_nil", deps.balanceNotifyService == nil,
- )
- return
- }
- accountCost := p.Cost.TotalCost * p.AccountRateMultiplier
- var quotaState *AccountQuotaState
- if result != nil {
- quotaState = result.QuotaState
- }
- slog.Debug("notifyAccountQuota: calling CheckAccountQuotaAfterIncrement",
- "account_id", p.Account.ID,
- "account_cost", accountCost,
- "has_quota_state", quotaState != nil,
- )
- deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost, quotaState)
- }
- func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) {
- base := context.Background()
- if ctx != nil {
- base = context.WithoutCancel(ctx)
- }
- return context.WithTimeout(base, postUsageBillingTimeout)
- }
- func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Context, context.CancelFunc) {
- if ctx == nil {
- return context.Background(), func() {}
- }
- if !stream {
- return ctx, func() {}
- }
- return context.WithoutCancel(ctx), func() {}
- }
- func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) {
- if ctx == nil {
- return context.Background(), func() {}
- }
- return context.WithoutCancel(ctx), func() {}
- }
- // billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供)
- type billingDeps struct {
- accountRepo AccountRepository
- userRepo UserRepository
- userSubRepo UserSubscriptionRepository
- billingCacheService *BillingCacheService
- deferredService *DeferredService
- balanceNotifyService *BalanceNotifyService
- userPlatformQuotaRepo UserPlatformQuotaRepository
- cfg *config.Config
- }
- func (s *GatewayService) billingDeps() *billingDeps {
- return &billingDeps{
- accountRepo: s.accountRepo,
- userRepo: s.userRepo,
- userSubRepo: s.userSubRepo,
- billingCacheService: s.billingCacheService,
- deferredService: s.deferredService,
- balanceNotifyService: s.balanceNotifyService,
- userPlatformQuotaRepo: s.userPlatformQuotaRepo,
- cfg: s.cfg,
- }
- }
- func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usageLog *UsageLog, logKey string) {
- if repo == nil || usageLog == nil {
- return
- }
- usageCtx, cancel := detachedBillingContext(ctx)
- defer cancel()
- if writer, ok := repo.(usageLogBestEffortWriter); ok {
- if err := writer.CreateBestEffort(usageCtx, usageLog); err != nil {
- logger.LegacyPrintf(logKey, "Create usage log failed: %v", err)
- // 计费已在此前完成,日志必须落库:dropped(批处理队列超时)同样走同步兜底,
- // 否则会出现“已扣费但无 usage_log”的对账缺口(issue #3656)。
- // 重复写入由 usage_logs 的 ON CONFLICT (request_id, api_key_id) DO NOTHING 防护。
- fallbackCtx := usageCtx
- if usageCtx.Err() != nil {
- // usageCtx 已耗尽(best-effort 入队阻塞到期限):换新的 detached 窗口,避免兜底必然失败。
- var fallbackCancel context.CancelFunc
- fallbackCtx, fallbackCancel = detachedBillingContext(context.Background())
- defer fallbackCancel()
- }
- if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil {
- logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr)
- }
- }
- return
- }
- if _, err := repo.Create(usageCtx, usageLog); err != nil {
- logger.LegacyPrintf(logKey, "Create usage log failed: %v", err)
- }
- }
- // RecordUsage 记录使用量并扣费(或更新订阅用量)
- func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
- return s.recordUsageCore(ctx, &recordUsageCoreInput{
- Result: input.Result,
- APIKey: input.APIKey,
- User: input.User,
- Account: input.Account,
- Subscription: input.Subscription,
- PricingAt: input.PricingAt,
- InboundEndpoint: input.InboundEndpoint,
- UpstreamEndpoint: input.UpstreamEndpoint,
- UserAgent: input.UserAgent,
- IPAddress: input.IPAddress,
- SessionID: input.SessionID,
- RequestPayloadHash: input.RequestPayloadHash,
- ForceCacheBilling: input.ForceCacheBilling,
- APIKeyService: input.APIKeyService,
- QuotaPlatform: input.QuotaPlatform,
- ChannelUsageFields: input.ChannelUsageFields,
- })
- }
- // recordUsageCoreInput 是 recordUsageCore 的公共输入字段,从两种输入结构体中提取。
- type recordUsageCoreInput struct {
- Result *ForwardResult
- APIKey *APIKey
- User *User
- Account *Account
- Subscription *UserSubscription
- PricingAt time.Time
- InboundEndpoint string
- UpstreamEndpoint string
- UserAgent string
- IPAddress string
- SessionID string
- RequestPayloadHash string
- ForceCacheBilling bool
- APIKeyService APIKeyQuotaUpdater
- QuotaPlatform string
- ChannelUsageFields
- }
- // responseModelBillingCostEpsilon 吸收两次成本计算之间的浮点末位误差,
- // 避免同价模型因浮点误差被判成"更贵"而白白放弃采纳。
- const responseModelBillingCostEpsilon = 1e-12
- // responseModelBillingDeclaration 返回可用于计费的上游响应模型;返回空字符串表示
- // 必须沿用基线计费模型。两条计费主干(Anthropic 系 / OpenAI 系)共用本准入判断。
- //
- // 渠道把 billing_model_source 设为 response_model,等于把"按哪个模型计价"的一部分
- // 决定权交给上游,因此准入条件必须收紧:
- // - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型;
- // - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型);
- // - 图片 / 视频 / 网页搜索 / 语音 / 搜索附加费这类按次按量计费的请求不采纳:它们按张、
- // 按秒、按次定价,与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过
- // token 价的模型名去决定媒体单价。新增按次计费形态时必须同步扩这个入参。
- //
- // 调用方还必须额外满足两条:模型能被价格表确定性识别(见
- // hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及通过
- // responseModelBillingAdoptable 的成本准入。
- func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string {
- if source != BillingModelSourceResponse || conflict || mediaBilled {
- return ""
- }
- return strings.TrimSpace(responseModel)
- }
- // responseModelBillingAdoptable 判定按响应模型重算出的成本能否取代基线成本。
- // 三条不变式,任一不满足都必须沿用基线(即开启本模式前的既有行为):
- //
- // 1. 不得更贵——上游声明永远不能抬高用户费用;epsilon 吸收两次计算之间的浮点末位误差。
- // 2. 不得把一笔本应计费的请求归零。价格表里存在把 token 价显式写成 0 的条目
- // (TokenPricingAbsent 只在 input/output 价**都缺失**时才为真,显式 0 算"有价"因而
- // 能通过确定性识别那道门),放任归零等于让上游自报一个免费模型名就能白嫖。
- // 基线本身就是 0 时不受影响,采纳与否都不改变金额。
- // 3. 不得把计费从管理员显式配置的渠道定价切到全局价格表。渠道定价查表只做精确键与
- // 前缀通配、**不剥日期后缀**,而全局价格表的确定性识别**会剥** 8 位日期后缀;上游
- // 普遍自报带日期的模型 ID(如 claude-opus-4-5-20251101),若允许跨源比较,渠道加价
- // 会被这类自报名字静默绕过。管理员若确实想让降级目标享受折扣,为它显式配一条渠道
- // 定价即可——那是一次可审计的显式授权。
- func responseModelBillingAdoptable(baseline, response *CostBreakdown, baselineChannelPriced, responseChannelPriced bool) bool {
- if baseline == nil || response == nil {
- return false
- }
- if response.TotalCost > baseline.TotalCost+responseModelBillingCostEpsilon {
- return false
- }
- if response.TotalCost <= 0 && baseline.TotalCost > 0 {
- return false
- }
- return !baselineChannelPriced || responseChannelPriced
- }
- // logResponseModelBillingApplied 记录一次实际生效的响应模型计费切换。
- // 本模式下的少收由上游声明驱动,必须留下可审计痕迹;计费基准未变时不记录,避免刷屏。
- func logResponseModelBillingApplied(component string, account *Account, requestID, baselineModel, responseModel string, baselineCost, responseCost *CostBreakdown) {
- baselineModel = strings.TrimSpace(baselineModel)
- responseModel = strings.TrimSpace(responseModel)
- if strings.EqualFold(baselineModel, responseModel) {
- return
- }
- attrs := []any{
- "component", component,
- "request_id", strings.TrimSpace(requestID),
- "baseline_model", baselineModel,
- "response_model", responseModel,
- }
- if baselineCost != nil && responseCost != nil {
- attrs = append(attrs, "baseline_cost", baselineCost.TotalCost, "billed_cost", responseCost.TotalCost)
- }
- if account != nil {
- attrs = append(attrs, "platform", account.Platform, "account_id", account.ID)
- }
- slog.Info("billing.response_model_applied", attrs...)
- }
- // recordUsageCore 是 RecordUsage 的核心实现。
- func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error {
- result := input.Result
- apiKey := input.APIKey
- user := input.User
- account := input.Account
- subscription := input.Subscription
- ApplyForwardImageBillingResolution(result)
- logServiceTierBillingDowngrade("service.gateway", account, result.RequestID, ApplyForwardServiceTierBillingResolution(result))
- // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens
- // 用于粘性会话切换时的特殊计费处理
- if input.ForceCacheBilling && result.Usage.InputTokens > 0 {
- logger.LegacyPrintf("service.gateway", "force_cache_billing: %d input_tokens → cache_read_input_tokens (account=%d)",
- result.Usage.InputTokens, account.ID)
- result.Usage.CacheReadInputTokens += result.Usage.InputTokens
- result.Usage.InputTokens = 0
- }
- // Cache TTL Override: 确保计费时 token 分类与账号设置一致。
- // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
- cacheTTLOverridden := false
- if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
- applyCacheTTLOverride(&result.Usage, overrideTarget)
- cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
- }
- // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
- multiplier := 1.0
- if s.cfg != nil {
- multiplier = s.cfg.Default.RateMultiplier
- }
- if apiKey.GroupID != nil && apiKey.Group != nil {
- groupDefault := apiKey.Group.RateMultiplier
- multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault)
- }
- // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算,
- // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。
- pricingAt := input.PricingAt
- if pricingAt.IsZero() {
- pricingAt = timezone.Now()
- }
- multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, pricingAt)
- // 确定计费模型
- concreteBillingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
- billingModel := concreteBillingModel
- if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" {
- billingModel = input.ChannelMappedModel
- }
- if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" {
- billingModel = input.OriginalModel
- }
- // composite 分组的公开别名(如 all/claude)会经 OriginalModel/ChannelMappedModel
- // 进入上面的来源覆盖:任意别名查无价会静默落 $0,含家族词的别名则被价格表的
- // 家族模糊匹配错计(如 Opus 流量按 Sonnet 兜底价)。除非管理员为别名显式配置了
- // 渠道定价(OpenRouter 式自定价),composite 请求一律按实际转发的具体模型计费。
- if apiKey.Group != nil && apiKey.Group.Platform == PlatformComposite {
- billingModel = s.compositeBillableModel(ctx, apiKey, billingModel, concreteBillingModel)
- }
- // 通用兜底(与 OpenAI 路径的 usageBillingModelCandidates 语义对齐):
- // 选定模型查不到任何价格时回退到实际转发的具体模型。已定价流量不受影响。
- billingModel = s.billableModelWithFallback(ctx, apiKey, billingModel, result.UpstreamModel, result.Model)
- // 确定 RequestedModel(渠道映射前的原始模型)
- requestedModel := result.Model
- if input.OriginalModel != "" {
- requestedModel = input.OriginalModel
- }
- // 计算费用
- cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt)
- // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
- // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
- // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
- // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。
- if responseModel := responseModelBillingDeclaration(
- input.BillingModelSource,
- result.UpstreamResponseModel,
- result.UpstreamResponseModelConflict,
- result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0,
- ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) {
- if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified {
- responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt)
- baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil
- if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
- // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost,
- // 因此这里不改写它,改由日志记录实际生效的计费基准。
- logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost)
- cost = responseCost
- }
- }
- }
- // 判断计费方式:订阅模式 vs 余额模式
- isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
- billingType := BillingTypeBalance
- if isSubscriptionBilling {
- billingType = BillingTypeSubscription
- }
- // 创建使用日志
- accountRateMultiplier := account.BillingRateMultiplier()
- usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
- requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost)
- // 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
- if apiKey.GroupID != nil {
- applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
- account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
- // Anthropic's input_tokens excludes cache_read and cache_creation (billed separately);
- // OpenAI gateway uses actualInputTokens which also excludes cache_read for the same reason.
- UsageTokens{
- InputTokens: result.Usage.InputTokens,
- OutputTokens: result.Usage.OutputTokens,
- CacheCreationTokens: result.Usage.CacheCreationInputTokens,
- CacheReadTokens: result.Usage.CacheReadInputTokens,
- ImageOutputTokens: result.Usage.ImageOutputTokens,
- },
- cost.TotalCost, pricingAt,
- )
- }
- if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
- writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
- logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
- s.deferredService.ScheduleLastUsedUpdate(account.ID)
- return nil
- }
- // 配额平台由 handler 在请求 ctx 内经 QuotaPlatform() 算定并通过 input 传入;
- // 后扣运行在 worker 池的 background ctx 上,无法再从 ctx 取 ForcePlatform。
- // 缺省(未设置)时回退到分组平台,保持对其它调用方的兼容。
- quotaPlatform := input.QuotaPlatform
- if quotaPlatform == "" {
- quotaPlatform = PlatformFromAPIKey(apiKey)
- if quotaPlatform == PlatformComposite && account != nil {
- quotaPlatform = account.Platform
- }
- }
- requestID := usageLog.RequestID
- _, billingErr := 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)
- if billingErr != nil {
- usageLog.ActualCost = 0
- writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
- return billingErr
- }
- writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
- return nil
- }
- // calculateRecordUsageCost 根据请求类型计算费用。
- func (s *GatewayService) calculateRecordUsageCost(
- ctx context.Context,
- result *ForwardResult,
- apiKey *APIKey,
- billingModel string,
- multiplier float64,
- imageMultiplier float64,
- pricingAt time.Time,
- ) *CostBreakdown {
- // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
- if result.ImageCount > 0 {
- if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
- return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
- }
- return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
- }
- // Voice audio (TTS / STT / realtime) when present on the forward result.
- if result.AudioUsage != nil {
- if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
- resolved.Mode == BillingModePerRequest {
- gid := apiKey.Group.ID
- cost, err := s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
- UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode,
- RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
- })
- if err == nil {
- return cost
- }
- }
- cfg := groupAudioPriceConfigFromAPIKey(apiKey)
- return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, multiplier)
- }
- // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。
- tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
- if result.SearchCount > 0 {
- price := groupSearchPricePer1kFromAPIKey(apiKey)
- if price != nil && *price == 0 {
- logger.LegacyPrintf("service.gateway", "[Billing] search_price_per_1k explicit 0; search free group_model=%s count=%d", billingModel, result.SearchCount)
- }
- searchCost := s.billingService.CalculateSearchCost(result.SearchCount, price, multiplier)
- if searchCost != nil && (searchCost.TotalCost > 0 || searchCost.ActualCost > 0) {
- if tokenCost == nil {
- return searchCost
- }
- tokenCost.TotalCost += searchCost.TotalCost
- tokenCost.ActualCost += searchCost.ActualCost
- }
- }
- return tokenCost
- }
- // compositeBillableModel 决定 composite 分组请求的计费模型:来源覆盖把计费模型
- // 换成公开别名等非具体模型时,只有管理员为该名字显式配置了渠道定价才按其计费
- // (OpenRouter 式自定价),否则回退到实际转发的具体模型,避免别名落入价格表的
- // 家族模糊匹配(错价)或查无价($0)。未发生来源覆盖时原样返回。
- func (s *GatewayService) compositeBillableModel(ctx context.Context, apiKey *APIKey, billingModel, concreteBillingModel string) string {
- if concreteBillingModel == "" || billingModel == concreteBillingModel {
- return billingModel
- }
- if s.resolveChannelPricing(ctx, billingModel, apiKey) != nil {
- return billingModel
- }
- logger.LegacyPrintf("service.gateway", "[Billing] composite billing model %q has no explicit channel pricing, billing by concrete model %q", billingModel, concreteBillingModel)
- return concreteBillingModel
- }
- // billableModelWithFallback 在选定计费模型(可能是 composite 公开别名或未定价的映射名)
- // 查不到任何价格(渠道价与全局价均无)时,按序回退到实际转发的具体模型,避免静默 $0 计费。
- // 所有候选都无价时保持原值,走既有的 warn + 零成本路径。
- func (s *GatewayService) billableModelWithFallback(ctx context.Context, apiKey *APIKey, billingModel string, fallbacks ...string) string {
- if s.hasResolvableTokenPricing(ctx, billingModel, apiKey) {
- return billingModel
- }
- for _, fallback := range fallbacks {
- fallback = strings.TrimSpace(fallback)
- if fallback == "" || fallback == billingModel {
- continue
- }
- if s.hasResolvableTokenPricing(ctx, fallback, apiKey) {
- logger.LegacyPrintf("service.gateway", "[Billing] billing model %q has no pricing, falling back to concrete model %q", billingModel, fallback)
- return fallback
- }
- }
- return billingModel
- }
- // hasResolvableTokenPricing 判断模型是否能在渠道定价或全局价格表中解析出 token 价格。
- func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model string, apiKey *APIKey) bool {
- if strings.TrimSpace(model) == "" {
- return false
- }
- if s.resolveChannelPricing(ctx, model, apiKey) != nil {
- return true
- }
- if s.billingService == nil {
- return false
- }
- _, err := s.billingService.GetModelPricing(model)
- return err == nil
- }
- // hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准,
- // 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用,
- // 避免为此再解析一次)。
- // 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的
- // 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。
- // 详见 responseModelBillingDeclaration 的说明。
- func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) {
- if strings.TrimSpace(model) == "" {
- return false, false
- }
- if s.resolveChannelPricing(ctx, model, apiKey) != nil {
- return true, true
- }
- return s.billingService.HasIdentifiedTokenPricing(model), false
- }
- // resolveChannelPricing 检查指定模型是否存在渠道级别定价。
- // 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。
- func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
- if s.resolver == 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
- }
- // calculateImageCost 计算图片生成费用:渠道级别定价优先,否则走按次计费。
- func (s *GatewayService) calculateImageCost(
- ctx context.Context,
- result *ForwardResult,
- apiKey *APIKey,
- billingModel string,
- multiplier float64,
- ) *CostBreakdown {
- sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
- resolved := s.resolveChannelPricing(ctx, billingModel, apiKey)
- if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup) {
- 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 resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceChannel) {
- tokens := UsageTokens{
- InputTokens: result.Usage.InputTokens,
- OutputTokens: result.Usage.OutputTokens,
- ImageOutputTokens: result.Usage.ImageOutputTokens,
- }
- gid := apiKey.Group.ID
- cost, err := s.billingService.CalculateCostUnified(CostInput{
- Ctx: ctx,
- Model: billingModel,
- GroupID: &gid,
- Group: apiKey.Group,
- Tokens: tokens,
- RequestCount: result.ImageCount,
- SizeTier: sizeTier,
- RateMultiplier: multiplier,
- Resolver: s.resolver,
- Resolved: resolved,
- })
- if err != nil {
- logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err)
- return &CostBreakdown{ActualCost: 0}
- }
- return cost
- }
- return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
- }
- // calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价)
- // 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。
- func (s *GatewayService) calculateTokenCost(
- ctx context.Context,
- result *ForwardResult,
- apiKey *APIKey,
- billingModel string,
- multiplier float64,
- pricingAt time.Time,
- ) *CostBreakdown {
- tokens := UsageTokens{
- InputTokens: result.Usage.InputTokens,
- OutputTokens: result.Usage.OutputTokens,
- CacheCreationTokens: result.Usage.CacheCreationInputTokens,
- CacheReadTokens: result.Usage.CacheReadInputTokens,
- CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
- CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
- ImageOutputTokens: result.Usage.ImageOutputTokens,
- }
- var resolved *ResolvedPricing
- if s.resolver != nil && apiKey.Group != nil {
- gid := apiKey.Group.ID
- resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group})
- }
- cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{
- Ctx: ctx,
- Model: billingModel,
- Group: apiKey.Group,
- Tokens: tokens,
- RateMultiplier: multiplier,
- PricingAt: pricingAt,
- ServiceTier: optionalStringValue(result.ServiceTier),
- ReasoningEffort: optionalStringValue(result.ReasoningEffort),
- Resolver: s.resolver,
- Resolved: resolved,
- })
- if err != nil {
- logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
- return &CostBreakdown{ActualCost: 0}
- }
- return cost
- }
- // buildRecordUsageLog 构建使用日志并设置计费模式。
- func (s *GatewayService) buildRecordUsageLog(
- ctx context.Context,
- input *recordUsageCoreInput,
- result *ForwardResult,
- apiKey *APIKey,
- user *User,
- account *Account,
- subscription *UserSubscription,
- requestedModel string,
- multiplier float64,
- imageMultiplier float64,
- accountRateMultiplier float64,
- billingType int8,
- cacheTTLOverridden bool,
- cost *CostBreakdown,
- ) *UsageLog {
- durationMs := int(result.Duration.Milliseconds())
- requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
- sentModel := upstreamSentModel(result.Model, result.UpstreamModel)
- if result.UpstreamResponseModelConflict {
- slog.Warn("upstream_response_model_conflict",
- "platform", account.Platform,
- "account_id", account.ID,
- "request_id", requestID,
- "sent_model", sentModel,
- "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, false),
- 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: result.Usage.InputTokens,
- OutputTokens: result.Usage.OutputTokens,
- CacheCreationTokens: result.Usage.CacheCreationInputTokens,
- CacheReadTokens: result.Usage.CacheReadInputTokens,
- CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
- CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
- ImageOutputTokens: result.Usage.ImageOutputTokens,
- RateMultiplier: multiplier,
- AccountRateMultiplier: &accountRateMultiplier,
- BillingType: billingType,
- BillingMode: resolveBillingMode(result, cost),
- Stream: result.Stream,
- DurationMs: &durationMs,
- FirstTokenMs: result.FirstTokenMs,
- ImageCount: result.ImageCount,
- ImageSize: optionalTrimmedStringPtr(result.ImageSize),
- ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
- ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
- ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
- ImageSizeBreakdown: result.ImageSizeBreakdown,
- CacheTTLOverridden: cacheTTLOverridden,
- ChannelID: optionalInt64Ptr(input.ChannelID),
- ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
- UserAgent: optionalTrimmedStringPtr(input.UserAgent),
- IPAddress: optionalTrimmedStringPtr(input.IPAddress),
- SessionID: optionalTrimmedStringPtr(input.SessionID),
- GroupID: apiKey.GroupID,
- SubscriptionID: optionalSubscriptionID(subscription),
- CreatedAt: time.Now(),
- }
- if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
- usageLog.RateMultiplier = imageMultiplier
- }
- if cost != nil {
- usageLog.InputCost = cost.InputCost
- 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
- }
- return usageLog
- }
- // resolveBillingMode 根据计费结果和请求类型确定计费模式。
- func resolveBillingMode(result *ForwardResult, cost *CostBreakdown) *string {
- var mode string
- switch {
- case cost != nil && cost.BillingMode != "":
- mode = cost.BillingMode
- case result.ImageCount > 0:
- mode = string(BillingModeImage)
- default:
- mode = string(BillingModeToken)
- }
- return &mode
- }
- func optionalSubscriptionID(subscription *UserSubscription) *int64 {
- if subscription != nil {
- return &subscription.ID
- }
- return nil
- }
|