Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

328 строки
11 KiB

  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "github.com/QuantumNous/new-api/common"
  7. "github.com/QuantumNous/new-api/constant"
  8. "github.com/QuantumNous/new-api/logger"
  9. "github.com/QuantumNous/new-api/model"
  10. relaycommon "github.com/QuantumNous/new-api/relay/common"
  11. "github.com/QuantumNous/new-api/setting/ratio_setting"
  12. "github.com/gin-gonic/gin"
  13. )
  14. // LogTaskConsumption 记录任务消费日志和统计信息(仅记录,不涉及实际扣费)。
  15. // 实际扣费已由 BillingSession(PreConsumeBilling + SettleBilling)完成。
  16. func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
  17. tokenName := c.GetString("token_name")
  18. logContent := fmt.Sprintf("操作 %s", info.Action)
  19. // 支持任务仅按次计费
  20. if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) {
  21. logContent = fmt.Sprintf("%s,按次计费", logContent)
  22. } else {
  23. if len(info.PriceData.OtherRatios) > 0 {
  24. var contents []string
  25. for key, ra := range info.PriceData.OtherRatios {
  26. if 1.0 != ra {
  27. contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra))
  28. }
  29. }
  30. if len(contents) > 0 {
  31. logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
  32. }
  33. }
  34. }
  35. other := make(map[string]interface{})
  36. other["request_path"] = c.Request.URL.Path
  37. other["model_price"] = info.PriceData.ModelPrice
  38. other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
  39. if info.PriceData.GroupRatioInfo.HasSpecialRatio {
  40. other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
  41. }
  42. if info.IsModelMapped {
  43. other["is_model_mapped"] = true
  44. other["upstream_model_name"] = info.UpstreamModelName
  45. }
  46. model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
  47. ChannelId: info.ChannelId,
  48. ModelName: info.OriginModelName,
  49. TokenName: tokenName,
  50. Quota: info.PriceData.Quota,
  51. Content: logContent,
  52. TokenId: info.TokenId,
  53. Group: info.UsingGroup,
  54. Other: other,
  55. })
  56. model.UpdateUserUsedQuotaAndRequestCount(info.UserId, info.PriceData.Quota)
  57. model.UpdateChannelUsedQuota(info.ChannelId, info.PriceData.Quota)
  58. }
  59. // ---------------------------------------------------------------------------
  60. // 异步任务计费辅助函数
  61. // ---------------------------------------------------------------------------
  62. // resolveTokenKey 通过 TokenId 运行时获取令牌 Key(用于 Redis 缓存操作)。
  63. // 如果令牌已被删除或查询失败,返回空字符串。
  64. func resolveTokenKey(ctx context.Context, tokenId int, taskID string) string {
  65. token, err := model.GetTokenById(tokenId)
  66. if err != nil {
  67. logger.LogWarn(ctx, fmt.Sprintf("获取令牌 key 失败 (tokenId=%d, task=%s): %s", tokenId, taskID, err.Error()))
  68. return ""
  69. }
  70. return token.Key
  71. }
  72. // taskIsSubscription 判断任务是否通过订阅计费。
  73. func taskIsSubscription(task *model.Task) bool {
  74. return task.PrivateData.BillingSource == BillingSourceSubscription && task.PrivateData.SubscriptionId > 0
  75. }
  76. // taskAdjustFunding 调整任务的资金来源(钱包或订阅),delta > 0 表示扣费,delta < 0 表示退还。
  77. func taskAdjustFunding(task *model.Task, delta int) error {
  78. if taskIsSubscription(task) {
  79. return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
  80. }
  81. if delta > 0 {
  82. return model.DecreaseUserQuota(task.UserId, delta)
  83. }
  84. return model.IncreaseUserQuota(task.UserId, -delta, false)
  85. }
  86. // taskAdjustTokenQuota 调整任务的令牌额度,delta > 0 表示扣费,delta < 0 表示退还。
  87. // 需要通过 resolveTokenKey 运行时获取 key(不从 PrivateData 中读取)。
  88. func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
  89. if task.PrivateData.TokenId <= 0 || delta == 0 {
  90. return
  91. }
  92. tokenKey := resolveTokenKey(ctx, task.PrivateData.TokenId, task.TaskID)
  93. if tokenKey == "" {
  94. return
  95. }
  96. var err error
  97. if delta > 0 {
  98. err = model.DecreaseTokenQuota(task.PrivateData.TokenId, tokenKey, delta)
  99. } else {
  100. err = model.IncreaseTokenQuota(task.PrivateData.TokenId, tokenKey, -delta)
  101. }
  102. if err != nil {
  103. logger.LogWarn(ctx, fmt.Sprintf("调整令牌额度失败 (delta=%d, task=%s): %s", delta, task.TaskID, err.Error()))
  104. }
  105. }
  106. // taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
  107. func taskBillingOther(task *model.Task) map[string]interface{} {
  108. other := make(map[string]interface{})
  109. if bc := task.PrivateData.BillingContext; bc != nil {
  110. other["model_price"] = bc.ModelPrice
  111. other["group_ratio"] = bc.GroupRatio
  112. if bc.BillingMode != "" {
  113. other["billing_mode"] = bc.BillingMode
  114. other["billing_unit"] = bc.BillingUnit
  115. other["token_unit_price_usd"] = bc.TokenUnitPriceUSD
  116. other["pricing_snapshot"] = bc.PricingSnapshot
  117. }
  118. if len(bc.OtherRatios) > 0 {
  119. for k, v := range bc.OtherRatios {
  120. other[k] = v
  121. }
  122. }
  123. }
  124. props := task.Properties
  125. if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
  126. other["is_model_mapped"] = true
  127. other["upstream_model_name"] = props.UpstreamModelName
  128. }
  129. return other
  130. }
  131. // taskModelName 从 BillingContext 或 Properties 中获取模型名称。
  132. func taskModelName(task *model.Task) string {
  133. if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" {
  134. return bc.OriginModelName
  135. }
  136. return task.Properties.OriginModelName
  137. }
  138. // RefundTaskQuota 统一的任务失败退款逻辑。
  139. // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。
  140. func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
  141. quota := task.Quota
  142. if quota == 0 {
  143. return
  144. }
  145. // 1. 退还资金来源(钱包或订阅)
  146. if err := taskAdjustFunding(task, -quota); err != nil {
  147. logger.LogWarn(ctx, fmt.Sprintf("退还资金来源失败 task %s: %s", task.TaskID, err.Error()))
  148. return
  149. }
  150. // 2. 退还令牌额度
  151. taskAdjustTokenQuota(ctx, task, -quota)
  152. // 3. 记录日志
  153. other := taskBillingOther(task)
  154. other["task_id"] = task.TaskID
  155. other["reason"] = reason
  156. model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
  157. UserId: task.UserId,
  158. LogType: model.LogTypeRefund,
  159. Content: "",
  160. ChannelId: task.ChannelId,
  161. ModelName: taskModelName(task),
  162. Quota: quota,
  163. TokenId: task.PrivateData.TokenId,
  164. Group: task.Group,
  165. Other: other,
  166. })
  167. }
  168. // RecalculateTaskQuota 通用的异步差额结算。
  169. // actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
  170. // reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
  171. func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string) {
  172. if actualQuota <= 0 {
  173. return
  174. }
  175. preConsumedQuota := task.Quota
  176. quotaDelta := actualQuota - preConsumedQuota
  177. if quotaDelta == 0 {
  178. logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)",
  179. task.TaskID, logger.LogQuota(actualQuota), reason))
  180. return
  181. }
  182. logger.LogInfo(ctx, fmt.Sprintf("任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
  183. task.TaskID,
  184. logger.LogQuota(quotaDelta),
  185. logger.LogQuota(actualQuota),
  186. logger.LogQuota(preConsumedQuota),
  187. reason,
  188. ))
  189. // 调整资金来源
  190. if err := taskAdjustFunding(task, quotaDelta); err != nil {
  191. logger.LogError(ctx, fmt.Sprintf("差额结算资金调整失败 task %s: %s", task.TaskID, err.Error()))
  192. return
  193. }
  194. // 调整令牌额度
  195. taskAdjustTokenQuota(ctx, task, quotaDelta)
  196. task.Quota = actualQuota
  197. var logType int
  198. var logQuota int
  199. if quotaDelta > 0 {
  200. logType = model.LogTypeConsume
  201. logQuota = quotaDelta
  202. model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
  203. model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
  204. } else {
  205. logType = model.LogTypeRefund
  206. logQuota = -quotaDelta
  207. }
  208. other := taskBillingOther(task)
  209. other["task_id"] = task.TaskID
  210. other["reason"] = reason
  211. other["pre_consumed_quota"] = preConsumedQuota
  212. other["actual_quota"] = actualQuota
  213. model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
  214. UserId: task.UserId,
  215. LogType: logType,
  216. Content: "",
  217. ChannelId: task.ChannelId,
  218. ModelName: taskModelName(task),
  219. Quota: logQuota,
  220. TokenId: task.PrivateData.TokenId,
  221. Group: task.Group,
  222. Other: other,
  223. })
  224. }
  225. // RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。
  226. // 当任务成功且返回了 totalTokens 时,根据模型倍率和分组倍率重新计算实际扣费额度,
  227. // 与预扣费的差额进行补扣或退还。支持钱包和订阅计费来源。
  228. func RecalculateTaskQuotaWithOther(ctx context.Context, task *model.Task, actualQuota int, reason string, extra map[string]any) {
  229. if len(extra) == 0 {
  230. RecalculateTaskQuota(ctx, task, actualQuota, reason)
  231. return
  232. }
  233. if task.PrivateData.BillingContext == nil {
  234. task.PrivateData.BillingContext = &model.TaskBillingContext{}
  235. }
  236. if task.PrivateData.BillingContext.PricingSnapshot == nil {
  237. task.PrivateData.BillingContext.PricingSnapshot = map[string]any{}
  238. }
  239. for k, v := range extra {
  240. task.PrivateData.BillingContext.PricingSnapshot[k] = v
  241. }
  242. RecalculateTaskQuota(ctx, task, actualQuota, reason)
  243. }
  244. func RecalculateMatrixUsageTaskQuota(ctx context.Context, task *model.Task, totalTokens int) {
  245. if totalTokens <= 0 {
  246. return
  247. }
  248. bc := task.PrivateData.BillingContext
  249. if bc == nil {
  250. return
  251. }
  252. actualQuota := int(float64(totalTokens) * bc.TokenUnitPriceUSD / 1_000_000 * common.QuotaPerUnit * bc.GroupRatio)
  253. if actualQuota <= 0 && totalTokens > 0 && bc.TokenUnitPriceUSD > 0 && bc.GroupRatio > 0 {
  254. actualQuota = 1
  255. }
  256. reason := fmt.Sprintf("matrix usage billing: tokens=%d, token_unit_price_usd=%.6f, group_ratio=%.4f", totalTokens, bc.TokenUnitPriceUSD, bc.GroupRatio)
  257. RecalculateTaskQuotaWithOther(ctx, task, actualQuota, reason, map[string]any{
  258. "total_tokens": totalTokens,
  259. })
  260. }
  261. func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) {
  262. if totalTokens <= 0 {
  263. return
  264. }
  265. modelName := taskModelName(task)
  266. // 获取模型价格和倍率
  267. modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
  268. // 只有配置了倍率(非固定价格)时才按 token 重新计费
  269. if !hasRatioSetting || modelRatio <= 0 {
  270. return
  271. }
  272. // 获取用户和组的倍率信息
  273. group := task.Group
  274. if group == "" {
  275. user, err := model.GetUserById(task.UserId, false)
  276. if err == nil {
  277. group = user.Group
  278. }
  279. }
  280. if group == "" {
  281. return
  282. }
  283. groupRatio := ratio_setting.GetGroupRatio(group)
  284. userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
  285. var finalGroupRatio float64
  286. if hasUserGroupRatio {
  287. finalGroupRatio = userGroupRatio
  288. } else {
  289. finalGroupRatio = groupRatio
  290. }
  291. // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
  292. actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
  293. reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
  294. RecalculateTaskQuota(ctx, task, actualQuota, reason)
  295. }