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

384 строки
13 KiB

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