pricing_service_override_test.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. package service
  2. import (
  3. "os"
  4. "path/filepath"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // gpt55OverrideCatalogJSON 镜像真实目录形态:长上下文以 above_272k 绝对价字段表达。
  10. const gpt55OverrideCatalogJSON = `{
  11. "gpt-5.5": {"litellm_provider": "openai", "mode": "chat",
  12. "input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1.25e-05,
  13. "output_cost_per_token": 3e-05, "output_cost_per_token_priority": 7.5e-05,
  14. "cache_read_input_token_cost": 5e-07,
  15. "input_cost_per_token_above_272k_tokens": 1e-05,
  16. "output_cost_per_token_above_272k_tokens": 4.5e-05,
  17. "cache_read_input_token_cost_above_272k_tokens": 1e-06},
  18. "gpt-5.4": {"litellm_provider": "openai", "mode": "chat",
  19. "input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05,
  20. "cache_read_input_token_cost": 2.5e-07,
  21. "input_cost_per_token_above_272k_tokens": 5e-06,
  22. "output_cost_per_token_above_272k_tokens": 2.25e-05}
  23. }`
  24. func newPricingServiceWithOverride(t *testing.T, overrideJSON string) *PricingService {
  25. t.Helper()
  26. path := filepath.Join(t.TempDir(), "overrides.json")
  27. require.NoError(t, os.WriteFile(path, []byte(overrideJSON), 0644))
  28. svc := &PricingService{cfg: &config.Config{}}
  29. svc.cfg.Pricing.OverrideFile = path
  30. return svc
  31. }
  32. // override 的旗舰用例:显式 threshold=0 压住 above 折算,把目录条目的阶梯关成标准价。
  33. func TestPricingOverride_ExplicitZeroThresholdDisablesCatalogLadder(t *testing.T) {
  34. svc := newPricingServiceWithOverride(t, `{"gpt-5.5": {"long_context_input_token_threshold": 0}}`)
  35. data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
  36. require.NoError(t, err)
  37. patched := data["gpt-5.5"]
  38. require.NotNil(t, patched)
  39. require.Zero(t, patched.LongContextInputTokenThreshold)
  40. require.Zero(t, patched.LongContextInputCostMultiplier)
  41. require.InDelta(t, 5e-6, patched.InputCostPerToken, 1e-12, "补丁不得影响基础价")
  42. require.InDelta(t, 3e-5, patched.OutputCostPerToken, 1e-12)
  43. require.Equal(t, 272000, data["gpt-5.4"].LongContextInputTokenThreshold, "未覆盖的模型保持目录阶梯")
  44. svc.pricingData = data
  45. billing := NewBillingService(&config.Config{}, svc)
  46. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000}
  47. cost, err := billing.CalculateCost("gpt-5.5", tokens, 1)
  48. require.NoError(t, err)
  49. require.False(t, cost.LongContextBillingApplied)
  50. require.InDelta(t, 300000*5e-6, cost.InputCost, 1e-10)
  51. require.InDelta(t, 1000*3e-5, cost.OutputCost, 1e-10)
  52. require.InDelta(t, 10000*5e-7, cost.CacheReadCost, 1e-10)
  53. }
  54. func TestPricingOverride_FieldLevelMergeKeepsOtherFields(t *testing.T) {
  55. svc := newPricingServiceWithOverride(t, `{"gpt-5.4": {"input_cost_per_token": 3e-06}}`)
  56. data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
  57. require.NoError(t, err)
  58. patched := data["gpt-5.4"]
  59. require.InDelta(t, 3e-6, patched.InputCostPerToken, 1e-12)
  60. require.InDelta(t, 1.5e-5, patched.OutputCostPerToken, 1e-12, "未覆盖字段保持目录值")
  61. require.Equal(t, "openai", patched.LiteLLMProvider)
  62. require.Equal(t, 272000, patched.LongContextInputTokenThreshold, "above 折算仍生效")
  63. // 折算发生在合并之后:above 价不变、基础价被补丁改小,倍率随之变化。
  64. require.InDelta(t, 5.0/3.0, patched.LongContextInputCostMultiplier, 1e-9)
  65. }
  66. func TestPricingOverride_NullFieldValueRemovesField(t *testing.T) {
  67. svc := newPricingServiceWithOverride(t, `{"gpt-5.5": {
  68. "input_cost_per_token_above_272k_tokens": null,
  69. "output_cost_per_token_above_272k_tokens": null,
  70. "cache_read_input_token_cost_above_272k_tokens": null}}`)
  71. data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
  72. require.NoError(t, err)
  73. require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold, "above 字段删除后不再折算阶梯")
  74. require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12)
  75. }
  76. // 完整加载管线:纯补丁不得抢在回退合并前建条目(否则回退完整条目被跳过、
  77. // 其余分项价变 0 少收);目录/回退都没有的模型作为独立条目并入。
  78. func TestPricingOverride_LoadPipelineAddsNewModelAndPatchesFallbackOnly(t *testing.T) {
  79. dir := t.TempDir()
  80. catalogPath := filepath.Join(dir, "catalog.json")
  81. require.NoError(t, os.WriteFile(catalogPath, []byte(`{
  82. "remote-model": {"litellm_provider": "test", "mode": "chat",
  83. "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06}
  84. }`), 0644))
  85. fallbackPath := filepath.Join(dir, "fallback.json")
  86. require.NoError(t, os.WriteFile(fallbackPath, []byte(`{
  87. "fallback-only-model": {"litellm_provider": "test", "mode": "chat",
  88. "input_cost_per_token": 4e-06, "output_cost_per_token": 8e-06,
  89. "cache_read_input_token_cost": 4e-07}
  90. }`), 0644))
  91. overridePath := filepath.Join(dir, "overrides.json")
  92. require.NoError(t, os.WriteFile(overridePath, []byte(`{
  93. "fallback-only-model": {"input_cost_per_token": 9e-06},
  94. "override-new-model": {"litellm_provider": "test", "mode": "chat",
  95. "input_cost_per_token": 5e-06, "output_cost_per_token": 1e-05}
  96. }`), 0644))
  97. svc := &PricingService{cfg: &config.Config{}}
  98. svc.cfg.Pricing.FallbackFile = fallbackPath
  99. svc.cfg.Pricing.OverrideFile = overridePath
  100. require.NoError(t, svc.loadPricingData(catalogPath))
  101. patched := svc.pricingData["fallback-only-model"]
  102. require.NotNil(t, patched)
  103. require.InDelta(t, 9e-6, patched.InputCostPerToken, 1e-12)
  104. require.InDelta(t, 8e-6, patched.OutputCostPerToken, 1e-12, "回退条目的其余字段必须保留")
  105. require.InDelta(t, 4e-7, patched.CacheReadInputTokenCost, 1e-12)
  106. added := svc.pricingData["override-new-model"]
  107. require.NotNil(t, added)
  108. require.InDelta(t, 5e-6, added.InputCostPerToken, 1e-12)
  109. require.InDelta(t, 1e-5, added.OutputCostPerToken, 1e-12)
  110. require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
  111. }
  112. // 拼错模型名(或纯补丁落在不存在的模型上)会被有效性过滤丢弃,必须有哨兵 WARN。
  113. func TestPricingOverride_IneffectiveEntryWarns(t *testing.T) {
  114. logSink, restore := captureStructuredLog(t)
  115. defer restore()
  116. dir := t.TempDir()
  117. catalogPath := filepath.Join(dir, "catalog.json")
  118. require.NoError(t, os.WriteFile(catalogPath, []byte(`{
  119. "remote-model": {"litellm_provider": "test", "mode": "chat", "input_cost_per_token": 1e-06}
  120. }`), 0644))
  121. overridePath := filepath.Join(dir, "overrides.json")
  122. require.NoError(t, os.WriteFile(overridePath, []byte(`{
  123. "typo-model": {"long_context_input_token_threshold": 0}
  124. }`), 0644))
  125. svc := &PricingService{cfg: &config.Config{}}
  126. svc.cfg.Pricing.OverrideFile = overridePath
  127. require.NoError(t, svc.loadPricingData(catalogPath))
  128. require.NotContains(t, svc.pricingData, "typo-model")
  129. require.True(t, logSink.ContainsMessageAtLevel("override had no effect for 1 model(s): typo-model", "warn"))
  130. }
  131. func TestPricingOverride_NonObjectEntryKeepsCatalogEntry(t *testing.T) {
  132. svc := newPricingServiceWithOverride(t, `{"gpt-5.5": "oops"}`)
  133. data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
  134. require.NoError(t, err)
  135. require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold, "非法补丁忽略,目录条目原样保留")
  136. require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12)
  137. }
  138. func TestPricingOverride_MissingOrInvalidFileIsIgnored(t *testing.T) {
  139. t.Run("missing file", func(t *testing.T) {
  140. svc := &PricingService{cfg: &config.Config{}}
  141. svc.cfg.Pricing.OverrideFile = filepath.Join(t.TempDir(), "absent.json")
  142. data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
  143. require.NoError(t, err)
  144. require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold)
  145. })
  146. t.Run("invalid json", func(t *testing.T) {
  147. svc := newPricingServiceWithOverride(t, `{invalid`)
  148. data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
  149. require.NoError(t, err)
  150. require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold)
  151. })
  152. }
  153. // 对真实出厂目录快照关闭 gpt-5.5 阶梯:计费视角阈值归零、基础价不变,
  154. // 其他模型(gpt-5.4)的目录阶梯不受影响。
  155. func TestPricingOverride_DisablesGPT55LadderOnDefaultCatalog(t *testing.T) {
  156. body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
  157. require.NoError(t, err)
  158. svc := newPricingServiceWithOverride(t, `{
  159. "gpt-5.5": {"long_context_input_token_threshold": 0},
  160. "gpt-5.5-2026-04-23": {"long_context_input_token_threshold": 0}
  161. }`)
  162. data, err := svc.parsePricingData(body)
  163. require.NoError(t, err)
  164. svc.pricingData = data
  165. billing := NewBillingService(&config.Config{}, svc)
  166. for _, model := range []string{"gpt-5.5", "gpt-5.5-2026-04-23"} {
  167. pricing, err := billing.GetModelPricing(model)
  168. require.NoError(t, err)
  169. require.Zero(t, pricing.LongContextInputThreshold, model)
  170. require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12, model)
  171. }
  172. pricing, err := billing.GetModelPricing("gpt-5.4")
  173. require.NoError(t, err)
  174. require.Equal(t, 272000, pricing.LongContextInputThreshold, "其他模型的目录阶梯不受影响")
  175. }