mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
feat(pricing): fallback/override 定价文件按内容哈希随定时器热重载
PricingService 的定时器(hash_check_interval_minutes,默认 10 分钟)每轮先做 远程目录哈希同步,再比对 fallback_file/override_file 两个文件内容的联合 sha256 (customFilesHash),变化时从本地 model_pricing.json 重建叠加层,只替换内存数据 与指纹,不改远程同步锚点 localHash。只配置了自定义文件而 remote_url 为空时 调度器同样启动。 - 三条重建路径(启动加载、远程下载、热重载)统一走 buildPricingData,指纹在合并 读取之前采样,并发改文件只会落后于数据、下一轮补重建 - 删除文件视为该层清空,下一轮生效;文件存在但不可读或不是 JSON 对象时保留旧数据、 不更新指纹,每轮重试并告警 - config.example.yaml 同步生效说明;新增 pricing_service_hot_reload_test.go
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -170,6 +171,9 @@ type PricingService struct {
|
||||
pricingData map[string]*LiteLLMModelPricing
|
||||
lastUpdated time.Time
|
||||
localHash string
|
||||
// fallback/override 文件在最近一次成功重建时的内容指纹,定时器据此判断是否
|
||||
// 需要从本地目录缓存重建叠加层。
|
||||
customFilesHash string
|
||||
|
||||
// 停止信号
|
||||
stopCh chan struct{}
|
||||
@@ -216,14 +220,21 @@ func (s *PricingService) Stop() {
|
||||
logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped")
|
||||
}
|
||||
|
||||
// startUpdateScheduler 启动定时更新调度器
|
||||
// startUpdateScheduler 启动定时调度器:每个周期先做远程目录哈希同步(配置了 remote_url 时),
|
||||
// 再比对 fallback/override 文件指纹做本地热重载(配置了任一文件时)。两者都未配置则不启动。
|
||||
func (s *PricingService) startUpdateScheduler() {
|
||||
if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" {
|
||||
if s == nil || s.cfg == nil {
|
||||
return
|
||||
}
|
||||
remoteEnabled := strings.TrimSpace(s.cfg.Pricing.RemoteURL) != ""
|
||||
watchCustom := s.hasCustomPricingFiles()
|
||||
if !remoteEnabled {
|
||||
logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty")
|
||||
}
|
||||
if !remoteEnabled && !watchCustom {
|
||||
return
|
||||
}
|
||||
|
||||
// 定期检查哈希更新
|
||||
hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute
|
||||
if hashInterval < time.Minute {
|
||||
hashInterval = 10 * time.Minute
|
||||
@@ -238,8 +249,13 @@ func (s *PricingService) startUpdateScheduler() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := s.syncWithRemote(); err != nil {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err)
|
||||
if remoteEnabled {
|
||||
if err := s.syncWithRemote(); err != nil {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
if watchCustom {
|
||||
s.reloadIfCustomFilesChanged()
|
||||
}
|
||||
case <-s.stopCh:
|
||||
return
|
||||
@@ -247,7 +263,7 @@ func (s *PricingService) startUpdateScheduler() {
|
||||
}
|
||||
}()
|
||||
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v)", hashInterval)
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v, remote sync=%t, custom file watch=%t)", hashInterval, remoteEnabled, watchCustom)
|
||||
}
|
||||
|
||||
// checkAndUpdatePricing 检查并更新价格数据
|
||||
@@ -348,6 +364,101 @@ func (s *PricingService) syncWithRemote() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasCustomPricingFiles 报告是否配置了 fallback/override 任一文件路径(不要求文件存在)。
|
||||
func (s *PricingService) hasCustomPricingFiles() bool {
|
||||
if s == nil || s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(s.cfg.Pricing.FallbackFile) != "" || strings.TrimSpace(s.cfg.Pricing.OverrideFile) != ""
|
||||
}
|
||||
|
||||
// customPricingFilesFingerprint 返回 fallback、override 两个文件当前内容的联合 sha256。
|
||||
// 每个文件以"长度前缀 + 正文"参与计算,不可读的文件按空正文处理;未配置任何文件返回空串。
|
||||
func (s *PricingService) customPricingFilesFingerprint() string {
|
||||
if !s.hasCustomPricingFiles() {
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} {
|
||||
var body []byte
|
||||
if p := strings.TrimSpace(path); p != "" {
|
||||
body, _ = os.ReadFile(p)
|
||||
}
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(body)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write(body)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// validateCustomPricingFiles 要求每个已配置且存在的 fallback/override 文件可读且为 JSON
|
||||
// 对象,任一不满足即返回带路径的错误;文件不存在视为该层为空,属合法状态。
|
||||
func (s *PricingService) validateCustomPricingFiles() error {
|
||||
for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(p)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var entries map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
return fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadIfCustomFilesChanged 比对 fallback/override 文件指纹,与最近一次重建时不同则从
|
||||
// 本地目录缓存重建内存数据。文件被删除视为该层清空,照常重建;文件存在但不可读或不是
|
||||
// JSON 对象时保留当前数据且不更新指纹,下一轮会再次尝试并重复告警。目录正文与远程同步
|
||||
// 锚点(localHash)不受本路径影响。
|
||||
func (s *PricingService) reloadIfCustomFilesChanged() {
|
||||
fingerprint := s.customPricingFilesFingerprint()
|
||||
s.mu.RLock()
|
||||
unchanged := fingerprint == s.customFilesHash
|
||||
s.mu.RUnlock()
|
||||
if unchanged {
|
||||
return
|
||||
}
|
||||
if err := s.validateCustomPricingFiles(); err != nil {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but previous data kept: %v", err)
|
||||
return
|
||||
}
|
||||
if err := s.reloadCustomPricingLayers(); err != nil {
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but reload failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// reloadCustomPricingLayers 读取本地目录缓存并重新叠加 fallback/override,只替换内存数据
|
||||
// 与叠加层指纹。
|
||||
func (s *PricingService) reloadCustomPricingLayers() error {
|
||||
pricingFile := s.getPricingFilePath()
|
||||
body, err := os.ReadFile(pricingFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read file failed: %w", err)
|
||||
}
|
||||
data, fingerprint, err := s.buildPricingData(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse pricing data: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
warnDroppedLongContextLadders(s.pricingData, data)
|
||||
s.pricingData = data
|
||||
s.customFilesHash = fingerprint
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing files changed, reloaded %d models from %s", len(data), pricingFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadPricingData 从远程下载价格数据
|
||||
func (s *PricingService) downloadPricingData() error {
|
||||
remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL)
|
||||
@@ -382,13 +493,10 @@ func (s *PricingService) downloadPricingData() error {
|
||||
remoteHash[:min(8, len(remoteHash))], dataHashStr[:8])
|
||||
}
|
||||
|
||||
// 解析JSON数据(使用灵活的解析方式)
|
||||
data, err := s.parsePricingData(body)
|
||||
data, customFilesHash, err := s.buildPricingData(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse pricing data: %w", err)
|
||||
}
|
||||
data = s.mergeFallbackPricingData(data)
|
||||
data = s.mergeOverrideOnlyModels(data)
|
||||
|
||||
// 保存到本地文件
|
||||
pricingFile := s.getPricingFilePath()
|
||||
@@ -413,6 +521,7 @@ func (s *PricingService) downloadPricingData() error {
|
||||
s.pricingData = data
|
||||
s.lastUpdated = time.Now()
|
||||
s.localHash = syncHash
|
||||
s.customFilesHash = customFilesHash
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data))
|
||||
@@ -798,6 +907,20 @@ func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPr
|
||||
return data
|
||||
}
|
||||
|
||||
// buildPricingData 解析目录正文并依次叠加 fallback、override 两层,返回合并结果与
|
||||
// 叠加层文件指纹。指纹在合并读取之前采样:并发改文件只会让存下的指纹落后于实际
|
||||
// 合并的数据、不会领先,下一轮定时比对因此会再次重建。
|
||||
func (s *PricingService) buildPricingData(body []byte) (map[string]*LiteLLMModelPricing, string, error) {
|
||||
fingerprint := s.customPricingFilesFingerprint()
|
||||
data, err := s.parsePricingData(body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data = s.mergeFallbackPricingData(data)
|
||||
data = s.mergeOverrideOnlyModels(data)
|
||||
return data, fingerprint, nil
|
||||
}
|
||||
|
||||
// loadPricingData 从本地文件加载价格数据
|
||||
func (s *PricingService) loadPricingData(filePath string) error {
|
||||
data, err := os.ReadFile(filePath)
|
||||
@@ -805,13 +928,10 @@ func (s *PricingService) loadPricingData(filePath string) error {
|
||||
return fmt.Errorf("read file failed: %w", err)
|
||||
}
|
||||
|
||||
// 使用灵活的解析方式
|
||||
pricingData, err := s.parsePricingData(data)
|
||||
pricingData, customFilesHash, err := s.buildPricingData(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse pricing data: %w", err)
|
||||
}
|
||||
pricingData = s.mergeFallbackPricingData(pricingData)
|
||||
pricingData = s.mergeOverrideOnlyModels(pricingData)
|
||||
|
||||
// 计算哈希
|
||||
hash := sha256.Sum256(data)
|
||||
@@ -821,6 +941,7 @@ func (s *PricingService) loadPricingData(filePath string) error {
|
||||
warnDroppedLongContextLadders(s.pricingData, pricingData)
|
||||
s.pricingData = pricingData
|
||||
s.localHash = hashStr
|
||||
s.customFilesHash = customFilesHash
|
||||
|
||||
info, _ := os.Stat(filePath)
|
||||
if info != nil {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const hotReloadCatalogJSON = `{
|
||||
"remote-model": {"litellm_provider": "test", "mode": "chat",
|
||||
"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06}
|
||||
}`
|
||||
|
||||
func hotReloadModelJSON(name string, input, output float64) string {
|
||||
return `"` + name + `": {"litellm_provider": "test", "mode": "chat",
|
||||
"input_cost_per_token": ` + formatFloat(input) + `, "output_cost_per_token": ` + formatFloat(output) + `}`
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// newHotReloadPricingService 在数据目录里放好目录缓存与 fallback/override 文件并完成首次加载。
|
||||
// 传空串表示不配置对应文件。
|
||||
func newHotReloadPricingService(t *testing.T, fallbackJSON, overrideJSON string) *PricingService {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
svc := &PricingService{cfg: &config.Config{}}
|
||||
svc.cfg.Pricing.DataDir = dir
|
||||
require.NoError(t, os.WriteFile(svc.getPricingFilePath(), []byte(hotReloadCatalogJSON), 0644))
|
||||
if fallbackJSON != "" {
|
||||
svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json")
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(fallbackJSON), 0644))
|
||||
}
|
||||
if overrideJSON != "" {
|
||||
svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json")
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(overrideJSON), 0644))
|
||||
}
|
||||
require.NoError(t, svc.loadPricingData(svc.getPricingFilePath()))
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestPricingCustomFilesFingerprint(t *testing.T) {
|
||||
svc := &PricingService{cfg: &config.Config{}}
|
||||
require.Empty(t, svc.customPricingFilesFingerprint(), "未配置文件返回空串")
|
||||
|
||||
dir := t.TempDir()
|
||||
svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json")
|
||||
missing := svc.customPricingFilesFingerprint()
|
||||
require.NotEmpty(t, missing)
|
||||
require.Equal(t, missing, svc.customPricingFilesFingerprint(), "同一状态下指纹稳定")
|
||||
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{}`), 0644))
|
||||
present := svc.customPricingFilesFingerprint()
|
||||
require.NotEqual(t, missing, present, "文件出现即视为变化")
|
||||
|
||||
svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json")
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644))
|
||||
require.NotEqual(t, present, svc.customPricingFilesFingerprint(), "override 内容参与指纹")
|
||||
}
|
||||
|
||||
func TestPricingHotReload_FallbackChangeRebuildsWithoutTouchingSyncAnchor(t *testing.T) {
|
||||
svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "")
|
||||
require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12)
|
||||
require.Nil(t, svc.pricingData["custom-b"])
|
||||
require.NotEmpty(t, svc.customFilesHash)
|
||||
anchor, updated := svc.localHash, svc.lastUpdated
|
||||
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+
|
||||
hotReloadModelJSON("custom-a", 5e-6, 8e-6)+`,`+
|
||||
hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
|
||||
require.InDelta(t, 5e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "改价即时生效")
|
||||
require.NotNil(t, svc.pricingData["custom-b"], "新模型即时并入")
|
||||
require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响")
|
||||
require.Equal(t, anchor, svc.localHash, "热重载不得改动远程同步锚点")
|
||||
require.Equal(t, updated, svc.lastUpdated)
|
||||
require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash)
|
||||
}
|
||||
|
||||
func TestPricingHotReload_UnchangedFilesSkipRebuild(t *testing.T) {
|
||||
svc := newHotReloadPricingService(t,
|
||||
`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`,
|
||||
`{"remote-model": {"input_cost_per_token": 7e-06}}`)
|
||||
svc.pricingData["sentinel"] = &LiteLLMModelPricing{}
|
||||
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
|
||||
require.Contains(t, svc.pricingData, "sentinel", "指纹未变时不得重建")
|
||||
}
|
||||
|
||||
func TestPricingHotReload_OverrideChangePatchesCatalogAndAddsModels(t *testing.T) {
|
||||
svc := newHotReloadPricingService(t, "", `{"remote-model": {"input_cost_per_token": 7e-06}}`)
|
||||
require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
|
||||
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{
|
||||
"remote-model": {"input_cost_per_token": 9e-06},
|
||||
`+hotReloadModelJSON("override-new-model", 5e-6, 1e-5)+`}`), 0644))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
|
||||
require.InDelta(t, 9e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
|
||||
require.InDelta(t, 2e-6, svc.pricingData["remote-model"].OutputCostPerToken, 1e-12, "未覆盖字段保持目录值")
|
||||
require.NotNil(t, svc.pricingData["override-new-model"])
|
||||
|
||||
// 清空补丁:目录条目回到原价,补丁新增的模型随之消失。
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
|
||||
require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
|
||||
require.Nil(t, svc.pricingData["override-new-model"])
|
||||
}
|
||||
|
||||
func TestPricingHotReload_InvalidFileKeepsCurrentDataUntilFixed(t *testing.T) {
|
||||
svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "")
|
||||
before := svc.customFilesHash
|
||||
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{"custom-a": {"input_cost_per_token": `), 0644))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "半写文件不得替换数据")
|
||||
require.Equal(t, before, svc.customFilesHash, "指纹不更新,下一轮继续尝试")
|
||||
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 6e-6, 8e-6)+`}`), 0644))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.InDelta(t, 6e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "文件修好后正常重建")
|
||||
require.NotEqual(t, before, svc.customFilesHash)
|
||||
}
|
||||
|
||||
// 删除文件等于清空该层:override 补丁撤销、fallback 独有模型消失,都在下一轮比对时生效,
|
||||
// 且缺失状态被记录,之后不会每轮重建。
|
||||
func TestPricingHotReload_DeletedFileDropsItsLayer(t *testing.T) {
|
||||
svc := newHotReloadPricingService(t,
|
||||
`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`,
|
||||
`{"remote-model": {"input_cost_per_token": 7e-06}}`)
|
||||
require.NotNil(t, svc.pricingData["custom-a"])
|
||||
require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12)
|
||||
|
||||
require.NoError(t, os.Remove(svc.cfg.Pricing.OverrideFile))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "删除 override 后目录条目回到原价")
|
||||
require.NotNil(t, svc.pricingData["custom-a"], "fallback 层不受影响")
|
||||
|
||||
require.NoError(t, os.Remove(svc.cfg.Pricing.FallbackFile))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.Nil(t, svc.pricingData["custom-a"], "删除 fallback 后其独有模型消失")
|
||||
require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响")
|
||||
|
||||
svc.pricingData["sentinel"] = &LiteLLMModelPricing{}
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.Contains(t, svc.pricingData, "sentinel", "缺失状态已记录,不得每轮重建")
|
||||
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`), 0644))
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.NotNil(t, svc.pricingData["custom-a"], "文件重新出现即恢复")
|
||||
}
|
||||
|
||||
type stubPricingRemoteClient struct{ body string }
|
||||
|
||||
func (c stubPricingRemoteClient) FetchPricingJSON(context.Context, string) ([]byte, error) {
|
||||
return []byte(c.body), nil
|
||||
}
|
||||
|
||||
func (c stubPricingRemoteClient) FetchHashText(context.Context, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 远程下载重建后指纹必须同步到当前文件内容,否则下一轮定时比对会多做一次无意义重载。
|
||||
func TestPricingHotReload_DownloadRefreshesFingerprint(t *testing.T) {
|
||||
svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "")
|
||||
svc.cfg.Pricing.RemoteURL = "https://example.com/pricing.json"
|
||||
svc.remoteClient = stubPricingRemoteClient{body: hotReloadCatalogJSON}
|
||||
require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644))
|
||||
|
||||
require.NoError(t, svc.downloadPricingData())
|
||||
|
||||
require.NotNil(t, svc.pricingData["custom-b"])
|
||||
require.Nil(t, svc.pricingData["custom-a"])
|
||||
require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash)
|
||||
|
||||
svc.pricingData["sentinel"] = &LiteLLMModelPricing{}
|
||||
svc.reloadIfCustomFilesChanged()
|
||||
require.Contains(t, svc.pricingData, "sentinel", "下载已消化文件变化,不得再次重建")
|
||||
}
|
||||
|
||||
// 只配置了 fallback/override 而没有 remote_url 时调度器也要运行,否则文件改动无人比对。
|
||||
func TestPricingSchedulerStartsForCustomFilesWithoutRemoteURL(t *testing.T) {
|
||||
svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{
|
||||
RemoteURL: "",
|
||||
FallbackFile: filepath.Join(t.TempDir(), "fallback.json"),
|
||||
}}, nil)
|
||||
|
||||
svc.startUpdateScheduler()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
svc.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("custom file watch must keep the scheduler running")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
svc.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("scheduler must exit after Stop")
|
||||
}
|
||||
}
|
||||
@@ -1063,22 +1063,27 @@ pricing:
|
||||
# Local data directory for caching
|
||||
# 本地数据缓存目录
|
||||
data_dir: "./data"
|
||||
# Fallback pricing file
|
||||
# 备用定价文件
|
||||
# Fallback pricing file: fills in models missing from the catalog. Edits are
|
||||
# detected by content hash at the next hash check and reloaded without restart.
|
||||
# 备用定价文件:只补目录缺失的模型。修改后在下一次哈希检查时按内容哈希自动重载,无需重启。
|
||||
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.
|
||||
# Model names must match catalog keys exactly. Edits are detected by content hash
|
||||
# at the next hash check and reloaded without restart.
|
||||
# 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据,
|
||||
# 字段值为 null 表示删除该字段。模型名须与目录键完全一致。
|
||||
# 修改后重启或下次目录下载时生效。
|
||||
# 修改后在下一次哈希检查时按内容哈希自动重载,无需重启。
|
||||
# override_file: "./data/model_pricing_overrides.json"
|
||||
# Update interval in hours
|
||||
# 更新间隔(小时)
|
||||
update_interval_hours: 24
|
||||
# Hash check interval in minutes
|
||||
# 哈希检查间隔(分钟)
|
||||
# Hash check interval in minutes. Each check compares the remote catalog hash and
|
||||
# the local fallback/override file contents. Deleting a local file drops its
|
||||
# entries at the next check; a present but non-JSON file keeps the previous data
|
||||
# and is retried next check.
|
||||
# 哈希检查间隔(分钟)。每次检查同时比对远程目录哈希与本地 fallback/override 文件内容;
|
||||
# 删除本地文件会在下次检查时移除其条目,文件存在但不是 JSON 时保留旧数据、下次检查再试。
|
||||
hash_check_interval_minutes: 10
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user