channel_test.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776
  1. //go:build unit
  2. package service
  3. import (
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. func TestGetModelPricing(t *testing.T) {
  8. ch := &Channel{
  9. ModelPricing: []ChannelModelPricing{
  10. {ID: 1, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(3e-6)},
  11. {ID: 3, Models: []string{"gpt-5.1"}, BillingMode: BillingModePerRequest},
  12. },
  13. }
  14. tests := []struct {
  15. name string
  16. model string
  17. wantID int64
  18. wantNil bool
  19. }{
  20. {"exact match", "claude-sonnet-4", 1, false},
  21. {"case insensitive", "Claude-Sonnet-4", 1, false},
  22. {"not found", "gemini-3.1-pro", 0, true},
  23. {"wildcard pattern not matched", "claude-opus-4-20250514", 0, true},
  24. {"per_request model", "gpt-5.1", 3, false},
  25. }
  26. for _, tt := range tests {
  27. t.Run(tt.name, func(t *testing.T) {
  28. result := ch.GetModelPricing(tt.model)
  29. if tt.wantNil {
  30. require.Nil(t, result)
  31. return
  32. }
  33. require.NotNil(t, result)
  34. require.Equal(t, tt.wantID, result.ID)
  35. })
  36. }
  37. }
  38. func TestGetModelPricing_ReturnsCopy(t *testing.T) {
  39. ch := &Channel{
  40. ModelPricing: []ChannelModelPricing{
  41. {ID: 1, Models: []string{"claude-sonnet-4"}, InputPrice: testPtrFloat64(3e-6)},
  42. },
  43. }
  44. result := ch.GetModelPricing("claude-sonnet-4")
  45. require.NotNil(t, result)
  46. // Modify the returned copy's slice — original should be unchanged
  47. result.Models = append(result.Models, "hacked")
  48. // Original should be unchanged
  49. require.Equal(t, 1, len(ch.ModelPricing[0].Models))
  50. }
  51. func TestGetModelPricing_EmptyPricing(t *testing.T) {
  52. ch := &Channel{ModelPricing: nil}
  53. require.Nil(t, ch.GetModelPricing("any-model"))
  54. ch2 := &Channel{ModelPricing: []ChannelModelPricing{}}
  55. require.Nil(t, ch2.GetModelPricing("any-model"))
  56. }
  57. func TestGetIntervalForContext(t *testing.T) {
  58. p := &ChannelModelPricing{
  59. Intervals: []PricingInterval{
  60. {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
  61. {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
  62. },
  63. }
  64. tests := []struct {
  65. name string
  66. tokens int
  67. wantPrice *float64
  68. wantNil bool
  69. }{
  70. {"first interval", 50000, testPtrFloat64(1e-6), false},
  71. // (min, max] — 128000 在第一个区间的 max,包含,所以匹配第一个
  72. {"boundary: max of first (inclusive)", 128000, testPtrFloat64(1e-6), false},
  73. // 128001 > 128000,匹配第二个区间
  74. {"boundary: just above first max", 128001, testPtrFloat64(2e-6), false},
  75. {"unbounded interval", 500000, testPtrFloat64(2e-6), false},
  76. // (0, max] — 0 不匹配任何区间(左开)
  77. {"zero tokens: no match", 0, nil, true},
  78. }
  79. for _, tt := range tests {
  80. t.Run(tt.name, func(t *testing.T) {
  81. result := p.GetIntervalForContext(tt.tokens)
  82. if tt.wantNil {
  83. require.Nil(t, result)
  84. return
  85. }
  86. require.NotNil(t, result)
  87. require.InDelta(t, *tt.wantPrice, *result.InputPrice, 1e-12)
  88. })
  89. }
  90. }
  91. func TestGetIntervalForContext_NoMatch(t *testing.T) {
  92. p := &ChannelModelPricing{
  93. Intervals: []PricingInterval{
  94. {MinTokens: 10000, MaxTokens: testPtrInt(50000)},
  95. },
  96. }
  97. require.Nil(t, p.GetIntervalForContext(5000)) // 5000 <= 10000, not > min
  98. require.Nil(t, p.GetIntervalForContext(10000)) // 10000 not > 10000 (left-open)
  99. require.NotNil(t, p.GetIntervalForContext(50000)) // 50000 <= 50000 (right-closed)
  100. require.Nil(t, p.GetIntervalForContext(50001)) // 50001 > 50000
  101. }
  102. func TestGetIntervalForContext_Empty(t *testing.T) {
  103. p := &ChannelModelPricing{Intervals: nil}
  104. require.Nil(t, p.GetIntervalForContext(1000))
  105. }
  106. func TestGetTierByLabel(t *testing.T) {
  107. p := &ChannelModelPricing{
  108. Intervals: []PricingInterval{
  109. {TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
  110. {TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.08)},
  111. {TierLabel: "HD", PerRequestPrice: testPtrFloat64(0.12)},
  112. },
  113. }
  114. tests := []struct {
  115. name string
  116. label string
  117. wantNil bool
  118. want float64
  119. }{
  120. {"exact match", "1K", false, 0.04},
  121. {"case insensitive", "hd", false, 0.12},
  122. {"not found", "4K", true, 0},
  123. }
  124. for _, tt := range tests {
  125. t.Run(tt.name, func(t *testing.T) {
  126. result := p.GetTierByLabel(tt.label)
  127. if tt.wantNil {
  128. require.Nil(t, result)
  129. return
  130. }
  131. require.NotNil(t, result)
  132. require.InDelta(t, tt.want, *result.PerRequestPrice, 1e-12)
  133. })
  134. }
  135. }
  136. func TestGetTierByLabel_Empty(t *testing.T) {
  137. p := &ChannelModelPricing{Intervals: nil}
  138. require.Nil(t, p.GetTierByLabel("1K"))
  139. }
  140. func TestChannelClone(t *testing.T) {
  141. original := &Channel{
  142. ID: 1,
  143. Name: "test",
  144. GroupIDs: []int64{10, 20},
  145. ModelPricing: []ChannelModelPricing{
  146. {
  147. ID: 100,
  148. Models: []string{"model-a"},
  149. InputPrice: testPtrFloat64(5e-6),
  150. },
  151. },
  152. }
  153. cloned := original.Clone()
  154. require.NotNil(t, cloned)
  155. require.Equal(t, original.ID, cloned.ID)
  156. require.Equal(t, original.Name, cloned.Name)
  157. // Modify clone slices — original should not change
  158. cloned.GroupIDs[0] = 999
  159. require.Equal(t, int64(10), original.GroupIDs[0])
  160. cloned.ModelPricing[0].Models[0] = "hacked"
  161. require.Equal(t, "model-a", original.ModelPricing[0].Models[0])
  162. }
  163. func TestChannelClone_Nil(t *testing.T) {
  164. var ch *Channel
  165. require.Nil(t, ch.Clone())
  166. }
  167. func TestChannelModelPricingClone(t *testing.T) {
  168. original := ChannelModelPricing{
  169. Models: []string{"a", "b"},
  170. Intervals: []PricingInterval{
  171. {MinTokens: 0, TierLabel: "tier1"},
  172. },
  173. TimePricing: &ChannelTimePricing{
  174. Timezone: "Asia/Shanghai",
  175. WeekdaysOnly: true,
  176. Periods: []ChannelTimePricingPeriod{{
  177. StartTime: "09:00",
  178. EndTime: "12:00",
  179. Multiplier: 2,
  180. }},
  181. },
  182. }
  183. cloned := original.Clone()
  184. // Modify clone slices — original unchanged
  185. cloned.Models[0] = "hacked"
  186. require.Equal(t, "a", original.Models[0])
  187. cloned.Intervals[0].TierLabel = "hacked"
  188. require.Equal(t, "tier1", original.Intervals[0].TierLabel)
  189. cloned.TimePricing.Timezone = "America/New_York"
  190. cloned.TimePricing.WeekdaysOnly = false
  191. cloned.TimePricing.Periods[0].StartTime = "10:00"
  192. cloned.TimePricing.Periods[0].Multiplier = 3
  193. require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone)
  194. require.True(t, original.TimePricing.WeekdaysOnly)
  195. require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime)
  196. require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier)
  197. }
  198. // --- BillingMode.IsValid ---
  199. func TestBillingModeIsValid(t *testing.T) {
  200. tests := []struct {
  201. name string
  202. mode BillingMode
  203. want bool
  204. }{
  205. {"token", BillingModeToken, true},
  206. {"per_request", BillingModePerRequest, true},
  207. {"image", BillingModeImage, true},
  208. {"empty", BillingMode(""), true},
  209. {"unknown", BillingMode("unknown"), false},
  210. {"random", BillingMode("xyz"), false},
  211. }
  212. for _, tt := range tests {
  213. t.Run(tt.name, func(t *testing.T) {
  214. require.Equal(t, tt.want, tt.mode.IsValid())
  215. })
  216. }
  217. }
  218. // --- Channel.IsActive ---
  219. func TestChannelIsActive(t *testing.T) {
  220. tests := []struct {
  221. name string
  222. status string
  223. want bool
  224. }{
  225. {"active", StatusActive, true},
  226. {"disabled", "disabled", false},
  227. {"empty", "", false},
  228. }
  229. for _, tt := range tests {
  230. t.Run(tt.name, func(t *testing.T) {
  231. ch := &Channel{Status: tt.status}
  232. require.Equal(t, tt.want, ch.IsActive())
  233. })
  234. }
  235. }
  236. // --- ChannelModelPricing.Clone edge cases ---
  237. func TestChannelModelPricingClone_EdgeCases(t *testing.T) {
  238. t.Run("nil models", func(t *testing.T) {
  239. original := ChannelModelPricing{Models: nil}
  240. cloned := original.Clone()
  241. require.Nil(t, cloned.Models)
  242. })
  243. t.Run("nil intervals", func(t *testing.T) {
  244. original := ChannelModelPricing{Intervals: nil}
  245. cloned := original.Clone()
  246. require.Nil(t, cloned.Intervals)
  247. })
  248. t.Run("empty models", func(t *testing.T) {
  249. original := ChannelModelPricing{Models: []string{}}
  250. cloned := original.Clone()
  251. require.NotNil(t, cloned.Models)
  252. require.Empty(t, cloned.Models)
  253. })
  254. }
  255. // --- Channel.Clone edge cases ---
  256. func TestChannelClone_EdgeCases(t *testing.T) {
  257. t.Run("nil model mapping", func(t *testing.T) {
  258. original := &Channel{ID: 1, ModelMapping: nil}
  259. cloned := original.Clone()
  260. require.Nil(t, cloned.ModelMapping)
  261. })
  262. t.Run("nil model pricing", func(t *testing.T) {
  263. original := &Channel{ID: 1, ModelPricing: nil}
  264. cloned := original.Clone()
  265. require.Nil(t, cloned.ModelPricing)
  266. })
  267. t.Run("deep copy model mapping", func(t *testing.T) {
  268. original := &Channel{
  269. ID: 1,
  270. ModelMapping: map[string]map[string]string{
  271. "openai": {"gpt-4": "gpt-4-turbo"},
  272. },
  273. }
  274. cloned := original.Clone()
  275. // Modify the cloned nested map
  276. cloned.ModelMapping["openai"]["gpt-4"] = "hacked"
  277. // Original must remain unchanged
  278. require.Equal(t, "gpt-4-turbo", original.ModelMapping["openai"]["gpt-4"])
  279. })
  280. }
  281. // --- ValidateIntervals ---
  282. func TestValidateIntervals_Empty(t *testing.T) {
  283. require.NoError(t, ValidateIntervals(nil, BillingModeToken))
  284. require.NoError(t, ValidateIntervals([]PricingInterval{}, BillingModeToken))
  285. }
  286. func TestValidateIntervals_ValidIntervals(t *testing.T) {
  287. tests := []struct {
  288. name string
  289. intervals []PricingInterval
  290. }{
  291. {
  292. name: "single bounded interval",
  293. intervals: []PricingInterval{
  294. {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
  295. },
  296. },
  297. {
  298. name: "two intervals with gap",
  299. intervals: []PricingInterval{
  300. {MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(1e-6)},
  301. {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
  302. },
  303. },
  304. {
  305. name: "two contiguous intervals",
  306. intervals: []PricingInterval{
  307. {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
  308. {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
  309. },
  310. },
  311. {
  312. name: "unsorted input (auto-sorted by validator)",
  313. intervals: []PricingInterval{
  314. {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
  315. {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
  316. },
  317. },
  318. {
  319. name: "single unbounded interval",
  320. intervals: []PricingInterval{
  321. {MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)},
  322. },
  323. },
  324. }
  325. for _, tt := range tests {
  326. t.Run(tt.name, func(t *testing.T) {
  327. require.NoError(t, ValidateIntervals(tt.intervals, BillingModeToken))
  328. })
  329. }
  330. }
  331. func TestValidateIntervals_NegativeMinTokens(t *testing.T) {
  332. intervals := []PricingInterval{
  333. {MinTokens: -1, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
  334. }
  335. err := ValidateIntervals(intervals, BillingModeToken)
  336. require.Error(t, err)
  337. require.Contains(t, err.Error(), "min_tokens")
  338. require.Contains(t, err.Error(), ">= 0")
  339. }
  340. func TestValidateIntervals_MaxTokensZero(t *testing.T) {
  341. intervals := []PricingInterval{
  342. {MinTokens: 0, MaxTokens: testPtrInt(0), InputPrice: testPtrFloat64(1e-6)},
  343. }
  344. err := ValidateIntervals(intervals, BillingModeToken)
  345. require.Error(t, err)
  346. require.Contains(t, err.Error(), "max_tokens")
  347. require.Contains(t, err.Error(), "> 0")
  348. }
  349. func TestValidateIntervals_MaxLessThanMin(t *testing.T) {
  350. intervals := []PricingInterval{
  351. {MinTokens: 100, MaxTokens: testPtrInt(50), InputPrice: testPtrFloat64(1e-6)},
  352. }
  353. err := ValidateIntervals(intervals, BillingModeToken)
  354. require.Error(t, err)
  355. require.Contains(t, err.Error(), "max_tokens")
  356. require.Contains(t, err.Error(), "> min_tokens")
  357. }
  358. func TestValidateIntervals_MaxEqualsMin(t *testing.T) {
  359. intervals := []PricingInterval{
  360. {MinTokens: 100, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
  361. }
  362. err := ValidateIntervals(intervals, BillingModeToken)
  363. require.Error(t, err)
  364. require.Contains(t, err.Error(), "max_tokens")
  365. require.Contains(t, err.Error(), "> min_tokens")
  366. }
  367. func TestValidateIntervals_NegativePrice(t *testing.T) {
  368. negPrice := -0.01
  369. intervals := []PricingInterval{
  370. {MinTokens: 0, MaxTokens: testPtrInt(100), InputPrice: &negPrice},
  371. }
  372. err := ValidateIntervals(intervals, BillingModeToken)
  373. require.Error(t, err)
  374. require.Contains(t, err.Error(), "input_price")
  375. require.Contains(t, err.Error(), ">= 0")
  376. }
  377. func TestValidateIntervals_OverlappingIntervals(t *testing.T) {
  378. intervals := []PricingInterval{
  379. {MinTokens: 0, MaxTokens: testPtrInt(200), InputPrice: testPtrFloat64(1e-6)},
  380. {MinTokens: 100, MaxTokens: testPtrInt(300), InputPrice: testPtrFloat64(2e-6)},
  381. }
  382. err := ValidateIntervals(intervals, BillingModeToken)
  383. require.Error(t, err)
  384. require.Contains(t, err.Error(), "overlap")
  385. }
  386. func TestValidateIntervals_UnboundedNotLast(t *testing.T) {
  387. intervals := []PricingInterval{
  388. {MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)},
  389. {MinTokens: 128000, MaxTokens: testPtrInt(256000), InputPrice: testPtrFloat64(2e-6)},
  390. }
  391. err := ValidateIntervals(intervals, BillingModeToken)
  392. require.Error(t, err)
  393. require.Contains(t, err.Error(), "unbounded")
  394. require.Contains(t, err.Error(), "last")
  395. }
  396. func TestValidateIntervals_ImageModeAllowsMultipleUnboundedTiers(t *testing.T) {
  397. // image / per_request 按 tier_label 匹配,多条 min=0/max=nil 是合法形态。
  398. intervals := []PricingInterval{
  399. {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
  400. {MinTokens: 0, MaxTokens: nil, TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.06)},
  401. {MinTokens: 0, MaxTokens: nil, TierLabel: "4K", PerRequestPrice: testPtrFloat64(0.08)},
  402. }
  403. require.NoError(t, ValidateIntervals(intervals, BillingModeImage))
  404. require.NoError(t, ValidateIntervals(intervals, BillingModePerRequest))
  405. }
  406. func TestValidateIntervals_ImageModeStillRejectsNegativePrice(t *testing.T) {
  407. // image 模式只跳过区间重叠校验,单条字段自洽(价格非负)仍要校验。
  408. intervals := []PricingInterval{
  409. {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(-1)},
  410. }
  411. err := ValidateIntervals(intervals, BillingModeImage)
  412. require.Error(t, err)
  413. require.Contains(t, err.Error(), "must be >= 0")
  414. }
  415. func TestValidateIntervals_ImageModeStillRejectsBadMaxTokens(t *testing.T) {
  416. // image 模式仍校验 max <= min 这种单条不合法。
  417. intervals := []PricingInterval{
  418. {MinTokens: 100, MaxTokens: testPtrInt(50), TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
  419. }
  420. err := ValidateIntervals(intervals, BillingModeImage)
  421. require.Error(t, err)
  422. require.Contains(t, err.Error(), "must be > min_tokens")
  423. }
  424. func TestSupportedModels_ExactKeysAndPricing(t *testing.T) {
  425. ch := &Channel{
  426. ModelPricing: []ChannelModelPricing{
  427. {ID: 10, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(3e-6)},
  428. {ID: 11, Platform: "anthropic", Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(1.5e-5)},
  429. },
  430. ModelMapping: map[string]map[string]string{
  431. "anthropic": {
  432. "claude-sonnet-4-6": "claude-sonnet-4-6",
  433. "claude-opus-4-6": "claude-opus-4-6",
  434. },
  435. },
  436. }
  437. got := ch.SupportedModels()
  438. require.Len(t, got, 2)
  439. require.Equal(t, "anthropic", got[0].Platform)
  440. require.Equal(t, "claude-opus-4-6", got[0].Name)
  441. require.NotNil(t, got[0].Pricing)
  442. require.Equal(t, int64(11), got[0].Pricing.ID)
  443. require.Equal(t, "claude-sonnet-4-6", got[1].Name)
  444. require.Equal(t, int64(10), got[1].Pricing.ID)
  445. }
  446. func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) {
  447. ch := &Channel{
  448. ModelPricing: []ChannelModelPricing{
  449. {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6", "claude-sonnet-4-5"}},
  450. {ID: 2, Platform: "anthropic", Models: []string{"claude-opus-4-6"}},
  451. },
  452. ModelMapping: map[string]map[string]string{
  453. "anthropic": {
  454. "claude-sonnet-*": "claude-sonnet-4-6",
  455. },
  456. },
  457. }
  458. got := ch.SupportedModels()
  459. names := make([]string, 0, len(got))
  460. for _, m := range got {
  461. names = append(names, m.Name)
  462. }
  463. require.ElementsMatch(t, []string{"claude-sonnet-4-5", "claude-sonnet-4-6", "claude-opus-4-6"}, names)
  464. for _, m := range got {
  465. require.NotContains(t, m.Name, "*")
  466. }
  467. }
  468. func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) {
  469. ch := &Channel{
  470. ModelMapping: map[string]map[string]string{
  471. "anthropic": {"claude-sonnet-4-6": "claude-sonnet-4-6"},
  472. },
  473. }
  474. got := ch.SupportedModels()
  475. require.Len(t, got, 1)
  476. require.Equal(t, "claude-sonnet-4-6", got[0].Name)
  477. require.Nil(t, got[0].Pricing)
  478. }
  479. func TestSupportedModels_DedupAndSort(t *testing.T) {
  480. ch := &Channel{
  481. ModelPricing: []ChannelModelPricing{
  482. {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6", "claude-sonnet-4-5"}},
  483. {ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
  484. },
  485. ModelMapping: map[string]map[string]string{
  486. "anthropic": {
  487. "claude-sonnet-4-6": "upstream-a",
  488. "claude-sonnet-*": "upstream-a",
  489. },
  490. "openai": {"gpt-4o": "gpt-4o"},
  491. },
  492. }
  493. got := ch.SupportedModels()
  494. require.Len(t, got, 3)
  495. require.Equal(t, "anthropic", got[0].Platform)
  496. require.Equal(t, "claude-sonnet-4-5", got[0].Name)
  497. require.Equal(t, "anthropic", got[1].Platform)
  498. require.Equal(t, "claude-sonnet-4-6", got[1].Name)
  499. require.Equal(t, "openai", got[2].Platform)
  500. require.Equal(t, "gpt-4o", got[2].Name)
  501. }
  502. func TestSupportedModels_NilChannelAndEmpty(t *testing.T) {
  503. var nilCh *Channel
  504. require.Nil(t, nilCh.SupportedModels())
  505. empty := &Channel{}
  506. require.Nil(t, empty.SupportedModels())
  507. }
  508. func TestGetModelPricingByPlatform(t *testing.T) {
  509. ch := &Channel{
  510. ModelPricing: []ChannelModelPricing{
  511. {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(3e-6)},
  512. {ID: 2, Platform: "openai", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(1e-6)},
  513. },
  514. }
  515. ant := ch.GetModelPricingByPlatform("anthropic", "claude-sonnet-4-6")
  516. require.NotNil(t, ant)
  517. require.Equal(t, int64(1), ant.ID)
  518. oa := ch.GetModelPricingByPlatform("openai", "claude-sonnet-4-6")
  519. require.NotNil(t, oa)
  520. require.Equal(t, int64(2), oa.ID)
  521. require.Nil(t, ch.GetModelPricingByPlatform("gemini", "claude-sonnet-4-6"))
  522. }
  523. func TestSupportedModels_WildcardOnlyPricingRowsSkipped(t *testing.T) {
  524. // 定价中含通配符条目(pattern),不应被当作具体模型名展开。
  525. ch := &Channel{
  526. ModelPricing: []ChannelModelPricing{
  527. {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-*", "claude-sonnet-4-6"}},
  528. },
  529. ModelMapping: map[string]map[string]string{
  530. "anthropic": {"claude-sonnet-*": "claude-sonnet-4-6"},
  531. },
  532. }
  533. got := ch.SupportedModels()
  534. require.Len(t, got, 1)
  535. require.Equal(t, "claude-sonnet-4-6", got[0].Name)
  536. for _, m := range got {
  537. require.NotContains(t, m.Name, "*")
  538. }
  539. }
  540. func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) {
  541. // 通配符模式无任何对应定价模型时,该平台 mapping 路不产出;
  542. // 但其他平台的 pricing-only 模型仍会通过 Pass B 出现。
  543. ch := &Channel{
  544. ModelPricing: []ChannelModelPricing{
  545. {ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
  546. },
  547. ModelMapping: map[string]map[string]string{
  548. "anthropic": {"gpt-foo-*": "gpt-foo-1"},
  549. },
  550. }
  551. got := ch.SupportedModels()
  552. require.Len(t, got, 1)
  553. require.Equal(t, "openai", got[0].Platform)
  554. require.Equal(t, "gpt-4o", got[0].Name)
  555. }
  556. func TestSupportedModels_CrossPlatformPricingDoesNotBleed(t *testing.T) {
  557. // anthropic 的通配符不应把 openai 定价行拉到 anthropic 平台下;
  558. // openai 的 pricing-only 模型则正常通过 Pass B 暴露在 openai 平台下。
  559. ch := &Channel{
  560. ModelPricing: []ChannelModelPricing{
  561. {ID: 1, Platform: "openai", Models: []string{"claude-sonnet-4-6"}},
  562. },
  563. ModelMapping: map[string]map[string]string{
  564. "anthropic": {"claude-sonnet-*": "x"},
  565. },
  566. }
  567. got := ch.SupportedModels()
  568. require.Len(t, got, 1)
  569. require.Equal(t, "openai", got[0].Platform, "不能把 openai 定价标记为 anthropic 模型")
  570. require.Equal(t, "claude-sonnet-4-6", got[0].Name)
  571. }
  572. func TestSupportedModels_CaseInsensitiveDedup(t *testing.T) {
  573. // 两行定价用不同大小写定义了同一模型,结果应去重为 1 条;首次出现的原始大小写保留。
  574. ch := &Channel{
  575. ModelPricing: []ChannelModelPricing{
  576. {ID: 1, Platform: "openai", Models: []string{"GPT-4o"}},
  577. {ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
  578. },
  579. ModelMapping: map[string]map[string]string{
  580. "openai": {"gpt-*": "x"},
  581. },
  582. }
  583. got := ch.SupportedModels()
  584. require.Len(t, got, 1)
  585. require.Equal(t, "GPT-4o", got[0].Name)
  586. }
  587. func TestSupportedModels_EmptyPlatformMapping(t *testing.T) {
  588. // ModelMapping 平台 key 存在但 value 为空 map:mapping 路跳过该平台,
  589. // 但 pricing 路仍会把该平台的定价模型补齐(关键修复:azcc 这种"只配定价不配映射"渠道)。
  590. ch := &Channel{
  591. ModelPricing: []ChannelModelPricing{
  592. {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}},
  593. },
  594. ModelMapping: map[string]map[string]string{
  595. "anthropic": {},
  596. },
  597. }
  598. got := ch.SupportedModels()
  599. require.Len(t, got, 1)
  600. require.Equal(t, "anthropic", got[0].Platform)
  601. require.Equal(t, "claude-sonnet-4-6", got[0].Name)
  602. require.NotNil(t, got[0].Pricing)
  603. }
  604. func TestSupportedModels_ExactKeyUsesPricedCaseWhenAvailable(t *testing.T) {
  605. // mapping key uses uppercase, pricing uses lowercase — pricing's case should win.
  606. ch := &Channel{
  607. ModelPricing: []ChannelModelPricing{
  608. {ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
  609. },
  610. ModelMapping: map[string]map[string]string{
  611. "openai": {"GPT-4o": "gpt-4o"},
  612. },
  613. }
  614. got := ch.SupportedModels()
  615. require.Len(t, got, 1)
  616. require.Equal(t, "gpt-4o", got[0].Name) // pricing's case wins
  617. }
  618. func TestSupportedModels_AsteriskOnlyMappingExpandsAllPriced(t *testing.T) {
  619. // 映射 key 为单独的 "*":前缀为空 → 命中该平台所有定价模型(透传场景)。
  620. ch := &Channel{
  621. ModelPricing: []ChannelModelPricing{
  622. {ID: 1, Platform: "openai", Models: []string{"gpt-4o", "gpt-4o-mini"}},
  623. },
  624. ModelMapping: map[string]map[string]string{
  625. "openai": {"*": "gpt-4o"},
  626. },
  627. }
  628. got := ch.SupportedModels()
  629. require.Len(t, got, 2)
  630. names := []string{got[0].Name, got[1].Name}
  631. require.ElementsMatch(t, []string{"gpt-4o", "gpt-4o-mini"}, names)
  632. }
  633. func TestSupportedModels_PricingOnlyNoMapping(t *testing.T) {
  634. // 渠道完全没配 mapping,只配了定价 —— 应该把所有定价模型作为支持模型返回。
  635. // 这是修复前的核心 bug 场景(前端显示"未配置模型")。
  636. ch := &Channel{
  637. ModelPricing: []ChannelModelPricing{
  638. {ID: 1, Platform: "anthropic", Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(1.5e-5)},
  639. {ID: 2, Platform: "anthropic", Models: []string{"claude-haiku-4-5"}, InputPrice: testPtrFloat64(3e-7)},
  640. },
  641. }
  642. got := ch.SupportedModels()
  643. require.Len(t, got, 2)
  644. require.Equal(t, "claude-haiku-4-5", got[0].Name)
  645. require.NotNil(t, got[0].Pricing)
  646. require.Equal(t, int64(2), got[0].Pricing.ID)
  647. require.Equal(t, "claude-opus-4-6", got[1].Name)
  648. require.Equal(t, int64(1), got[1].Pricing.ID)
  649. }
  650. func TestSupportedModels_ExactMappingUsesTargetPricing(t *testing.T) {
  651. // 精确 mapping `src → target`:定价应按 target 查(实际计费的是 target),
  652. // 而不是按 src 自查。
  653. ch := &Channel{
  654. ModelPricing: []ChannelModelPricing{
  655. {ID: 100, Platform: "anthropic", Models: []string{"req-model"}, InputPrice: testPtrFloat64(3e-6)},
  656. {ID: 200, Platform: "anthropic", Models: []string{"served-model"}, InputPrice: testPtrFloat64(1.5e-5)},
  657. },
  658. ModelMapping: map[string]map[string]string{
  659. "anthropic": {
  660. "req-model": "served-model",
  661. },
  662. },
  663. }
  664. got := ch.SupportedModels()
  665. require.Len(t, got, 2)
  666. require.Equal(t, "req-model", got[0].Name)
  667. require.NotNil(t, got[0].Pricing)
  668. require.Equal(t, int64(200), got[0].Pricing.ID, "req-model 显示但定价是 served-model 的(mapping target)")
  669. require.Equal(t, "served-model", got[1].Name)
  670. require.Equal(t, int64(200), got[1].Pricing.ID)
  671. }
  672. func TestSupportedModels_ExactMappingTargetMissingFromPricing(t *testing.T) {
  673. // `src → target` 但 target 不在渠道定价里 —— 结果中 src 的 Pricing 为 nil
  674. // (等待 ListAvailable 阶段的全局 LiteLLM 回落填充)。
  675. ch := &Channel{
  676. ModelPricing: []ChannelModelPricing{
  677. {ID: 1, Platform: "anthropic", Models: []string{"some-priced-model"}, InputPrice: testPtrFloat64(1.5e-5)},
  678. },
  679. ModelMapping: map[string]map[string]string{
  680. "anthropic": {
  681. "missing-src": "missing-target",
  682. },
  683. },
  684. }
  685. got := ch.SupportedModels()
  686. require.Len(t, got, 2)
  687. require.Equal(t, "missing-src", got[0].Name)
  688. require.Nil(t, got[0].Pricing, "target 在渠道定价中缺失时不虚假填充,留给 ListAvailable 走 LiteLLM 回落")
  689. require.Equal(t, "some-priced-model", got[1].Name)
  690. require.NotNil(t, got[1].Pricing)
  691. }