model_plaza_service_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // newPlazaService 构造 ListGroups 测试用的 ModelPlazaService(不接计费服务:展示定价原样透传)。
  11. func newPlazaService(channels []Channel, groups []Group, pricing *PricingService) *ModelPlazaService {
  12. repo := &mockChannelRepository{
  13. listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
  14. }
  15. return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, pricing, nil, nil)
  16. }
  17. func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel {
  18. return Channel{
  19. ID: id,
  20. Name: name,
  21. Status: StatusActive,
  22. GroupIDs: groupIDs,
  23. ModelPricing: []ChannelModelPricing{{
  24. Platform: platform,
  25. Models: models,
  26. BillingMode: BillingModeToken,
  27. InputPrice: testPtrFloat64(3e-6),
  28. OutputPrice: testPtrFloat64(1.5e-5),
  29. }},
  30. }
  31. }
  32. func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) {
  33. // 两个渠道挂同一分组:模型并入同一 PlazaGroup;无模型的分组不返回。
  34. channels := []Channel{
  35. plazaPricedChannel(1, "chA", []int64{10}, "anthropic", "claude-sonnet"),
  36. plazaPricedChannel(2, "chB", []int64{10}, "anthropic", "claude-opus"),
  37. }
  38. groups := []Group{
  39. {ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1},
  40. {ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5},
  41. }
  42. svc := newPlazaService(channels, groups, nil)
  43. out, err := svc.ListGroups(context.Background())
  44. require.NoError(t, err)
  45. require.Len(t, out, 1, "无模型的分组不应返回")
  46. require.Equal(t, int64(10), out[0].ID)
  47. require.Equal(t, "desc", out[0].Description)
  48. require.Len(t, out[0].Models, 2)
  49. // 组内模型按名称排序
  50. require.Equal(t, "claude-opus", out[0].Models[0].Name)
  51. require.Equal(t, "claude-sonnet", out[0].Models[1].Name)
  52. }
  53. func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) {
  54. base := &ChannelModelPricing{BillingMode: BillingModeToken}
  55. got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1")
  56. require.NotSame(t, base, got)
  57. require.NotNil(t, got.MaxReasoningEffortMultiplier)
  58. require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier)
  59. require.Nil(t, base.MaxReasoningEffortMultiplier)
  60. configured := 1.25
  61. custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured}
  62. require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1"))
  63. }
  64. func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) {
  65. // 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。
  66. unpriced := Channel{
  67. ID: 1, Name: "alpha", Status: StatusActive, GroupIDs: []int64{10},
  68. // mapping-only → SupportedModels 产出无定价条目
  69. ModelMapping: map[string]map[string]string{
  70. "anthropic": {"claude-sonnet": "claude-sonnet"},
  71. },
  72. }
  73. priced := plazaPricedChannel(2, "beta", []int64{10}, "anthropic", "claude-sonnet")
  74. groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
  75. // alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。
  76. svc := newPlazaService([]Channel{priced, unpriced}, groups, nil)
  77. out, err := svc.ListGroups(context.Background())
  78. require.NoError(t, err)
  79. require.Len(t, out, 1)
  80. require.Len(t, out[0].Models, 1)
  81. require.NotNil(t, out[0].Models[0].Pricing, "无价条目应被有价条目升级")
  82. require.NotNil(t, out[0].Models[0].Pricing.InputPrice)
  83. }
  84. func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
  85. // 渠道同时有 anthropic/openai 定价,anthropic 分组只应看到 anthropic 模型。
  86. ch := Channel{
  87. ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20},
  88. ModelPricing: []ChannelModelPricing{
  89. {Platform: "anthropic", Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)},
  90. {Platform: "openai", Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)},
  91. },
  92. }
  93. groups := []Group{
  94. {ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1},
  95. {ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1},
  96. }
  97. svc := newPlazaService([]Channel{ch}, groups, nil)
  98. out, err := svc.ListGroups(context.Background())
  99. require.NoError(t, err)
  100. require.Len(t, out, 2)
  101. byName := map[string][]PlazaModel{}
  102. for _, g := range out {
  103. byName[g.Name] = g.Models
  104. }
  105. require.Len(t, byName["g-claude"], 1)
  106. require.Equal(t, "claude-sonnet", byName["g-claude"][0].Name)
  107. require.Len(t, byName["g-gpt"], 1)
  108. require.Equal(t, "gpt-5", byName["g-gpt"][0].Name)
  109. }
  110. func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing.T) {
  111. anthropicPrice := 3e-6
  112. openAIPrice := 2e-6
  113. ch := Channel{
  114. ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10},
  115. ModelPricing: []ChannelModelPricing{
  116. {Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: &anthropicPrice},
  117. {Platform: PlatformOpenAI, Models: []string{"shared-model"}, InputPrice: &openAIPrice},
  118. {Platform: "", Models: []string{"empty-platform"}},
  119. {Platform: PlatformComposite, Models: []string{"nested-composite"}},
  120. {Platform: "unknown-platform", Models: []string{"unknown-platform"}},
  121. },
  122. }
  123. groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}}
  124. out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background())
  125. require.NoError(t, err)
  126. require.Len(t, out, 1)
  127. require.Len(t, out[0].Models, 2, "only concrete platforms are included and same-named models remain distinct")
  128. require.Equal(t, PlatformAnthropic, out[0].Models[0].Platform)
  129. require.Equal(t, PlatformOpenAI, out[0].Models[1].Platform)
  130. require.InDelta(t, anthropicPrice, *out[0].Models[0].Pricing.InputPrice, 1e-12)
  131. require.InDelta(t, openAIPrice, *out[0].Models[1].Pricing.InputPrice, 1e-12)
  132. }
  133. func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing.T) {
  134. ch := Channel{
  135. ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20},
  136. ModelPricing: []ChannelModelPricing{
  137. {Platform: PlatformAnthropic, Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)},
  138. {Platform: PlatformOpenAI, Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)},
  139. },
  140. }
  141. groups := []Group{
  142. {ID: 10, Name: "anthropic-only", Platform: PlatformAnthropic, RateMultiplier: 1},
  143. {ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1},
  144. }
  145. out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background())
  146. require.NoError(t, err)
  147. require.Len(t, out, 2)
  148. byName := map[string]PlazaGroup{}
  149. for _, group := range out {
  150. byName[group.Name] = group
  151. }
  152. require.Len(t, byName["anthropic-only"].Models, 1)
  153. require.Equal(t, []PlazaModel{{
  154. Name: "claude-sonnet", Platform: PlatformAnthropic, Pricing: byName["anthropic-only"].Models[0].Pricing,
  155. }}, byName["anthropic-only"].Models)
  156. require.Len(t, byName["composite"].Models, 2)
  157. require.Equal(t, []string{"claude-sonnet", "gpt-5"}, []string{
  158. byName["composite"].Models[0].Name,
  159. byName["composite"].Models[1].Name,
  160. })
  161. require.Equal(t, []string{PlatformAnthropic, PlatformOpenAI}, []string{
  162. byName["composite"].Models[0].Platform,
  163. byName["composite"].Models[1].Platform,
  164. })
  165. }
  166. func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
  167. inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
  168. inactive.Status = "inactive"
  169. groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
  170. svc := newPlazaService([]Channel{inactive}, groups, nil)
  171. out, err := svc.ListGroups(context.Background())
  172. require.NoError(t, err)
  173. require.Empty(t, out)
  174. }
  175. func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) {
  176. channels := []Channel{
  177. plazaPricedChannel(1, "ch", []int64{10, 20, 30}, "anthropic", "claude-sonnet"),
  178. }
  179. groups := []Group{
  180. {ID: 10, Name: "b-standard", Platform: "anthropic", RateMultiplier: 1},
  181. {ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1},
  182. {ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5},
  183. }
  184. svc := newPlazaService(channels, groups, nil)
  185. out, err := svc.ListGroups(context.Background())
  186. require.NoError(t, err)
  187. require.Len(t, out, 3)
  188. require.Equal(t, "cheap", out[0].Name, "倍率低者在前")
  189. require.Equal(t, "a-standard", out[1].Name, "同倍率按名称")
  190. require.Equal(t, "b-standard", out[2].Name)
  191. }
  192. func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
  193. pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
  194. "claude-sonnet": {
  195. Mode: "chat",
  196. InputCostPerToken: 3e-6,
  197. OutputCostPerToken: 1.5e-5,
  198. CacheCreationInputTokenCost: 3.75e-6,
  199. CacheCreationInputTokenCostAbove1hr: 6e-6,
  200. CacheReadInputTokenCost: 3e-7,
  201. },
  202. "token-absent": {Mode: "image_generation", TokenPricingAbsent: true, OutputCostPerImage: 0.04},
  203. })
  204. channels := []Channel{
  205. plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"),
  206. }
  207. groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
  208. svc := newPlazaService(channels, groups, pricingSvc)
  209. // 官方价与计费同源:需要计费服务与解析器(官方参考不查渠道,解析器无需渠道服务)。
  210. svc.billingService = NewBillingService(&config.Config{}, pricingSvc)
  211. svc.resolver = NewModelPricingResolver(nil, svc.billingService)
  212. out, err := svc.ListGroups(context.Background())
  213. require.NoError(t, err)
  214. require.Len(t, out, 1)
  215. require.Len(t, out[0].Models, 3)
  216. byName := map[string]PlazaModel{}
  217. for _, m := range out[0].Models {
  218. byName[m.Name] = m
  219. }
  220. // 命中:填充完整官方价(含 1h 缓存写入)
  221. official := byName["claude-sonnet"].OfficialPricing
  222. require.NotNil(t, official)
  223. require.InDelta(t, 3e-6, *official.InputPrice, 1e-12)
  224. require.InDelta(t, 6e-6, *official.CacheWrite1hPrice, 1e-12)
  225. require.InDelta(t, 3e-7, *official.CacheReadPrice, 1e-12)
  226. // 未命中:nil(GetModelPricing 的 claude 系列模糊匹配对非 claude 名不生效)
  227. require.Nil(t, byName["unknown-model"].OfficialPricing)
  228. // TokenPricingAbsent 条目不作为官方 token 价展示
  229. require.Nil(t, byName["token-absent"].OfficialPricing)
  230. }
  231. func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
  232. // 图片计费模型:档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价),
  233. // 分组独立倍率字段透传;未配图片价的分组保持渠道定价原样。
  234. perReq := 0.2
  235. tier4K := 0.3
  236. imgPrice := 0.02
  237. channels := []Channel{{
  238. ID: 1, Name: "img-ch", Status: StatusActive, GroupIDs: []int64{10, 20},
  239. ModelPricing: []ChannelModelPricing{{
  240. Platform: "openai",
  241. Models: []string{"gpt-image-2"},
  242. BillingMode: BillingModeImage,
  243. PerRequestPrice: &perReq,
  244. Intervals: []PricingInterval{{TierLabel: "4K", PerRequestPrice: &tier4K}},
  245. }},
  246. }}
  247. groups := []Group{
  248. {ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1,
  249. ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1},
  250. {ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1},
  251. }
  252. svc := newPlazaService(channels, groups, nil)
  253. out, err := svc.ListGroups(context.Background())
  254. require.NoError(t, err)
  255. require.Len(t, out, 2)
  256. byName := map[string]PlazaGroup{}
  257. for _, g := range out {
  258. byName[g.Name] = g
  259. }
  260. media := byName["g-media"]
  261. require.True(t, media.ImageRateIndependent)
  262. require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9)
  263. require.Len(t, media.Models, 1)
  264. p := media.Models[0].Pricing
  265. require.NotNil(t, p)
  266. require.Len(t, p.Intervals, 3)
  267. tierPrices := map[string]float64{}
  268. for _, iv := range p.Intervals {
  269. require.NotNil(t, iv.PerRequestPrice)
  270. tierPrices[iv.TierLabel] = *iv.PerRequestPrice
  271. }
  272. require.InDelta(t, 0.02, tierPrices["1K"], 1e-9, "1K 用分组图片价")
  273. require.InDelta(t, 0.2, tierPrices["2K"], 1e-9, "2K 分组未配,回落渠道默认按次价")
  274. require.InDelta(t, 0.3, tierPrices["4K"], 1e-9, "4K 分组未配,回落渠道档位价")
  275. plain := byName["g-plain"]
  276. require.False(t, plain.ImageRateIndependent)
  277. require.Len(t, plain.Models, 1)
  278. pp := plain.Models[0].Pricing
  279. require.NotNil(t, pp)
  280. require.Len(t, pp.Intervals, 1, "未配分组图片价:渠道定价原样")
  281. require.InDelta(t, 0.2, *pp.PerRequestPrice, 1e-9)
  282. // 合成为克隆,渠道原始定价不被修改
  283. require.Len(t, channels[0].ModelPricing[0].Intervals, 1)
  284. }
  285. func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) {
  286. // token 模式定价不受分组图片价影响。
  287. imgPrice := 0.02
  288. channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, "openai", "gpt-5")}
  289. groups := []Group{{ID: 10, Name: "g", Platform: "openai", RateMultiplier: 1, ImagePrice1K: &imgPrice}}
  290. svc := newPlazaService(channels, groups, nil)
  291. out, err := svc.ListGroups(context.Background())
  292. require.NoError(t, err)
  293. require.Len(t, out, 1)
  294. p := out[0].Models[0].Pricing
  295. require.NotNil(t, p)
  296. require.Empty(t, p.Intervals)
  297. require.NotNil(t, p.InputPrice)
  298. require.Nil(t, p.PerRequestPrice)
  299. }
  300. func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
  301. sentinel := errors.New("boom")
  302. repo := &mockChannelRepository{
  303. listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
  304. }
  305. svc := NewModelPlazaService(repo, &stubGroupRepoForAvailable{}, nil, nil, nil)
  306. out, err := svc.ListGroups(context.Background())
  307. require.Nil(t, out)
  308. require.ErrorIs(t, err, sentinel)
  309. svc2 := NewModelPlazaService(
  310. &mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }},
  311. &stubGroupRepoForAvailable{listActiveErr: sentinel},
  312. nil, nil, nil,
  313. )
  314. out2, err2 := svc2.ListGroups(context.Background())
  315. require.Nil(t, out2)
  316. require.ErrorIs(t, err2, sentinel)
  317. }
  318. // newPlazaServiceWithBilling 构造接入计费服务与解析器的广场服务:解析器的渠道服务与广场共用同一份渠道数据。
  319. func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatforms map[int64]string, catalog *PricingService) *ModelPlazaService {
  320. repo := &mockChannelRepository{
  321. listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
  322. getGroupPlatformsFn: func(ctx context.Context, _ []int64) (map[int64]string, error) {
  323. return groupPlatforms, nil
  324. },
  325. }
  326. cs := NewChannelService(repo, nil, nil, nil, nil)
  327. bs := NewBillingService(&config.Config{}, catalog)
  328. return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs))
  329. }
  330. func plazaModelsByName(models []PlazaModel) map[string]PlazaModel {
  331. out := make(map[string]PlazaModel, len(models))
  332. for _, m := range models {
  333. out[m.Name] = m
  334. }
  335. return out
  336. }
  337. func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
  338. // 同一渠道挂开启/关闭阶梯的两个分组:实付档位随分组开关,官方阶梯不受影响。
  339. channels := []Channel{{
  340. ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10, 20},
  341. ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken}},
  342. }}
  343. groups := []Group{
  344. {ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
  345. {ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
  346. }
  347. svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI},
  348. newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
  349. out, err := svc.ListGroups(context.Background())
  350. require.NoError(t, err)
  351. require.Len(t, out, 2)
  352. on, off := out[0], out[1]
  353. require.True(t, on.LongContextPricingEnabled)
  354. require.False(t, off.LongContextPricingEnabled)
  355. onModel := on.Models[0]
  356. require.Equal(t, ContextPricingBasisWholeRequest, onModel.LongContextBasis)
  357. require.Len(t, onModel.Pricing.Intervals, 2)
  358. require.Equal(t, "≤272K", onModel.Pricing.Intervals[0].TierLabel)
  359. require.Equal(t, ">272K", onModel.Pricing.Intervals[1].TierLabel)
  360. require.InDelta(t, 2.5e-6, *onModel.Pricing.InputPrice, 1e-15)
  361. require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].InputPrice, 1e-15)
  362. require.InDelta(t, 22.5e-6, *onModel.Pricing.Intervals[1].OutputPrice, 1e-15)
  363. require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].CacheWritePrice, 1e-15)
  364. require.InDelta(t, 0.5e-6, *onModel.Pricing.Intervals[1].CacheReadPrice, 1e-15)
  365. offModel := off.Models[0]
  366. require.Empty(t, offModel.LongContextBasis)
  367. require.Empty(t, offModel.Pricing.Intervals)
  368. require.InDelta(t, 2.5e-6, *offModel.Pricing.InputPrice, 1e-15)
  369. for _, m := range []PlazaModel{onModel, offModel} {
  370. require.NotNil(t, m.OfficialPricing)
  371. require.Len(t, m.OfficialPricing.Intervals, 2, "官方阶梯不受分组开关影响")
  372. require.InDelta(t, 5e-6, *m.OfficialPricing.Intervals[1].InputPrice, 1e-15)
  373. require.InDelta(t, 2.5e-6, *m.OfficialPricing.InputPrice, 1e-15)
  374. }
  375. }
  376. func TestListGroups_GeminiCatalogLadderShownWholeRequest(t *testing.T) {
  377. channels := []Channel{{
  378. ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
  379. ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
  380. }}
  381. groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}}
  382. svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini},
  383. newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON))
  384. out, err := svc.ListGroups(context.Background())
  385. require.NoError(t, err)
  386. require.Len(t, out, 1)
  387. m := out[0].Models[0]
  388. require.Equal(t, ContextPricingBasisWholeRequest, m.LongContextBasis)
  389. require.Len(t, m.Pricing.Intervals, 2)
  390. require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
  391. require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel)
  392. require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15)
  393. require.InDelta(t, 15e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
  394. // 官方参考价与实付同源:都来自目录数据的阶梯字段
  395. require.NotNil(t, m.OfficialPricing)
  396. require.Len(t, m.OfficialPricing.Intervals, 2)
  397. }
  398. func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
  399. channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "claude-sonnet-4")}
  400. groups := []Group{{
  401. ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true,
  402. ModelPricing: []ChannelModelPricing{{Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6)}},
  403. }}
  404. svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil)
  405. out, err := svc.ListGroups(context.Background())
  406. require.NoError(t, err)
  407. m := out[0].Models[0]
  408. require.InDelta(t, 1e-6, *m.Pricing.InputPrice, 1e-15, "分组价卡优先于渠道平价")
  409. require.InDelta(t, 15e-6, *m.Pricing.OutputPrice, 1e-15, "卡未配置的项回落目录价")
  410. require.Empty(t, m.Pricing.Intervals)
  411. }
  412. func TestListGroups_ImageModelKeepsTierSynthesisWithBilling(t *testing.T) {
  413. channels := []Channel{{
  414. ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
  415. ModelPricing: []ChannelModelPricing{{
  416. Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage,
  417. PerRequestPrice: testPtrFloat64(0.04),
  418. }},
  419. }}
  420. groups := []Group{{
  421. ID: 10, Name: "g", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true,
  422. ImagePrice1K: testPtrFloat64(0.02),
  423. }}
  424. svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI}, nil)
  425. out, err := svc.ListGroups(context.Background())
  426. require.NoError(t, err)
  427. m := out[0].Models[0]
  428. require.Equal(t, BillingModeImage, m.Pricing.BillingMode)
  429. require.Empty(t, m.LongContextBasis)
  430. require.Len(t, m.Pricing.Intervals, 3)
  431. require.InDelta(t, 0.02, *m.Pricing.Intervals[0].PerRequestPrice, 1e-12)
  432. require.InDelta(t, 0.04, *m.Pricing.Intervals[1].PerRequestPrice, 1e-12)
  433. }
  434. func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) {
  435. // 目录查不到的模型:计费按渠道平价(未配置项 $0),广场单档展示渠道平价,官方价为空。
  436. channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "unknown-model-xyz")}
  437. groups := []Group{{ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true}}
  438. svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil)
  439. out, err := svc.ListGroups(context.Background())
  440. require.NoError(t, err)
  441. m := out[0].Models[0]
  442. require.NotNil(t, m.Pricing)
  443. require.InDelta(t, 3e-6, *m.Pricing.InputPrice, 1e-15)
  444. require.Empty(t, m.Pricing.Intervals)
  445. require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价")
  446. require.Nil(t, m.OfficialPricing)
  447. }
  448. func TestListGroups_TimePricingPassthrough(t *testing.T) {
  449. channels := []Channel{{
  450. ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
  451. ModelPricing: []ChannelModelPricing{{
  452. Platform: PlatformDeepseek, Models: []string{"deepseek-chat"}, BillingMode: BillingModeToken,
  453. InputPrice: testPtrFloat64(0.28e-6), OutputPrice: testPtrFloat64(0.42e-6),
  454. TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
  455. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  456. }},
  457. }},
  458. }}
  459. groups := []Group{{ID: 10, Name: "cn", Platform: PlatformDeepseek, RateMultiplier: 1, LongContextPricingEnabled: true}}
  460. svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformDeepseek}, nil)
  461. out, err := svc.ListGroups(context.Background())
  462. require.NoError(t, err)
  463. m := out[0].Models[0]
  464. require.NotNil(t, m.TimePricing)
  465. require.Equal(t, "Asia/Shanghai", m.TimePricing.Timezone)
  466. require.Len(t, m.TimePricing.Periods, 1)
  467. require.InDelta(t, 0.5, m.TimePricing.Periods[0].Multiplier, 1e-12)
  468. // 展示单价为标准时段价
  469. require.InDelta(t, 0.28e-6, *m.Pricing.InputPrice, 1e-15)
  470. }