mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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 只补缺失模型的语义不变
This commit is contained in:
@@ -667,6 +667,8 @@ type PricingConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
// 回退文件路径
|
||||
FallbackFile string `mapstructure:"fallback_file"`
|
||||
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
|
||||
OverrideFile string `mapstructure:"override_file"`
|
||||
// 更新间隔(小时)
|
||||
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
|
||||
// 哈希校验间隔(分钟)
|
||||
@@ -2284,6 +2286,7 @@ func setDefaults() {
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.data_dir", "./data")
|
||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.override_file", "")
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -382,6 +383,7 @@ func (s *PricingService) downloadPricingData() error {
|
||||
return fmt.Errorf("parse pricing data: %w", err)
|
||||
}
|
||||
data = s.mergeFallbackPricingData(data)
|
||||
data = s.mergeOverrideOnlyModels(data)
|
||||
|
||||
// 保存到本地文件
|
||||
pricingFile := s.getPricingFilePath()
|
||||
@@ -419,6 +421,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
|
||||
if err := json.Unmarshal(body, &rawData); err != nil {
|
||||
return nil, fmt.Errorf("parse raw JSON: %w", err)
|
||||
}
|
||||
rawData = s.applyPricingOverrides(rawData)
|
||||
|
||||
result := make(map[string]*LiteLLMModelPricing)
|
||||
skipped := 0
|
||||
@@ -591,6 +594,124 @@ func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *Lit
|
||||
pricing.LongContextOutputCostMultiplier = outputMultiplier
|
||||
}
|
||||
|
||||
// applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退
|
||||
// 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补
|
||||
// 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后
|
||||
// 统一并入——若在此处抢先建条目,纯 override 条目会挡住回退文件中同名完整条目的合并。
|
||||
func (s *PricingService) applyPricingOverrides(rawData map[string]json.RawMessage) map[string]json.RawMessage {
|
||||
overrides := s.loadPricingOverrideEntries()
|
||||
if len(overrides) == 0 {
|
||||
return rawData
|
||||
}
|
||||
for name, patch := range overrides {
|
||||
base, ok := rawData[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
merged, valid := mergePricingOverrideEntry(base, patch)
|
||||
if !valid {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override entry %q skipped: not a JSON object", name)
|
||||
continue
|
||||
}
|
||||
rawData[name] = merged
|
||||
}
|
||||
return rawData
|
||||
}
|
||||
|
||||
// loadPricingOverrideEntries 读取 override 文件的原始条目。未配置返回 nil;
|
||||
// 读取或解析失败打日志并跳过,不影响目录加载。
|
||||
func (s *PricingService) loadPricingOverrideEntries() map[string]json.RawMessage {
|
||||
if s == nil || s.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimSpace(s.cfg.Pricing.OverrideFile)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err)
|
||||
return nil
|
||||
}
|
||||
var entries map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err)
|
||||
return nil
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// mergePricingOverrideEntry 在 JSON 字段层浅合并:patch 字段覆盖 base 同名字段,
|
||||
// 值为 null 的 patch 字段从结果中删除,base 为空时结果即 patch 本身。
|
||||
// patch 不是 JSON 对象时返回 ok=false。
|
||||
func mergePricingOverrideEntry(base, patch json.RawMessage) (json.RawMessage, bool) {
|
||||
var patchFields map[string]any
|
||||
if err := json.Unmarshal(patch, &patchFields); err != nil || patchFields == nil {
|
||||
return nil, false
|
||||
}
|
||||
merged := make(map[string]any, len(patchFields))
|
||||
if len(base) > 0 {
|
||||
// base 非对象时忽略,仅以 patch 为准。
|
||||
if err := json.Unmarshal(base, &merged); err != nil {
|
||||
merged = make(map[string]any, len(patchFields))
|
||||
}
|
||||
}
|
||||
for k, v := range patchFields {
|
||||
if v == nil {
|
||||
delete(merged, k)
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
out, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// mergeOverrideOnlyModels 把 override 中目录/回退两层都不存在的模型作为独立条目并入
|
||||
// (条目须自带价格字段才能通过有效性过滤),并对最终仍未生效的条目打 WARN:
|
||||
// 模型名拼错、或纯补丁条目落在不存在的模型上时会被静默丢弃,让"已改价/已关阶梯"
|
||||
// 的运营预期与实际计费脱节,这里是唯一的哨兵。
|
||||
func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing {
|
||||
overrides := s.loadPricingOverrideEntries()
|
||||
if len(overrides) == 0 {
|
||||
return data
|
||||
}
|
||||
if data == nil {
|
||||
data = make(map[string]*LiteLLMModelPricing)
|
||||
}
|
||||
leftover := make(map[string]json.RawMessage)
|
||||
for name, patch := range overrides {
|
||||
if _, ok := data[name]; !ok {
|
||||
leftover[name] = patch
|
||||
}
|
||||
}
|
||||
if len(leftover) == 0 {
|
||||
return data
|
||||
}
|
||||
// 复用主解析路径(含 above_XXXk 折算与有效性过滤);applyPricingOverrides
|
||||
// 对已存在条目做的自我修补是幂等的,不会二次改值。
|
||||
if body, err := json.Marshal(leftover); err == nil {
|
||||
if parsed, err := s.parsePricingData(body); err == nil {
|
||||
maps.Copy(data, parsed)
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for name := range leftover {
|
||||
if _, ok := data[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return data
|
||||
}
|
||||
sort.Strings(missing)
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override had no effect for %d model(s): %s (unknown model name, or patch-only entry without price fields)", len(missing), strings.Join(missing, ", "))
|
||||
return data
|
||||
}
|
||||
|
||||
// loadPricingData 从本地文件加载价格数据
|
||||
func (s *PricingService) loadPricingData(filePath string) error {
|
||||
data, err := os.ReadFile(filePath)
|
||||
@@ -604,6 +725,7 @@ func (s *PricingService) loadPricingData(filePath string) error {
|
||||
return fmt.Errorf("parse pricing data: %w", err)
|
||||
}
|
||||
pricingData = s.mergeFallbackPricingData(pricingData)
|
||||
pricingData = s.mergeOverrideOnlyModels(pricingData)
|
||||
|
||||
// 计算哈希
|
||||
hash := sha256.Sum256(data)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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, "其他模型的目录阶梯不受影响")
|
||||
}
|
||||
@@ -1066,6 +1066,14 @@ pricing:
|
||||
# Fallback pricing file
|
||||
# 备用定价文件
|
||||
fallback_file: "./resources/model-pricing/model_prices_and_context_window.json"
|
||||
# Override patch file (optional, highest priority): entries are shallow-merged
|
||||
# field-by-field over catalog/fallback data; a null field value removes that field.
|
||||
# Model names must match catalog keys exactly. Edits take effect on restart or
|
||||
# the next catalog download.
|
||||
# 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据,
|
||||
# 字段值为 null 表示删除该字段。模型名须与目录键完全一致。
|
||||
# 修改后重启或下次目录下载时生效。
|
||||
# override_file: "./data/model_pricing_overrides.json"
|
||||
# Update interval in hours
|
||||
# 更新间隔(小时)
|
||||
update_interval_hours: 24
|
||||
|
||||
Reference in New Issue
Block a user