global_model_pricing.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "log/slog"
  7. "strings"
  8. "sync"
  9. "time"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. )
  12. // GlobalModelPrice 全站模型定价覆盖项:命中模型的请求在全部分组/账号
  13. // 强制使用此处价格;未命中的模型走原定价链路(分组→渠道→LiteLLM→兜底)。
  14. type GlobalModelPrice struct {
  15. ID int64 `json:"id"`
  16. ModelPattern string `json:"model_pattern"`
  17. BillingMode BillingMode `json:"billing_mode"`
  18. InputPrice *float64 `json:"input_price,omitempty"`
  19. OutputPrice *float64 `json:"output_price,omitempty"`
  20. CacheWritePrice *float64 `json:"cache_write_price,omitempty"`
  21. CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
  22. CacheReadPrice *float64 `json:"cache_read_price,omitempty"`
  23. PerRequestPrice *float64 `json:"per_request_price,omitempty"`
  24. Enabled bool `json:"enabled"`
  25. CreatedAt time.Time `json:"created_at,omitempty"`
  26. UpdatedAt time.Time `json:"updated_at,omitempty"`
  27. }
  28. // GlobalModelPriceInput 全站定价的写输入(价格均为可选,nil 表示不覆盖该项)。
  29. type GlobalModelPriceInput struct {
  30. ModelPattern string `json:"model_pattern"`
  31. BillingMode BillingMode `json:"billing_mode"`
  32. InputPrice *float64 `json:"input_price"`
  33. OutputPrice *float64 `json:"output_price"`
  34. CacheWritePrice *float64 `json:"cache_write_price"`
  35. CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
  36. CacheReadPrice *float64 `json:"cache_read_price"`
  37. PerRequestPrice *float64 `json:"per_request_price"`
  38. Enabled bool `json:"enabled"`
  39. }
  40. // GlobalModelPricingRepository 全站定价存储接口(repository 包实现)。
  41. type GlobalModelPricingRepository interface {
  42. ListEnabled(ctx context.Context) ([]GlobalModelPrice, error)
  43. ListAll(ctx context.Context) ([]GlobalModelPrice, error)
  44. Create(ctx context.Context, in GlobalModelPriceInput) (*GlobalModelPrice, error)
  45. GetByID(ctx context.Context, id int64) (*GlobalModelPrice, error)
  46. Update(ctx context.Context, id int64, in GlobalModelPriceInput) (*GlobalModelPrice, error)
  47. Delete(ctx context.Context, id int64) error
  48. SetEnabled(ctx context.Context, id int64, enabled bool) error
  49. }
  50. var ErrGlobalModelPricingNotFound = errors.New("global model pricing not found")
  51. // ErrGlobalModelPricingDuplicate 模型已存在全站定价(model_pattern 唯一)。
  52. var ErrGlobalModelPricingDuplicate = errors.New("global model pricing already exists for this model")
  53. // globalModelPricingSnapshotTTL 快照 TTL:管理端改价最多延迟 60s 生效。
  54. const globalModelPricingSnapshotTTL = 60 * time.Second
  55. // GlobalModelPricingService 全站定价服务:内存快照 + 精确/通配匹配。
  56. type GlobalModelPricingService struct {
  57. repo GlobalModelPricingRepository
  58. mu sync.RWMutex
  59. snapshot []GlobalModelPrice
  60. loadedAt time.Time
  61. }
  62. // NewGlobalModelPricingService 创建全站定价服务(repo 可为 nil,nil 时恒返回未命中)。
  63. func NewGlobalModelPricingService(repo GlobalModelPricingRepository) *GlobalModelPricingService {
  64. return &GlobalModelPricingService{repo: repo}
  65. }
  66. // ValidateGlobalModelPriceInput 校验写输入(单位与渠道定价一致:每 token 美元价)。
  67. // 全部价格为空的条目无意义(会产生“覆盖却无价格”的歧义),直接拒绝;
  68. // 非 token 模式必须给出按次价,否则计费会归零。
  69. func ValidateGlobalModelPriceInput(in GlobalModelPriceInput) error {
  70. pattern := strings.ToLower(strings.TrimSpace(in.ModelPattern))
  71. if pattern == "" {
  72. return infraerrors.BadRequest("GLOBAL_PRICING_PATTERN_REQUIRED", "model_pattern must not be empty")
  73. }
  74. if len([]rune(pattern)) > 200 {
  75. return infraerrors.BadRequest("GLOBAL_PRICING_PATTERN_TOO_LONG", "model_pattern must not exceed 200 runes")
  76. }
  77. if strings.Contains(pattern, " ") {
  78. return infraerrors.BadRequest("GLOBAL_PRICING_PATTERN_INVALID", "model_pattern must not contain spaces")
  79. }
  80. if stars := strings.Count(pattern, "*"); stars > 1 ||
  81. (stars == 1 && !strings.HasSuffix(pattern, "*")) {
  82. return infraerrors.BadRequest("GLOBAL_PRICING_PATTERN_INVALID", "only a single trailing * wildcard is supported (e.g. gpt-5*)")
  83. }
  84. if core := strings.TrimSuffix(pattern, "*"); core == "" {
  85. return infraerrors.BadRequest("GLOBAL_PRICING_PATTERN_INVALID", "model_pattern must contain a model name")
  86. }
  87. mode := in.BillingMode
  88. if mode == "" {
  89. mode = BillingModeToken
  90. }
  91. switch mode {
  92. case BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo:
  93. default:
  94. return infraerrors.BadRequest("GLOBAL_PRICING_MODE_INVALID", "unsupported billing_mode "+string(in.BillingMode))
  95. }
  96. for name, v := range map[string]*float64{
  97. "input_price": in.InputPrice, "output_price": in.OutputPrice,
  98. "cache_write_price": in.CacheWritePrice, "cache_write_1h_price": in.CacheWrite1hPrice,
  99. "cache_read_price": in.CacheReadPrice, "per_request_price": in.PerRequestPrice,
  100. } {
  101. if v != nil && *v < 0 {
  102. return infraerrors.BadRequest("GLOBAL_PRICING_PRICE_INVALID", name+" must not be negative")
  103. }
  104. }
  105. if in.InputPrice == nil && in.OutputPrice == nil && in.CacheWritePrice == nil &&
  106. in.CacheWrite1hPrice == nil && in.CacheReadPrice == nil && in.PerRequestPrice == nil {
  107. return infraerrors.BadRequest("GLOBAL_PRICING_PRICE_REQUIRED", "at least one price must be set")
  108. }
  109. if mode != BillingModeToken && in.PerRequestPrice == nil {
  110. return infraerrors.BadRequest("GLOBAL_PRICING_PRICE_REQUIRED", "per_request_price is required for "+string(mode)+" billing mode")
  111. }
  112. return nil
  113. }
  114. // snapshotLocked 读取快照(调用方需持有至少读锁判断新鲜度;过期时升级写锁重载)。
  115. func (s *GlobalModelPricingService) snapshotPrices(ctx context.Context) []GlobalModelPrice {
  116. if s == nil || s.repo == nil {
  117. return nil
  118. }
  119. s.mu.RLock()
  120. if time.Since(s.loadedAt) < globalModelPricingSnapshotTTL && s.snapshot != nil {
  121. out := s.snapshot
  122. s.mu.RUnlock()
  123. return out
  124. }
  125. s.mu.RUnlock()
  126. s.mu.Lock()
  127. defer s.mu.Unlock()
  128. if time.Since(s.loadedAt) < globalModelPricingSnapshotTTL && s.snapshot != nil {
  129. return s.snapshot
  130. }
  131. rows, err := s.repo.ListEnabled(ctx)
  132. if err != nil {
  133. slog.Warn("global_pricing.snapshot_failed", "error", err)
  134. return s.snapshot
  135. }
  136. s.snapshot = rows
  137. s.loadedAt = time.Now()
  138. return rows
  139. }
  140. // Invalidate 清空快照,管理端写操作后调用以立即生效。
  141. func (s *GlobalModelPricingService) Invalidate() {
  142. if s == nil {
  143. return
  144. }
  145. s.mu.Lock()
  146. s.snapshot = nil
  147. s.loadedAt = time.Time{}
  148. s.mu.Unlock()
  149. }
  150. // Match 命中指定模型的全站定价(精确优先,通配取最长前缀),未命中返回 nil。
  151. // pattern 与请求名两侧都经过 normalizeChannelPricingModelName(如 claude- 系
  152. // 的点/横线归一),避免“claude-3.5*”这类条目永不命中。
  153. // 官方变体名(如 gpt-5.6-luna-high)会再用归一化基名重试一次,与渠道定价一致。
  154. func (s *GlobalModelPricingService) Match(ctx context.Context, model string) *ChannelModelPricing {
  155. rows := s.snapshotPrices(ctx)
  156. if len(rows) == 0 {
  157. return nil
  158. }
  159. if hit := matchGlobalRows(rows, normalizeChannelPricingModelName(model)); hit != nil {
  160. return hit
  161. }
  162. if normalized := normalizeKnownOpenAICodexModel(model); normalized != "" &&
  163. !strings.EqualFold(strings.TrimSpace(normalized), strings.TrimSpace(model)) {
  164. return matchGlobalRows(rows, normalizeChannelPricingModelName(normalized))
  165. }
  166. return nil
  167. }
  168. func matchGlobalRows(rows []GlobalModelPrice, name string) *ChannelModelPricing {
  169. var wildcard *GlobalModelPrice
  170. var wildcardLen int
  171. for i := range rows {
  172. entry := &rows[i]
  173. pattern := normalizeChannelPricingModelName(strings.TrimSpace(entry.ModelPattern))
  174. if pattern == "" {
  175. continue
  176. }
  177. if strings.HasSuffix(pattern, "*") {
  178. prefix := normalizeChannelPricingModelName(strings.TrimSuffix(pattern, "*"))
  179. if prefix != "" && strings.HasPrefix(name, prefix) && len(prefix) > wildcardLen {
  180. cp := *entry
  181. wildcard = &cp
  182. wildcardLen = len(prefix)
  183. }
  184. continue
  185. }
  186. if pattern == name {
  187. out := globalPriceToChannel(entry)
  188. return &out
  189. }
  190. }
  191. if wildcard == nil {
  192. return nil
  193. }
  194. out := globalPriceToChannel(wildcard)
  195. return &out
  196. }
  197. // globalPriceToChannel 深拷贝价格指针:快照行与其派生物不共享 *float64,
  198. // 下游任何原地修改都不会污染 60s 快照。
  199. func globalPriceToChannel(p *GlobalModelPrice) ChannelModelPricing {
  200. mode := p.BillingMode
  201. if mode == "" {
  202. mode = BillingModeToken
  203. }
  204. return ChannelModelPricing{
  205. Models: []string{p.ModelPattern},
  206. BillingMode: mode,
  207. InputPrice: copyFloatPtr(p.InputPrice),
  208. OutputPrice: copyFloatPtr(p.OutputPrice),
  209. CacheWritePrice: copyFloatPtr(p.CacheWritePrice),
  210. CacheWrite1hPrice: copyFloatPtr(p.CacheWrite1hPrice),
  211. CacheReadPrice: copyFloatPtr(p.CacheReadPrice),
  212. PerRequestPrice: copyFloatPtr(p.PerRequestPrice),
  213. }
  214. }
  215. func copyFloatPtr(v *float64) *float64 {
  216. if v == nil {
  217. return nil
  218. }
  219. cp := *v
  220. return &cp
  221. }
  222. // Get 返回单个全站定价(不存在返回 ErrGlobalModelPricingNotFound)。
  223. func (s *GlobalModelPricingService) Get(ctx context.Context, id int64) (*GlobalModelPrice, error) {
  224. if s == nil || s.repo == nil {
  225. return nil, errors.New("global pricing service unavailable")
  226. }
  227. out, err := s.repo.GetByID(ctx, id)
  228. if err != nil {
  229. if errors.Is(err, sql.ErrNoRows) {
  230. return nil, ErrGlobalModelPricingNotFound
  231. }
  232. return nil, err
  233. }
  234. return out, nil
  235. }
  236. // List 返回管理端列表(含禁用)。
  237. func (s *GlobalModelPricingService) List(ctx context.Context) ([]GlobalModelPrice, error) {
  238. if s == nil || s.repo == nil {
  239. return nil, errors.New("global pricing service unavailable")
  240. }
  241. return s.repo.ListAll(ctx)
  242. }
  243. // Create 新建全站定价并刷新快照。
  244. func (s *GlobalModelPricingService) Create(ctx context.Context, in GlobalModelPriceInput) (*GlobalModelPrice, error) {
  245. if s == nil || s.repo == nil {
  246. return nil, errors.New("global pricing service unavailable")
  247. }
  248. if err := ValidateGlobalModelPriceInput(in); err != nil {
  249. return nil, err
  250. }
  251. in.ModelPattern = strings.ToLower(strings.TrimSpace(in.ModelPattern))
  252. out, err := s.repo.Create(ctx, in)
  253. if err != nil {
  254. return nil, err
  255. }
  256. s.Invalidate()
  257. return out, nil
  258. }
  259. // Update 更新全站定价并刷新快照(id 不存在返回 ErrGlobalModelPricingNotFound)。
  260. func (s *GlobalModelPricingService) Update(ctx context.Context, id int64, in GlobalModelPriceInput) (*GlobalModelPrice, error) {
  261. if s == nil || s.repo == nil {
  262. return nil, errors.New("global pricing service unavailable")
  263. }
  264. if err := ValidateGlobalModelPriceInput(in); err != nil {
  265. return nil, err
  266. }
  267. in.ModelPattern = strings.ToLower(strings.TrimSpace(in.ModelPattern))
  268. out, err := s.repo.Update(ctx, id, in)
  269. if err != nil {
  270. if errors.Is(err, sql.ErrNoRows) {
  271. return nil, ErrGlobalModelPricingNotFound
  272. }
  273. return nil, err
  274. }
  275. s.Invalidate()
  276. return out, nil
  277. }
  278. // Delete 删除全站定价并刷新快照。
  279. func (s *GlobalModelPricingService) Delete(ctx context.Context, id int64) error {
  280. if s == nil || s.repo == nil {
  281. return errors.New("global pricing service unavailable")
  282. }
  283. if err := s.repo.Delete(ctx, id); err != nil {
  284. if errors.Is(err, sql.ErrNoRows) {
  285. return ErrGlobalModelPricingNotFound
  286. }
  287. return err
  288. }
  289. s.Invalidate()
  290. return nil
  291. }
  292. // SetEnabled 启停全站定价并刷新快照。
  293. func (s *GlobalModelPricingService) SetEnabled(ctx context.Context, id int64, enabled bool) (*GlobalModelPrice, error) {
  294. if s == nil || s.repo == nil {
  295. return nil, errors.New("global pricing service unavailable")
  296. }
  297. if err := s.repo.SetEnabled(ctx, id, enabled); err != nil {
  298. if errors.Is(err, sql.ErrNoRows) {
  299. return nil, ErrGlobalModelPricingNotFound
  300. }
  301. return nil, err
  302. }
  303. s.Invalidate()
  304. return s.repo.GetByID(ctx, id)
  305. }