pricing_service.go 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "encoding/binary"
  7. "encoding/hex"
  8. "encoding/json"
  9. "fmt"
  10. "maps"
  11. "os"
  12. "path/filepath"
  13. "regexp"
  14. "sort"
  15. "strconv"
  16. "strings"
  17. "sync"
  18. "time"
  19. "github.com/Wei-Shaw/sub2api/internal/config"
  20. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  21. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  22. "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
  23. "go.uber.org/zap"
  24. )
  25. var (
  26. openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
  27. openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
  28. // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名
  29. // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。
  30. // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。
  31. aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`)
  32. // cacheTierPricePattern 匹配 cache 侧的长上下文绝对价字段名
  33. // (cache_creation_input_token_cost_above_200k_tokens、cache_read_input_token_cost_above_272k_tokens_priority、
  34. // cache_creation_input_token_cost_above_1hr_above_200k_tokens 等)。
  35. // 组 1 为基础价字段名主干,组 2 为 1h 缓存时长段(可为空),组 3 为服务档后缀(可为空)。
  36. cacheTierPricePattern = regexp.MustCompile(`^(cache_(?:creation|read)_input_token_cost)(_above_1hr)?_above_\d+k_tokens((?:_[a-z]+)?)$`)
  37. // Official GPT Image 2.5 token rates (2026-09-08):
  38. // https://developers.openai.com/api/docs/pricing#image-generation-models
  39. openAIGPTImage25FallbackPricing = &LiteLLMModelPricing{
  40. InputCostPerToken: 5e-06,
  41. CacheReadInputTokenCost: 1.25e-06,
  42. InputCostPerImageToken: 8e-06,
  43. OutputCostPerImageToken: 3e-05,
  44. LiteLLMProvider: "openai",
  45. Mode: "image_generation",
  46. SupportsPromptCaching: true,
  47. }
  48. openAIGPT54FallbackPricing = &LiteLLMModelPricing{
  49. InputCostPerToken: 2.5e-06, // $2.5 per MTok
  50. OutputCostPerToken: 1.5e-05, // $15 per MTok
  51. CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
  52. LiteLLMProvider: "openai",
  53. Mode: "chat",
  54. SupportsPromptCaching: true,
  55. }
  56. openAIGPT6AstraFallbackPricing = &LiteLLMModelPricing{
  57. InputCostPerToken: 1e-05,
  58. InputCostPerTokenPriority: 2e-05,
  59. OutputCostPerToken: 5e-05,
  60. OutputCostPerTokenPriority: 1e-04,
  61. CacheCreationInputTokenCost: 1.25e-05,
  62. CacheCreationInputTokenCostPriority: 2.5e-05,
  63. CacheReadInputTokenCost: 1e-06,
  64. CacheReadInputTokenCostPriority: 2e-06,
  65. LongContextInputTokenThreshold: 272_000,
  66. LongContextInputCostMultiplier: 2,
  67. LongContextOutputCostMultiplier: 1.5,
  68. SupportsServiceTier: true,
  69. LiteLLMProvider: "openai",
  70. Mode: "chat",
  71. SupportsPromptCaching: true,
  72. }
  73. openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{
  74. InputCostPerToken: 5e-06,
  75. InputCostPerTokenPriority: 1e-05,
  76. OutputCostPerToken: 3e-05,
  77. OutputCostPerTokenPriority: 6e-05,
  78. CacheCreationInputTokenCost: 6.25e-06,
  79. CacheCreationInputTokenCostPriority: 1.25e-05,
  80. CacheReadInputTokenCost: 5e-07,
  81. CacheReadInputTokenCostPriority: 1e-06,
  82. SupportsServiceTier: true,
  83. LiteLLMProvider: "openai",
  84. Mode: "chat",
  85. SupportsPromptCaching: true,
  86. }
  87. openAIGPT56TerraFallbackPricing = &LiteLLMModelPricing{
  88. InputCostPerToken: 2e-06,
  89. InputCostPerTokenPriority: 4e-06,
  90. OutputCostPerToken: 1.2e-05,
  91. OutputCostPerTokenPriority: 2.4e-05,
  92. CacheCreationInputTokenCost: 2.5e-06,
  93. CacheCreationInputTokenCostPriority: 5e-06,
  94. CacheReadInputTokenCost: 2e-07,
  95. CacheReadInputTokenCostPriority: 4e-07,
  96. SupportsServiceTier: true,
  97. LiteLLMProvider: "openai",
  98. Mode: "chat",
  99. SupportsPromptCaching: true,
  100. }
  101. openAIGPT56LunaFallbackPricing = &LiteLLMModelPricing{
  102. InputCostPerToken: 2e-07,
  103. InputCostPerTokenPriority: 4e-07,
  104. OutputCostPerToken: 1.2e-06,
  105. OutputCostPerTokenPriority: 2.4e-06,
  106. CacheCreationInputTokenCost: 2.5e-07,
  107. CacheCreationInputTokenCostPriority: 5e-07,
  108. CacheReadInputTokenCost: 2e-08,
  109. CacheReadInputTokenCostPriority: 4e-08,
  110. SupportsServiceTier: true,
  111. LiteLLMProvider: "openai",
  112. Mode: "chat",
  113. SupportsPromptCaching: true,
  114. }
  115. openAIGPT54MiniFallbackPricing = &LiteLLMModelPricing{
  116. InputCostPerToken: 7.5e-07,
  117. OutputCostPerToken: 4.5e-06,
  118. CacheReadInputTokenCost: 7.5e-08,
  119. LiteLLMProvider: "openai",
  120. Mode: "chat",
  121. SupportsPromptCaching: true,
  122. }
  123. openAIGPT54NanoFallbackPricing = &LiteLLMModelPricing{
  124. InputCostPerToken: 2e-07,
  125. OutputCostPerToken: 1.25e-06,
  126. CacheReadInputTokenCost: 2e-08,
  127. LiteLLMProvider: "openai",
  128. Mode: "chat",
  129. SupportsPromptCaching: true,
  130. }
  131. )
  132. // LiteLLMModelPricing LiteLLM价格数据结构
  133. // 只保留我们需要的字段,使用指针来处理可能缺失的值
  134. type LiteLLMModelPricing struct {
  135. InputCostPerToken float64 `json:"input_cost_per_token"`
  136. InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"`
  137. OutputCostPerToken float64 `json:"output_cost_per_token"`
  138. OutputCostPerTokenPriority float64 `json:"output_cost_per_token_priority"`
  139. CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"`
  140. CacheCreationInputTokenCostPriority float64 `json:"cache_creation_input_token_cost_priority"`
  141. CacheCreationInputTokenCostAbove1hr float64 `json:"cache_creation_input_token_cost_above_1hr"`
  142. CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"`
  143. CacheReadInputTokenCostPriority float64 `json:"cache_read_input_token_cost_priority"`
  144. LongContextInputTokenThreshold int `json:"long_context_input_token_threshold,omitempty"`
  145. LongContextInputCostMultiplier float64 `json:"long_context_input_cost_multiplier,omitempty"`
  146. LongContextOutputCostMultiplier float64 `json:"long_context_output_cost_multiplier,omitempty"`
  147. SupportsServiceTier bool `json:"supports_service_tier"`
  148. LiteLLMProvider string `json:"litellm_provider"`
  149. Mode string `json:"mode"`
  150. SupportsPromptCaching bool `json:"supports_prompt_caching"`
  151. OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
  152. OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格
  153. InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑)
  154. // TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。
  155. // 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed,
  156. // 否则 token 流量会被按 $0 计费。零值(false)表示条目具备 token 价格。
  157. TokenPricingAbsent bool `json:"-"`
  158. }
  159. // PricingRemoteClient 远程价格数据获取接口
  160. type PricingRemoteClient interface {
  161. FetchPricingJSON(ctx context.Context, url string) ([]byte, error)
  162. FetchHashText(ctx context.Context, url string) (string, error)
  163. }
  164. // LiteLLMRawEntry 用于解析原始JSON数据
  165. type LiteLLMRawEntry struct {
  166. InputCostPerToken *float64 `json:"input_cost_per_token"`
  167. InputCostPerTokenPriority *float64 `json:"input_cost_per_token_priority"`
  168. OutputCostPerToken *float64 `json:"output_cost_per_token"`
  169. OutputCostPerTokenPriority *float64 `json:"output_cost_per_token_priority"`
  170. CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"`
  171. CacheCreationInputTokenCostPriority *float64 `json:"cache_creation_input_token_cost_priority"`
  172. CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"`
  173. CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"`
  174. CacheReadInputTokenCostPriority *float64 `json:"cache_read_input_token_cost_priority"`
  175. LongContextInputTokenThreshold *int `json:"long_context_input_token_threshold"`
  176. LongContextInputCostMultiplier *float64 `json:"long_context_input_cost_multiplier"`
  177. LongContextOutputCostMultiplier *float64 `json:"long_context_output_cost_multiplier"`
  178. SupportsServiceTier bool `json:"supports_service_tier"`
  179. LiteLLMProvider string `json:"litellm_provider"`
  180. Mode string `json:"mode"`
  181. SupportsPromptCaching bool `json:"supports_prompt_caching"`
  182. OutputCostPerImage *float64 `json:"output_cost_per_image"`
  183. OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"`
  184. InputCostPerImageToken *float64 `json:"input_cost_per_image_token"`
  185. }
  186. // PricingService 动态价格服务
  187. type PricingService struct {
  188. cfg *config.Config
  189. remoteClient PricingRemoteClient
  190. mu sync.RWMutex
  191. pricingData map[string]*LiteLLMModelPricing
  192. lastUpdated time.Time
  193. localHash string
  194. // fallback/override 文件在最近一次成功重建时的内容指纹,定时器据此判断是否
  195. // 需要从本地目录缓存重建叠加层。
  196. customFilesHash string
  197. // 停止信号
  198. stopCh chan struct{}
  199. wg sync.WaitGroup
  200. }
  201. // NewPricingService 创建价格服务
  202. func NewPricingService(cfg *config.Config, remoteClient PricingRemoteClient) *PricingService {
  203. s := &PricingService{
  204. cfg: cfg,
  205. remoteClient: remoteClient,
  206. pricingData: make(map[string]*LiteLLMModelPricing),
  207. stopCh: make(chan struct{}),
  208. }
  209. return s
  210. }
  211. // Initialize 初始化价格服务
  212. func (s *PricingService) Initialize() error {
  213. // 确保数据目录存在
  214. if err := os.MkdirAll(s.cfg.Pricing.DataDir, 0755); err != nil {
  215. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to create data directory: %v", err)
  216. }
  217. // 首次加载价格数据
  218. if err := s.checkAndUpdatePricing(); err != nil {
  219. logger.LegacyPrintf("service.pricing", "[Pricing] Initial load failed, using fallback: %v", err)
  220. if err := s.useFallbackPricing(); err != nil {
  221. return fmt.Errorf("failed to load pricing data: %w", err)
  222. }
  223. }
  224. // 启动定时更新
  225. s.startUpdateScheduler()
  226. logger.LegacyPrintf("service.pricing", "[Pricing] Service initialized with %d models", len(s.pricingData))
  227. return nil
  228. }
  229. // Stop 停止价格服务
  230. func (s *PricingService) Stop() {
  231. close(s.stopCh)
  232. s.wg.Wait()
  233. logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped")
  234. }
  235. // startUpdateScheduler 启动定时调度器:每个周期先做远程目录哈希同步(配置了 remote_url 时),
  236. // 再比对 fallback/override 文件指纹做本地热重载(配置了任一文件时)。两者都未配置则不启动。
  237. func (s *PricingService) startUpdateScheduler() {
  238. if s == nil || s.cfg == nil {
  239. return
  240. }
  241. remoteEnabled := strings.TrimSpace(s.cfg.Pricing.RemoteURL) != ""
  242. watchCustom := s.hasCustomPricingFiles()
  243. if !remoteEnabled {
  244. logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty")
  245. }
  246. if !remoteEnabled && !watchCustom {
  247. return
  248. }
  249. hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute
  250. if hashInterval < time.Minute {
  251. hashInterval = 10 * time.Minute
  252. }
  253. s.wg.Add(1)
  254. go func() {
  255. defer s.wg.Done()
  256. ticker := time.NewTicker(hashInterval)
  257. defer ticker.Stop()
  258. for {
  259. select {
  260. case <-ticker.C:
  261. if remoteEnabled {
  262. if err := s.syncWithRemote(); err != nil {
  263. logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err)
  264. }
  265. }
  266. if watchCustom {
  267. s.reloadIfCustomFilesChanged()
  268. }
  269. case <-s.stopCh:
  270. return
  271. }
  272. }
  273. }()
  274. logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v, remote sync=%t, custom file watch=%t)", hashInterval, remoteEnabled, watchCustom)
  275. }
  276. // checkAndUpdatePricing 检查并更新价格数据
  277. func (s *PricingService) checkAndUpdatePricing() error {
  278. pricingFile := s.getPricingFilePath()
  279. // 检查本地文件是否存在
  280. if _, err := os.Stat(pricingFile); os.IsNotExist(err) {
  281. logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Local pricing file not found, downloading...")
  282. return s.downloadPricingData()
  283. }
  284. // 先加载本地文件(确保服务可用),再检查是否需要更新
  285. if err := s.loadPricingData(pricingFile); err != nil {
  286. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to load local file, downloading: %v", err)
  287. return s.downloadPricingData()
  288. }
  289. // 如果配置了哈希URL,通过远程哈希检查是否有更新
  290. if s.cfg.Pricing.HashURL != "" {
  291. remoteHash, err := s.fetchRemoteHash()
  292. if err != nil {
  293. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash on startup: %v", err)
  294. return nil // 已加载本地文件,哈希获取失败不影响启动
  295. }
  296. s.mu.RLock()
  297. localHash := s.localHash
  298. s.mu.RUnlock()
  299. if localHash == "" || remoteHash != localHash {
  300. logger.LegacyPrintf("service.pricing", "[Pricing] Remote hash differs on startup (local=%s remote=%s), downloading...",
  301. localHash[:min(8, len(localHash))], remoteHash[:min(8, len(remoteHash))])
  302. if err := s.downloadPricingData(); err != nil {
  303. logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err)
  304. }
  305. }
  306. return nil
  307. }
  308. // 没有哈希URL时,基于文件年龄检查
  309. info, err := os.Stat(pricingFile)
  310. if err != nil {
  311. return nil // 已加载本地文件
  312. }
  313. fileAge := time.Since(info.ModTime())
  314. maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour
  315. if fileAge > maxAge {
  316. logger.LegacyPrintf("service.pricing", "[Pricing] Local file is %v old, updating...", fileAge.Round(time.Hour))
  317. if err := s.downloadPricingData(); err != nil {
  318. logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err)
  319. }
  320. }
  321. return nil
  322. }
  323. // syncWithRemote 与远程同步(基于哈希校验)
  324. func (s *PricingService) syncWithRemote() error {
  325. // 如果配置了哈希URL,从远程获取哈希进行比对
  326. if s.cfg.Pricing.HashURL != "" {
  327. remoteHash, err := s.fetchRemoteHash()
  328. if err != nil {
  329. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash: %v", err)
  330. return nil // 哈希获取失败不影响正常使用
  331. }
  332. s.mu.RLock()
  333. localHash := s.localHash
  334. s.mu.RUnlock()
  335. if localHash == "" || remoteHash != localHash {
  336. logger.LegacyPrintf("service.pricing", "[Pricing] Remote hash differs (local=%s remote=%s), downloading new version...",
  337. localHash[:min(8, len(localHash))], remoteHash[:min(8, len(remoteHash))])
  338. return s.downloadPricingData()
  339. }
  340. logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Hash check passed, no update needed")
  341. return nil
  342. }
  343. // 没有哈希URL时,基于时间检查
  344. pricingFile := s.getPricingFilePath()
  345. info, err := os.Stat(pricingFile)
  346. if err != nil {
  347. return s.downloadPricingData()
  348. }
  349. fileAge := time.Since(info.ModTime())
  350. maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour
  351. if fileAge > maxAge {
  352. logger.LegacyPrintf("service.pricing", "[Pricing] File is %v old, downloading...", fileAge.Round(time.Hour))
  353. return s.downloadPricingData()
  354. }
  355. return nil
  356. }
  357. // hasCustomPricingFiles 报告是否配置了 fallback/override 任一文件路径(不要求文件存在)。
  358. func (s *PricingService) hasCustomPricingFiles() bool {
  359. if s == nil || s.cfg == nil {
  360. return false
  361. }
  362. return strings.TrimSpace(s.cfg.Pricing.FallbackFile) != "" || strings.TrimSpace(s.cfg.Pricing.OverrideFile) != ""
  363. }
  364. // customPricingFilesFingerprint 返回 fallback、override 两个文件当前内容的联合 sha256。
  365. // 每个文件以"长度前缀 + 正文"参与计算,不可读的文件按空正文处理;未配置任何文件返回空串。
  366. func (s *PricingService) customPricingFilesFingerprint() string {
  367. if !s.hasCustomPricingFiles() {
  368. return ""
  369. }
  370. h := sha256.New()
  371. for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} {
  372. var body []byte
  373. if p := strings.TrimSpace(path); p != "" {
  374. body, _ = os.ReadFile(p)
  375. }
  376. var size [8]byte
  377. binary.BigEndian.PutUint64(size[:], uint64(len(body)))
  378. _, _ = h.Write(size[:])
  379. _, _ = h.Write(body)
  380. }
  381. return hex.EncodeToString(h.Sum(nil))
  382. }
  383. // validateCustomPricingFiles 要求每个已配置且存在的 fallback/override 文件可读且为 JSON
  384. // 对象,任一不满足即返回带路径的错误;文件不存在视为该层为空,属合法状态。
  385. func (s *PricingService) validateCustomPricingFiles() error {
  386. for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} {
  387. p := strings.TrimSpace(path)
  388. if p == "" {
  389. continue
  390. }
  391. body, err := os.ReadFile(p)
  392. if os.IsNotExist(err) {
  393. continue
  394. }
  395. if err != nil {
  396. return err
  397. }
  398. var entries map[string]json.RawMessage
  399. if err := json.Unmarshal(body, &entries); err != nil {
  400. return fmt.Errorf("%s: %w", p, err)
  401. }
  402. }
  403. return nil
  404. }
  405. // reloadIfCustomFilesChanged 比对 fallback/override 文件指纹,与最近一次重建时不同则从
  406. // 本地目录缓存重建内存数据。文件被删除视为该层清空,照常重建;文件存在但不可读或不是
  407. // JSON 对象时保留当前数据且不更新指纹,下一轮会再次尝试并重复告警。目录正文与远程同步
  408. // 锚点(localHash)不受本路径影响。
  409. func (s *PricingService) reloadIfCustomFilesChanged() {
  410. fingerprint := s.customPricingFilesFingerprint()
  411. s.mu.RLock()
  412. unchanged := fingerprint == s.customFilesHash
  413. s.mu.RUnlock()
  414. if unchanged {
  415. return
  416. }
  417. if err := s.reloadCustomPricingLayers(); err != nil {
  418. logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but reload failed: %v", err)
  419. }
  420. }
  421. // reloadCustomPricingLayers 读取本地目录缓存并重新叠加 fallback/override,只替换内存数据
  422. // 与叠加层指纹。
  423. func (s *PricingService) reloadCustomPricingLayers() error {
  424. pricingFile := s.getPricingFilePath()
  425. // 定价层文件可能在读取期间被替换。只有构建前后指纹一致时才提交,
  426. // 否则丢弃这次混合快照并重试,避免短暂应用不匹配的 fallback/override。
  427. var data map[string]*LiteLLMModelPricing
  428. var fingerprint string
  429. var err error
  430. for attempt := 0; attempt < 3; attempt++ {
  431. if validateErr := s.validateCustomPricingFiles(); validateErr != nil {
  432. return fmt.Errorf("validate custom pricing files: %w", validateErr)
  433. }
  434. before := s.customPricingFilesFingerprint()
  435. body, readErr := os.ReadFile(pricingFile)
  436. if readErr != nil {
  437. return fmt.Errorf("read file failed: %w", readErr)
  438. }
  439. data, fingerprint, err = s.buildPricingData(body)
  440. if err != nil {
  441. return fmt.Errorf("parse pricing data: %w", err)
  442. }
  443. after := s.customPricingFilesFingerprint()
  444. if validateErr := s.validateCustomPricingFiles(); validateErr != nil {
  445. return fmt.Errorf("validate custom pricing files: %w", validateErr)
  446. }
  447. if before == after && after == fingerprint {
  448. break
  449. }
  450. if attempt == 2 {
  451. return fmt.Errorf("custom pricing files changed during reload")
  452. }
  453. }
  454. s.mu.Lock()
  455. warnDroppedLongContextLadders(s.pricingData, data)
  456. s.pricingData = data
  457. s.customFilesHash = fingerprint
  458. s.mu.Unlock()
  459. logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing files changed, reloaded %d models from %s", len(data), pricingFile)
  460. return nil
  461. }
  462. // downloadPricingData 从远程下载价格数据
  463. func (s *PricingService) downloadPricingData() error {
  464. remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL)
  465. if err != nil {
  466. return err
  467. }
  468. logger.LegacyPrintf("service.pricing", "[Pricing] Downloading from %s", remoteURL)
  469. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  470. defer cancel()
  471. // 获取远程哈希(用于同步锚点,不作为完整性校验)
  472. var remoteHash string
  473. if strings.TrimSpace(s.cfg.Pricing.HashURL) != "" {
  474. remoteHash, err = s.fetchRemoteHash()
  475. if err != nil {
  476. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash (continuing): %v", err)
  477. }
  478. }
  479. body, err := s.remoteClient.FetchPricingJSON(ctx, remoteURL)
  480. if err != nil {
  481. return fmt.Errorf("download failed: %w", err)
  482. }
  483. // 哈希校验:不匹配时仅告警,不阻止更新
  484. // 远程哈希文件可能与数据文件不同步(如维护者更新了数据但未更新哈希文件)
  485. dataHash := sha256.Sum256(body)
  486. dataHashStr := hex.EncodeToString(dataHash[:])
  487. if remoteHash != "" && !strings.EqualFold(remoteHash, dataHashStr) {
  488. logger.LegacyPrintf("service.pricing", "[Pricing] Hash mismatch warning: remote=%s data=%s (hash file may be out of sync)",
  489. remoteHash[:min(8, len(remoteHash))], dataHashStr[:8])
  490. }
  491. data, customFilesHash, err := s.buildPricingData(body)
  492. if err != nil {
  493. return fmt.Errorf("parse pricing data: %w", err)
  494. }
  495. // 保存到本地文件
  496. pricingFile := s.getPricingFilePath()
  497. if err := os.WriteFile(pricingFile, body, 0644); err != nil {
  498. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save file: %v", err)
  499. }
  500. // 使用远程哈希作为同步锚点,防止重复下载
  501. // 当远程哈希不可用时,回退到数据本身的哈希
  502. syncHash := dataHashStr
  503. if remoteHash != "" {
  504. syncHash = remoteHash
  505. }
  506. hashFile := s.getHashFilePath()
  507. if err := os.WriteFile(hashFile, []byte(syncHash+"\n"), 0644); err != nil {
  508. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save hash: %v", err)
  509. }
  510. // 更新内存数据
  511. s.mu.Lock()
  512. warnDroppedLongContextLadders(s.pricingData, data)
  513. s.pricingData = data
  514. s.lastUpdated = time.Now()
  515. s.localHash = syncHash
  516. s.customFilesHash = customFilesHash
  517. s.mu.Unlock()
  518. logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data))
  519. return nil
  520. }
  521. // parsePricingData 解析价格数据(处理各种格式)
  522. func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModelPricing, error) {
  523. // 首先解析为 map[string]json.RawMessage
  524. var rawData map[string]json.RawMessage
  525. if err := json.Unmarshal(body, &rawData); err != nil {
  526. return nil, fmt.Errorf("parse raw JSON: %w", err)
  527. }
  528. rawData = s.applyPricingOverrides(rawData)
  529. result := make(map[string]*LiteLLMModelPricing)
  530. skipped := 0
  531. var orphanCacheTiers, lopsidedLadders []string
  532. for modelName, rawEntry := range rawData {
  533. // 跳过 sample_spec 等文档条目
  534. if modelName == "sample_spec" {
  535. continue
  536. }
  537. // 尝试解析每个条目
  538. var entry LiteLLMRawEntry
  539. if err := json.Unmarshal(rawEntry, &entry); err != nil {
  540. skipped++
  541. continue
  542. }
  543. // 只保留有有效价格的条目
  544. if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil && entry.InputCostPerImageToken == nil {
  545. continue
  546. }
  547. pricing := &LiteLLMModelPricing{
  548. LiteLLMProvider: entry.LiteLLMProvider,
  549. Mode: entry.Mode,
  550. SupportsPromptCaching: entry.SupportsPromptCaching,
  551. SupportsServiceTier: entry.SupportsServiceTier,
  552. TokenPricingAbsent: entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil,
  553. }
  554. if entry.InputCostPerToken != nil {
  555. pricing.InputCostPerToken = *entry.InputCostPerToken
  556. }
  557. if entry.InputCostPerTokenPriority != nil {
  558. pricing.InputCostPerTokenPriority = *entry.InputCostPerTokenPriority
  559. }
  560. if entry.OutputCostPerToken != nil {
  561. pricing.OutputCostPerToken = *entry.OutputCostPerToken
  562. }
  563. if entry.OutputCostPerTokenPriority != nil {
  564. pricing.OutputCostPerTokenPriority = *entry.OutputCostPerTokenPriority
  565. }
  566. if entry.CacheCreationInputTokenCost != nil {
  567. pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost
  568. }
  569. if entry.CacheCreationInputTokenCostPriority != nil {
  570. pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority
  571. }
  572. if entry.CacheCreationInputTokenCostAbove1hr != nil {
  573. pricing.CacheCreationInputTokenCostAbove1hr = *entry.CacheCreationInputTokenCostAbove1hr
  574. }
  575. if entry.CacheReadInputTokenCost != nil {
  576. pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
  577. }
  578. if entry.CacheReadInputTokenCostPriority != nil {
  579. pricing.CacheReadInputTokenCostPriority = *entry.CacheReadInputTokenCostPriority
  580. }
  581. if entry.LongContextInputTokenThreshold != nil {
  582. pricing.LongContextInputTokenThreshold = *entry.LongContextInputTokenThreshold
  583. }
  584. if entry.LongContextInputCostMultiplier != nil {
  585. pricing.LongContextInputCostMultiplier = *entry.LongContextInputCostMultiplier
  586. }
  587. if entry.LongContextOutputCostMultiplier != nil {
  588. pricing.LongContextOutputCostMultiplier = *entry.LongContextOutputCostMultiplier
  589. }
  590. if entry.OutputCostPerImage != nil {
  591. pricing.OutputCostPerImage = *entry.OutputCostPerImage
  592. }
  593. if entry.OutputCostPerImageToken != nil {
  594. pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken
  595. }
  596. if entry.InputCostPerImageToken != nil {
  597. pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
  598. }
  599. hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil ||
  600. entry.LongContextInputCostMultiplier != nil ||
  601. entry.LongContextOutputCostMultiplier != nil
  602. if !hasExplicitLongContext {
  603. deriveLongContextFromAboveTierFields(rawEntry, pricing)
  604. if isLopsidedLongContextLadder(pricing) {
  605. lopsidedLadders = append(lopsidedLadders, fmt.Sprintf("%s(input x%.2f, output x%.2f)", modelName,
  606. pricing.LongContextInputCostMultiplier, pricing.LongContextOutputCostMultiplier))
  607. }
  608. }
  609. if orphans := orphanCacheTierFields(rawEntry); len(orphans) > 0 {
  610. orphanCacheTiers = append(orphanCacheTiers, modelName+"("+strings.Join(orphans, ",")+")")
  611. }
  612. result[modelName] = pricing
  613. }
  614. if skipped > 0 {
  615. logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped)
  616. }
  617. warnOrphanCacheTierFields(orphanCacheTiers)
  618. warnLopsidedLongContextLadders(lopsidedLadders)
  619. if len(result) == 0 {
  620. return nil, fmt.Errorf("no valid pricing entries found")
  621. }
  622. return result, nil
  623. }
  624. // deriveLongContextFromAboveTierFields 把 LiteLLM 目录的 *_above_XXXk_tokens 绝对价字段
  625. // 折算成 long_context_* 阈值+倍率(sub2api 计费机制的内部表达):阈值取自字段名,
  626. // 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由
  627. // 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该
  628. // 模型的阶梯。多个阈值并存时取最小阈值。
  629. // cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算:
  630. // 目录条目的 cache above 档须恰为基础价 × 输入倍率;缺基础价的 cache above 字段
  631. // 无法参与计费,由 orphanCacheTierFields 哨兵告警。
  632. func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) {
  633. if pricing == nil ||
  634. pricing.LongContextInputTokenThreshold > 0 ||
  635. pricing.LongContextInputCostMultiplier > 0 ||
  636. pricing.LongContextOutputCostMultiplier > 0 {
  637. return
  638. }
  639. if !bytes.Contains(rawEntry, []byte("_above_")) {
  640. return
  641. }
  642. var fields map[string]any
  643. if err := json.Unmarshal(rawEntry, &fields); err != nil {
  644. return
  645. }
  646. type tierPrices struct{ input, output float64 }
  647. tiers := make(map[int]*tierPrices)
  648. for key, value := range fields {
  649. m := aboveTierPricePattern.FindStringSubmatch(key)
  650. if m == nil {
  651. continue
  652. }
  653. price, ok := value.(float64)
  654. if !ok || price <= 0 {
  655. continue
  656. }
  657. thousands, err := strconv.Atoi(m[2])
  658. if err != nil || thousands <= 0 {
  659. continue
  660. }
  661. threshold := thousands * 1000
  662. tp := tiers[threshold]
  663. if tp == nil {
  664. tp = &tierPrices{}
  665. tiers[threshold] = tp
  666. }
  667. if m[1] == "input" {
  668. tp.input = price
  669. } else {
  670. tp.output = price
  671. }
  672. }
  673. if len(tiers) == 0 {
  674. return
  675. }
  676. threshold := 0
  677. for t := range tiers {
  678. if threshold == 0 || t < threshold {
  679. threshold = t
  680. }
  681. }
  682. tp := tiers[threshold]
  683. inputMultiplier, outputMultiplier := 1.0, 1.0
  684. if tp.input > 0 && pricing.InputCostPerToken > 0 {
  685. inputMultiplier = tp.input / pricing.InputCostPerToken
  686. }
  687. if tp.output > 0 && pricing.OutputCostPerToken > 0 {
  688. outputMultiplier = tp.output / pricing.OutputCostPerToken
  689. }
  690. // above 价不高于基础价时视为无附加费,不生成阶梯。
  691. if inputMultiplier <= 1 && outputMultiplier <= 1 {
  692. return
  693. }
  694. pricing.LongContextInputTokenThreshold = threshold
  695. pricing.LongContextInputCostMultiplier = inputMultiplier
  696. pricing.LongContextOutputCostMultiplier = outputMultiplier
  697. }
  698. // isLopsidedLongContextLadder 判断折算出的阶梯是否只有一侧带附加费。官方阶梯(OpenAI、
  699. // Google、Anthropic、xAI)都同时抬高 input 与 output;单侧附加费意味着条目的基础价与
  700. // above 档来自不同价格版本(如基础价被手工 pin、above 档随上游更新),折算出的倍率失真。
  701. func isLopsidedLongContextLadder(pricing *LiteLLMModelPricing) bool {
  702. if pricing == nil || pricing.LongContextInputTokenThreshold <= 0 {
  703. return false
  704. }
  705. return (pricing.LongContextInputCostMultiplier > 1) != (pricing.LongContextOutputCostMultiplier > 1)
  706. }
  707. // warnLopsidedLongContextLadders 对单侧附加费的折算阶梯打 WARN:应成组修正该条目的
  708. // 基础价与 above 档(目录或 pricing.override_file)。
  709. func warnLopsidedLongContextLadders(entries []string) {
  710. if len(entries) == 0 {
  711. return
  712. }
  713. sort.Strings(entries)
  714. total := len(entries)
  715. if total > 20 {
  716. entries = append(entries[:20], "...")
  717. }
  718. 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, ", "))
  719. }
  720. // orphanCacheTierFields 返回条目中没有对应基础价的 cache 侧 above 档字段名。
  721. // cache 侧 above 档不参与计费取值,计费按"基础价 × 输入倍率";基础价缺失或为 0 时,
  722. // 该缓存分项在整个阶梯上都按 0 计。计费对变体有回落:服务档变体(_priority/_flex)
  723. // 缺自身基础价时用标准基础价,1h 缓存写入缺 above_1hr 价时全部按 5m 价——因此沿
  724. // 回落链任一基础价存在即不算孤儿。
  725. func orphanCacheTierFields(rawEntry json.RawMessage) []string {
  726. if !bytes.Contains(rawEntry, []byte("_above_")) {
  727. return nil
  728. }
  729. var fields map[string]any
  730. if err := json.Unmarshal(rawEntry, &fields); err != nil {
  731. return nil
  732. }
  733. positive := func(key string) bool {
  734. price, ok := fields[key].(float64)
  735. return ok && price > 0
  736. }
  737. var orphans []string
  738. for key := range fields {
  739. m := cacheTierPricePattern.FindStringSubmatch(key)
  740. if m == nil || !positive(key) {
  741. continue
  742. }
  743. stem, hourly, tier := m[1], m[2], m[3]
  744. if positive(stem+hourly+tier) || positive(stem+hourly) || positive(stem+tier) || positive(stem) {
  745. continue
  746. }
  747. orphans = append(orphans, key)
  748. }
  749. sort.Strings(orphans)
  750. return orphans
  751. }
  752. // warnOrphanCacheTierFields 对带 cache 侧 above 档却没有基础价的条目打 WARN:
  753. // 该缓存分项按 0 计费,目录或 pricing.override_file 补上基础价即可消除。
  754. func warnOrphanCacheTierFields(entries []string) {
  755. if len(entries) == 0 {
  756. return
  757. }
  758. sort.Strings(entries)
  759. total := len(entries)
  760. if total > 20 {
  761. entries = append(entries[:20], "...")
  762. }
  763. 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, ", "))
  764. }
  765. // applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退
  766. // 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补
  767. // 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后
  768. // 统一并入——若在此处抢先建条目,纯 override 条目会挡住回退文件中同名完整条目的合并。
  769. func (s *PricingService) applyPricingOverrides(rawData map[string]json.RawMessage) map[string]json.RawMessage {
  770. overrides := s.loadPricingOverrideEntries()
  771. if len(overrides) == 0 {
  772. return rawData
  773. }
  774. for name, patch := range overrides {
  775. base, ok := rawData[name]
  776. if !ok {
  777. continue
  778. }
  779. merged, valid := mergePricingOverrideEntry(base, patch)
  780. if !valid {
  781. logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override entry %q skipped: not a JSON object", name)
  782. continue
  783. }
  784. rawData[name] = merged
  785. }
  786. return rawData
  787. }
  788. // loadPricingOverrideEntries 读取 override 文件的原始条目。未配置返回 nil;
  789. // 读取或解析失败打日志并跳过,不影响目录加载。
  790. func (s *PricingService) loadPricingOverrideEntries() map[string]json.RawMessage {
  791. if s == nil || s.cfg == nil {
  792. return nil
  793. }
  794. path := strings.TrimSpace(s.cfg.Pricing.OverrideFile)
  795. if path == "" {
  796. return nil
  797. }
  798. body, err := os.ReadFile(path)
  799. if err != nil {
  800. logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err)
  801. return nil
  802. }
  803. var entries map[string]json.RawMessage
  804. if err := json.Unmarshal(body, &entries); err != nil {
  805. logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err)
  806. return nil
  807. }
  808. return entries
  809. }
  810. // mergePricingOverrideEntry 在 JSON 字段层浅合并:patch 字段覆盖 base 同名字段,
  811. // 值为 null 的 patch 字段从结果中删除,base 为空时结果即 patch 本身。
  812. // patch 不是 JSON 对象时返回 ok=false。
  813. func mergePricingOverrideEntry(base, patch json.RawMessage) (json.RawMessage, bool) {
  814. var patchFields map[string]any
  815. if err := json.Unmarshal(patch, &patchFields); err != nil || patchFields == nil {
  816. return nil, false
  817. }
  818. merged := make(map[string]any, len(patchFields))
  819. if len(base) > 0 {
  820. // base 非对象时忽略,仅以 patch 为准。
  821. if err := json.Unmarshal(base, &merged); err != nil {
  822. merged = make(map[string]any, len(patchFields))
  823. }
  824. }
  825. for k, v := range patchFields {
  826. if v == nil {
  827. delete(merged, k)
  828. continue
  829. }
  830. merged[k] = v
  831. }
  832. out, err := json.Marshal(merged)
  833. if err != nil {
  834. return nil, false
  835. }
  836. return out, true
  837. }
  838. // mergeOverrideOnlyModels 把 override 中目录/回退两层都不存在的模型作为独立条目并入
  839. // (条目须自带价格字段才能通过有效性过滤),并对最终仍未生效的条目打 WARN:
  840. // 模型名拼错、或纯补丁条目落在不存在的模型上时会被静默丢弃,让"已改价/已关阶梯"
  841. // 的运营预期与实际计费脱节,这里是唯一的哨兵。
  842. func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing {
  843. overrides := s.loadPricingOverrideEntries()
  844. if len(overrides) == 0 {
  845. return data
  846. }
  847. if data == nil {
  848. data = make(map[string]*LiteLLMModelPricing)
  849. }
  850. leftover := make(map[string]json.RawMessage)
  851. for name, patch := range overrides {
  852. if _, ok := data[name]; !ok {
  853. leftover[name] = patch
  854. }
  855. }
  856. if len(leftover) == 0 {
  857. return data
  858. }
  859. // 复用主解析路径(含 above_XXXk 折算与有效性过滤);applyPricingOverrides
  860. // 对已存在条目做的自我修补是幂等的,不会二次改值。
  861. if body, err := json.Marshal(leftover); err == nil {
  862. if parsed, err := s.parsePricingData(body); err == nil {
  863. maps.Copy(data, parsed)
  864. }
  865. }
  866. var missing []string
  867. for name := range leftover {
  868. if _, ok := data[name]; !ok {
  869. missing = append(missing, name)
  870. }
  871. }
  872. if len(missing) == 0 {
  873. return data
  874. }
  875. sort.Strings(missing)
  876. 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, ", "))
  877. return data
  878. }
  879. // buildPricingData 解析目录正文并依次叠加 fallback、override 两层,返回合并结果与
  880. // 叠加层文件指纹。指纹在合并读取之前采样:并发改文件只会让存下的指纹落后于实际
  881. // 合并的数据、不会领先,下一轮定时比对因此会再次重建。
  882. func (s *PricingService) buildPricingData(body []byte) (map[string]*LiteLLMModelPricing, string, error) {
  883. fingerprint := s.customPricingFilesFingerprint()
  884. data, err := s.parsePricingData(body)
  885. if err != nil {
  886. return nil, "", err
  887. }
  888. data = s.mergeFallbackPricingData(data)
  889. data = s.mergeOverrideOnlyModels(data)
  890. return data, fingerprint, nil
  891. }
  892. // loadPricingData 从本地文件加载价格数据
  893. func (s *PricingService) loadPricingData(filePath string) error {
  894. data, err := os.ReadFile(filePath)
  895. if err != nil {
  896. return fmt.Errorf("read file failed: %w", err)
  897. }
  898. pricingData, customFilesHash, err := s.buildPricingData(data)
  899. if err != nil {
  900. return fmt.Errorf("parse pricing data: %w", err)
  901. }
  902. // 计算哈希
  903. hash := sha256.Sum256(data)
  904. hashStr := hex.EncodeToString(hash[:])
  905. s.mu.Lock()
  906. warnDroppedLongContextLadders(s.pricingData, pricingData)
  907. s.pricingData = pricingData
  908. s.localHash = hashStr
  909. s.customFilesHash = customFilesHash
  910. info, _ := os.Stat(filePath)
  911. if info != nil {
  912. s.lastUpdated = info.ModTime()
  913. } else {
  914. s.lastUpdated = time.Now()
  915. }
  916. s.mu.Unlock()
  917. logger.LegacyPrintf("service.pricing", "[Pricing] Loaded %d models from %s", len(pricingData), filePath)
  918. return nil
  919. }
  920. func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing {
  921. if data == nil {
  922. data = make(map[string]*LiteLLMModelPricing)
  923. }
  924. if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.FallbackFile) == "" {
  925. return data
  926. }
  927. fallbackBody, err := os.ReadFile(s.cfg.Pricing.FallbackFile)
  928. if err != nil {
  929. logger.LegacyPrintf("service.pricing", "[Pricing] Fallback merge skipped: %v", err)
  930. return data
  931. }
  932. fallbackData, err := s.parsePricingData(fallbackBody)
  933. if err != nil {
  934. logger.LegacyPrintf("service.pricing", "[Pricing] Fallback merge parse skipped: %v", err)
  935. return data
  936. }
  937. merged := 0
  938. for modelName, pricing := range fallbackData {
  939. if _, ok := data[modelName]; ok {
  940. continue
  941. }
  942. data[modelName] = pricing
  943. merged++
  944. }
  945. if merged > 0 {
  946. logger.LegacyPrintf("service.pricing", "[Pricing] Merged %d fallback-only models", merged)
  947. }
  948. return data
  949. }
  950. // warnDroppedLongContextLadders 对比新旧目录数据:原本带长上下文阶梯的条目在新数据里
  951. // 丢失阈值时打 WARN。阶梯已完全数据驱动(无代码兜底),数据源一次误提交就会把阶梯
  952. // 静默变成基础价少收(07-14~08-21 漏收事故的形态),这里是唯一的哨兵。
  953. // 调用方需持有 s.mu 写锁。
  954. func warnDroppedLongContextLadders(old, next map[string]*LiteLLMModelPricing) {
  955. if len(old) == 0 {
  956. return
  957. }
  958. var dropped []string
  959. for name, prev := range old {
  960. if prev == nil || prev.LongContextInputTokenThreshold <= 0 {
  961. continue
  962. }
  963. if cur, ok := next[name]; ok && (cur == nil || cur.LongContextInputTokenThreshold <= 0) {
  964. dropped = append(dropped, name)
  965. }
  966. }
  967. if len(dropped) == 0 {
  968. return
  969. }
  970. sort.Strings(dropped)
  971. total := len(dropped)
  972. if total > 20 {
  973. dropped = append(dropped[:20], "...")
  974. }
  975. 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, ", "))
  976. }
  977. // useFallbackPricing 使用回退价格文件
  978. func (s *PricingService) useFallbackPricing() error {
  979. fallbackFile := s.cfg.Pricing.FallbackFile
  980. if _, err := os.Stat(fallbackFile); os.IsNotExist(err) {
  981. return fmt.Errorf("fallback file not found: %s", fallbackFile)
  982. }
  983. logger.LegacyPrintf("service.pricing", "[Pricing] Using fallback file: %s", fallbackFile)
  984. // 复制到数据目录
  985. data, err := os.ReadFile(fallbackFile)
  986. if err != nil {
  987. return fmt.Errorf("read fallback failed: %w", err)
  988. }
  989. pricingFile := s.getPricingFilePath()
  990. if err := os.WriteFile(pricingFile, data, 0644); err != nil { //nolint:gosec // G703: 路径为配置的数据目录 + 硬编码文件名,非请求输入
  991. logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err)
  992. }
  993. return s.loadPricingData(fallbackFile)
  994. }
  995. // fetchRemoteHash 从远程获取哈希值
  996. func (s *PricingService) fetchRemoteHash() (string, error) {
  997. hashURL, err := s.validatePricingURL(s.cfg.Pricing.HashURL)
  998. if err != nil {
  999. return "", err
  1000. }
  1001. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  1002. defer cancel()
  1003. hash, err := s.remoteClient.FetchHashText(ctx, hashURL)
  1004. if err != nil {
  1005. return "", err
  1006. }
  1007. return strings.TrimSpace(hash), nil
  1008. }
  1009. func (s *PricingService) validatePricingURL(raw string) (string, error) {
  1010. if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
  1011. normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
  1012. if err != nil {
  1013. return "", fmt.Errorf("invalid pricing url: %w", err)
  1014. }
  1015. return normalized, nil
  1016. }
  1017. normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
  1018. AllowedHosts: s.cfg.Security.URLAllowlist.PricingHosts,
  1019. RequireAllowlist: true,
  1020. AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
  1021. })
  1022. if err != nil {
  1023. return "", fmt.Errorf("invalid pricing url: %w", err)
  1024. }
  1025. return normalized, nil
  1026. }
  1027. // GetModelPricing 获取模型价格(带模糊匹配)
  1028. func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing {
  1029. s.mu.RLock()
  1030. defer s.mu.RUnlock()
  1031. if modelName == "" {
  1032. return nil
  1033. }
  1034. // 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀)
  1035. modelLower := strings.ToLower(strings.TrimSpace(modelName))
  1036. lookupCandidates := s.buildModelLookupCandidates(modelLower)
  1037. // 1~3. 确定性识别(精确名 / 已知拼写变体 / 去掉日期版本后缀)
  1038. if pricing := s.lookupIdentifiedModelPricingLocked(lookupCandidates); pricing != nil {
  1039. return pricing
  1040. }
  1041. // 4. 基于模型系列匹配(Claude)
  1042. if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
  1043. return pricing
  1044. }
  1045. // 5. OpenAI 模型回退策略
  1046. if strings.HasPrefix(lookupCandidates[0], "gpt-") {
  1047. return s.matchOpenAIModel(lookupCandidates[0])
  1048. }
  1049. return nil
  1050. }
  1051. // lookupIdentifiedModelPricingLocked 只做"确定性识别"的三步查找:精确键、已知拼写
  1052. // 变体、去掉日期/版本后缀后的同名条目。它刻意不包含 matchByModelFamily /
  1053. // matchOpenAIModel 这类按子串猜系列的兜底——那些兜底会给任意名字都返回一个价格。
  1054. // 调用方必须持有 s.mu 读锁。
  1055. func (s *PricingService) lookupIdentifiedModelPricingLocked(lookupCandidates []string) *LiteLLMModelPricing {
  1056. if len(lookupCandidates) == 0 {
  1057. return nil
  1058. }
  1059. // 1. 精确匹配
  1060. for _, candidate := range lookupCandidates {
  1061. if candidate == "" {
  1062. continue
  1063. }
  1064. if pricing, ok := s.pricingData[candidate]; ok {
  1065. return pricing
  1066. }
  1067. }
  1068. // 2. 处理常见的模型名称变体
  1069. // claude-opus-4-5-20251101 -> claude-opus-4.5-20251101
  1070. for _, candidate := range lookupCandidates {
  1071. normalized := strings.ReplaceAll(candidate, "-4-5-", "-4.5-")
  1072. if pricing, ok := s.pricingData[normalized]; ok {
  1073. return pricing
  1074. }
  1075. }
  1076. // 3. 尝试模糊匹配(去掉版本号后缀)
  1077. // claude-opus-4-5-20251101 -> claude-opus-4.5
  1078. baseName := s.extractBaseName(lookupCandidates[0])
  1079. for key, pricing := range s.pricingData {
  1080. keyBase := s.extractBaseName(strings.ToLower(key))
  1081. if keyBase == baseName {
  1082. return pricing
  1083. }
  1084. }
  1085. return nil
  1086. }
  1087. // GetIdentifiedModelPricing 在价格表中确定性地识别模型,识别不到时返回 nil。
  1088. // 与 GetModelPricing 的区别:不会退化成按 "opus"/"haiku" 之类子串猜出的系列兜底价。
  1089. // 用于必须区分"这是价格表里已知的模型"和"这只是名字里带某个关键词"的场景。
  1090. func (s *PricingService) GetIdentifiedModelPricing(modelName string) *LiteLLMModelPricing {
  1091. if s == nil {
  1092. return nil
  1093. }
  1094. s.mu.RLock()
  1095. defer s.mu.RUnlock()
  1096. modelLower := strings.ToLower(strings.TrimSpace(modelName))
  1097. if modelLower == "" {
  1098. return nil
  1099. }
  1100. return s.lookupIdentifiedModelPricingLocked(s.buildModelLookupCandidates(modelLower))
  1101. }
  1102. func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
  1103. rawCandidates := []string{
  1104. modelLower,
  1105. strings.TrimPrefix(modelLower, "models/"),
  1106. lastSegment(modelLower),
  1107. lastSegment(strings.TrimPrefix(modelLower, "models/")),
  1108. }
  1109. normalized := normalizeModelNameForPricing(modelLower)
  1110. // A tier-specific entry should take precedence when the pricing catalog gains
  1111. // one later. Antigravity's Gemini Flash thinking tiers share the base rate,
  1112. // so the normalized base remains the fallback after the exact aliases.
  1113. candidates := rawCandidates
  1114. if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) {
  1115. candidates = append(candidates, normalized)
  1116. } else {
  1117. // Prefer canonical model names for all other aliases (including models/xxx).
  1118. candidates = append([]string{normalized}, candidates...)
  1119. }
  1120. seen := make(map[string]struct{}, len(candidates))
  1121. out := make([]string, 0, len(candidates))
  1122. for _, c := range candidates {
  1123. c = strings.TrimSpace(c)
  1124. if c == "" {
  1125. continue
  1126. }
  1127. if _, ok := seen[c]; ok {
  1128. continue
  1129. }
  1130. seen[c] = struct{}{}
  1131. out = append(out, c)
  1132. }
  1133. if len(out) == 0 {
  1134. return []string{modelLower}
  1135. }
  1136. return out
  1137. }
  1138. func normalizeModelNameForPricing(model string) string {
  1139. // Common Gemini/VertexAI forms:
  1140. // - models/gemini-2.0-flash-exp
  1141. // - publishers/google/models/gemini-2.5-pro
  1142. // - projects/.../locations/.../publishers/google/models/gemini-2.5-pro
  1143. model = strings.TrimSpace(model)
  1144. model = strings.TrimLeft(model, "/")
  1145. model = strings.TrimPrefix(model, "models/")
  1146. model = strings.TrimPrefix(model, "publishers/google/models/")
  1147. if idx := strings.LastIndex(model, "/publishers/google/models/"); idx != -1 {
  1148. model = model[idx+len("/publishers/google/models/"):]
  1149. }
  1150. if idx := strings.LastIndex(model, "/models/"); idx != -1 {
  1151. model = model[idx+len("/models/"):]
  1152. }
  1153. model = strings.TrimLeft(model, "/")
  1154. if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" {
  1155. if canonical == "gpt-6" {
  1156. return "gpt-6-astra"
  1157. }
  1158. if canonical == "gpt-5.6" {
  1159. return "gpt-5.6-sol"
  1160. }
  1161. if suffix, ok := strings.CutPrefix(canonical, "gpt-5.6-"); ok && (suffix == "max" || isKnownCodexModelSuffix(suffix)) {
  1162. return "gpt-5.6-sol"
  1163. }
  1164. return canonical
  1165. }
  1166. return normalizeGeminiThinkingTierAlias(model)
  1167. }
  1168. // normalizeGeminiThinkingTierAlias maps Antigravity's Gemini Flash
  1169. // thinking-tier model IDs to the public base model. The tier controls reasoning
  1170. // behavior, not the published token rate, so this keeps -high/-low/-medium and
  1171. // -tiered requests on the corresponding base model's price card.
  1172. func normalizeGeminiThinkingTierAlias(model string) string {
  1173. for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} {
  1174. for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} {
  1175. if model == baseModel+tier {
  1176. return baseModel
  1177. }
  1178. }
  1179. }
  1180. return model
  1181. }
  1182. func lastSegment(model string) string {
  1183. if idx := strings.LastIndex(model, "/"); idx != -1 {
  1184. return model[idx+1:]
  1185. }
  1186. return model
  1187. }
  1188. // extractBaseName 提取基础模型名称(去掉日期版本号)
  1189. func (s *PricingService) extractBaseName(model string) string {
  1190. // 移除日期后缀 (如 -20251101, -20241022)
  1191. parts := strings.Split(model, "-")
  1192. result := make([]string, 0, len(parts))
  1193. for _, part := range parts {
  1194. // 跳过看起来像日期的部分(8位数字)
  1195. if len(part) == 8 && isNumeric(part) {
  1196. continue
  1197. }
  1198. // 跳过版本号(如 v1:0)
  1199. if strings.Contains(part, ":") {
  1200. continue
  1201. }
  1202. result = append(result, part)
  1203. }
  1204. return strings.Join(result, "-")
  1205. }
  1206. // matchByModelFamily 基于模型系列匹配
  1207. func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
  1208. // modelFamily 定义一个模型系列的匹配和定价查找规则。
  1209. type modelFamily struct {
  1210. name string // 系列名称
  1211. match []string // 用于将模型归类到此系列的模式(strings.Contains 匹配)
  1212. pricing []string // 用于在定价数据中查找价格的模式(nil 则复用 match;可包含低版本 fallback)
  1213. }
  1214. // 按特异性降序排列:高版本号在前,避免 "claude-opus-4"(opus-4 系列)
  1215. // 因子串关系误匹配 "claude-opus-4-7"(opus-4.7 系列)。
  1216. // 注意:原 map 实现存在 Go map 迭代随机性导致的同类 bug,此处改为有序切片修复。
  1217. families := []modelFamily{
  1218. // Opus 5 与 Opus 4.8 同价($5/$25 per MTok)。定价数据缺失 claude-opus-5 时
  1219. // 必须回退到 4.8,否则会掉进 "opus-4" 系列按 $15/$75 计费(3 倍超收)。
  1220. {name: "opus-5", match: []string{"claude-opus-5"}, pricing: []string{"claude-opus-5", "claude-opus-4-8"}},
  1221. {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"}},
  1222. {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"}},
  1223. {name: "opus-4.6", match: []string{"claude-opus-4-6", "claude-opus-4.6"}},
  1224. {name: "opus-4.5", match: []string{"claude-opus-4-5", "claude-opus-4.5"}},
  1225. {name: "opus-4", match: []string{"claude-opus-4", "claude-3-opus"}},
  1226. {name: "sonnet-4.5", match: []string{"claude-sonnet-4-5", "claude-sonnet-4.5"}},
  1227. {name: "sonnet-4", match: []string{"claude-sonnet-4", "claude-3-5-sonnet"}},
  1228. {name: "sonnet-3.5", match: []string{"claude-3-5-sonnet", "claude-3.5-sonnet"}},
  1229. {name: "sonnet-3", match: []string{"claude-3-sonnet"}},
  1230. {name: "haiku-3.5", match: []string{"claude-3-5-haiku", "claude-3.5-haiku"}},
  1231. {name: "haiku-3", match: []string{"claude-3-haiku"}},
  1232. }
  1233. // Phase 1: 按有序切片归类(最具体的系列优先匹配)
  1234. var matched *modelFamily
  1235. for i := range families {
  1236. for _, pattern := range families[i].match {
  1237. if strings.Contains(model, pattern) || strings.Contains(model, strings.ReplaceAll(pattern, "-", "")) {
  1238. matched = &families[i]
  1239. break
  1240. }
  1241. }
  1242. if matched != nil {
  1243. break
  1244. }
  1245. }
  1246. // Phase 2: 二次兜底——当模型 ID 不含已知模式串时,按关键字粗分
  1247. if matched == nil {
  1248. var fallbackName string
  1249. switch {
  1250. case strings.Contains(model, "opus"):
  1251. switch {
  1252. // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
  1253. case strings.Contains(model, "opus-5") || strings.Contains(model, "opus5"):
  1254. fallbackName = "opus-5"
  1255. case strings.Contains(model, "4.8") || strings.Contains(model, "4-8"):
  1256. fallbackName = "opus-4.8"
  1257. case strings.Contains(model, "4.7") || strings.Contains(model, "4-7"):
  1258. fallbackName = "opus-4.7"
  1259. case strings.Contains(model, "4.6") || strings.Contains(model, "4-6"):
  1260. fallbackName = "opus-4.6"
  1261. case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"):
  1262. fallbackName = "opus-4.5"
  1263. default:
  1264. fallbackName = "opus-4"
  1265. }
  1266. case strings.Contains(model, "sonnet"):
  1267. switch {
  1268. case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"):
  1269. fallbackName = "sonnet-4.5"
  1270. case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"):
  1271. fallbackName = "sonnet-3.5"
  1272. default:
  1273. fallbackName = "sonnet-4"
  1274. }
  1275. case strings.Contains(model, "haiku"):
  1276. switch {
  1277. case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"):
  1278. fallbackName = "haiku-3.5"
  1279. default:
  1280. fallbackName = "haiku-3"
  1281. }
  1282. }
  1283. if fallbackName != "" {
  1284. for i := range families {
  1285. if families[i].name == fallbackName {
  1286. matched = &families[i]
  1287. break
  1288. }
  1289. }
  1290. }
  1291. }
  1292. if matched == nil {
  1293. return nil
  1294. }
  1295. // Phase 3: 在定价数据中查找该系列的价格
  1296. lookups := matched.pricing
  1297. if lookups == nil {
  1298. lookups = matched.match
  1299. }
  1300. for _, pattern := range lookups {
  1301. for key, pricing := range s.pricingData {
  1302. keyLower := strings.ToLower(key)
  1303. if strings.Contains(keyLower, pattern) {
  1304. logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key)
  1305. return pricing
  1306. }
  1307. }
  1308. }
  1309. return nil
  1310. }
  1311. // matchOpenAIModel OpenAI 模型回退匹配策略
  1312. // 回退顺序:
  1313. // 1. gpt-5.3-codex-spark* -> gpt-5.1-codex(按业务要求固定计费)
  1314. // 2. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等)
  1315. // 3. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号)
  1316. // 4. gpt-5.3-codex -> gpt-5.2-codex
  1317. // 5. gpt-5.4* -> 业务静态兜底价
  1318. // 6. 最终回退到 DefaultTestModel (gpt-5.1-codex)
  1319. func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
  1320. if strings.HasPrefix(model, "gpt-5.3-codex-spark") {
  1321. if pricing, ok := s.pricingData["gpt-5.1-codex"]; ok {
  1322. logger.LegacyPrintf("service.pricing", "[Pricing][SparkBilling] %s -> %s billing", model, "gpt-5.1-codex")
  1323. logger.With(zap.String("component", "service.pricing")).
  1324. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.1-codex"))
  1325. return pricing
  1326. }
  1327. }
  1328. // 尝试的回退变体
  1329. variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern)
  1330. for _, variant := range variants {
  1331. if pricing, ok := s.pricingData[variant]; ok {
  1332. logger.With(zap.String("component", "service.pricing")).
  1333. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, variant))
  1334. return pricing
  1335. }
  1336. }
  1337. if strings.HasPrefix(model, "gpt-5.3-codex") {
  1338. if pricing, ok := s.pricingData["gpt-5.2-codex"]; ok {
  1339. logger.With(zap.String("component", "service.pricing")).
  1340. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.2-codex"))
  1341. return pricing
  1342. }
  1343. }
  1344. if isOpenAIGPT6AstraModel(model) {
  1345. logger.With(zap.String("component", "service.pricing")).
  1346. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-6-astra(static)"))
  1347. return openAIGPT6AstraFallbackPricing
  1348. }
  1349. if strings.HasPrefix(model, "gpt-5.6-sol") {
  1350. logger.With(zap.String("component", "service.pricing")).
  1351. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-sol(static)"))
  1352. return openAIGPT56SolFallbackPricing
  1353. }
  1354. if strings.HasPrefix(model, "gpt-5.6-terra") {
  1355. logger.With(zap.String("component", "service.pricing")).
  1356. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-terra(static)"))
  1357. return openAIGPT56TerraFallbackPricing
  1358. }
  1359. if strings.HasPrefix(model, "gpt-5.6-luna") {
  1360. logger.With(zap.String("component", "service.pricing")).
  1361. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-luna(static)"))
  1362. return openAIGPT56LunaFallbackPricing
  1363. }
  1364. // GPT-5.5 回退到 GPT-5.4 定价
  1365. if strings.HasPrefix(model, "gpt-5.5") {
  1366. logger.With(zap.String("component", "service.pricing")).
  1367. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)"))
  1368. return openAIGPT54FallbackPricing
  1369. }
  1370. if strings.HasPrefix(model, "gpt-5.4-mini") {
  1371. logger.With(zap.String("component", "service.pricing")).
  1372. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-mini(static)"))
  1373. return openAIGPT54MiniFallbackPricing
  1374. }
  1375. if strings.HasPrefix(model, "gpt-5.4-nano") {
  1376. logger.With(zap.String("component", "service.pricing")).
  1377. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-nano(static)"))
  1378. return openAIGPT54NanoFallbackPricing
  1379. }
  1380. if strings.HasPrefix(model, "gpt-5.4") {
  1381. logger.With(zap.String("component", "service.pricing")).
  1382. Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)"))
  1383. return openAIGPT54FallbackPricing
  1384. }
  1385. // Remote price mirrors can lag new releases. Never bill GPT Image 2.5
  1386. // using the older image model's rates when its entry is absent.
  1387. for _, imageModel := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} {
  1388. if model == imageModel || model == imageModel+"-2026-09-08" {
  1389. return openAIGPTImage25FallbackPricing
  1390. }
  1391. }
  1392. if isOpenAIImageGenerationModel(model) {
  1393. for _, candidate := range []string{"gpt-image-2", "gpt-image-1.5", "gpt-image-1"} {
  1394. if pricing, ok := s.pricingData[candidate]; ok {
  1395. logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI image fallback matched %s -> %s", model, candidate)
  1396. return pricing
  1397. }
  1398. }
  1399. return nil
  1400. }
  1401. // 最终回退到 DefaultTestModel
  1402. defaultModel := strings.ToLower(openai.DefaultTestModel)
  1403. if pricing, ok := s.pricingData[defaultModel]; ok {
  1404. logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI fallback to default model %s -> %s", model, defaultModel)
  1405. return pricing
  1406. }
  1407. return nil
  1408. }
  1409. // generateOpenAIModelVariants 生成 OpenAI 模型的回退变体列表
  1410. func (s *PricingService) generateOpenAIModelVariants(model string, datePattern *regexp.Regexp) []string {
  1411. seen := make(map[string]bool)
  1412. var variants []string
  1413. addVariant := func(v string) {
  1414. if v != model && !seen[v] {
  1415. seen[v] = true
  1416. variants = append(variants, v)
  1417. }
  1418. }
  1419. // 1. 去掉日期版本号: gpt-5.2-20251222 -> gpt-5.2
  1420. withoutDate := datePattern.ReplaceAllString(model, "")
  1421. if withoutDate != model {
  1422. addVariant(withoutDate)
  1423. }
  1424. // 2. 提取基础版本号: gpt-5.2-codex -> gpt-5.2
  1425. // 只匹配纯数字版本号格式 gpt-X 或 gpt-X.Y,不匹配 gpt-4o 这种带字母后缀的
  1426. if matches := openAIModelBasePattern.FindStringSubmatch(model); len(matches) > 1 {
  1427. addVariant(matches[1])
  1428. }
  1429. // 3. 同时去掉日期后再提取基础版本号
  1430. if withoutDate != model {
  1431. if matches := openAIModelBasePattern.FindStringSubmatch(withoutDate); len(matches) > 1 {
  1432. addVariant(matches[1])
  1433. }
  1434. }
  1435. return variants
  1436. }
  1437. // GetStatus 获取服务状态
  1438. func (s *PricingService) GetStatus() map[string]any {
  1439. s.mu.RLock()
  1440. defer s.mu.RUnlock()
  1441. return map[string]any{
  1442. "model_count": len(s.pricingData),
  1443. "last_updated": s.lastUpdated,
  1444. "local_hash": s.localHash[:min(8, len(s.localHash))],
  1445. }
  1446. }
  1447. // ForceUpdate 强制更新
  1448. func (s *PricingService) ForceUpdate() error {
  1449. return s.downloadPricingData()
  1450. }
  1451. // getPricingFilePath 获取价格文件路径
  1452. func (s *PricingService) getPricingFilePath() string {
  1453. return filepath.Join(s.cfg.Pricing.DataDir, "model_pricing.json")
  1454. }
  1455. // getHashFilePath 获取哈希文件路径
  1456. func (s *PricingService) getHashFilePath() string {
  1457. return filepath.Join(s.cfg.Pricing.DataDir, "model_pricing.sha256")
  1458. }
  1459. // ListModelNamesByProvider returns all model names in the catalog whose
  1460. // LiteLLMProvider matches the given provider string (case-insensitive).
  1461. // The returned slice is sorted alphabetically.
  1462. func (s *PricingService) ListModelNamesByProvider(provider string) []string {
  1463. s.mu.RLock()
  1464. defer s.mu.RUnlock()
  1465. provider = strings.ToLower(strings.TrimSpace(provider))
  1466. names := make([]string, 0)
  1467. for name, p := range s.pricingData {
  1468. if strings.ToLower(p.LiteLLMProvider) == provider {
  1469. names = append(names, name)
  1470. }
  1471. }
  1472. sort.Strings(names)
  1473. return names
  1474. }
  1475. // isNumeric 检查字符串是否为纯数字
  1476. func isNumeric(s string) bool {
  1477. for _, c := range s {
  1478. if c < '0' || c > '9' {
  1479. return false
  1480. }
  1481. }
  1482. return true
  1483. }