| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634 |
- package service
- import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/binary"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "maps"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
- "go.uber.org/zap"
- )
- var (
- openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
- openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
- // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名
- // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。
- // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。
- aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`)
- // cacheTierPricePattern 匹配 cache 侧的长上下文绝对价字段名
- // (cache_creation_input_token_cost_above_200k_tokens、cache_read_input_token_cost_above_272k_tokens_priority、
- // cache_creation_input_token_cost_above_1hr_above_200k_tokens 等)。
- // 组 1 为基础价字段名主干,组 2 为 1h 缓存时长段(可为空),组 3 为服务档后缀(可为空)。
- cacheTierPricePattern = regexp.MustCompile(`^(cache_(?:creation|read)_input_token_cost)(_above_1hr)?_above_\d+k_tokens((?:_[a-z]+)?)$`)
- // Official GPT Image 2.5 token rates (2026-09-08):
- // https://developers.openai.com/api/docs/pricing#image-generation-models
- openAIGPTImage25FallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 5e-06,
- CacheReadInputTokenCost: 1.25e-06,
- InputCostPerImageToken: 8e-06,
- OutputCostPerImageToken: 3e-05,
- LiteLLMProvider: "openai",
- Mode: "image_generation",
- SupportsPromptCaching: true,
- }
- openAIGPT54FallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 2.5e-06, // $2.5 per MTok
- OutputCostPerToken: 1.5e-05, // $15 per MTok
- CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- openAIGPT6AstraFallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 1e-05,
- InputCostPerTokenPriority: 2e-05,
- OutputCostPerToken: 5e-05,
- OutputCostPerTokenPriority: 1e-04,
- CacheCreationInputTokenCost: 1.25e-05,
- CacheCreationInputTokenCostPriority: 2.5e-05,
- CacheReadInputTokenCost: 1e-06,
- CacheReadInputTokenCostPriority: 2e-06,
- LongContextInputTokenThreshold: 272_000,
- LongContextInputCostMultiplier: 2,
- LongContextOutputCostMultiplier: 1.5,
- SupportsServiceTier: true,
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 5e-06,
- InputCostPerTokenPriority: 1e-05,
- OutputCostPerToken: 3e-05,
- OutputCostPerTokenPriority: 6e-05,
- CacheCreationInputTokenCost: 6.25e-06,
- CacheCreationInputTokenCostPriority: 1.25e-05,
- CacheReadInputTokenCost: 5e-07,
- CacheReadInputTokenCostPriority: 1e-06,
- SupportsServiceTier: true,
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- openAIGPT56TerraFallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 2e-06,
- InputCostPerTokenPriority: 4e-06,
- OutputCostPerToken: 1.2e-05,
- OutputCostPerTokenPriority: 2.4e-05,
- CacheCreationInputTokenCost: 2.5e-06,
- CacheCreationInputTokenCostPriority: 5e-06,
- CacheReadInputTokenCost: 2e-07,
- CacheReadInputTokenCostPriority: 4e-07,
- SupportsServiceTier: true,
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- openAIGPT56LunaFallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 2e-07,
- InputCostPerTokenPriority: 4e-07,
- OutputCostPerToken: 1.2e-06,
- OutputCostPerTokenPriority: 2.4e-06,
- CacheCreationInputTokenCost: 2.5e-07,
- CacheCreationInputTokenCostPriority: 5e-07,
- CacheReadInputTokenCost: 2e-08,
- CacheReadInputTokenCostPriority: 4e-08,
- SupportsServiceTier: true,
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- openAIGPT54MiniFallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 7.5e-07,
- OutputCostPerToken: 4.5e-06,
- CacheReadInputTokenCost: 7.5e-08,
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- openAIGPT54NanoFallbackPricing = &LiteLLMModelPricing{
- InputCostPerToken: 2e-07,
- OutputCostPerToken: 1.25e-06,
- CacheReadInputTokenCost: 2e-08,
- LiteLLMProvider: "openai",
- Mode: "chat",
- SupportsPromptCaching: true,
- }
- )
- // LiteLLMModelPricing LiteLLM价格数据结构
- // 只保留我们需要的字段,使用指针来处理可能缺失的值
- type LiteLLMModelPricing struct {
- InputCostPerToken float64 `json:"input_cost_per_token"`
- InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"`
- OutputCostPerToken float64 `json:"output_cost_per_token"`
- OutputCostPerTokenPriority float64 `json:"output_cost_per_token_priority"`
- CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"`
- CacheCreationInputTokenCostPriority float64 `json:"cache_creation_input_token_cost_priority"`
- CacheCreationInputTokenCostAbove1hr float64 `json:"cache_creation_input_token_cost_above_1hr"`
- CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"`
- CacheReadInputTokenCostPriority float64 `json:"cache_read_input_token_cost_priority"`
- LongContextInputTokenThreshold int `json:"long_context_input_token_threshold,omitempty"`
- LongContextInputCostMultiplier float64 `json:"long_context_input_cost_multiplier,omitempty"`
- LongContextOutputCostMultiplier float64 `json:"long_context_output_cost_multiplier,omitempty"`
- SupportsServiceTier bool `json:"supports_service_tier"`
- LiteLLMProvider string `json:"litellm_provider"`
- Mode string `json:"mode"`
- SupportsPromptCaching bool `json:"supports_prompt_caching"`
- OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
- OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格
- InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑)
- // TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。
- // 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed,
- // 否则 token 流量会被按 $0 计费。零值(false)表示条目具备 token 价格。
- TokenPricingAbsent bool `json:"-"`
- }
- // PricingRemoteClient 远程价格数据获取接口
- type PricingRemoteClient interface {
- FetchPricingJSON(ctx context.Context, url string) ([]byte, error)
- FetchHashText(ctx context.Context, url string) (string, error)
- }
- // LiteLLMRawEntry 用于解析原始JSON数据
- type LiteLLMRawEntry struct {
- InputCostPerToken *float64 `json:"input_cost_per_token"`
- InputCostPerTokenPriority *float64 `json:"input_cost_per_token_priority"`
- OutputCostPerToken *float64 `json:"output_cost_per_token"`
- OutputCostPerTokenPriority *float64 `json:"output_cost_per_token_priority"`
- CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"`
- CacheCreationInputTokenCostPriority *float64 `json:"cache_creation_input_token_cost_priority"`
- CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"`
- CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"`
- CacheReadInputTokenCostPriority *float64 `json:"cache_read_input_token_cost_priority"`
- LongContextInputTokenThreshold *int `json:"long_context_input_token_threshold"`
- LongContextInputCostMultiplier *float64 `json:"long_context_input_cost_multiplier"`
- LongContextOutputCostMultiplier *float64 `json:"long_context_output_cost_multiplier"`
- SupportsServiceTier bool `json:"supports_service_tier"`
- LiteLLMProvider string `json:"litellm_provider"`
- Mode string `json:"mode"`
- SupportsPromptCaching bool `json:"supports_prompt_caching"`
- OutputCostPerImage *float64 `json:"output_cost_per_image"`
- OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"`
- InputCostPerImageToken *float64 `json:"input_cost_per_image_token"`
- }
- // PricingService 动态价格服务
- type PricingService struct {
- cfg *config.Config
- remoteClient PricingRemoteClient
- mu sync.RWMutex
- pricingData map[string]*LiteLLMModelPricing
- lastUpdated time.Time
- localHash string
- // fallback/override 文件在最近一次成功重建时的内容指纹,定时器据此判断是否
- // 需要从本地目录缓存重建叠加层。
- customFilesHash string
- // 停止信号
- stopCh chan struct{}
- wg sync.WaitGroup
- }
- // NewPricingService 创建价格服务
- func NewPricingService(cfg *config.Config, remoteClient PricingRemoteClient) *PricingService {
- s := &PricingService{
- cfg: cfg,
- remoteClient: remoteClient,
- pricingData: make(map[string]*LiteLLMModelPricing),
- stopCh: make(chan struct{}),
- }
- return s
- }
- // Initialize 初始化价格服务
- func (s *PricingService) Initialize() error {
- // 确保数据目录存在
- if err := os.MkdirAll(s.cfg.Pricing.DataDir, 0755); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to create data directory: %v", err)
- }
- // 首次加载价格数据
- if err := s.checkAndUpdatePricing(); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Initial load failed, using fallback: %v", err)
- if err := s.useFallbackPricing(); err != nil {
- return fmt.Errorf("failed to load pricing data: %w", err)
- }
- }
- // 启动定时更新
- s.startUpdateScheduler()
- logger.LegacyPrintf("service.pricing", "[Pricing] Service initialized with %d models", len(s.pricingData))
- return nil
- }
- // Stop 停止价格服务
- func (s *PricingService) Stop() {
- close(s.stopCh)
- s.wg.Wait()
- logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped")
- }
- // startUpdateScheduler 启动定时调度器:每个周期先做远程目录哈希同步(配置了 remote_url 时),
- // 再比对 fallback/override 文件指纹做本地热重载(配置了任一文件时)。两者都未配置则不启动。
- func (s *PricingService) startUpdateScheduler() {
- 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
- }
- s.wg.Add(1)
- go func() {
- defer s.wg.Done()
- ticker := time.NewTicker(hashInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- 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
- }
- }
- }()
- logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v, remote sync=%t, custom file watch=%t)", hashInterval, remoteEnabled, watchCustom)
- }
- // checkAndUpdatePricing 检查并更新价格数据
- func (s *PricingService) checkAndUpdatePricing() error {
- pricingFile := s.getPricingFilePath()
- // 检查本地文件是否存在
- if _, err := os.Stat(pricingFile); os.IsNotExist(err) {
- logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Local pricing file not found, downloading...")
- return s.downloadPricingData()
- }
- // 先加载本地文件(确保服务可用),再检查是否需要更新
- if err := s.loadPricingData(pricingFile); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to load local file, downloading: %v", err)
- return s.downloadPricingData()
- }
- // 如果配置了哈希URL,通过远程哈希检查是否有更新
- if s.cfg.Pricing.HashURL != "" {
- remoteHash, err := s.fetchRemoteHash()
- if err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash on startup: %v", err)
- return nil // 已加载本地文件,哈希获取失败不影响启动
- }
- s.mu.RLock()
- localHash := s.localHash
- s.mu.RUnlock()
- if localHash == "" || remoteHash != localHash {
- logger.LegacyPrintf("service.pricing", "[Pricing] Remote hash differs on startup (local=%s remote=%s), downloading...",
- localHash[:min(8, len(localHash))], remoteHash[:min(8, len(remoteHash))])
- if err := s.downloadPricingData(); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err)
- }
- }
- return nil
- }
- // 没有哈希URL时,基于文件年龄检查
- info, err := os.Stat(pricingFile)
- if err != nil {
- return nil // 已加载本地文件
- }
- fileAge := time.Since(info.ModTime())
- maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour
- if fileAge > maxAge {
- logger.LegacyPrintf("service.pricing", "[Pricing] Local file is %v old, updating...", fileAge.Round(time.Hour))
- if err := s.downloadPricingData(); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err)
- }
- }
- return nil
- }
- // syncWithRemote 与远程同步(基于哈希校验)
- func (s *PricingService) syncWithRemote() error {
- // 如果配置了哈希URL,从远程获取哈希进行比对
- if s.cfg.Pricing.HashURL != "" {
- remoteHash, err := s.fetchRemoteHash()
- if err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash: %v", err)
- return nil // 哈希获取失败不影响正常使用
- }
- s.mu.RLock()
- localHash := s.localHash
- s.mu.RUnlock()
- if localHash == "" || remoteHash != localHash {
- logger.LegacyPrintf("service.pricing", "[Pricing] Remote hash differs (local=%s remote=%s), downloading new version...",
- localHash[:min(8, len(localHash))], remoteHash[:min(8, len(remoteHash))])
- return s.downloadPricingData()
- }
- logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Hash check passed, no update needed")
- return nil
- }
- // 没有哈希URL时,基于时间检查
- pricingFile := s.getPricingFilePath()
- info, err := os.Stat(pricingFile)
- if err != nil {
- return s.downloadPricingData()
- }
- fileAge := time.Since(info.ModTime())
- maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour
- if fileAge > maxAge {
- logger.LegacyPrintf("service.pricing", "[Pricing] File is %v old, downloading...", fileAge.Round(time.Hour))
- return s.downloadPricingData()
- }
- 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.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()
- // 定价层文件可能在读取期间被替换。只有构建前后指纹一致时才提交,
- // 否则丢弃这次混合快照并重试,避免短暂应用不匹配的 fallback/override。
- var data map[string]*LiteLLMModelPricing
- var fingerprint string
- var err error
- for attempt := 0; attempt < 3; attempt++ {
- if validateErr := s.validateCustomPricingFiles(); validateErr != nil {
- return fmt.Errorf("validate custom pricing files: %w", validateErr)
- }
- before := s.customPricingFilesFingerprint()
- body, readErr := os.ReadFile(pricingFile)
- if readErr != nil {
- return fmt.Errorf("read file failed: %w", readErr)
- }
- data, fingerprint, err = s.buildPricingData(body)
- if err != nil {
- return fmt.Errorf("parse pricing data: %w", err)
- }
- after := s.customPricingFilesFingerprint()
- if validateErr := s.validateCustomPricingFiles(); validateErr != nil {
- return fmt.Errorf("validate custom pricing files: %w", validateErr)
- }
- if before == after && after == fingerprint {
- break
- }
- if attempt == 2 {
- return fmt.Errorf("custom pricing files changed during reload")
- }
- }
- 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)
- if err != nil {
- return err
- }
- logger.LegacyPrintf("service.pricing", "[Pricing] Downloading from %s", remoteURL)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- // 获取远程哈希(用于同步锚点,不作为完整性校验)
- var remoteHash string
- if strings.TrimSpace(s.cfg.Pricing.HashURL) != "" {
- remoteHash, err = s.fetchRemoteHash()
- if err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash (continuing): %v", err)
- }
- }
- body, err := s.remoteClient.FetchPricingJSON(ctx, remoteURL)
- if err != nil {
- return fmt.Errorf("download failed: %w", err)
- }
- // 哈希校验:不匹配时仅告警,不阻止更新
- // 远程哈希文件可能与数据文件不同步(如维护者更新了数据但未更新哈希文件)
- dataHash := sha256.Sum256(body)
- dataHashStr := hex.EncodeToString(dataHash[:])
- if remoteHash != "" && !strings.EqualFold(remoteHash, dataHashStr) {
- logger.LegacyPrintf("service.pricing", "[Pricing] Hash mismatch warning: remote=%s data=%s (hash file may be out of sync)",
- remoteHash[:min(8, len(remoteHash))], dataHashStr[:8])
- }
- data, customFilesHash, err := s.buildPricingData(body)
- if err != nil {
- return fmt.Errorf("parse pricing data: %w", err)
- }
- // 保存到本地文件
- pricingFile := s.getPricingFilePath()
- if err := os.WriteFile(pricingFile, body, 0644); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save file: %v", err)
- }
- // 使用远程哈希作为同步锚点,防止重复下载
- // 当远程哈希不可用时,回退到数据本身的哈希
- syncHash := dataHashStr
- if remoteHash != "" {
- syncHash = remoteHash
- }
- hashFile := s.getHashFilePath()
- if err := os.WriteFile(hashFile, []byte(syncHash+"\n"), 0644); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save hash: %v", err)
- }
- // 更新内存数据
- s.mu.Lock()
- warnDroppedLongContextLadders(s.pricingData, data)
- 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))
- return nil
- }
- // parsePricingData 解析价格数据(处理各种格式)
- func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModelPricing, error) {
- // 首先解析为 map[string]json.RawMessage
- var rawData map[string]json.RawMessage
- 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
- var orphanCacheTiers, lopsidedLadders []string
- for modelName, rawEntry := range rawData {
- // 跳过 sample_spec 等文档条目
- if modelName == "sample_spec" {
- continue
- }
- // 尝试解析每个条目
- var entry LiteLLMRawEntry
- if err := json.Unmarshal(rawEntry, &entry); err != nil {
- skipped++
- continue
- }
- // 只保留有有效价格的条目
- if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil && entry.InputCostPerImageToken == nil {
- continue
- }
- pricing := &LiteLLMModelPricing{
- LiteLLMProvider: entry.LiteLLMProvider,
- Mode: entry.Mode,
- SupportsPromptCaching: entry.SupportsPromptCaching,
- SupportsServiceTier: entry.SupportsServiceTier,
- TokenPricingAbsent: entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil,
- }
- if entry.InputCostPerToken != nil {
- pricing.InputCostPerToken = *entry.InputCostPerToken
- }
- if entry.InputCostPerTokenPriority != nil {
- pricing.InputCostPerTokenPriority = *entry.InputCostPerTokenPriority
- }
- if entry.OutputCostPerToken != nil {
- pricing.OutputCostPerToken = *entry.OutputCostPerToken
- }
- if entry.OutputCostPerTokenPriority != nil {
- pricing.OutputCostPerTokenPriority = *entry.OutputCostPerTokenPriority
- }
- if entry.CacheCreationInputTokenCost != nil {
- pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost
- }
- if entry.CacheCreationInputTokenCostPriority != nil {
- pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority
- }
- if entry.CacheCreationInputTokenCostAbove1hr != nil {
- pricing.CacheCreationInputTokenCostAbove1hr = *entry.CacheCreationInputTokenCostAbove1hr
- }
- if entry.CacheReadInputTokenCost != nil {
- pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
- }
- if entry.CacheReadInputTokenCostPriority != nil {
- pricing.CacheReadInputTokenCostPriority = *entry.CacheReadInputTokenCostPriority
- }
- if entry.LongContextInputTokenThreshold != nil {
- pricing.LongContextInputTokenThreshold = *entry.LongContextInputTokenThreshold
- }
- if entry.LongContextInputCostMultiplier != nil {
- pricing.LongContextInputCostMultiplier = *entry.LongContextInputCostMultiplier
- }
- if entry.LongContextOutputCostMultiplier != nil {
- pricing.LongContextOutputCostMultiplier = *entry.LongContextOutputCostMultiplier
- }
- if entry.OutputCostPerImage != nil {
- pricing.OutputCostPerImage = *entry.OutputCostPerImage
- }
- if entry.OutputCostPerImageToken != nil {
- pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken
- }
- if entry.InputCostPerImageToken != nil {
- pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
- }
- hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil ||
- entry.LongContextInputCostMultiplier != nil ||
- entry.LongContextOutputCostMultiplier != nil
- if !hasExplicitLongContext {
- deriveLongContextFromAboveTierFields(rawEntry, pricing)
- if isLopsidedLongContextLadder(pricing) {
- lopsidedLadders = append(lopsidedLadders, fmt.Sprintf("%s(input x%.2f, output x%.2f)", modelName,
- pricing.LongContextInputCostMultiplier, pricing.LongContextOutputCostMultiplier))
- }
- }
- if orphans := orphanCacheTierFields(rawEntry); len(orphans) > 0 {
- orphanCacheTiers = append(orphanCacheTiers, modelName+"("+strings.Join(orphans, ",")+")")
- }
- result[modelName] = pricing
- }
- if skipped > 0 {
- logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped)
- }
- warnOrphanCacheTierFields(orphanCacheTiers)
- warnLopsidedLongContextLadders(lopsidedLadders)
- if len(result) == 0 {
- return nil, fmt.Errorf("no valid pricing entries found")
- }
- return result, nil
- }
- // deriveLongContextFromAboveTierFields 把 LiteLLM 目录的 *_above_XXXk_tokens 绝对价字段
- // 折算成 long_context_* 阈值+倍率(sub2api 计费机制的内部表达):阈值取自字段名,
- // 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由
- // 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该
- // 模型的阶梯。多个阈值并存时取最小阈值。
- // cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算:
- // 目录条目的 cache above 档须恰为基础价 × 输入倍率;缺基础价的 cache above 字段
- // 无法参与计费,由 orphanCacheTierFields 哨兵告警。
- func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) {
- if pricing == nil ||
- pricing.LongContextInputTokenThreshold > 0 ||
- pricing.LongContextInputCostMultiplier > 0 ||
- pricing.LongContextOutputCostMultiplier > 0 {
- return
- }
- if !bytes.Contains(rawEntry, []byte("_above_")) {
- return
- }
- var fields map[string]any
- if err := json.Unmarshal(rawEntry, &fields); err != nil {
- return
- }
- type tierPrices struct{ input, output float64 }
- tiers := make(map[int]*tierPrices)
- for key, value := range fields {
- m := aboveTierPricePattern.FindStringSubmatch(key)
- if m == nil {
- continue
- }
- price, ok := value.(float64)
- if !ok || price <= 0 {
- continue
- }
- thousands, err := strconv.Atoi(m[2])
- if err != nil || thousands <= 0 {
- continue
- }
- threshold := thousands * 1000
- tp := tiers[threshold]
- if tp == nil {
- tp = &tierPrices{}
- tiers[threshold] = tp
- }
- if m[1] == "input" {
- tp.input = price
- } else {
- tp.output = price
- }
- }
- if len(tiers) == 0 {
- return
- }
- threshold := 0
- for t := range tiers {
- if threshold == 0 || t < threshold {
- threshold = t
- }
- }
- tp := tiers[threshold]
- inputMultiplier, outputMultiplier := 1.0, 1.0
- if tp.input > 0 && pricing.InputCostPerToken > 0 {
- inputMultiplier = tp.input / pricing.InputCostPerToken
- }
- if tp.output > 0 && pricing.OutputCostPerToken > 0 {
- outputMultiplier = tp.output / pricing.OutputCostPerToken
- }
- // above 价不高于基础价时视为无附加费,不生成阶梯。
- if inputMultiplier <= 1 && outputMultiplier <= 1 {
- return
- }
- pricing.LongContextInputTokenThreshold = threshold
- pricing.LongContextInputCostMultiplier = inputMultiplier
- pricing.LongContextOutputCostMultiplier = outputMultiplier
- }
- // isLopsidedLongContextLadder 判断折算出的阶梯是否只有一侧带附加费。官方阶梯(OpenAI、
- // Google、Anthropic、xAI)都同时抬高 input 与 output;单侧附加费意味着条目的基础价与
- // above 档来自不同价格版本(如基础价被手工 pin、above 档随上游更新),折算出的倍率失真。
- func isLopsidedLongContextLadder(pricing *LiteLLMModelPricing) bool {
- if pricing == nil || pricing.LongContextInputTokenThreshold <= 0 {
- return false
- }
- return (pricing.LongContextInputCostMultiplier > 1) != (pricing.LongContextOutputCostMultiplier > 1)
- }
- // warnLopsidedLongContextLadders 对单侧附加费的折算阶梯打 WARN:应成组修正该条目的
- // 基础价与 above 档(目录或 pricing.override_file)。
- func warnLopsidedLongContextLadders(entries []string) {
- if len(entries) == 0 {
- return
- }
- sort.Strings(entries)
- total := len(entries)
- if total > 20 {
- entries = append(entries[:20], "...")
- }
- logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) derive a one-sided long-context ladder (surcharge on only input or only output); base prices and above-tier prices likely come from different price versions: %s", total, strings.Join(entries, ", "))
- }
- // orphanCacheTierFields 返回条目中没有对应基础价的 cache 侧 above 档字段名。
- // cache 侧 above 档不参与计费取值,计费按"基础价 × 输入倍率";基础价缺失或为 0 时,
- // 该缓存分项在整个阶梯上都按 0 计。计费对变体有回落:服务档变体(_priority/_flex)
- // 缺自身基础价时用标准基础价,1h 缓存写入缺 above_1hr 价时全部按 5m 价——因此沿
- // 回落链任一基础价存在即不算孤儿。
- func orphanCacheTierFields(rawEntry json.RawMessage) []string {
- if !bytes.Contains(rawEntry, []byte("_above_")) {
- return nil
- }
- var fields map[string]any
- if err := json.Unmarshal(rawEntry, &fields); err != nil {
- return nil
- }
- positive := func(key string) bool {
- price, ok := fields[key].(float64)
- return ok && price > 0
- }
- var orphans []string
- for key := range fields {
- m := cacheTierPricePattern.FindStringSubmatch(key)
- if m == nil || !positive(key) {
- continue
- }
- stem, hourly, tier := m[1], m[2], m[3]
- if positive(stem+hourly+tier) || positive(stem+hourly) || positive(stem+tier) || positive(stem) {
- continue
- }
- orphans = append(orphans, key)
- }
- sort.Strings(orphans)
- return orphans
- }
- // warnOrphanCacheTierFields 对带 cache 侧 above 档却没有基础价的条目打 WARN:
- // 该缓存分项按 0 计费,目录或 pricing.override_file 补上基础价即可消除。
- func warnOrphanCacheTierFields(entries []string) {
- if len(entries) == 0 {
- return
- }
- sort.Strings(entries)
- total := len(entries)
- if total > 20 {
- entries = append(entries[:20], "...")
- }
- logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) carry cache above-tier prices without a base cache price; that cache item bills at $0 until the catalog/override supplies the base: %s", total, strings.Join(entries, ", "))
- }
- // 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
- }
- // 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)
- if err != nil {
- return fmt.Errorf("read file failed: %w", err)
- }
- pricingData, customFilesHash, err := s.buildPricingData(data)
- if err != nil {
- return fmt.Errorf("parse pricing data: %w", err)
- }
- // 计算哈希
- hash := sha256.Sum256(data)
- hashStr := hex.EncodeToString(hash[:])
- s.mu.Lock()
- warnDroppedLongContextLadders(s.pricingData, pricingData)
- s.pricingData = pricingData
- s.localHash = hashStr
- s.customFilesHash = customFilesHash
- info, _ := os.Stat(filePath)
- if info != nil {
- s.lastUpdated = info.ModTime()
- } else {
- s.lastUpdated = time.Now()
- }
- s.mu.Unlock()
- logger.LegacyPrintf("service.pricing", "[Pricing] Loaded %d models from %s", len(pricingData), filePath)
- return nil
- }
- func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing {
- if data == nil {
- data = make(map[string]*LiteLLMModelPricing)
- }
- if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.FallbackFile) == "" {
- return data
- }
- fallbackBody, err := os.ReadFile(s.cfg.Pricing.FallbackFile)
- if err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Fallback merge skipped: %v", err)
- return data
- }
- fallbackData, err := s.parsePricingData(fallbackBody)
- if err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Fallback merge parse skipped: %v", err)
- return data
- }
- merged := 0
- for modelName, pricing := range fallbackData {
- if _, ok := data[modelName]; ok {
- continue
- }
- data[modelName] = pricing
- merged++
- }
- if merged > 0 {
- logger.LegacyPrintf("service.pricing", "[Pricing] Merged %d fallback-only models", merged)
- }
- return data
- }
- // warnDroppedLongContextLadders 对比新旧目录数据:原本带长上下文阶梯的条目在新数据里
- // 丢失阈值时打 WARN。阶梯已完全数据驱动(无代码兜底),数据源一次误提交就会把阶梯
- // 静默变成基础价少收(07-14~08-21 漏收事故的形态),这里是唯一的哨兵。
- // 调用方需持有 s.mu 写锁。
- func warnDroppedLongContextLadders(old, next map[string]*LiteLLMModelPricing) {
- if len(old) == 0 {
- return
- }
- var dropped []string
- for name, prev := range old {
- if prev == nil || prev.LongContextInputTokenThreshold <= 0 {
- continue
- }
- if cur, ok := next[name]; ok && (cur == nil || cur.LongContextInputTokenThreshold <= 0) {
- dropped = append(dropped, name)
- }
- }
- if len(dropped) == 0 {
- return
- }
- sort.Strings(dropped)
- total := len(dropped)
- if total > 20 {
- dropped = append(dropped[:20], "...")
- }
- logger.LegacyPrintf("service.pricing", "[Pricing] Long-context ladder dropped for %d model(s) after reload: %s (verify catalog/override data if unintended)", total, strings.Join(dropped, ", "))
- }
- // useFallbackPricing 使用回退价格文件
- func (s *PricingService) useFallbackPricing() error {
- fallbackFile := s.cfg.Pricing.FallbackFile
- if _, err := os.Stat(fallbackFile); os.IsNotExist(err) {
- return fmt.Errorf("fallback file not found: %s", fallbackFile)
- }
- logger.LegacyPrintf("service.pricing", "[Pricing] Using fallback file: %s", fallbackFile)
- // 复制到数据目录
- data, err := os.ReadFile(fallbackFile)
- if err != nil {
- return fmt.Errorf("read fallback failed: %w", err)
- }
- pricingFile := s.getPricingFilePath()
- if err := os.WriteFile(pricingFile, data, 0644); err != nil { //nolint:gosec // G703: 路径为配置的数据目录 + 硬编码文件名,非请求输入
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err)
- }
- return s.loadPricingData(fallbackFile)
- }
- // fetchRemoteHash 从远程获取哈希值
- func (s *PricingService) fetchRemoteHash() (string, error) {
- hashURL, err := s.validatePricingURL(s.cfg.Pricing.HashURL)
- if err != nil {
- return "", err
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- hash, err := s.remoteClient.FetchHashText(ctx, hashURL)
- if err != nil {
- return "", err
- }
- return strings.TrimSpace(hash), nil
- }
- func (s *PricingService) validatePricingURL(raw string) (string, error) {
- if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- if err != nil {
- return "", fmt.Errorf("invalid pricing url: %w", err)
- }
- return normalized, nil
- }
- normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.PricingHosts,
- RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
- })
- if err != nil {
- return "", fmt.Errorf("invalid pricing url: %w", err)
- }
- return normalized, nil
- }
- // GetModelPricing 获取模型价格(带模糊匹配)
- func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing {
- s.mu.RLock()
- defer s.mu.RUnlock()
- if modelName == "" {
- return nil
- }
- // 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀)
- modelLower := strings.ToLower(strings.TrimSpace(modelName))
- lookupCandidates := s.buildModelLookupCandidates(modelLower)
- // 1~3. 确定性识别(精确名 / 已知拼写变体 / 去掉日期版本后缀)
- if pricing := s.lookupIdentifiedModelPricingLocked(lookupCandidates); pricing != nil {
- return pricing
- }
- // 4. 基于模型系列匹配(Claude)
- if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
- return pricing
- }
- // 5. OpenAI 模型回退策略
- if strings.HasPrefix(lookupCandidates[0], "gpt-") {
- return s.matchOpenAIModel(lookupCandidates[0])
- }
- return nil
- }
- // lookupIdentifiedModelPricingLocked 只做"确定性识别"的三步查找:精确键、已知拼写
- // 变体、去掉日期/版本后缀后的同名条目。它刻意不包含 matchByModelFamily /
- // matchOpenAIModel 这类按子串猜系列的兜底——那些兜底会给任意名字都返回一个价格。
- // 调用方必须持有 s.mu 读锁。
- func (s *PricingService) lookupIdentifiedModelPricingLocked(lookupCandidates []string) *LiteLLMModelPricing {
- if len(lookupCandidates) == 0 {
- return nil
- }
- // 1. 精确匹配
- for _, candidate := range lookupCandidates {
- if candidate == "" {
- continue
- }
- if pricing, ok := s.pricingData[candidate]; ok {
- return pricing
- }
- }
- // 2. 处理常见的模型名称变体
- // claude-opus-4-5-20251101 -> claude-opus-4.5-20251101
- for _, candidate := range lookupCandidates {
- normalized := strings.ReplaceAll(candidate, "-4-5-", "-4.5-")
- if pricing, ok := s.pricingData[normalized]; ok {
- return pricing
- }
- }
- // 3. 尝试模糊匹配(去掉版本号后缀)
- // claude-opus-4-5-20251101 -> claude-opus-4.5
- baseName := s.extractBaseName(lookupCandidates[0])
- for key, pricing := range s.pricingData {
- keyBase := s.extractBaseName(strings.ToLower(key))
- if keyBase == baseName {
- return pricing
- }
- }
- return nil
- }
- // GetIdentifiedModelPricing 在价格表中确定性地识别模型,识别不到时返回 nil。
- // 与 GetModelPricing 的区别:不会退化成按 "opus"/"haiku" 之类子串猜出的系列兜底价。
- // 用于必须区分"这是价格表里已知的模型"和"这只是名字里带某个关键词"的场景。
- func (s *PricingService) GetIdentifiedModelPricing(modelName string) *LiteLLMModelPricing {
- if s == nil {
- return nil
- }
- s.mu.RLock()
- defer s.mu.RUnlock()
- modelLower := strings.ToLower(strings.TrimSpace(modelName))
- if modelLower == "" {
- return nil
- }
- return s.lookupIdentifiedModelPricingLocked(s.buildModelLookupCandidates(modelLower))
- }
- func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
- rawCandidates := []string{
- modelLower,
- strings.TrimPrefix(modelLower, "models/"),
- lastSegment(modelLower),
- lastSegment(strings.TrimPrefix(modelLower, "models/")),
- }
- normalized := normalizeModelNameForPricing(modelLower)
- // A tier-specific entry should take precedence when the pricing catalog gains
- // one later. Antigravity's Gemini Flash thinking tiers share the base rate,
- // so the normalized base remains the fallback after the exact aliases.
- candidates := rawCandidates
- if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) {
- candidates = append(candidates, normalized)
- } else {
- // Prefer canonical model names for all other aliases (including models/xxx).
- candidates = append([]string{normalized}, candidates...)
- }
- seen := make(map[string]struct{}, len(candidates))
- out := make([]string, 0, len(candidates))
- for _, c := range candidates {
- c = strings.TrimSpace(c)
- if c == "" {
- continue
- }
- if _, ok := seen[c]; ok {
- continue
- }
- seen[c] = struct{}{}
- out = append(out, c)
- }
- if len(out) == 0 {
- return []string{modelLower}
- }
- return out
- }
- func normalizeModelNameForPricing(model string) string {
- // Common Gemini/VertexAI forms:
- // - models/gemini-2.0-flash-exp
- // - publishers/google/models/gemini-2.5-pro
- // - projects/.../locations/.../publishers/google/models/gemini-2.5-pro
- model = strings.TrimSpace(model)
- model = strings.TrimLeft(model, "/")
- model = strings.TrimPrefix(model, "models/")
- model = strings.TrimPrefix(model, "publishers/google/models/")
- if idx := strings.LastIndex(model, "/publishers/google/models/"); idx != -1 {
- model = model[idx+len("/publishers/google/models/"):]
- }
- if idx := strings.LastIndex(model, "/models/"); idx != -1 {
- model = model[idx+len("/models/"):]
- }
- model = strings.TrimLeft(model, "/")
- if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" {
- if canonical == "gpt-6" {
- return "gpt-6-astra"
- }
- if canonical == "gpt-5.6" {
- return "gpt-5.6-sol"
- }
- if suffix, ok := strings.CutPrefix(canonical, "gpt-5.6-"); ok && (suffix == "max" || isKnownCodexModelSuffix(suffix)) {
- return "gpt-5.6-sol"
- }
- return canonical
- }
- return normalizeGeminiThinkingTierAlias(model)
- }
- // normalizeGeminiThinkingTierAlias maps Antigravity's Gemini Flash
- // thinking-tier model IDs to the public base model. The tier controls reasoning
- // behavior, not the published token rate, so this keeps -high/-low/-medium and
- // -tiered requests on the corresponding base model's price card.
- func normalizeGeminiThinkingTierAlias(model string) string {
- for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} {
- for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} {
- if model == baseModel+tier {
- return baseModel
- }
- }
- }
- return model
- }
- func lastSegment(model string) string {
- if idx := strings.LastIndex(model, "/"); idx != -1 {
- return model[idx+1:]
- }
- return model
- }
- // extractBaseName 提取基础模型名称(去掉日期版本号)
- func (s *PricingService) extractBaseName(model string) string {
- // 移除日期后缀 (如 -20251101, -20241022)
- parts := strings.Split(model, "-")
- result := make([]string, 0, len(parts))
- for _, part := range parts {
- // 跳过看起来像日期的部分(8位数字)
- if len(part) == 8 && isNumeric(part) {
- continue
- }
- // 跳过版本号(如 v1:0)
- if strings.Contains(part, ":") {
- continue
- }
- result = append(result, part)
- }
- return strings.Join(result, "-")
- }
- // matchByModelFamily 基于模型系列匹配
- func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
- // modelFamily 定义一个模型系列的匹配和定价查找规则。
- type modelFamily struct {
- name string // 系列名称
- match []string // 用于将模型归类到此系列的模式(strings.Contains 匹配)
- pricing []string // 用于在定价数据中查找价格的模式(nil 则复用 match;可包含低版本 fallback)
- }
- // 按特异性降序排列:高版本号在前,避免 "claude-opus-4"(opus-4 系列)
- // 因子串关系误匹配 "claude-opus-4-7"(opus-4.7 系列)。
- // 注意:原 map 实现存在 Go map 迭代随机性导致的同类 bug,此处改为有序切片修复。
- families := []modelFamily{
- // Opus 5 与 Opus 4.8 同价($5/$25 per MTok)。定价数据缺失 claude-opus-5 时
- // 必须回退到 4.8,否则会掉进 "opus-4" 系列按 $15/$75 计费(3 倍超收)。
- {name: "opus-5", match: []string{"claude-opus-5"}, pricing: []string{"claude-opus-5", "claude-opus-4-8"}},
- {name: "opus-4.8", match: []string{"claude-opus-4-8", "claude-opus-4.8"}, pricing: []string{"claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7"}},
- {name: "opus-4.7", match: []string{"claude-opus-4-7", "claude-opus-4.7"}, pricing: []string{"claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-6"}},
- {name: "opus-4.6", match: []string{"claude-opus-4-6", "claude-opus-4.6"}},
- {name: "opus-4.5", match: []string{"claude-opus-4-5", "claude-opus-4.5"}},
- {name: "opus-4", match: []string{"claude-opus-4", "claude-3-opus"}},
- {name: "sonnet-4.5", match: []string{"claude-sonnet-4-5", "claude-sonnet-4.5"}},
- {name: "sonnet-4", match: []string{"claude-sonnet-4", "claude-3-5-sonnet"}},
- {name: "sonnet-3.5", match: []string{"claude-3-5-sonnet", "claude-3.5-sonnet"}},
- {name: "sonnet-3", match: []string{"claude-3-sonnet"}},
- {name: "haiku-3.5", match: []string{"claude-3-5-haiku", "claude-3.5-haiku"}},
- {name: "haiku-3", match: []string{"claude-3-haiku"}},
- }
- // Phase 1: 按有序切片归类(最具体的系列优先匹配)
- var matched *modelFamily
- for i := range families {
- for _, pattern := range families[i].match {
- if strings.Contains(model, pattern) || strings.Contains(model, strings.ReplaceAll(pattern, "-", "")) {
- matched = &families[i]
- break
- }
- }
- if matched != nil {
- break
- }
- }
- // Phase 2: 二次兜底——当模型 ID 不含已知模式串时,按关键字粗分
- if matched == nil {
- var fallbackName string
- switch {
- case strings.Contains(model, "opus"):
- switch {
- // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
- case strings.Contains(model, "opus-5") || strings.Contains(model, "opus5"):
- fallbackName = "opus-5"
- case strings.Contains(model, "4.8") || strings.Contains(model, "4-8"):
- fallbackName = "opus-4.8"
- case strings.Contains(model, "4.7") || strings.Contains(model, "4-7"):
- fallbackName = "opus-4.7"
- case strings.Contains(model, "4.6") || strings.Contains(model, "4-6"):
- fallbackName = "opus-4.6"
- case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"):
- fallbackName = "opus-4.5"
- default:
- fallbackName = "opus-4"
- }
- case strings.Contains(model, "sonnet"):
- switch {
- case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"):
- fallbackName = "sonnet-4.5"
- case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"):
- fallbackName = "sonnet-3.5"
- default:
- fallbackName = "sonnet-4"
- }
- case strings.Contains(model, "haiku"):
- switch {
- case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"):
- fallbackName = "haiku-3.5"
- default:
- fallbackName = "haiku-3"
- }
- }
- if fallbackName != "" {
- for i := range families {
- if families[i].name == fallbackName {
- matched = &families[i]
- break
- }
- }
- }
- }
- if matched == nil {
- return nil
- }
- // Phase 3: 在定价数据中查找该系列的价格
- lookups := matched.pricing
- if lookups == nil {
- lookups = matched.match
- }
- for _, pattern := range lookups {
- for key, pricing := range s.pricingData {
- keyLower := strings.ToLower(key)
- if strings.Contains(keyLower, pattern) {
- logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key)
- return pricing
- }
- }
- }
- return nil
- }
- // matchOpenAIModel OpenAI 模型回退匹配策略
- // 回退顺序:
- // 1. gpt-5.3-codex-spark* -> gpt-5.1-codex(按业务要求固定计费)
- // 2. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等)
- // 3. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号)
- // 4. gpt-5.3-codex -> gpt-5.2-codex
- // 5. gpt-5.4* -> 业务静态兜底价
- // 6. 最终回退到 DefaultTestModel (gpt-5.1-codex)
- func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
- if strings.HasPrefix(model, "gpt-5.3-codex-spark") {
- if pricing, ok := s.pricingData["gpt-5.1-codex"]; ok {
- logger.LegacyPrintf("service.pricing", "[Pricing][SparkBilling] %s -> %s billing", model, "gpt-5.1-codex")
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.1-codex"))
- return pricing
- }
- }
- // 尝试的回退变体
- variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern)
- for _, variant := range variants {
- if pricing, ok := s.pricingData[variant]; ok {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, variant))
- return pricing
- }
- }
- if strings.HasPrefix(model, "gpt-5.3-codex") {
- if pricing, ok := s.pricingData["gpt-5.2-codex"]; ok {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.2-codex"))
- return pricing
- }
- }
- if isOpenAIGPT6AstraModel(model) {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-6-astra(static)"))
- return openAIGPT6AstraFallbackPricing
- }
- if strings.HasPrefix(model, "gpt-5.6-sol") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-sol(static)"))
- return openAIGPT56SolFallbackPricing
- }
- if strings.HasPrefix(model, "gpt-5.6-terra") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-terra(static)"))
- return openAIGPT56TerraFallbackPricing
- }
- if strings.HasPrefix(model, "gpt-5.6-luna") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-luna(static)"))
- return openAIGPT56LunaFallbackPricing
- }
- // GPT-5.5 回退到 GPT-5.4 定价
- if strings.HasPrefix(model, "gpt-5.5") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)"))
- return openAIGPT54FallbackPricing
- }
- if strings.HasPrefix(model, "gpt-5.4-mini") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-mini(static)"))
- return openAIGPT54MiniFallbackPricing
- }
- if strings.HasPrefix(model, "gpt-5.4-nano") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-nano(static)"))
- return openAIGPT54NanoFallbackPricing
- }
- if strings.HasPrefix(model, "gpt-5.4") {
- logger.With(zap.String("component", "service.pricing")).
- Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)"))
- return openAIGPT54FallbackPricing
- }
- // Remote price mirrors can lag new releases. Never bill GPT Image 2.5
- // using the older image model's rates when its entry is absent.
- for _, imageModel := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} {
- if model == imageModel || model == imageModel+"-2026-09-08" {
- return openAIGPTImage25FallbackPricing
- }
- }
- if isOpenAIImageGenerationModel(model) {
- for _, candidate := range []string{"gpt-image-2", "gpt-image-1.5", "gpt-image-1"} {
- if pricing, ok := s.pricingData[candidate]; ok {
- logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI image fallback matched %s -> %s", model, candidate)
- return pricing
- }
- }
- return nil
- }
- // 最终回退到 DefaultTestModel
- defaultModel := strings.ToLower(openai.DefaultTestModel)
- if pricing, ok := s.pricingData[defaultModel]; ok {
- logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI fallback to default model %s -> %s", model, defaultModel)
- return pricing
- }
- return nil
- }
- // generateOpenAIModelVariants 生成 OpenAI 模型的回退变体列表
- func (s *PricingService) generateOpenAIModelVariants(model string, datePattern *regexp.Regexp) []string {
- seen := make(map[string]bool)
- var variants []string
- addVariant := func(v string) {
- if v != model && !seen[v] {
- seen[v] = true
- variants = append(variants, v)
- }
- }
- // 1. 去掉日期版本号: gpt-5.2-20251222 -> gpt-5.2
- withoutDate := datePattern.ReplaceAllString(model, "")
- if withoutDate != model {
- addVariant(withoutDate)
- }
- // 2. 提取基础版本号: gpt-5.2-codex -> gpt-5.2
- // 只匹配纯数字版本号格式 gpt-X 或 gpt-X.Y,不匹配 gpt-4o 这种带字母后缀的
- if matches := openAIModelBasePattern.FindStringSubmatch(model); len(matches) > 1 {
- addVariant(matches[1])
- }
- // 3. 同时去掉日期后再提取基础版本号
- if withoutDate != model {
- if matches := openAIModelBasePattern.FindStringSubmatch(withoutDate); len(matches) > 1 {
- addVariant(matches[1])
- }
- }
- return variants
- }
- // GetStatus 获取服务状态
- func (s *PricingService) GetStatus() map[string]any {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return map[string]any{
- "model_count": len(s.pricingData),
- "last_updated": s.lastUpdated,
- "local_hash": s.localHash[:min(8, len(s.localHash))],
- }
- }
- // ForceUpdate 强制更新
- func (s *PricingService) ForceUpdate() error {
- return s.downloadPricingData()
- }
- // getPricingFilePath 获取价格文件路径
- func (s *PricingService) getPricingFilePath() string {
- return filepath.Join(s.cfg.Pricing.DataDir, "model_pricing.json")
- }
- // getHashFilePath 获取哈希文件路径
- func (s *PricingService) getHashFilePath() string {
- return filepath.Join(s.cfg.Pricing.DataDir, "model_pricing.sha256")
- }
- // ListModelNamesByProvider returns all model names in the catalog whose
- // LiteLLMProvider matches the given provider string (case-insensitive).
- // The returned slice is sorted alphabetically.
- func (s *PricingService) ListModelNamesByProvider(provider string) []string {
- s.mu.RLock()
- defer s.mu.RUnlock()
- provider = strings.ToLower(strings.TrimSpace(provider))
- names := make([]string, 0)
- for name, p := range s.pricingData {
- if strings.ToLower(p.LiteLLMProvider) == provider {
- names = append(names, name)
- }
- }
- sort.Strings(names)
- return names
- }
- // isNumeric 检查字符串是否为纯数字
- func isNumeric(s string) bool {
- for _, c := range s {
- if c < '0' || c > '9' {
- return false
- }
- }
- return true
- }
|