pricing_service_hot_reload_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. package service
  2. import (
  3. "context"
  4. "os"
  5. "path/filepath"
  6. "strconv"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/stretchr/testify/require"
  11. )
  12. const hotReloadCatalogJSON = `{
  13. "remote-model": {"litellm_provider": "test", "mode": "chat",
  14. "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06}
  15. }`
  16. func hotReloadModelJSON(name string, input, output float64) string {
  17. return `"` + name + `": {"litellm_provider": "test", "mode": "chat",
  18. "input_cost_per_token": ` + formatFloat(input) + `, "output_cost_per_token": ` + formatFloat(output) + `}`
  19. }
  20. func formatFloat(v float64) string {
  21. return strconv.FormatFloat(v, 'g', -1, 64)
  22. }
  23. // newHotReloadPricingService 在数据目录里放好目录缓存与 fallback/override 文件并完成首次加载。
  24. // 传空串表示不配置对应文件。
  25. func newHotReloadPricingService(t *testing.T, fallbackJSON, overrideJSON string) *PricingService {
  26. t.Helper()
  27. dir := t.TempDir()
  28. svc := &PricingService{cfg: &config.Config{}}
  29. svc.cfg.Pricing.DataDir = dir
  30. require.NoError(t, os.WriteFile(svc.getPricingFilePath(), []byte(hotReloadCatalogJSON), 0644))
  31. if fallbackJSON != "" {
  32. svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json")
  33. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(fallbackJSON), 0644))
  34. }
  35. if overrideJSON != "" {
  36. svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json")
  37. require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(overrideJSON), 0644))
  38. }
  39. require.NoError(t, svc.loadPricingData(svc.getPricingFilePath()))
  40. return svc
  41. }
  42. func TestPricingCustomFilesFingerprint(t *testing.T) {
  43. svc := &PricingService{cfg: &config.Config{}}
  44. require.Empty(t, svc.customPricingFilesFingerprint(), "未配置文件返回空串")
  45. dir := t.TempDir()
  46. svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json")
  47. missing := svc.customPricingFilesFingerprint()
  48. require.NotEmpty(t, missing)
  49. require.Equal(t, missing, svc.customPricingFilesFingerprint(), "同一状态下指纹稳定")
  50. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{}`), 0644))
  51. present := svc.customPricingFilesFingerprint()
  52. require.NotEqual(t, missing, present, "文件出现即视为变化")
  53. svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json")
  54. require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644))
  55. require.NotEqual(t, present, svc.customPricingFilesFingerprint(), "override 内容参与指纹")
  56. }
  57. func TestPricingHotReload_FallbackChangeRebuildsWithoutTouchingSyncAnchor(t *testing.T) {
  58. svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "")
  59. require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12)
  60. require.Nil(t, svc.pricingData["custom-b"])
  61. require.NotEmpty(t, svc.customFilesHash)
  62. anchor, updated := svc.localHash, svc.lastUpdated
  63. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+
  64. hotReloadModelJSON("custom-a", 5e-6, 8e-6)+`,`+
  65. hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644))
  66. svc.reloadIfCustomFilesChanged()
  67. require.InDelta(t, 5e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "改价即时生效")
  68. require.NotNil(t, svc.pricingData["custom-b"], "新模型即时并入")
  69. require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响")
  70. require.Equal(t, anchor, svc.localHash, "热重载不得改动远程同步锚点")
  71. require.Equal(t, updated, svc.lastUpdated)
  72. require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash)
  73. }
  74. func TestPricingHotReload_UnchangedFilesSkipRebuild(t *testing.T) {
  75. svc := newHotReloadPricingService(t,
  76. `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`,
  77. `{"remote-model": {"input_cost_per_token": 7e-06}}`)
  78. svc.pricingData["sentinel"] = &LiteLLMModelPricing{}
  79. svc.reloadIfCustomFilesChanged()
  80. require.Contains(t, svc.pricingData, "sentinel", "指纹未变时不得重建")
  81. }
  82. func TestPricingHotReload_OverrideChangePatchesCatalogAndAddsModels(t *testing.T) {
  83. svc := newHotReloadPricingService(t, "", `{"remote-model": {"input_cost_per_token": 7e-06}}`)
  84. require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
  85. require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{
  86. "remote-model": {"input_cost_per_token": 9e-06},
  87. `+hotReloadModelJSON("override-new-model", 5e-6, 1e-5)+`}`), 0644))
  88. svc.reloadIfCustomFilesChanged()
  89. require.InDelta(t, 9e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
  90. require.InDelta(t, 2e-6, svc.pricingData["remote-model"].OutputCostPerToken, 1e-12, "未覆盖字段保持目录值")
  91. require.NotNil(t, svc.pricingData["override-new-model"])
  92. // 清空补丁:目录条目回到原价,补丁新增的模型随之消失。
  93. require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644))
  94. svc.reloadIfCustomFilesChanged()
  95. require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
  96. require.Nil(t, svc.pricingData["override-new-model"])
  97. }
  98. func TestPricingHotReload_InvalidFileKeepsCurrentDataUntilFixed(t *testing.T) {
  99. svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "")
  100. before := svc.customFilesHash
  101. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{"custom-a": {"input_cost_per_token": `), 0644))
  102. svc.reloadIfCustomFilesChanged()
  103. require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "半写文件不得替换数据")
  104. require.Equal(t, before, svc.customFilesHash, "指纹不更新,下一轮继续尝试")
  105. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 6e-6, 8e-6)+`}`), 0644))
  106. svc.reloadIfCustomFilesChanged()
  107. require.InDelta(t, 6e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "文件修好后正常重建")
  108. require.NotEqual(t, before, svc.customFilesHash)
  109. }
  110. // 删除文件等于清空该层:override 补丁撤销、fallback 独有模型消失,都在下一轮比对时生效,
  111. // 且缺失状态被记录,之后不会每轮重建。
  112. func TestPricingHotReload_DeletedFileDropsItsLayer(t *testing.T) {
  113. svc := newHotReloadPricingService(t,
  114. `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`,
  115. `{"remote-model": {"input_cost_per_token": 7e-06}}`)
  116. require.NotNil(t, svc.pricingData["custom-a"])
  117. require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
  118. require.NoError(t, os.Remove(svc.cfg.Pricing.OverrideFile))
  119. svc.reloadIfCustomFilesChanged()
  120. require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "删除 override 后目录条目回到原价")
  121. require.NotNil(t, svc.pricingData["custom-a"], "fallback 层不受影响")
  122. require.NoError(t, os.Remove(svc.cfg.Pricing.FallbackFile))
  123. svc.reloadIfCustomFilesChanged()
  124. require.Nil(t, svc.pricingData["custom-a"], "删除 fallback 后其独有模型消失")
  125. require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响")
  126. svc.pricingData["sentinel"] = &LiteLLMModelPricing{}
  127. svc.reloadIfCustomFilesChanged()
  128. require.Contains(t, svc.pricingData, "sentinel", "缺失状态已记录,不得每轮重建")
  129. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`), 0644))
  130. svc.reloadIfCustomFilesChanged()
  131. require.NotNil(t, svc.pricingData["custom-a"], "文件重新出现即恢复")
  132. }
  133. type stubPricingRemoteClient struct{ body string }
  134. func (c stubPricingRemoteClient) FetchPricingJSON(context.Context, string) ([]byte, error) {
  135. return []byte(c.body), nil
  136. }
  137. func (c stubPricingRemoteClient) FetchHashText(context.Context, string) (string, error) {
  138. return "", nil
  139. }
  140. // 远程下载重建后指纹必须同步到当前文件内容,否则下一轮定时比对会多做一次无意义重载。
  141. func TestPricingHotReload_DownloadRefreshesFingerprint(t *testing.T) {
  142. svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "")
  143. svc.cfg.Pricing.RemoteURL = "https://example.com/pricing.json"
  144. svc.remoteClient = stubPricingRemoteClient{body: hotReloadCatalogJSON}
  145. require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644))
  146. require.NoError(t, svc.downloadPricingData())
  147. require.NotNil(t, svc.pricingData["custom-b"])
  148. require.Nil(t, svc.pricingData["custom-a"])
  149. require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash)
  150. svc.pricingData["sentinel"] = &LiteLLMModelPricing{}
  151. svc.reloadIfCustomFilesChanged()
  152. require.Contains(t, svc.pricingData, "sentinel", "下载已消化文件变化,不得再次重建")
  153. }
  154. // 只配置了 fallback/override 而没有 remote_url 时调度器也要运行,否则文件改动无人比对。
  155. func TestPricingSchedulerStartsForCustomFilesWithoutRemoteURL(t *testing.T) {
  156. svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{
  157. RemoteURL: "",
  158. FallbackFile: filepath.Join(t.TempDir(), "fallback.json"),
  159. }}, nil)
  160. svc.startUpdateScheduler()
  161. done := make(chan struct{})
  162. go func() {
  163. svc.wg.Wait()
  164. close(done)
  165. }()
  166. select {
  167. case <-done:
  168. t.Fatal("custom file watch must keep the scheduler running")
  169. case <-time.After(50 * time.Millisecond):
  170. }
  171. svc.Stop()
  172. select {
  173. case <-done:
  174. case <-time.After(time.Second):
  175. t.Fatal("scheduler must exit after Stop")
  176. }
  177. }