Files
sub2api/backend/internal/service/pricing_service_override_test.go
T
feeeei 593fc93659 feat(pricing): 新增 override_file 价格目录覆盖补丁
新增 pricing.override_file 配置(默认关闭):稀疏补丁文件按 JSON 字段
浅合并覆盖目录/回退数据,作为最高优先级数据源,用于本地自定义官方
目录条目(如显式 long_context_input_token_threshold=0 关闭某模型阶梯)
而无需自建价格镜像:

- applyPricingOverrides 挂在 parsePricingData 解析入口,只修补已存在
  条目(补丁字段覆盖同名字段,值为 null 删除字段);目录、回退与灾备
  路径都经该入口,覆盖语义三处一致
- mergeOverrideOnlyModels 在回退合并后把目录/回退都没有的模型作为
  独立条目并入(复用主解析路径,须自带价格字段通过有效性过滤);
  不在主解析时抢先建条目,避免纯补丁挡住回退完整条目、其余分项价
  静默变 0
- 最终未生效的条目(模型名拼错、纯补丁落在不存在的模型上)打 WARN
  哨兵;override 文件缺失/损坏仅跳过合并,不影响目录加载
- 生效时机为重启或下次目录下载,与 fallback_file 一致;fallback_file
  只补缺失模型的语义不变
2026-09-01 01:26:55 +08:00

202 lines
8.9 KiB
Go

package service
import (
"os"
"path/filepath"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// gpt55OverrideCatalogJSON 镜像真实目录形态:长上下文以 above_272k 绝对价字段表达。
const gpt55OverrideCatalogJSON = `{
"gpt-5.5": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1.25e-05,
"output_cost_per_token": 3e-05, "output_cost_per_token_priority": 7.5e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token_above_272k_tokens": 1e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05,
"cache_read_input_token_cost_above_272k_tokens": 1e-06},
"gpt-5.4": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05,
"cache_read_input_token_cost": 2.5e-07,
"input_cost_per_token_above_272k_tokens": 5e-06,
"output_cost_per_token_above_272k_tokens": 2.25e-05}
}`
func newPricingServiceWithOverride(t *testing.T, overrideJSON string) *PricingService {
t.Helper()
path := filepath.Join(t.TempDir(), "overrides.json")
require.NoError(t, os.WriteFile(path, []byte(overrideJSON), 0644))
svc := &PricingService{cfg: &config.Config{}}
svc.cfg.Pricing.OverrideFile = path
return svc
}
// override 的旗舰用例:显式 threshold=0 压住 above 折算,把目录条目的阶梯关成标准价。
func TestPricingOverride_ExplicitZeroThresholdDisablesCatalogLadder(t *testing.T) {
svc := newPricingServiceWithOverride(t, `{"gpt-5.5": {"long_context_input_token_threshold": 0}}`)
data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
require.NoError(t, err)
patched := data["gpt-5.5"]
require.NotNil(t, patched)
require.Zero(t, patched.LongContextInputTokenThreshold)
require.Zero(t, patched.LongContextInputCostMultiplier)
require.InDelta(t, 5e-6, patched.InputCostPerToken, 1e-12, "补丁不得影响基础价")
require.InDelta(t, 3e-5, patched.OutputCostPerToken, 1e-12)
require.Equal(t, 272000, data["gpt-5.4"].LongContextInputTokenThreshold, "未覆盖的模型保持目录阶梯")
svc.pricingData = data
billing := NewBillingService(&config.Config{}, svc)
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000}
cost, err := billing.CalculateCost("gpt-5.5", tokens, 1)
require.NoError(t, err)
require.False(t, cost.LongContextBillingApplied)
require.InDelta(t, 300000*5e-6, cost.InputCost, 1e-10)
require.InDelta(t, 1000*3e-5, cost.OutputCost, 1e-10)
require.InDelta(t, 10000*5e-7, cost.CacheReadCost, 1e-10)
}
func TestPricingOverride_FieldLevelMergeKeepsOtherFields(t *testing.T) {
svc := newPricingServiceWithOverride(t, `{"gpt-5.4": {"input_cost_per_token": 3e-06}}`)
data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
require.NoError(t, err)
patched := data["gpt-5.4"]
require.InDelta(t, 3e-6, patched.InputCostPerToken, 1e-12)
require.InDelta(t, 1.5e-5, patched.OutputCostPerToken, 1e-12, "未覆盖字段保持目录值")
require.Equal(t, "openai", patched.LiteLLMProvider)
require.Equal(t, 272000, patched.LongContextInputTokenThreshold, "above 折算仍生效")
// 折算发生在合并之后:above 价不变、基础价被补丁改小,倍率随之变化。
require.InDelta(t, 5.0/3.0, patched.LongContextInputCostMultiplier, 1e-9)
}
func TestPricingOverride_NullFieldValueRemovesField(t *testing.T) {
svc := newPricingServiceWithOverride(t, `{"gpt-5.5": {
"input_cost_per_token_above_272k_tokens": null,
"output_cost_per_token_above_272k_tokens": null,
"cache_read_input_token_cost_above_272k_tokens": null}}`)
data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
require.NoError(t, err)
require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold, "above 字段删除后不再折算阶梯")
require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12)
}
// 完整加载管线:纯补丁不得抢在回退合并前建条目(否则回退完整条目被跳过、
// 其余分项价变 0 少收);目录/回退都没有的模型作为独立条目并入。
func TestPricingOverride_LoadPipelineAddsNewModelAndPatchesFallbackOnly(t *testing.T) {
dir := t.TempDir()
catalogPath := filepath.Join(dir, "catalog.json")
require.NoError(t, os.WriteFile(catalogPath, []byte(`{
"remote-model": {"litellm_provider": "test", "mode": "chat",
"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06}
}`), 0644))
fallbackPath := filepath.Join(dir, "fallback.json")
require.NoError(t, os.WriteFile(fallbackPath, []byte(`{
"fallback-only-model": {"litellm_provider": "test", "mode": "chat",
"input_cost_per_token": 4e-06, "output_cost_per_token": 8e-06,
"cache_read_input_token_cost": 4e-07}
}`), 0644))
overridePath := filepath.Join(dir, "overrides.json")
require.NoError(t, os.WriteFile(overridePath, []byte(`{
"fallback-only-model": {"input_cost_per_token": 9e-06},
"override-new-model": {"litellm_provider": "test", "mode": "chat",
"input_cost_per_token": 5e-06, "output_cost_per_token": 1e-05}
}`), 0644))
svc := &PricingService{cfg: &config.Config{}}
svc.cfg.Pricing.FallbackFile = fallbackPath
svc.cfg.Pricing.OverrideFile = overridePath
require.NoError(t, svc.loadPricingData(catalogPath))
patched := svc.pricingData["fallback-only-model"]
require.NotNil(t, patched)
require.InDelta(t, 9e-6, patched.InputCostPerToken, 1e-12)
require.InDelta(t, 8e-6, patched.OutputCostPerToken, 1e-12, "回退条目的其余字段必须保留")
require.InDelta(t, 4e-7, patched.CacheReadInputTokenCost, 1e-12)
added := svc.pricingData["override-new-model"]
require.NotNil(t, added)
require.InDelta(t, 5e-6, added.InputCostPerToken, 1e-12)
require.InDelta(t, 1e-5, added.OutputCostPerToken, 1e-12)
require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
}
// 拼错模型名(或纯补丁落在不存在的模型上)会被有效性过滤丢弃,必须有哨兵 WARN。
func TestPricingOverride_IneffectiveEntryWarns(t *testing.T) {
logSink, restore := captureStructuredLog(t)
defer restore()
dir := t.TempDir()
catalogPath := filepath.Join(dir, "catalog.json")
require.NoError(t, os.WriteFile(catalogPath, []byte(`{
"remote-model": {"litellm_provider": "test", "mode": "chat", "input_cost_per_token": 1e-06}
}`), 0644))
overridePath := filepath.Join(dir, "overrides.json")
require.NoError(t, os.WriteFile(overridePath, []byte(`{
"typo-model": {"long_context_input_token_threshold": 0}
}`), 0644))
svc := &PricingService{cfg: &config.Config{}}
svc.cfg.Pricing.OverrideFile = overridePath
require.NoError(t, svc.loadPricingData(catalogPath))
require.NotContains(t, svc.pricingData, "typo-model")
require.True(t, logSink.ContainsMessageAtLevel("override had no effect for 1 model(s): typo-model", "warn"))
}
func TestPricingOverride_NonObjectEntryKeepsCatalogEntry(t *testing.T) {
svc := newPricingServiceWithOverride(t, `{"gpt-5.5": "oops"}`)
data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
require.NoError(t, err)
require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold, "非法补丁忽略,目录条目原样保留")
require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12)
}
func TestPricingOverride_MissingOrInvalidFileIsIgnored(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
svc := &PricingService{cfg: &config.Config{}}
svc.cfg.Pricing.OverrideFile = filepath.Join(t.TempDir(), "absent.json")
data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
require.NoError(t, err)
require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold)
})
t.Run("invalid json", func(t *testing.T) {
svc := newPricingServiceWithOverride(t, `{invalid`)
data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON))
require.NoError(t, err)
require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold)
})
}
// 对真实出厂目录快照关闭 gpt-5.5 阶梯:计费视角阈值归零、基础价不变,
// 其他模型(gpt-5.4)的目录阶梯不受影响。
func TestPricingOverride_DisablesGPT55LadderOnDefaultCatalog(t *testing.T) {
body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
require.NoError(t, err)
svc := newPricingServiceWithOverride(t, `{
"gpt-5.5": {"long_context_input_token_threshold": 0},
"gpt-5.5-2026-04-23": {"long_context_input_token_threshold": 0}
}`)
data, err := svc.parsePricingData(body)
require.NoError(t, err)
svc.pricingData = data
billing := NewBillingService(&config.Config{}, svc)
for _, model := range []string{"gpt-5.5", "gpt-5.5-2026-04-23"} {
pricing, err := billing.GetModelPricing(model)
require.NoError(t, err)
require.Zero(t, pricing.LongContextInputThreshold, model)
require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12, model)
}
pricing, err := billing.GetModelPricing("gpt-5.4")
require.NoError(t, err)
require.Equal(t, 272000, pricing.LongContextInputThreshold, "其他模型的目录阶梯不受影响")
}