openai_gateway_usage.go 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  1. package service
  2. // 本文件由 openai_gateway_service.go 纯移动拆分而来:用量记录、计费成本计算与
  3. // Codex 用量快照。仅做代码搬迁,无任何行为变更。
  4. import (
  5. "context"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  15. "go.uber.org/zap"
  16. )
  17. // OpenAIRecordUsageInput input for recording usage
  18. type OpenAIRecordUsageInput struct {
  19. Result *OpenAIForwardResult
  20. APIKey *APIKey
  21. User *User
  22. Account *Account
  23. Subscription *UserSubscription
  24. InboundEndpoint string
  25. UpstreamEndpoint string
  26. UserAgent string // 请求的 User-Agent
  27. IPAddress string // 请求的客户端 IP 地址
  28. SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
  29. RequestPayloadHash string
  30. APIKeyService APIKeyQuotaUpdater
  31. QuotaPlatform string // user×platform quota platform resolved by the handler before async billing.
  32. // PricingAt 是请求级定价时刻(请求开始捕获,与利润门的 D 同源):高峰因子
  33. // 按该时刻计算,保证同一请求从准入到扣费不中途变价。零值回退记录时刻
  34. //(既有行为),供未装配的路径(图片/异步/cyber 等)沿用。
  35. PricingAt time.Time
  36. // CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。
  37. CyberBlocked bool
  38. // NativeCompactionV2 is an orthogonal semantic flag captured by the
  39. // Responses handler from stream=true + compaction_trigger. It never stores
  40. // the request payload and does not replace the transport request type.
  41. NativeCompactionV2 bool
  42. ChannelUsageFields
  43. }
  44. // CyberPolicyUsageInput 是 cyber 拒绝、未走正常 RecordUsage 的请求记录用量的入参。
  45. // 用量按上游真实 token 计费,与 WS cyber 及正常请求口径一致(InputTokens/OutputTokens
  46. // 取自上游 response.failed 报告的 usage,即 mark.UpstreamInTok/OutTok)。
  47. type CyberPolicyUsageInput struct {
  48. APIKey *APIKey
  49. Account *Account
  50. Subscription *UserSubscription
  51. RequestID string
  52. Model string
  53. Stream bool
  54. InputTokens int
  55. OutputTokens int
  56. // 渠道归因与请求级 meta,使 cyber 计费行与正常 RecordUsage 行口径一致
  57. // (否则 cyber 行 channel_id 等为空,渠道维度统计会遗漏 cyber 命中)。
  58. InboundEndpoint string
  59. UpstreamEndpoint string
  60. UserAgent string
  61. IPAddress string
  62. SessionID string
  63. RequestPayloadHash string
  64. APIKeyService APIKeyQuotaUpdater
  65. NativeCompactionV2 bool
  66. ChannelUsageFields
  67. }
  68. // RecordCyberPolicyUsageLog 为被上游 cyber_policy 拒绝、未走正常 RecordUsage 的请求
  69. // (HTTP forward 返回错误路径)记录用量并按上游真实 token 计费,使其与 WS cyber 路径、
  70. // 与正常请求的计费口径统一(不再是 tokens=0 免费行)。token 取自上游 response.failed
  71. // 报告的 usage(非流式直接拒通常为 0,cost 随之为 0)。复用 RecordUsage 完成成本计算、
  72. // 扣费与用量行写入(request_type=cyber 由 CyberBlocked 置位)。仅 forward 返回错误的
  73. // 路径由 handler 调用,避免与成功路径的正常 RecordUsage 重复。
  74. func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in CyberPolicyUsageInput) {
  75. if s == nil || in.APIKey == nil || in.APIKey.User == nil || in.Account == nil || strings.TrimSpace(in.Model) == "" {
  76. return
  77. }
  78. result := &OpenAIForwardResult{
  79. RequestID: in.RequestID,
  80. Model: in.Model,
  81. Stream: in.Stream,
  82. Usage: OpenAIUsage{
  83. InputTokens: in.InputTokens,
  84. OutputTokens: in.OutputTokens,
  85. },
  86. }
  87. if err := s.RecordUsage(ctx, &OpenAIRecordUsageInput{
  88. Result: result,
  89. APIKey: in.APIKey,
  90. User: in.APIKey.User,
  91. Account: in.Account,
  92. Subscription: in.Subscription,
  93. InboundEndpoint: in.InboundEndpoint,
  94. UpstreamEndpoint: in.UpstreamEndpoint,
  95. UserAgent: in.UserAgent,
  96. IPAddress: in.IPAddress,
  97. SessionID: in.SessionID,
  98. RequestPayloadHash: in.RequestPayloadHash,
  99. APIKeyService: in.APIKeyService,
  100. ChannelUsageFields: in.ChannelUsageFields,
  101. CyberBlocked: true,
  102. NativeCompactionV2: in.NativeCompactionV2,
  103. }); err != nil {
  104. logger.LegacyPrintf("service.openai_gateway", "cyber usage record failed: request_id=%s err=%v", in.RequestID, err)
  105. }
  106. }
  107. // ResolveUserGroupRateMultiplier resolves the same cached multiplier used by OpenAI usage billing.
  108. func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
  109. if s == nil {
  110. return groupDefaultMultiplier
  111. }
  112. resolver := s.userGroupRateResolver
  113. if resolver == nil {
  114. resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway")
  115. }
  116. return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
  117. }
  118. // openAIUsagePricingAt 返回本次用量记录使用的定价时刻:优先请求级 PricingAt
  119. // (与利润门 D 同源同刻),未装配时回退记录时刻(既有行为)。
  120. func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time {
  121. if input != nil && !input.PricingAt.IsZero() {
  122. return input.PricingAt
  123. }
  124. return timezone.Now()
  125. }
  126. func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool {
  127. if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast {
  128. return false
  129. }
  130. if account == nil || !account.IsOpenAI() {
  131. return false
  132. }
  133. if !groupSupportsOpenAIFast(apiKey.Group.Platform) {
  134. return false
  135. }
  136. switch normalizeBillingServiceTier(serviceTier) {
  137. case "priority", "fast":
  138. return true
  139. default:
  140. return false
  141. }
  142. }
  143. // RecordUsage records usage and deducts balance
  144. func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error {
  145. if input == nil {
  146. return errors.New("openai usage input is nil")
  147. }
  148. result := input.Result
  149. if result == nil {
  150. return errors.New("openai usage result is nil")
  151. }
  152. if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI {
  153. s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID)
  154. }
  155. apiKey := input.APIKey
  156. user := input.User
  157. account := input.Account
  158. subscription := input.Subscription
  159. billingAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  160. if err != nil {
  161. return err
  162. }
  163. if !isGrokVideoUsageResult(result, nil) {
  164. ApplyOpenAIImageBillingResolution(result)
  165. }
  166. logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(billingAccount, result))
  167. // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。
  168. // 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。
  169. actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens - result.Usage.CacheCreationInputTokens
  170. if actualInputTokens < 0 {
  171. actualInputTokens = 0
  172. }
  173. // Calculate cost
  174. tokens := UsageTokens{
  175. InputTokens: actualInputTokens,
  176. ImageInputTokens: result.Usage.ImageInputTokens,
  177. OutputTokens: result.Usage.OutputTokens,
  178. CacheCreationTokens: result.Usage.CacheCreationInputTokens,
  179. CacheReadTokens: result.Usage.CacheReadInputTokens,
  180. ImageOutputTokens: result.Usage.ImageOutputTokens,
  181. }
  182. // Get rate multiplier
  183. multiplier := 1.0
  184. if s.cfg != nil {
  185. multiplier = s.cfg.Default.RateMultiplier
  186. }
  187. if apiKey.GroupID != nil && apiKey.Group != nil {
  188. multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier)
  189. }
  190. // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。
  191. // 高峰因子按请求级 PricingAt 现算(与利润门 D 同源同刻,跨峰谷请求不中途
  192. // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的
  193. // Resolve,以免污染 user:group 倍率缓存。
  194. baseMultiplier := multiplier
  195. pricingAt := openAIUsagePricingAt(input)
  196. multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt)
  197. videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier)
  198. var cost *CostBreakdown
  199. billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
  200. if result.BillingModel != "" {
  201. billingModel = strings.TrimSpace(result.BillingModel)
  202. }
  203. if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" && input.ChannelMappedModel != input.OriginalModel {
  204. billingModel = input.ChannelMappedModel
  205. }
  206. if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" {
  207. billingModel = input.OriginalModel
  208. }
  209. billingModels := usageBillingModelCandidates(
  210. billingModel,
  211. result.BillingModel,
  212. input.ChannelMappedModel,
  213. input.OriginalModel,
  214. result.UpstreamModel,
  215. result.Model,
  216. )
  217. billingModels = s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, billingModels)
  218. serviceTier := ""
  219. if result.ServiceTier != nil {
  220. serviceTier = strings.TrimSpace(*result.ServiceTier)
  221. }
  222. longContextBillingGate := openAILongContextBillingGate(billingAccount)
  223. cost, err = s.calculateOpenAIRecordUsageCost(
  224. ctx,
  225. result,
  226. apiKey,
  227. billingModels,
  228. multiplier,
  229. imageMultiplier,
  230. videoMultiplier,
  231. baseMultiplier,
  232. tokens,
  233. serviceTier,
  234. longContextBillingGate,
  235. pricingAt,
  236. )
  237. if err != nil {
  238. if !isUsagePricingUnavailableError(err) {
  239. return err
  240. }
  241. logger.L().With(
  242. zap.String("component", "service.openai_gateway"),
  243. zap.Strings("billing_models", billingModels),
  244. zap.String("requested_model", input.OriginalModel),
  245. zap.String("mapped_model", input.ChannelMappedModel),
  246. zap.String("upstream_model", result.UpstreamModel),
  247. zap.Int64("api_key_id", apiKey.ID),
  248. zap.Int64("account_id", account.ID),
  249. ).Warn("openai_usage.pricing_missing_record_zero_cost", zap.Error(err))
  250. cost = &CostBreakdown{BillingMode: string(BillingModeToken)}
  251. }
  252. // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
  253. // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing
  254. // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
  255. // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。
  256. baselineBillingModel := firstUsageBillingModel(billingModels)
  257. if responseModel := responseModelBillingDeclaration(
  258. input.BillingModelSource,
  259. result.UpstreamResponseModel,
  260. result.UpstreamResponseModelConflict,
  261. result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0 ||
  262. result.AudioUsage != nil || result.SearchCount > 0,
  263. ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) {
  264. if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified {
  265. responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel))
  266. responseCost, responseErr := s.calculateOpenAIRecordUsageCost(
  267. ctx, result, apiKey, responseModels, multiplier, imageMultiplier,
  268. videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt,
  269. )
  270. // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost
  271. // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际
  272. // 定价基准(有渠道价就一定能算出价,循环不会落到后续候选)。
  273. baselineChannelPriced := s.resolveOpenAIChannelPricing(ctx, baselineBillingModel, apiKey) != nil
  274. if responseErr == nil && responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
  275. logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID,
  276. baselineBillingModel, responseModel, cost, responseCost)
  277. billingModels = responseModels
  278. cost = responseCost
  279. }
  280. }
  281. }
  282. // Free Fast changes only the customer charge. Keep priority TotalCost and
  283. // service_tier for upstream accounting, but evaluate ActualCost once more at
  284. // the Standard tier using the same channel, peak, and long-context policy.
  285. if groupBillsOpenAIFastAtStandard(apiKey, billingAccount, serviceTier) {
  286. standardCost, standardErr := s.calculateOpenAIRecordUsageCost(
  287. ctx,
  288. result,
  289. apiKey,
  290. billingModels,
  291. multiplier,
  292. imageMultiplier,
  293. videoMultiplier,
  294. baseMultiplier,
  295. tokens,
  296. "",
  297. longContextBillingGate,
  298. pricingAt,
  299. )
  300. if standardErr != nil {
  301. return standardErr
  302. }
  303. if cost != nil && standardCost != nil {
  304. cost.ActualCost = standardCost.ActualCost
  305. }
  306. }
  307. // Determine billing type
  308. isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
  309. billingType := BillingTypeBalance
  310. if isSubscriptionBilling {
  311. billingType = BillingTypeSubscription
  312. }
  313. // Create usage log
  314. durationMs := int(result.Duration.Milliseconds())
  315. accountRateMultiplier := account.BillingRateMultiplier()
  316. requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
  317. if result.OpenAIWSMode {
  318. if upstreamRequestID := strings.TrimSpace(result.RequestID); upstreamRequestID != "" {
  319. requestID = upstreamRequestID
  320. }
  321. }
  322. // Async Grok video: always use the stable task id for dedup (status + content polls
  323. // share one bill). Context-local client/local IDs would otherwise create a new row
  324. // per poll if Redis claim is lost.
  325. if result.VideoCount > 0 {
  326. if stable := StableGrokVideoBillingRequestID(firstNonEmpty(
  327. strings.TrimPrefix(strings.TrimSpace(result.RequestID), "grok-video:"),
  328. strings.TrimSpace(result.ResponseID),
  329. strings.TrimPrefix(strings.TrimSpace(requestID), "grok-video:"),
  330. )); stable != "" {
  331. requestID = stable
  332. }
  333. }
  334. // 确定 RequestedModel(渠道映射前的原始模型)
  335. requestedModel := result.Model
  336. if input.OriginalModel != "" {
  337. requestedModel = input.OriginalModel
  338. }
  339. sentModel := upstreamSentModel(result.Model, result.UpstreamModel)
  340. if result.UpstreamResponseModelConflict {
  341. logger.L().Warn("upstream_response_model_conflict",
  342. zap.String("platform", account.Platform),
  343. zap.Int64("account_id", account.ID),
  344. zap.String("request_id", requestID),
  345. zap.String("sent_model", sentModel),
  346. zap.String("selected_response_model", strings.TrimSpace(result.UpstreamResponseModel)),
  347. )
  348. }
  349. usageLog := &UsageLog{
  350. UserID: user.ID,
  351. APIKeyID: apiKey.ID,
  352. AccountID: account.ID,
  353. RequestID: requestID,
  354. UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, result.OpenAIWSMode),
  355. Model: result.Model,
  356. RequestedModel: requestedModel,
  357. UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
  358. UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
  359. UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
  360. ServiceTier: result.ServiceTier,
  361. ReasoningEffort: result.ReasoningEffort,
  362. RequestedReasoningEffort: coalesceRequestedReasoningEffort(result.RequestedReasoningEffort, result.ReasoningEffort),
  363. InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
  364. UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
  365. InputTokens: actualInputTokens,
  366. OutputTokens: result.Usage.OutputTokens,
  367. CacheCreationTokens: result.Usage.CacheCreationInputTokens,
  368. CacheReadTokens: result.Usage.CacheReadInputTokens,
  369. ImageInputTokens: result.Usage.ImageInputTokens,
  370. ImageOutputTokens: result.Usage.ImageOutputTokens,
  371. ImageCount: result.ImageCount,
  372. ImageSize: optionalTrimmedStringPtr(result.ImageSize),
  373. ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
  374. ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
  375. ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
  376. ImageSizeBreakdown: result.ImageSizeBreakdown,
  377. NativeCompactionV2: input.NativeCompactionV2,
  378. }
  379. isVideoUsage := isGrokVideoUsageResult(result, billingModels)
  380. if isVideoUsage {
  381. usageLog.VideoCount = result.VideoCount
  382. usageLog.VideoResolution = optionalTrimmedStringPtr(NormalizeVideoBillingResolutionOrDefault(result.VideoResolution))
  383. videoDurationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds)
  384. usageLog.VideoDurationSeconds = &videoDurationSeconds
  385. }
  386. if cost != nil {
  387. usageLog.InputCost = cost.InputCost
  388. usageLog.ImageInputCost = cost.ImageInputCost
  389. usageLog.OutputCost = cost.OutputCost
  390. usageLog.ImageOutputCost = cost.ImageOutputCost
  391. usageLog.CacheCreationCost = cost.CacheCreationCost
  392. usageLog.CacheReadCost = cost.CacheReadCost
  393. usageLog.TotalCost = cost.TotalCost
  394. usageLog.ActualCost = cost.ActualCost
  395. usageLog.LongContextBillingApplied = cost.LongContextBillingApplied
  396. }
  397. if isVideoUsage && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
  398. usageLog.RateMultiplier = videoMultiplier
  399. } else if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
  400. usageLog.RateMultiplier = imageMultiplier
  401. } else {
  402. usageLog.RateMultiplier = multiplier
  403. }
  404. usageLog.AccountRateMultiplier = &accountRateMultiplier
  405. usageLog.BillingType = billingType
  406. usageLog.Stream = result.Stream
  407. if input.CyberBlocked {
  408. usageLog.RequestType = RequestTypeCyberBlocked
  409. }
  410. usageLog.OpenAIWSMode = result.OpenAIWSMode
  411. usageLog.DurationMs = &durationMs
  412. usageLog.FirstTokenMs = result.FirstTokenMs
  413. usageLog.CreatedAt = time.Now()
  414. // 设置渠道信息
  415. usageLog.ChannelID = optionalInt64Ptr(input.ChannelID)
  416. usageLog.ModelMappingChain = optionalTrimmedStringPtr(input.ModelMappingChain)
  417. // 设置计费模式
  418. if cost != nil && cost.BillingMode != "" {
  419. billingMode := cost.BillingMode
  420. usageLog.BillingMode = &billingMode
  421. } else if isVideoUsage {
  422. billingMode := string(BillingModeVideo)
  423. usageLog.BillingMode = &billingMode
  424. } else if result.ImageCount > 0 {
  425. billingMode := string(BillingModeImage)
  426. usageLog.BillingMode = &billingMode
  427. } else {
  428. billingMode := string(BillingModeToken)
  429. usageLog.BillingMode = &billingMode
  430. }
  431. // 添加 UserAgent
  432. if input.UserAgent != "" {
  433. usageLog.UserAgent = &input.UserAgent
  434. }
  435. // 添加 IPAddress
  436. if input.IPAddress != "" {
  437. usageLog.IPAddress = &input.IPAddress
  438. }
  439. // 添加 SessionID(客户端显式会话标识;缺失/无效时保持 nil)
  440. usageLog.SessionID = optionalTrimmedStringPtr(input.SessionID)
  441. if apiKey.GroupID != nil {
  442. usageLog.GroupID = apiKey.GroupID
  443. }
  444. if subscription != nil {
  445. usageLog.SubscriptionID = &subscription.ID
  446. }
  447. // 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
  448. if apiKey.GroupID != nil {
  449. applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
  450. account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
  451. tokens, cost.TotalCost, pricingAt,
  452. )
  453. }
  454. if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
  455. writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
  456. logger.LegacyPrintf("service.openai_gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
  457. s.deferredService.ScheduleLastUsedUpdate(account.ID)
  458. return nil
  459. }
  460. // Async usage billing runs outside the original request context, so it
  461. // cannot recover ForcePlatform there. Fall back for internal/test callers.
  462. quotaPlatform := input.QuotaPlatform
  463. if quotaPlatform == "" {
  464. quotaPlatform = PlatformFromAPIKey(apiKey)
  465. }
  466. billingErr := func() error {
  467. _, err := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{
  468. Cost: cost,
  469. User: user,
  470. APIKey: apiKey,
  471. Account: account,
  472. Subscription: subscription,
  473. RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
  474. IsSubscriptionBill: isSubscriptionBilling,
  475. AccountRateMultiplier: accountRateMultiplier,
  476. APIKeyService: input.APIKeyService,
  477. Platform: quotaPlatform,
  478. }, s.billingDeps(), s.usageBillingRepo)
  479. return err
  480. }()
  481. if billingErr != nil {
  482. usageLog.ActualCost = 0
  483. writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
  484. return billingErr
  485. }
  486. writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
  487. return nil
  488. }
  489. // hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准,
  490. // 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用,
  491. // 避免为此再解析一次)。
  492. // 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目;
  493. // 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把
  494. // 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。
  495. func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) {
  496. model = strings.TrimSpace(model)
  497. if model == "" {
  498. return false, false
  499. }
  500. if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil {
  501. return true, true
  502. }
  503. return s.billingService.HasIdentifiedTokenPricing(model), false
  504. }
  505. // openAILongContextBillingGate returns the per-account long-context opt-in.
  506. // The flag is an OpenAI-only account setting, so other platforms (Grok) return
  507. // nil — "no per-account gate" — and are governed by the group toggle alone.
  508. // Returning a hardcoded false for them would veto the official model ladders
  509. // (e.g. the Grok >=200k 2x card) that no account setting can ever re-enable.
  510. func openAILongContextBillingGate(account *Account) *bool {
  511. if account == nil || !account.IsOpenAI() {
  512. return nil
  513. }
  514. enabled := account.IsOpenAILongContextBillingEnabled()
  515. return &enabled
  516. }
  517. func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
  518. ctx context.Context,
  519. result *OpenAIForwardResult,
  520. apiKey *APIKey,
  521. billingModels []string,
  522. multiplier float64,
  523. imageMultiplier float64,
  524. videoMultiplier float64,
  525. webSearchMultiplier float64,
  526. tokens UsageTokens,
  527. serviceTier string,
  528. longContextBillingGate *bool,
  529. pricingAt time.Time,
  530. ) (*CostBreakdown, error) {
  531. billingModel := firstUsageBillingModel(billingModels)
  532. if result != nil && result.WebSearchCalls > 0 {
  533. // Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,单价只取
  534. // 分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),不参与渠道级模型定价。
  535. // 倍率与 image/video 按次口径一致:使用不含高峰因子的基础倍率
  536. //(用户专属 > 分组 rate_multiplier > 系统默认),与分组表单的价格预览承诺一致。
  537. return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), webSearchMultiplier), nil
  538. }
  539. if isGrokVideoUsageResult(result, billingModels) {
  540. if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
  541. return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil
  542. }
  543. }
  544. if result != nil && result.AudioUsage != nil {
  545. if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
  546. (resolved.Mode == BillingModePerRequest) {
  547. gid := apiKey.Group.ID
  548. return s.billingService.CalculateCostUnified(CostInput{
  549. Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
  550. UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode,
  551. RateMultiplier: webSearchMultiplier, Resolver: s.resolver, Resolved: resolved,
  552. })
  553. }
  554. cfg := groupAudioPriceConfigFromAPIKey(apiKey)
  555. return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, webSearchMultiplier), nil
  556. }
  557. if result != nil && result.ImageCount > 0 {
  558. // 渠道定价为 token 计费时走 token 路径,否则走图片计费
  559. if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {
  560. return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, imageMultiplier), nil
  561. }
  562. }
  563. // Token path (optional search surcharge is additive — never replaces token cost).
  564. var tokenCost *CostBreakdown
  565. var lastErr error
  566. if len(billingModels) > 0 && billingModel != "" {
  567. for _, candidate := range billingModels {
  568. candidate = strings.TrimSpace(candidate)
  569. if candidate == "" {
  570. continue
  571. }
  572. cost, err := s.calculateOpenAIRecordUsageTokenCost(
  573. ctx,
  574. apiKey,
  575. candidate,
  576. multiplier,
  577. pricingAt,
  578. tokens,
  579. serviceTier,
  580. optionalStringValue(result.ReasoningEffort),
  581. longContextBillingGate,
  582. )
  583. if err == nil {
  584. tokenCost = cost
  585. break
  586. }
  587. lastErr = err
  588. }
  589. }
  590. // Search surcharge is additive. Never let a zero/default search cost mask a
  591. // real token-pricing failure for requests that attempted token billing.
  592. searchCost := (*CostBreakdown)(nil)
  593. if result != nil && result.SearchCount > 0 {
  594. price := groupSearchPricePer1kFromAPIKey(apiKey)
  595. if price != nil && *price == 0 {
  596. logger.L().Info("openai_usage.search_price_per_1k_explicit_free",
  597. zap.Int("search_count", result.SearchCount),
  598. zap.String("model", billingModel),
  599. zap.Int64("api_key_id", apiKey.ID),
  600. zap.Any("group_id", apiKey.GroupID),
  601. )
  602. }
  603. searchCost = s.billingService.CalculateSearchCost(result.SearchCount, price, webSearchMultiplier)
  604. }
  605. tokenBillingAttempted := len(billingModels) > 0 && billingModel != ""
  606. if tokenCost == nil {
  607. if tokenBillingAttempted {
  608. if lastErr == nil {
  609. lastErr = fmt.Errorf("%w: no non-empty billing model candidates", ErrModelPricingUnavailable)
  610. }
  611. return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
  612. }
  613. // Search-only (no model / pure tool path): allow search billing alone.
  614. if searchCost != nil {
  615. return searchCost, nil
  616. }
  617. // 空候选按「无价可循」处理并携带 ErrModelPricingUnavailable:上层据此走
  618. // 零成本+告警落账,而不是丢弃整条 usage 记录。CN 账号的 claude-* 候选被
  619. // filterCNProviderBillingModelCandidates 全数过滤后即落到这里。
  620. if lastErr == nil {
  621. lastErr = fmt.Errorf("%w: openai usage billing model is empty", ErrModelPricingUnavailable)
  622. }
  623. return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr)
  624. }
  625. if searchCost == nil || (searchCost.TotalCost == 0 && searchCost.ActualCost == 0) {
  626. return tokenCost, nil
  627. }
  628. // Additive: tokens + search surcharge.
  629. tokenCost.TotalCost += searchCost.TotalCost
  630. tokenCost.ActualCost += searchCost.ActualCost
  631. return tokenCost, nil
  632. }
  633. func isGrokVideoBillingModel(model string) bool {
  634. return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-imagine-video")
  635. }
  636. func isGrokVideoUsageResult(result *OpenAIForwardResult, billingModels []string) bool {
  637. if result == nil || result.VideoCount <= 0 {
  638. return false
  639. }
  640. // VideoCount alone is authoritative for async video completion billing.
  641. // Prefer model-family match when present; never drop video mode on rename/mapping.
  642. candidates := append([]string{}, billingModels...)
  643. candidates = append(candidates, result.BillingModel, result.Model, result.UpstreamModel)
  644. for _, candidate := range candidates {
  645. if isGrokVideoBillingModel(candidate) {
  646. return true
  647. }
  648. }
  649. return true
  650. }
  651. func isUsagePricingUnavailableError(err error) bool {
  652. if err == nil {
  653. return false
  654. }
  655. if errors.Is(err, ErrModelPricingUnavailable) {
  656. return true
  657. }
  658. msg := strings.ToLower(err.Error())
  659. return strings.Contains(msg, "no pricing available") || strings.Contains(msg, "pricing not found")
  660. }
  661. func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost(
  662. ctx context.Context,
  663. apiKey *APIKey,
  664. billingModel string,
  665. multiplier float64,
  666. pricingAt time.Time,
  667. tokens UsageTokens,
  668. serviceTier string,
  669. reasoningEffort string,
  670. longContextBillingGate *bool,
  671. ) (*CostBreakdown, error) {
  672. if s.resolver != nil && apiKey.Group != nil {
  673. gid := apiKey.Group.ID
  674. return s.billingService.CalculateCostUnified(CostInput{
  675. Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
  676. Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt,
  677. ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver,
  678. LongContextBillingEnabled: longContextBillingGate,
  679. })
  680. }
  681. breakdown, err := s.billingService.calculateCostWithServiceTierPolicy(
  682. billingModel,
  683. tokens,
  684. multiplier,
  685. serviceTier,
  686. longContextBillingGate == nil || *longContextBillingGate,
  687. )
  688. if err == nil {
  689. applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil))
  690. }
  691. return breakdown, err
  692. }
  693. func (s *OpenAIGatewayService) calculateOpenAIImageCost(
  694. ctx context.Context,
  695. billingModel string,
  696. apiKey *APIKey,
  697. result *OpenAIForwardResult,
  698. multiplier float64,
  699. ) *CostBreakdown {
  700. sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
  701. resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey)
  702. if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup) &&
  703. (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
  704. gid := apiKey.Group.ID
  705. cost, err := s.billingService.CalculateCostUnified(CostInput{
  706. Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
  707. RequestCount: result.ImageCount, SizeTier: sizeTier,
  708. RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
  709. })
  710. if err == nil {
  711. return cost
  712. }
  713. }
  714. groupConfig := imagePriceConfigFromAPIKey(apiKey)
  715. if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
  716. return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
  717. }
  718. if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey {
  719. apiKey = refreshed
  720. groupConfig = imagePriceConfigFromAPIKey(apiKey)
  721. if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) {
  722. return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
  723. }
  724. }
  725. if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceChannel) &&
  726. (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
  727. gid := apiKey.Group.ID
  728. cost, err := s.billingService.CalculateCostUnified(CostInput{
  729. Ctx: ctx,
  730. Model: billingModel,
  731. GroupID: &gid,
  732. Group: apiKey.Group,
  733. RequestCount: result.ImageCount,
  734. SizeTier: sizeTier,
  735. RateMultiplier: multiplier,
  736. Resolver: s.resolver,
  737. Resolved: resolved,
  738. })
  739. if err == nil {
  740. return cost
  741. }
  742. logger.LegacyPrintf("service.openai_gateway", "Calculate image channel cost failed: %v", err)
  743. }
  744. return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
  745. }
  746. func (s *OpenAIGatewayService) calculateOpenAIVideoCost(
  747. ctx context.Context,
  748. billingModel string,
  749. apiKey *APIKey,
  750. result *OpenAIForwardResult,
  751. multiplier float64,
  752. ) *CostBreakdown {
  753. videoCount := result.VideoCount
  754. if videoCount <= 0 {
  755. videoCount = 1
  756. }
  757. resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution)
  758. durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds)
  759. resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey)
  760. if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup) && resolved.Mode == BillingModeVideo {
  761. gid := apiKey.Group.ID
  762. cost, err := s.billingService.CalculateCostUnified(CostInput{
  763. Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
  764. UsageUnits: float64(videoCount * durationSeconds), SizeTier: resolution,
  765. RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
  766. })
  767. if err == nil {
  768. return cost
  769. }
  770. }
  771. groupConfig := videoPriceConfigFromAPIKey(apiKey)
  772. if apiKeyHasConfiguredVideoPrice(apiKey, billingModel, resolution) {
  773. return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
  774. }
  775. if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey {
  776. apiKey = refreshed
  777. groupConfig = videoPriceConfigFromAPIKey(apiKey)
  778. if apiKeyHasConfiguredVideoPrice(apiKey, billingModel, resolution) {
  779. return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
  780. }
  781. }
  782. if resolved != nil && (resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceChannel) &&
  783. (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage || resolved.Mode == BillingModeVideo) {
  784. // 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。
  785. gid := apiKey.Group.ID
  786. units := float64(videoCount)
  787. if resolved.Mode == BillingModeVideo {
  788. units = float64(videoCount * durationSeconds)
  789. }
  790. cost, err := s.billingService.CalculateCostUnified(CostInput{
  791. Ctx: ctx,
  792. Model: billingModel,
  793. GroupID: &gid,
  794. Group: apiKey.Group,
  795. RequestCount: videoCount,
  796. UsageUnits: units,
  797. SizeTier: resolution,
  798. RateMultiplier: multiplier,
  799. Resolver: s.resolver,
  800. Resolved: resolved,
  801. })
  802. if err == nil {
  803. cost.BillingMode = string(BillingModeVideo)
  804. return cost
  805. }
  806. logger.LegacyPrintf("service.openai_gateway", "Calculate video channel cost failed: %v", err)
  807. }
  808. return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier)
  809. }
  810. func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Context, apiKey *APIKey) *APIKey {
  811. if apiKey == nil || apiKey.GroupID == nil || *apiKey.GroupID <= 0 {
  812. return apiKey
  813. }
  814. if !groupMediaPricingLooksIncomplete(apiKey.Group) {
  815. return apiKey
  816. }
  817. if s == nil || s.channelService == nil || s.channelService.groupRepo == nil {
  818. return apiKey
  819. }
  820. group, err := s.channelService.groupRepo.GetByIDLite(ctx, *apiKey.GroupID)
  821. if err != nil || group == nil {
  822. return apiKey
  823. }
  824. clone := *apiKey
  825. clone.Group = group
  826. return &clone
  827. }
  828. // groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体/搜索/语音计费字段
  829. // (例如由不含这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在
  830. // 数据库中的默认值均为 1.0;正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、
  831. // 全部媒体/搜索/语音价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条
  832. // 用量都多打一次 DB 查询。
  833. //
  834. // 注意:apiKeyAuthSnapshotVersion 升级会强制刷新存量快照;本函数是热路径上的二次兜底,
  835. // 不能仅凭 legacy video_price_* 判定完整而跳过 VideoModelPrices/search/audio 的回源。
  836. func groupMediaPricingLooksIncomplete(group *Group) bool {
  837. if group == nil {
  838. return true
  839. }
  840. if group.ImageRateIndependent || group.VideoRateIndependent {
  841. return false
  842. }
  843. if group.ImageRateMultiplier != 0 || group.VideoRateMultiplier != 0 {
  844. return false
  845. }
  846. // Any first-class pricing field present means the projection is not a blank shell.
  847. if len(group.VideoModelPrices) > 0 {
  848. return false
  849. }
  850. if len(group.ModelPricing) > 0 || group.LongContextPricingEnabled {
  851. return false
  852. }
  853. if group.SearchPricePer1k != nil ||
  854. group.AudioRealtimePricePerMin != nil ||
  855. group.AudioTTSPricePerMillionChars != nil ||
  856. group.AudioSTTPricePerHour != nil ||
  857. group.WebSearchPricePerCall != nil {
  858. return false
  859. }
  860. return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil &&
  861. group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil
  862. }
  863. // filterCNProviderBillingModelCandidates 过滤国产供应商(kimi/zhipu/deepseek)
  864. // 账号的计费候选模型名:claude-* 候选仅在运营者显式配置了分组/渠道定价时保留。
  865. //
  866. // 背景:候选链的兜底候选含客户端请求的原始模型名。CN 上游的 Anthropic 兼容端点
  867. // 接受 claude-* 模型名但从不真正服务 Claude 模型;若放行,目录里的 Claude 价卡
  868. // 与 getFallbackPricing 的 "claude"→Sonnet 统一兜底会把 CN 流量按 Claude 原价
  869. // (数倍~数十倍)静默误计,且 usage 日志显示的正是 claude-* 名,无从察觉。
  870. // 候选全部落空时走既有的零成本+告警路径(openai_usage.pricing_missing_record_
  871. // zero_cost),与定价层「未知型号不回退以避免误计价」的既有设计意图一致;
  872. // 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
  873. // 分组/渠道显式定价。
  874. func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
  875. if account == nil || !account.IsCNProvider() {
  876. return candidates
  877. }
  878. out := make([]string, 0, len(candidates))
  879. for _, candidate := range candidates {
  880. trimmed := strings.TrimSpace(candidate)
  881. if trimmed == "" {
  882. continue
  883. }
  884. if strings.Contains(strings.ToLower(trimmed), "claude") &&
  885. s.resolveOpenAIChannelPricing(ctx, trimmed, apiKey) == nil {
  886. continue
  887. }
  888. out = append(out, candidate)
  889. }
  890. return out
  891. }
  892. func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
  893. if s.resolver == nil || apiKey == nil || apiKey.Group == nil {
  894. return nil
  895. }
  896. gid := apiKey.Group.ID
  897. resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group})
  898. if resolved.Source == PricingSourceGlobal || resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel {
  899. return resolved
  900. }
  901. return nil
  902. }
  903. // ParseCodexRateLimitHeaders extracts Codex usage limits from response headers.
  904. // Exported for use in ratelimit_service when handling OpenAI 429 responses.
  905. func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
  906. snapshot := &OpenAICodexUsageSnapshot{}
  907. hasData := false
  908. // Helper to parse float64 from header
  909. parseFloat := func(key string) *float64 {
  910. if v := headers.Get(key); v != "" {
  911. if f, err := strconv.ParseFloat(v, 64); err == nil {
  912. return &f
  913. }
  914. }
  915. return nil
  916. }
  917. // Helper to parse int from header
  918. parseInt := func(key string) *int {
  919. if v := headers.Get(key); v != "" {
  920. if i, err := strconv.Atoi(v); err == nil {
  921. return &i
  922. }
  923. }
  924. return nil
  925. }
  926. // Primary (weekly) limits
  927. if v := parseFloat("x-codex-primary-used-percent"); v != nil {
  928. snapshot.PrimaryUsedPercent = v
  929. hasData = true
  930. }
  931. if v := parseInt("x-codex-primary-reset-after-seconds"); v != nil {
  932. snapshot.PrimaryResetAfterSeconds = v
  933. hasData = true
  934. }
  935. if v := parseInt("x-codex-primary-window-minutes"); v != nil {
  936. snapshot.PrimaryWindowMinutes = v
  937. hasData = true
  938. }
  939. // Secondary (5h) limits
  940. if v := parseFloat("x-codex-secondary-used-percent"); v != nil {
  941. snapshot.SecondaryUsedPercent = v
  942. hasData = true
  943. }
  944. if v := parseInt("x-codex-secondary-reset-after-seconds"); v != nil {
  945. snapshot.SecondaryResetAfterSeconds = v
  946. hasData = true
  947. }
  948. if v := parseInt("x-codex-secondary-window-minutes"); v != nil {
  949. snapshot.SecondaryWindowMinutes = v
  950. hasData = true
  951. }
  952. // Overflow ratio
  953. if v := parseFloat("x-codex-primary-over-secondary-limit-percent"); v != nil {
  954. snapshot.PrimaryOverSecondaryPercent = v
  955. hasData = true
  956. }
  957. if !hasData {
  958. return nil
  959. }
  960. snapshot.UpdatedAt = time.Now().Format(time.RFC3339)
  961. return snapshot
  962. }
  963. func codexSnapshotBaseTime(snapshot *OpenAICodexUsageSnapshot, fallback time.Time) time.Time {
  964. if snapshot == nil {
  965. return fallback
  966. }
  967. if snapshot.UpdatedAt == "" {
  968. return fallback
  969. }
  970. base, err := time.Parse(time.RFC3339, snapshot.UpdatedAt)
  971. if err != nil {
  972. return fallback
  973. }
  974. return base
  975. }
  976. func codexResetAtRFC3339(base time.Time, resetAfterSeconds *int) *string {
  977. if resetAfterSeconds == nil {
  978. return nil
  979. }
  980. sec := *resetAfterSeconds
  981. if sec < 0 {
  982. sec = 0
  983. }
  984. resetAt := base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339)
  985. return &resetAt
  986. }
  987. func buildCodexUsageExtraUpdates(snapshot *OpenAICodexUsageSnapshot, fallbackNow time.Time) map[string]any {
  988. if snapshot == nil {
  989. return nil
  990. }
  991. baseTime := codexSnapshotBaseTime(snapshot, fallbackNow)
  992. updates := make(map[string]any)
  993. // 保存原始 primary/secondary 字段,便于排查问题
  994. if snapshot.PrimaryUsedPercent != nil {
  995. updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
  996. }
  997. if snapshot.PrimaryResetAfterSeconds != nil {
  998. updates["codex_primary_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds
  999. }
  1000. if snapshot.PrimaryWindowMinutes != nil {
  1001. updates["codex_primary_window_minutes"] = *snapshot.PrimaryWindowMinutes
  1002. }
  1003. if snapshot.SecondaryUsedPercent != nil {
  1004. updates["codex_secondary_used_percent"] = *snapshot.SecondaryUsedPercent
  1005. }
  1006. if snapshot.SecondaryResetAfterSeconds != nil {
  1007. updates["codex_secondary_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds
  1008. }
  1009. if snapshot.SecondaryWindowMinutes != nil {
  1010. updates["codex_secondary_window_minutes"] = *snapshot.SecondaryWindowMinutes
  1011. }
  1012. if snapshot.PrimaryOverSecondaryPercent != nil {
  1013. updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
  1014. }
  1015. updates["codex_usage_updated_at"] = baseTime.Format(time.RFC3339)
  1016. // 归一化到 5h/7d 规范字段
  1017. if normalized := snapshot.Normalize(); normalized != nil {
  1018. if normalized.Used5hPercent != nil {
  1019. updates["codex_5h_used_percent"] = *normalized.Used5hPercent
  1020. }
  1021. if normalized.Reset5hSeconds != nil {
  1022. updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds
  1023. }
  1024. if normalized.Window5hMinutes != nil {
  1025. updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes
  1026. }
  1027. if normalized.Used7dPercent != nil {
  1028. updates["codex_7d_used_percent"] = *normalized.Used7dPercent
  1029. }
  1030. if normalized.Reset7dSeconds != nil {
  1031. updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds
  1032. }
  1033. if normalized.Window7dMinutes != nil {
  1034. updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
  1035. }
  1036. if reset5hAt := codexResetAtRFC3339(baseTime, normalized.Reset5hSeconds); reset5hAt != nil {
  1037. updates["codex_5h_reset_at"] = *reset5hAt
  1038. }
  1039. if reset7dAt := codexResetAtRFC3339(baseTime, normalized.Reset7dSeconds); reset7dAt != nil {
  1040. updates["codex_7d_reset_at"] = *reset7dAt
  1041. }
  1042. }
  1043. return updates
  1044. }
  1045. // updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
  1046. // updateCodexUsageSnapshot 把 /responses 的 x-codex-* 全局头快照写入账号 codex_* Extra。
  1047. // ⚠️ 调用方必须排除 spark 影子账号(account.IsShadow()):影子的 codex_* 仅由 QueryUsage
  1048. // (/wham/usage bengalfox 道)更新,不能被全局头口径污染(外审第7轮 P1)。本函数仅持 accountID,
  1049. // 无法在此自检影子,故守卫前置到各调用点。
  1050. func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
  1051. if snapshot == nil {
  1052. return
  1053. }
  1054. if s == nil || s.accountRepo == nil {
  1055. return
  1056. }
  1057. now := time.Now()
  1058. updates := buildCodexUsageExtraUpdates(snapshot, now)
  1059. if len(updates) == 0 {
  1060. return
  1061. }
  1062. if !s.getCodexSnapshotThrottle().Allow(accountID, now) {
  1063. return
  1064. }
  1065. go func() {
  1066. updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  1067. defer cancel()
  1068. if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil {
  1069. notifyOpenAIAutoReset(accountID)
  1070. }
  1071. }()
  1072. }
  1073. func (s *OpenAIGatewayService) UpdateCodexUsageSnapshotFromHeaders(ctx context.Context, accountID int64, headers http.Header) {
  1074. if accountID <= 0 || headers == nil {
  1075. return
  1076. }
  1077. if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil {
  1078. s.updateCodexUsageSnapshot(ctx, accountID, snapshot)
  1079. }
  1080. }