channel.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. package service
  2. import (
  3. "fmt"
  4. "sort"
  5. "strings"
  6. "time"
  7. )
  8. // BillingMode 计费模式
  9. type BillingMode string
  10. const (
  11. BillingModeToken BillingMode = "token" // 按 token 区间计费
  12. BillingModePerRequest BillingMode = "per_request" // 按次计费(支持上下文窗口分层)
  13. BillingModeImage BillingMode = "image" // 图片计费(当前按次,预留 token 计费)
  14. BillingModeVideo BillingMode = "video" // 视频生成计费(按视频生成次数)
  15. )
  16. // IsValid 检查 BillingMode 是否为合法值
  17. func (m BillingMode) IsValid() bool {
  18. switch m {
  19. case BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo, "":
  20. return true
  21. }
  22. return false
  23. }
  24. // IsValidUsageFilter 检查 BillingMode 是否可用于使用记录筛选。
  25. func (m BillingMode) IsValidUsageFilter() bool {
  26. switch m {
  27. case BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo, "":
  28. return true
  29. }
  30. return false
  31. }
  32. const (
  33. BillingModelSourceRequested = "requested"
  34. BillingModelSourceUpstream = "upstream"
  35. BillingModelSourceChannelMapped = "channel_mapped"
  36. // BillingModelSourceResponse bills by a trusted model declaration observed
  37. // in the successful upstream response. It is deliberately distinct from
  38. // "upstream", which means the model sent to the provider.
  39. BillingModelSourceResponse = "response_model"
  40. )
  41. // Channel 渠道实体
  42. type Channel struct {
  43. ID int64
  44. Name string
  45. Description string
  46. Status string
  47. BillingModelSource string // "requested", "upstream", "channel_mapped", or "response_model"
  48. RestrictModels bool // 是否限制模型(仅允许定价列表中的模型)
  49. Features string // 渠道特性描述(JSON 数组),用于支付页面展示
  50. FeaturesConfig map[string]any // 渠道功能配置(如 web search emulation)
  51. CreatedAt time.Time
  52. UpdatedAt time.Time
  53. // 关联的分组 ID 列表
  54. GroupIDs []int64
  55. // 模型定价列表(每条含 Platform 字段)
  56. ModelPricing []ChannelModelPricing
  57. // 渠道级模型映射(按平台分组:platform → {src→dst})
  58. ModelMapping map[string]map[string]string
  59. // 账号统计定价
  60. ApplyPricingToAccountStats bool // 是否应用渠道模型定价到账号统计
  61. AccountStatsPricingRules []AccountStatsPricingRule // 自定义账号统计定价规则(按 SortOrder 排序,先命中为准)
  62. }
  63. // AccountStatsPricingRule 账号统计定价规则
  64. // 每条规则包含匹配条件(分组/账号)和独立的模型定价。
  65. // 多条规则按 SortOrder 排序,先命中为准。
  66. type AccountStatsPricingRule struct {
  67. ID int64
  68. ChannelID int64
  69. Name string
  70. GroupIDs []int64
  71. AccountIDs []int64
  72. SortOrder int
  73. Pricing []ChannelModelPricing // 规则内的模型定价(复用现有定价结构)
  74. CreatedAt time.Time
  75. UpdatedAt time.Time
  76. }
  77. // ChannelModelPricing 渠道模型定价条目
  78. type ChannelModelPricing struct {
  79. ID int64 `json:"id,omitempty"`
  80. ChannelID int64 `json:"channel_id,omitempty"`
  81. Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
  82. Models []string `json:"models"`
  83. BillingMode BillingMode `json:"billing_mode"`
  84. InputPrice *float64 `json:"input_price"`
  85. OutputPrice *float64 `json:"output_price"`
  86. CacheWritePrice *float64 `json:"cache_write_price"`
  87. CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
  88. CacheReadPrice *float64 `json:"cache_read_price"`
  89. FastMultiplier *float64 `json:"fast_multiplier"`
  90. FlexMultiplier *float64 `json:"flex_multiplier"`
  91. MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
  92. ImageInputPrice *float64 `json:"image_input_price"`
  93. ImageOutputPrice *float64 `json:"image_output_price"`
  94. PerRequestPrice *float64 `json:"per_request_price"`
  95. Intervals []PricingInterval `json:"intervals"`
  96. TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"`
  97. CreatedAt time.Time `json:"created_at,omitempty"`
  98. UpdatedAt time.Time `json:"updated_at,omitempty"`
  99. }
  100. // ChannelTimePricing 渠道模型定价的分时倍率配置。
  101. type ChannelTimePricing struct {
  102. Timezone string `json:"timezone"`
  103. WeekdaysOnly bool `json:"weekdays_only,omitempty"`
  104. Periods []ChannelTimePricingPeriod `json:"periods"`
  105. }
  106. // ChannelTimePricingPeriod 是秒级的左闭右开分时倍率区间,并兼容历史 HH:mm 数据。
  107. type ChannelTimePricingPeriod struct {
  108. StartTime string `json:"start_time"`
  109. EndTime string `json:"end_time"`
  110. Multiplier float64 `json:"multiplier"`
  111. }
  112. // PricingInterval 定价区间(token 区间 / 按次分层 / 图片分辨率分层)
  113. type PricingInterval struct {
  114. ID int64 `json:"id,omitempty"`
  115. PricingID int64 `json:"pricing_id,omitempty"`
  116. MinTokens int `json:"min_tokens"`
  117. MaxTokens *int `json:"max_tokens"`
  118. TierLabel string `json:"tier_label"`
  119. InputPrice *float64 `json:"input_price"`
  120. OutputPrice *float64 `json:"output_price"`
  121. CacheWritePrice *float64 `json:"cache_write_price"`
  122. CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
  123. CacheReadPrice *float64 `json:"cache_read_price"`
  124. InputMultiplier *float64 `json:"input_multiplier"`
  125. OutputMultiplier *float64 `json:"output_multiplier"`
  126. CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
  127. CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
  128. PerRequestPrice *float64 `json:"per_request_price"`
  129. SortOrder int `json:"sort_order"`
  130. CreatedAt time.Time `json:"created_at,omitempty"`
  131. UpdatedAt time.Time `json:"updated_at,omitempty"`
  132. }
  133. // IsActive 判断渠道是否启用
  134. func (c *Channel) IsActive() bool {
  135. return c.Status == StatusActive
  136. }
  137. // normalizeBillingModelSource 若 BillingModelSource 为空则回填默认值 ChannelMapped。
  138. // 作为 *Channel 的实体方法集中管理默认值,service 层只需在 Channel 进入内存
  139. // (缓存装填、repo 读出)时调用一次,下游读路径就无需重复兜底。
  140. func (c *Channel) normalizeBillingModelSource() {
  141. if c == nil {
  142. return
  143. }
  144. if c.BillingModelSource == "" {
  145. c.BillingModelSource = BillingModelSourceChannelMapped
  146. }
  147. }
  148. // GetModelPricing 根据模型名查找渠道定价,未找到返回 nil。
  149. // 精确匹配,大小写不敏感。返回值拷贝,不污染缓存。
  150. func (c *Channel) GetModelPricing(model string) *ChannelModelPricing {
  151. modelLower := strings.ToLower(model)
  152. for i := range c.ModelPricing {
  153. for _, m := range c.ModelPricing[i].Models {
  154. if strings.ToLower(m) == modelLower {
  155. cp := c.ModelPricing[i].Clone()
  156. return &cp
  157. }
  158. }
  159. }
  160. return nil
  161. }
  162. // FindMatchingInterval 在区间列表中查找匹配 totalTokens 的区间。
  163. // 区间为左开右闭 (min, max]:min 不含,max 包含。
  164. // 第一个区间 min=0 时,0 token 不匹配任何区间(回退到默认价格)。
  165. func FindMatchingInterval(intervals []PricingInterval, totalTokens int) *PricingInterval {
  166. for i := range intervals {
  167. iv := &intervals[i]
  168. if totalTokens > iv.MinTokens && (iv.MaxTokens == nil || totalTokens <= *iv.MaxTokens) {
  169. return iv
  170. }
  171. }
  172. return nil
  173. }
  174. // GetIntervalForContext 根据总 context token 数查找匹配的区间。
  175. func (p *ChannelModelPricing) GetIntervalForContext(totalTokens int) *PricingInterval {
  176. return FindMatchingInterval(p.Intervals, totalTokens)
  177. }
  178. // GetTierByLabel 根据标签查找层级(用于 per_request / image 模式)
  179. func (p *ChannelModelPricing) GetTierByLabel(label string) *PricingInterval {
  180. labelLower := strings.ToLower(label)
  181. for i := range p.Intervals {
  182. if strings.ToLower(p.Intervals[i].TierLabel) == labelLower {
  183. return &p.Intervals[i]
  184. }
  185. }
  186. return nil
  187. }
  188. // Clone 返回 ChannelModelPricing 的拷贝(切片独立,指针字段共享,调用方只读安全)
  189. func (p ChannelModelPricing) Clone() ChannelModelPricing {
  190. cp := p
  191. if p.Models != nil {
  192. cp.Models = make([]string, len(p.Models))
  193. copy(cp.Models, p.Models)
  194. }
  195. if p.Intervals != nil {
  196. cp.Intervals = make([]PricingInterval, len(p.Intervals))
  197. copy(cp.Intervals, p.Intervals)
  198. }
  199. if p.TimePricing != nil {
  200. cp.TimePricing = &ChannelTimePricing{
  201. Timezone: p.TimePricing.Timezone,
  202. WeekdaysOnly: p.TimePricing.WeekdaysOnly,
  203. }
  204. if p.TimePricing.Periods != nil {
  205. cp.TimePricing.Periods = append([]ChannelTimePricingPeriod(nil), p.TimePricing.Periods...)
  206. }
  207. }
  208. return cp
  209. }
  210. // Clone 返回 Channel 的深拷贝
  211. func (c *Channel) Clone() *Channel {
  212. if c == nil {
  213. return nil
  214. }
  215. cp := *c
  216. if c.GroupIDs != nil {
  217. cp.GroupIDs = make([]int64, len(c.GroupIDs))
  218. copy(cp.GroupIDs, c.GroupIDs)
  219. }
  220. if c.ModelPricing != nil {
  221. cp.ModelPricing = make([]ChannelModelPricing, len(c.ModelPricing))
  222. for i := range c.ModelPricing {
  223. cp.ModelPricing[i] = c.ModelPricing[i].Clone()
  224. }
  225. }
  226. if c.ModelMapping != nil {
  227. cp.ModelMapping = make(map[string]map[string]string, len(c.ModelMapping))
  228. for platform, mapping := range c.ModelMapping {
  229. inner := make(map[string]string, len(mapping))
  230. for k, v := range mapping {
  231. inner[k] = v
  232. }
  233. cp.ModelMapping[platform] = inner
  234. }
  235. }
  236. if c.FeaturesConfig != nil {
  237. cp.FeaturesConfig = deepCopyFeaturesConfig(c.FeaturesConfig)
  238. }
  239. if c.AccountStatsPricingRules != nil {
  240. cp.AccountStatsPricingRules = make([]AccountStatsPricingRule, len(c.AccountStatsPricingRules))
  241. for i, rule := range c.AccountStatsPricingRules {
  242. cp.AccountStatsPricingRules[i] = rule
  243. if rule.GroupIDs != nil {
  244. cp.AccountStatsPricingRules[i].GroupIDs = make([]int64, len(rule.GroupIDs))
  245. copy(cp.AccountStatsPricingRules[i].GroupIDs, rule.GroupIDs)
  246. }
  247. if rule.AccountIDs != nil {
  248. cp.AccountStatsPricingRules[i].AccountIDs = make([]int64, len(rule.AccountIDs))
  249. copy(cp.AccountStatsPricingRules[i].AccountIDs, rule.AccountIDs)
  250. }
  251. if rule.Pricing != nil {
  252. cp.AccountStatsPricingRules[i].Pricing = make([]ChannelModelPricing, len(rule.Pricing))
  253. for j := range rule.Pricing {
  254. cp.AccountStatsPricingRules[i].Pricing[j] = rule.Pricing[j].Clone()
  255. }
  256. }
  257. }
  258. }
  259. return &cp
  260. }
  261. // IsWebSearchEmulationEnabled 返回该渠道是否为指定平台启用了 web search 模拟。
  262. func (c *Channel) IsWebSearchEmulationEnabled(platform string) bool {
  263. if c == nil || c.FeaturesConfig == nil {
  264. return false
  265. }
  266. wse, ok := c.FeaturesConfig[featureKeyWebSearchEmulation].(map[string]any)
  267. if !ok {
  268. return false
  269. }
  270. enabled, ok := wse[platform].(bool)
  271. return ok && enabled
  272. }
  273. // IsBedrockCCCompatEnabled 返回该渠道是否启用了 Bedrock CC 兼容模式。
  274. // 一旦启用,该渠道下所有请求都会应用 CC 兼容转换,不区分账号 platform。
  275. func (c *Channel) IsBedrockCCCompatEnabled(platform string) bool {
  276. if c == nil || c.FeaturesConfig == nil {
  277. return false
  278. }
  279. // 直接检查 bedrock_cc_compat 开关,不再检查 platform 子字段
  280. enabled, ok := c.FeaturesConfig[featureKeyBedrockCCCompat].(bool)
  281. return ok && enabled
  282. }
  283. // deepCopyFeaturesConfig creates a deep copy of FeaturesConfig to prevent cache pollution.
  284. func deepCopyFeaturesConfig(src map[string]any) map[string]any {
  285. dst := make(map[string]any, len(src))
  286. for k, v := range src {
  287. if inner, ok := v.(map[string]any); ok {
  288. dst[k] = deepCopyFeaturesConfig(inner)
  289. } else {
  290. dst[k] = v
  291. }
  292. }
  293. return dst
  294. }
  295. // ValidateIntervals 校验区间列表的合法性。
  296. //
  297. // mode 决定区间语义:
  298. // - BillingModeToken(含空值):区间是上下文 token 数分段 (min, max],
  299. // 按 MinTokens 排序后无重叠,无界区间(MaxTokens=nil)必须是最后一个。
  300. // - BillingModePerRequest / BillingModeImage:区间是按 tier_label
  301. // (1K/2K/4K 等) 分层,匹配走 label 不依赖 min/max,因此跳过区间重叠
  302. // 与 last-unlimited 校验,仅做单条字段自洽(min/max/价格非负)检查。
  303. //
  304. // 通用规则:MinTokens >= 0;MaxTokens 若非 nil 则 > 0 且 > MinTokens;
  305. // 所有价格字段 >= 0。
  306. func ValidateIntervals(intervals []PricingInterval, mode BillingMode) error {
  307. if len(intervals) == 0 {
  308. return nil
  309. }
  310. sorted := make([]PricingInterval, len(intervals))
  311. copy(sorted, intervals)
  312. sort.Slice(sorted, func(i, j int) bool {
  313. return sorted[i].MinTokens < sorted[j].MinTokens
  314. })
  315. for i := range sorted {
  316. if err := validateSingleInterval(&sorted[i], i); err != nil {
  317. return err
  318. }
  319. }
  320. // per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验
  321. if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
  322. return nil
  323. }
  324. return validateIntervalOverlap(sorted)
  325. }
  326. // validateSingleInterval 校验单个区间的字段合法性
  327. func validateSingleInterval(iv *PricingInterval, idx int) error {
  328. if iv.MinTokens < 0 {
  329. return fmt.Errorf("interval #%d: min_tokens (%d) must be >= 0", idx+1, iv.MinTokens)
  330. }
  331. if iv.MaxTokens != nil {
  332. if *iv.MaxTokens <= 0 {
  333. return fmt.Errorf("interval #%d: max_tokens (%d) must be > 0", idx+1, *iv.MaxTokens)
  334. }
  335. if *iv.MaxTokens <= iv.MinTokens {
  336. return fmt.Errorf("interval #%d: max_tokens (%d) must be > min_tokens (%d)",
  337. idx+1, *iv.MaxTokens, iv.MinTokens)
  338. }
  339. }
  340. return validateIntervalPrices(iv, idx)
  341. }
  342. // validateIntervalPrices 校验区间价格 >= 0、倍率 > 0。
  343. func validateIntervalPrices(iv *PricingInterval, idx int) error {
  344. prices := []struct {
  345. name string
  346. val *float64
  347. }{
  348. {"input_price", iv.InputPrice},
  349. {"output_price", iv.OutputPrice},
  350. {"cache_write_price", iv.CacheWritePrice},
  351. {"cache_write_1h_price", iv.CacheWrite1hPrice},
  352. {"cache_read_price", iv.CacheReadPrice},
  353. {"per_request_price", iv.PerRequestPrice},
  354. }
  355. for _, p := range prices {
  356. if p.val != nil && *p.val < 0 {
  357. return fmt.Errorf("interval #%d: %s must be >= 0", idx+1, p.name)
  358. }
  359. }
  360. multipliers := []struct {
  361. name string
  362. val *float64
  363. }{
  364. {"input_multiplier", iv.InputMultiplier},
  365. {"output_multiplier", iv.OutputMultiplier},
  366. {"cache_write_multiplier", iv.CacheWriteMultiplier},
  367. {"cache_read_multiplier", iv.CacheReadMultiplier},
  368. }
  369. for _, multiplier := range multipliers {
  370. if multiplier.val != nil && *multiplier.val <= 0 {
  371. return fmt.Errorf("interval #%d: %s must be > 0", idx+1, multiplier.name)
  372. }
  373. }
  374. return nil
  375. }
  376. // validateIntervalOverlap 校验排序后的区间列表无重叠,且无界区间在最后
  377. func validateIntervalOverlap(sorted []PricingInterval) error {
  378. for i, iv := range sorted {
  379. // 无界区间必须是最后一个
  380. if iv.MaxTokens == nil && i < len(sorted)-1 {
  381. return fmt.Errorf("interval #%d: unbounded interval (max_tokens=null) must be the last one",
  382. i+1)
  383. }
  384. if i == 0 {
  385. continue
  386. }
  387. prev := sorted[i-1]
  388. // 检查重叠:前一个区间的上界 > 当前区间的下界则重叠
  389. // (min, max] 语义:prev 覆盖 (prev.Min, prev.Max],cur 覆盖 (cur.Min, cur.Max]
  390. if prev.MaxTokens == nil || *prev.MaxTokens > iv.MinTokens {
  391. return fmt.Errorf("interval #%d and #%d overlap: prev max=%s > cur min=%d",
  392. i, i+1, formatMaxTokensLabel(prev.MaxTokens), iv.MinTokens)
  393. }
  394. }
  395. return nil
  396. }
  397. func formatMaxTokensLabel(max *int) string {
  398. if max == nil {
  399. return "∞"
  400. }
  401. return fmt.Sprintf("%d", *max)
  402. }
  403. // ChannelUsageFields 渠道相关的使用记录字段(嵌入到各平台的 RecordUsageInput 中)
  404. type ChannelUsageFields struct {
  405. ChannelID int64 // 渠道 ID(0 = 无渠道)
  406. OriginalModel string // 用户原始请求模型(渠道映射前)
  407. ChannelMappedModel string // 渠道映射后的模型名(无映射时等于 OriginalModel)
  408. BillingModelSource string // 计费模型来源:"requested" / "upstream" / "channel_mapped"
  409. ModelMappingChain string // 映射链描述,如 "a→b→c"
  410. }
  411. // SupportedModel 渠道的一个支持模型条目(无通配符、可直接展示给用户)
  412. type SupportedModel struct {
  413. Name string // 用户侧模型名
  414. Platform string // 所属平台
  415. Pricing *ChannelModelPricing // 定价详情(nil 表示未配置定价)
  416. }
  417. // wildcardSuffix 是模型模式中的通配符后缀标记(仅支持尾部匹配)。
  418. const wildcardSuffix = "*"
  419. // splitWildcardSuffix 将模型模式拆分为 (prefix, isWildcard)。
  420. //
  421. // "claude-opus-*" → ("claude-opus-", true)
  422. // "claude-opus-4" → ("claude-opus-4", false)
  423. // "*" → ("", true)
  424. //
  425. // 注意:返回的 prefix 保持原始大小写,由调用方按需 ToLower。
  426. func splitWildcardSuffix(pattern string) (prefix string, isWildcard bool) {
  427. if strings.HasSuffix(pattern, wildcardSuffix) {
  428. return strings.TrimSuffix(pattern, wildcardSuffix), true
  429. }
  430. return pattern, false
  431. }
  432. // GetModelPricingByPlatform 在指定平台下查找精确模型的定价,未找到返回 nil。
  433. // 与 GetModelPricing 的区别:按 Platform 隔离,避免跨平台同名模型误匹配。
  434. func (c *Channel) GetModelPricingByPlatform(platform, model string) *ChannelModelPricing {
  435. if c == nil {
  436. return nil
  437. }
  438. modelLower := strings.ToLower(model)
  439. for i := range c.ModelPricing {
  440. if c.ModelPricing[i].Platform != platform {
  441. continue
  442. }
  443. for _, m := range c.ModelPricing[i].Models {
  444. if strings.ToLower(m) == modelLower {
  445. cp := c.ModelPricing[i].Clone()
  446. return &cp
  447. }
  448. }
  449. }
  450. return nil
  451. }
  452. // platformPricingIndex 是单个平台下定价信息的复合索引。
  453. // 一次扫描即可同时支持精确查找(exact 分支)与有序遍历(wildcard 分支),
  454. // 避免 SupportedModels 对每个平台重复扫描定价列表。
  455. //
  456. // byLower 与 names/originalCase 共享同一套去重规则:以 lower-case 模型名为 key,
  457. // 首个命中保留其原始大小写。names 维持按定价行扫描顺序的稳定迭代。
  458. type platformPricingIndex struct {
  459. byLower map[string]*ChannelModelPricing // lowercased model name → pricing (Clone'd)
  460. originalCase map[string]string // lowercased model name → original-case model name
  461. names []string // priced model names in their ORIGINAL case, insertion-ordered, deduped case-insensitively (first wins)
  462. }
  463. // buildPricingIndex 对渠道的定价列表做一次扫描,按 platform 聚合为查找索引。
  464. // 索引值是定价条目的 Clone 指针,调用方可安全按需返回副本而不污染缓存。
  465. // 通配符后缀条目(如 "claude-*")不被索引(它们是模式,不是具体模型名)。
  466. // 同一平台中以大小写不敏感方式去重,先出现者保留原始大小写。
  467. func buildPricingIndex(pricings []ChannelModelPricing) map[string]*platformPricingIndex {
  468. idx := make(map[string]*platformPricingIndex)
  469. for i := range pricings {
  470. p := pricings[i]
  471. pidx, ok := idx[p.Platform]
  472. if !ok {
  473. pidx = &platformPricingIndex{
  474. byLower: make(map[string]*ChannelModelPricing),
  475. originalCase: make(map[string]string),
  476. names: make([]string, 0),
  477. }
  478. idx[p.Platform] = pidx
  479. }
  480. for _, m := range p.Models {
  481. if _, wild := splitWildcardSuffix(m); wild {
  482. continue
  483. }
  484. lower := strings.ToLower(m)
  485. if _, exists := pidx.byLower[lower]; exists {
  486. continue // 首个命中胜出(case-insensitive 去重后第一个定价 / 第一个原始大小写)
  487. }
  488. cp := pricings[i].Clone()
  489. pidx.byLower[lower] = &cp
  490. pidx.originalCase[lower] = m
  491. pidx.names = append(pidx.names, m)
  492. }
  493. }
  494. return idx
  495. }
  496. // SupportedModels 计算渠道的支持模型列表,结果保证不含通配符。
  497. //
  498. // 算法(mapping ∪ pricing 并联):
  499. //
  500. // - Pass A(mapping):遍历 ModelMapping
  501. // - 精确 src → target:显示名 = src(用户视角),定价用 target 在同 platform 定价里查
  502. // (mapping 改写后实际计费的是 target;这是用户感知的"实际花费")。
  503. // target 为空或为通配符时退化为按 src 自查。
  504. // - 通配符 src(如 "claude-3-*"):用同 platform 定价里前缀匹配的模型作为候选展开,
  505. // 每个候选用自身定价(通配符场景一般是 passthrough,target 通常也是通配符)。
  506. // - "*" 单独 mapping key 走通配符分支(前缀为空 → 全展开)。
  507. // - Pass B(pricing-only):遍历 ModelPricing 中所有非通配符模型,对未在 Pass A 添加过的
  508. // 补齐——显示名 = 定价模型名,定价 = 自身(这是关键修复:定价存在即代表渠道支持该模型,
  509. // 即使没配映射)。
  510. //
  511. // 显示名命中定价时使用**定价的原始大小写**(定价是模型身份的事实来源)。
  512. // 按 (Platform, Name) 稳定排序,按 (Platform, lowercase(Name)) 去重,先到者胜出。
  513. //
  514. // 注意:定价仅在 channel.ModelPricing 内查找——全局 LiteLLM 回落由调用方
  515. // (`ChannelService.ListAvailable`)在合成展示数据时叠加。
  516. func (c *Channel) SupportedModels() []SupportedModel {
  517. if c == nil {
  518. return nil
  519. }
  520. if len(c.ModelMapping) == 0 && len(c.ModelPricing) == 0 {
  521. return nil
  522. }
  523. idx := buildPricingIndex(c.ModelPricing)
  524. type dedupKey struct {
  525. platform string
  526. name string
  527. }
  528. seen := make(map[dedupKey]struct{})
  529. result := make([]SupportedModel, 0)
  530. // lookup 在 platform pricing index 中按精确名查定价,命中时返回定价大小写。
  531. lookup := func(pidx *platformPricingIndex, name string) (display string, pricing *ChannelModelPricing) {
  532. if pidx == nil || name == "" {
  533. return name, nil
  534. }
  535. lower := strings.ToLower(name)
  536. if p, ok := pidx.byLower[lower]; ok {
  537. return pidx.originalCase[lower], p
  538. }
  539. return name, nil
  540. }
  541. add := func(platform, displayName string, pricing *ChannelModelPricing) {
  542. key := dedupKey{platform: platform, name: strings.ToLower(displayName)}
  543. if _, ok := seen[key]; ok {
  544. return
  545. }
  546. seen[key] = struct{}{}
  547. result = append(result, SupportedModel{
  548. Name: displayName,
  549. Platform: platform,
  550. Pricing: pricing,
  551. })
  552. }
  553. // Pass A:从 mapping 展开
  554. for platform, mapping := range c.ModelMapping {
  555. if len(mapping) == 0 {
  556. continue
  557. }
  558. pidx := idx[platform]
  559. for src, target := range mapping {
  560. prefix, isWild := splitWildcardSuffix(src)
  561. if isWild {
  562. if pidx == nil {
  563. continue
  564. }
  565. prefixLower := strings.ToLower(prefix)
  566. for _, candidate := range pidx.names {
  567. if strings.HasPrefix(strings.ToLower(candidate), prefixLower) {
  568. display, pricing := lookup(pidx, candidate)
  569. add(platform, display, pricing)
  570. }
  571. }
  572. continue
  573. }
  574. // 精确 mapping:定价按 target 查;target 缺失/通配则退化按 src 查
  575. pricingKey := target
  576. if pricingKey == "" {
  577. pricingKey = src
  578. }
  579. if _, targetWild := splitWildcardSuffix(pricingKey); targetWild {
  580. pricingKey = src
  581. }
  582. _, pricing := lookup(pidx, pricingKey)
  583. // 显示名优先用 src 在定价里的原始大小写(若 src 本身是个定价模型名)
  584. displayName, _ := lookup(pidx, src)
  585. add(platform, displayName, pricing)
  586. }
  587. }
  588. // Pass B:从 pricing 补齐 mapping 未覆盖的具体模型(修复"定价存在但没配映射 → 不显示")
  589. for platform, pidx := range idx {
  590. for _, name := range pidx.names {
  591. display, pricing := lookup(pidx, name)
  592. add(platform, display, pricing)
  593. }
  594. }
  595. sort.SliceStable(result, func(i, j int) bool {
  596. if result[i].Platform != result[j].Platform {
  597. return result[i].Platform < result[j].Platform
  598. }
  599. return result[i].Name < result[j].Name
  600. })
  601. return result
  602. }