2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2025-12-20 11:56:11 +08:00
|
|
|
|
"context"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2025-12-23 13:58:56 +08:00
|
|
|
|
"regexp"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2026-02-12 19:01:09 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
2026-01-02 17:40:57 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
2026-02-13 19:27:07 +08:00
|
|
|
|
"go.uber.org/zap"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-31 08:50:12 +08:00
|
|
|
|
var (
|
2026-03-06 08:14:04 +08:00
|
|
|
|
openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
|
|
|
|
|
|
openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
|
|
|
|
|
|
openAIGPT54FallbackPricing = &LiteLLMModelPricing{
|
2026-03-06 09:04:58 +08:00
|
|
|
|
InputCostPerToken: 2.5e-06, // $2.5 per MTok
|
|
|
|
|
|
OutputCostPerToken: 1.5e-05, // $15 per MTok
|
|
|
|
|
|
CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
|
|
|
|
|
|
LongContextInputTokenThreshold: 272000,
|
|
|
|
|
|
LongContextInputCostMultiplier: 2.0,
|
|
|
|
|
|
LongContextOutputCostMultiplier: 1.5,
|
|
|
|
|
|
LiteLLMProvider: "openai",
|
|
|
|
|
|
Mode: "chat",
|
|
|
|
|
|
SupportsPromptCaching: true,
|
2026-03-06 08:14:04 +08:00
|
|
|
|
}
|
2026-03-19 19:00:22 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2025-12-31 08:50:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// LiteLLMModelPricing LiteLLM价格数据结构
|
|
|
|
|
|
// 只保留我们需要的字段,使用指针来处理可能缺失的值
|
|
|
|
|
|
type LiteLLMModelPricing struct {
|
2026-02-14 18:15:35 +08:00
|
|
|
|
InputCostPerToken float64 `json:"input_cost_per_token"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"`
|
2026-02-14 18:15:35 +08:00
|
|
|
|
OutputCostPerToken float64 `json:"output_cost_per_token"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
OutputCostPerTokenPriority float64 `json:"output_cost_per_token_priority"`
|
2026-02-14 18:15:35 +08:00
|
|
|
|
CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"`
|
|
|
|
|
|
CacheCreationInputTokenCostAbove1hr float64 `json:"cache_creation_input_token_cost_above_1hr"`
|
|
|
|
|
|
CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
CacheReadInputTokenCostPriority float64 `json:"cache_read_input_token_cost_priority"`
|
2026-03-06 09:04:58 +08:00
|
|
|
|
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"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
SupportsServiceTier bool `json:"supports_service_tier"`
|
2026-02-14 18:15:35 +08:00
|
|
|
|
LiteLLMProvider string `json:"litellm_provider"`
|
|
|
|
|
|
Mode string `json:"mode"`
|
|
|
|
|
|
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
2026-04-01 15:08:57 +08:00
|
|
|
|
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
|
|
|
|
|
|
OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格
|
2025-12-20 11:56:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PricingRemoteClient 远程价格数据获取接口
|
|
|
|
|
|
type PricingRemoteClient interface {
|
|
|
|
|
|
FetchPricingJSON(ctx context.Context, url string) ([]byte, error)
|
|
|
|
|
|
FetchHashText(ctx context.Context, url string) (string, error)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// LiteLLMRawEntry 用于解析原始JSON数据
|
|
|
|
|
|
type LiteLLMRawEntry struct {
|
2026-02-14 18:15:35 +08:00
|
|
|
|
InputCostPerToken *float64 `json:"input_cost_per_token"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
InputCostPerTokenPriority *float64 `json:"input_cost_per_token_priority"`
|
2026-02-14 18:15:35 +08:00
|
|
|
|
OutputCostPerToken *float64 `json:"output_cost_per_token"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
OutputCostPerTokenPriority *float64 `json:"output_cost_per_token_priority"`
|
2026-02-14 18:15:35 +08:00
|
|
|
|
CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"`
|
|
|
|
|
|
CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"`
|
|
|
|
|
|
CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"`
|
2026-03-08 23:22:28 +08:00
|
|
|
|
CacheReadInputTokenCostPriority *float64 `json:"cache_read_input_token_cost_priority"`
|
|
|
|
|
|
SupportsServiceTier bool `json:"supports_service_tier"`
|
2026-02-14 18:15:35 +08:00
|
|
|
|
LiteLLMProvider string `json:"litellm_provider"`
|
|
|
|
|
|
Mode string `json:"mode"`
|
|
|
|
|
|
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
|
|
|
|
|
OutputCostPerImage *float64 `json:"output_cost_per_image"`
|
2026-04-01 15:08:57 +08:00
|
|
|
|
OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PricingService 动态价格服务
|
|
|
|
|
|
type PricingService struct {
|
2025-12-20 11:56:11 +08:00
|
|
|
|
cfg *config.Config
|
|
|
|
|
|
remoteClient PricingRemoteClient
|
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
|
pricingData map[string]*LiteLLMModelPricing
|
|
|
|
|
|
lastUpdated time.Time
|
|
|
|
|
|
localHash string
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 停止信号
|
|
|
|
|
|
stopCh chan struct{}
|
|
|
|
|
|
wg sync.WaitGroup
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewPricingService 创建价格服务
|
2025-12-20 11:56:11 +08:00
|
|
|
|
func NewPricingService(cfg *config.Config, remoteClient PricingRemoteClient) *PricingService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
s := &PricingService{
|
2025-12-20 11:56:11 +08:00
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
remoteClient: remoteClient,
|
|
|
|
|
|
pricingData: make(map[string]*LiteLLMModelPricing),
|
|
|
|
|
|
stopCh: make(chan struct{}),
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize 初始化价格服务
|
|
|
|
|
|
func (s *PricingService) Initialize() error {
|
|
|
|
|
|
// 确保数据目录存在
|
|
|
|
|
|
if err := os.MkdirAll(s.cfg.Pricing.DataDir, 0755); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to create data directory: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 首次加载价格数据
|
|
|
|
|
|
if err := s.checkAndUpdatePricing(); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Initial load failed, using fallback: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := s.useFallbackPricing(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to load pricing data: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 启动定时更新
|
|
|
|
|
|
s.startUpdateScheduler()
|
|
|
|
|
|
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Service initialized with %d models", len(s.pricingData))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Stop 停止价格服务
|
|
|
|
|
|
func (s *PricingService) Stop() {
|
|
|
|
|
|
close(s.stopCh)
|
|
|
|
|
|
s.wg.Wait()
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// startUpdateScheduler 启动定时更新调度器
|
|
|
|
|
|
func (s *PricingService) startUpdateScheduler() {
|
|
|
|
|
|
// 定期检查哈希更新
|
|
|
|
|
|
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 err := s.syncWithRemote(); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
case <-s.stopCh:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v)", hashInterval)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// checkAndUpdatePricing 检查并更新价格数据
|
|
|
|
|
|
func (s *PricingService) checkAndUpdatePricing() error {
|
|
|
|
|
|
pricingFile := s.getPricingFilePath()
|
|
|
|
|
|
|
|
|
|
|
|
// 检查本地文件是否存在
|
|
|
|
|
|
if _, err := os.Stat(pricingFile); os.IsNotExist(err) {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Local pricing file not found, downloading...")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return s.downloadPricingData()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 15:36:53 +08:00
|
|
|
|
// 先加载本地文件(确保服务可用),再检查是否需要更新
|
|
|
|
|
|
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时,基于文件年龄检查
|
2025-12-18 13:50:39 +08:00
|
|
|
|
info, err := os.Stat(pricingFile)
|
|
|
|
|
|
if err != nil {
|
2026-03-30 15:36:53 +08:00
|
|
|
|
return nil // 已加载本地文件
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fileAge := time.Since(info.ModTime())
|
|
|
|
|
|
maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour
|
|
|
|
|
|
|
|
|
|
|
|
if fileAge > maxAge {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Local file is %v old, updating...", fileAge.Round(time.Hour))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := s.downloadPricingData(); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 15:36:53 +08:00
|
|
|
|
return nil
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// syncWithRemote 与远程同步(基于哈希校验)
|
|
|
|
|
|
func (s *PricingService) syncWithRemote() error {
|
|
|
|
|
|
// 如果配置了哈希URL,从远程获取哈希进行比对
|
|
|
|
|
|
if s.cfg.Pricing.HashURL != "" {
|
|
|
|
|
|
remoteHash, err := s.fetchRemoteHash()
|
|
|
|
|
|
if err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil // 哈希获取失败不影响正常使用
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 15:36:53 +08:00
|
|
|
|
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))])
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return s.downloadPricingData()
|
|
|
|
|
|
}
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Hash check passed, no update needed")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 没有哈希URL时,基于时间检查
|
2026-03-30 15:36:53 +08:00
|
|
|
|
pricingFile := s.getPricingFilePath()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
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 {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] File is %v old, downloading...", fileAge.Round(time.Hour))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return s.downloadPricingData()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// downloadPricingData 从远程下载价格数据
|
|
|
|
|
|
func (s *PricingService) downloadPricingData() error {
|
2026-01-02 17:40:57 +08:00
|
|
|
|
remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Downloading from %s", remoteURL)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
|
|
|
|
defer cancel()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-03-30 15:36:53 +08:00
|
|
|
|
// 获取远程哈希(用于同步锚点,不作为完整性校验)
|
|
|
|
|
|
var remoteHash string
|
2026-01-02 17:40:57 +08:00
|
|
|
|
if strings.TrimSpace(s.cfg.Pricing.HashURL) != "" {
|
2026-03-30 15:36:53 +08:00
|
|
|
|
remoteHash, err = s.fetchRemoteHash()
|
2026-01-02 17:40:57 +08:00
|
|
|
|
if err != nil {
|
2026-03-30 15:36:53 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash (continuing): %v", err)
|
2026-01-02 17:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body, err := s.remoteClient.FetchPricingJSON(ctx, remoteURL)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
2025-12-20 11:56:11 +08:00
|
|
|
|
return fmt.Errorf("download failed: %w", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 15:36:53 +08:00
|
|
|
|
// 哈希校验:不匹配时仅告警,不阻止更新
|
|
|
|
|
|
// 远程哈希文件可能与数据文件不同步(如维护者更新了数据但未更新哈希文件)
|
|
|
|
|
|
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])
|
2026-01-02 17:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 解析JSON数据(使用灵活的解析方式)
|
|
|
|
|
|
data, err := s.parsePricingData(body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("parse pricing data: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保存到本地文件
|
|
|
|
|
|
pricingFile := s.getPricingFilePath()
|
|
|
|
|
|
if err := os.WriteFile(pricingFile, body, 0644); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save file: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 15:36:53 +08:00
|
|
|
|
// 使用远程哈希作为同步锚点,防止重复下载
|
|
|
|
|
|
// 当远程哈希不可用时,回退到数据本身的哈希
|
|
|
|
|
|
syncHash := dataHashStr
|
|
|
|
|
|
if remoteHash != "" {
|
|
|
|
|
|
syncHash = remoteHash
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
hashFile := s.getHashFilePath()
|
2026-03-30 15:36:53 +08:00
|
|
|
|
if err := os.WriteFile(hashFile, []byte(syncHash+"\n"), 0644); err != nil {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save hash: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新内存数据
|
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
|
s.pricingData = data
|
|
|
|
|
|
s.lastUpdated = time.Now()
|
2026-03-30 15:36:53 +08:00
|
|
|
|
s.localHash = syncHash
|
2025-12-18 13:50:39 +08:00
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result := make(map[string]*LiteLLMModelPricing)
|
|
|
|
|
|
skipped := 0
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pricing := &LiteLLMModelPricing{
|
|
|
|
|
|
LiteLLMProvider: entry.LiteLLMProvider,
|
|
|
|
|
|
Mode: entry.Mode,
|
|
|
|
|
|
SupportsPromptCaching: entry.SupportsPromptCaching,
|
2026-03-08 23:22:28 +08:00
|
|
|
|
SupportsServiceTier: entry.SupportsServiceTier,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if entry.InputCostPerToken != nil {
|
|
|
|
|
|
pricing.InputCostPerToken = *entry.InputCostPerToken
|
|
|
|
|
|
}
|
2026-03-08 23:22:28 +08:00
|
|
|
|
if entry.InputCostPerTokenPriority != nil {
|
|
|
|
|
|
pricing.InputCostPerTokenPriority = *entry.InputCostPerTokenPriority
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if entry.OutputCostPerToken != nil {
|
|
|
|
|
|
pricing.OutputCostPerToken = *entry.OutputCostPerToken
|
|
|
|
|
|
}
|
2026-03-08 23:22:28 +08:00
|
|
|
|
if entry.OutputCostPerTokenPriority != nil {
|
|
|
|
|
|
pricing.OutputCostPerTokenPriority = *entry.OutputCostPerTokenPriority
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if entry.CacheCreationInputTokenCost != nil {
|
|
|
|
|
|
pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost
|
|
|
|
|
|
}
|
2026-02-14 18:15:35 +08:00
|
|
|
|
if entry.CacheCreationInputTokenCostAbove1hr != nil {
|
|
|
|
|
|
pricing.CacheCreationInputTokenCostAbove1hr = *entry.CacheCreationInputTokenCostAbove1hr
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if entry.CacheReadInputTokenCost != nil {
|
|
|
|
|
|
pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
|
|
|
|
|
|
}
|
2026-03-08 23:22:28 +08:00
|
|
|
|
if entry.CacheReadInputTokenCostPriority != nil {
|
|
|
|
|
|
pricing.CacheReadInputTokenCostPriority = *entry.CacheReadInputTokenCostPriority
|
|
|
|
|
|
}
|
2026-01-05 17:07:29 +08:00
|
|
|
|
if entry.OutputCostPerImage != nil {
|
|
|
|
|
|
pricing.OutputCostPerImage = *entry.OutputCostPerImage
|
|
|
|
|
|
}
|
2026-04-01 15:08:57 +08:00
|
|
|
|
if entry.OutputCostPerImageToken != nil {
|
|
|
|
|
|
pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
result[modelName] = pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if skipped > 0 {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(result) == 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("no valid pricing entries found")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result, 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, err := s.parsePricingData(data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("parse pricing data: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算哈希
|
|
|
|
|
|
hash := sha256.Sum256(data)
|
|
|
|
|
|
hashStr := hex.EncodeToString(hash[:])
|
|
|
|
|
|
|
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
|
s.pricingData = pricingData
|
|
|
|
|
|
s.localHash = hashStr
|
|
|
|
|
|
|
|
|
|
|
|
info, _ := os.Stat(filePath)
|
|
|
|
|
|
if info != nil {
|
|
|
|
|
|
s.lastUpdated = info.ModTime()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
s.lastUpdated = time.Now()
|
|
|
|
|
|
}
|
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Loaded %d models from %s", len(pricingData), filePath)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Using fallback file: %s", fallbackFile)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 复制到数据目录
|
|
|
|
|
|
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 {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.loadPricingData(fallbackFile)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// fetchRemoteHash 从远程获取哈希值
|
|
|
|
|
|
func (s *PricingService) fetchRemoteHash() (string, error) {
|
2026-01-02 17:40:57 +08:00
|
|
|
|
hashURL, err := s.validatePricingURL(s.cfg.Pricing.HashURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
|
defer cancel()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
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) {
|
2026-01-05 13:54:43 +08:00
|
|
|
|
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
2026-01-05 14:41:08 +08:00
|
|
|
|
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("invalid pricing url: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized, nil
|
2026-01-05 13:54:43 +08:00
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
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
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetModelPricing 获取模型价格(带模糊匹配)
|
|
|
|
|
|
func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing {
|
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
|
|
if modelName == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 06:44:40 -08:00
|
|
|
|
// 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀)
|
|
|
|
|
|
modelLower := strings.ToLower(strings.TrimSpace(modelName))
|
|
|
|
|
|
lookupCandidates := s.buildModelLookupCandidates(modelLower)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 1. 精确匹配
|
2025-12-25 06:44:40 -08:00
|
|
|
|
for _, candidate := range lookupCandidates {
|
|
|
|
|
|
if candidate == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if pricing, ok := s.pricingData[candidate]; ok {
|
|
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 处理常见的模型名称变体
|
|
|
|
|
|
// claude-opus-4-5-20251101 -> claude-opus-4.5-20251101
|
2025-12-25 06:44:40 -08:00
|
|
|
|
for _, candidate := range lookupCandidates {
|
|
|
|
|
|
normalized := strings.ReplaceAll(candidate, "-4-5-", "-4.5-")
|
|
|
|
|
|
if pricing, ok := s.pricingData[normalized]; ok {
|
|
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 尝试模糊匹配(去掉版本号后缀)
|
|
|
|
|
|
// claude-opus-4-5-20251101 -> claude-opus-4.5
|
2025-12-25 06:44:40 -08:00
|
|
|
|
baseName := s.extractBaseName(lookupCandidates[0])
|
2025-12-18 13:50:39 +08:00
|
|
|
|
for key, pricing := range s.pricingData {
|
|
|
|
|
|
keyBase := s.extractBaseName(strings.ToLower(key))
|
|
|
|
|
|
if keyBase == baseName {
|
|
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 13:58:56 +08:00
|
|
|
|
// 4. 基于模型系列匹配(Claude)
|
2025-12-25 06:44:40 -08:00
|
|
|
|
if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
|
2025-12-23 13:58:56 +08:00
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. OpenAI 模型回退策略
|
2025-12-25 06:44:40 -08:00
|
|
|
|
if strings.HasPrefix(lookupCandidates[0], "gpt-") {
|
|
|
|
|
|
return s.matchOpenAIModel(lookupCandidates[0])
|
2025-12-23 13:58:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 06:44:40 -08:00
|
|
|
|
func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
|
|
|
|
|
|
// Prefer canonical model name first (this also improves billing compatibility with "models/xxx").
|
|
|
|
|
|
candidates := []string{
|
|
|
|
|
|
normalizeModelNameForPricing(modelLower),
|
|
|
|
|
|
modelLower,
|
|
|
|
|
|
}
|
2025-12-25 21:35:30 -08:00
|
|
|
|
candidates = append(candidates,
|
2025-12-25 06:44:40 -08:00
|
|
|
|
strings.TrimPrefix(modelLower, "models/"),
|
|
|
|
|
|
lastSegment(modelLower),
|
|
|
|
|
|
lastSegment(strings.TrimPrefix(modelLower, "models/")),
|
2025-12-25 21:35:30 -08:00
|
|
|
|
)
|
2025-12-25 06:44:40 -08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-16 23:47:42 +08:00
|
|
|
|
// - publishers/google/models/gemini-2.5-pro
|
|
|
|
|
|
// - projects/.../locations/.../publishers/google/models/gemini-2.5-pro
|
2025-12-25 06:44:40 -08:00
|
|
|
|
model = strings.TrimSpace(model)
|
|
|
|
|
|
model = strings.TrimLeft(model, "/")
|
2025-12-25 21:35:30 -08:00
|
|
|
|
model = strings.TrimPrefix(model, "models/")
|
|
|
|
|
|
model = strings.TrimPrefix(model, "publishers/google/models/")
|
2025-12-25 06:44:40 -08:00
|
|
|
|
|
|
|
|
|
|
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, "/")
|
|
|
|
|
|
return model
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func lastSegment(model string) string {
|
|
|
|
|
|
if idx := strings.LastIndex(model, "/"); idx != -1 {
|
|
|
|
|
|
return model[idx+1:]
|
|
|
|
|
|
}
|
|
|
|
|
|
return model
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 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 {
|
2026-04-17 09:37:25 +08:00
|
|
|
|
// 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{
|
|
|
|
|
|
{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 {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if strings.Contains(model, pattern) || strings.Contains(model, strings.ReplaceAll(pattern, "-", "")) {
|
2026-04-17 09:37:25 +08:00
|
|
|
|
matched = &families[i]
|
2025-12-18 13:50:39 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-17 09:37:25 +08:00
|
|
|
|
if matched != nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 09:37:25 +08:00
|
|
|
|
// Phase 2: 二次兜底——当模型 ID 不含已知模式串时,按关键字粗分
|
|
|
|
|
|
if matched == nil {
|
|
|
|
|
|
var fallbackName string
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case strings.Contains(model, "opus"):
|
|
|
|
|
|
switch {
|
|
|
|
|
|
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"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-04-17 09:37:25 +08:00
|
|
|
|
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"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-04-17 09:37:25 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 09:37:25 +08:00
|
|
|
|
if matched == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 09:37:25 +08:00
|
|
|
|
// Phase 3: 在定价数据中查找该系列的价格
|
|
|
|
|
|
lookups := matched.pricing
|
|
|
|
|
|
if lookups == nil {
|
|
|
|
|
|
lookups = matched.match
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, pattern := range lookups {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
for key, pricing := range s.pricingData {
|
|
|
|
|
|
keyLower := strings.ToLower(key)
|
|
|
|
|
|
if strings.Contains(keyLower, pattern) {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 13:58:56 +08:00
|
|
|
|
// matchOpenAIModel OpenAI 模型回退匹配策略
|
|
|
|
|
|
// 回退顺序:
|
2026-02-13 09:28:07 +08:00
|
|
|
|
// 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
|
2026-03-06 08:14:04 +08:00
|
|
|
|
// 5. gpt-5.4* -> 业务静态兜底价
|
|
|
|
|
|
// 6. 最终回退到 DefaultTestModel (gpt-5.1-codex)
|
2025-12-23 13:58:56 +08:00
|
|
|
|
func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
|
2026-02-13 09:28:07 +08:00
|
|
|
|
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")
|
2026-02-13 19:27:07 +08:00
|
|
|
|
logger.With(zap.String("component", "service.pricing")).
|
|
|
|
|
|
Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.1-codex"))
|
2026-02-13 09:28:07 +08:00
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 13:58:56 +08:00
|
|
|
|
// 尝试的回退变体
|
2025-12-31 08:50:12 +08:00
|
|
|
|
variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern)
|
2025-12-23 13:58:56 +08:00
|
|
|
|
|
|
|
|
|
|
for _, variant := range variants {
|
|
|
|
|
|
if pricing, ok := s.pricingData[variant]; ok {
|
2026-02-13 19:27:07 +08:00
|
|
|
|
logger.With(zap.String("component", "service.pricing")).
|
|
|
|
|
|
Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, variant))
|
2025-12-23 13:58:56 +08:00
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 07:40:38 +08:00
|
|
|
|
if strings.HasPrefix(model, "gpt-5.3-codex") {
|
|
|
|
|
|
if pricing, ok := s.pricingData["gpt-5.2-codex"]; ok {
|
2026-02-13 19:27:07 +08:00
|
|
|
|
logger.With(zap.String("component", "service.pricing")).
|
|
|
|
|
|
Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.2-codex"))
|
2026-02-06 07:40:38 +08:00
|
|
|
|
return pricing
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 19:00:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 08:14:04 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 13:58:56 +08:00
|
|
|
|
// 最终回退到 DefaultTestModel
|
|
|
|
|
|
defaultModel := strings.ToLower(openai.DefaultTestModel)
|
|
|
|
|
|
if pricing, ok := s.pricingData[defaultModel]; ok {
|
2026-02-12 19:01:09 +08:00
|
|
|
|
logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI fallback to default model %s -> %s", model, defaultModel)
|
2025-12-23 13:58:56 +08:00
|
|
|
|
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 这种带字母后缀的
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if matches := openAIModelBasePattern.FindStringSubmatch(model); len(matches) > 1 {
|
2025-12-23 13:58:56 +08:00
|
|
|
|
addVariant(matches[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 同时去掉日期后再提取基础版本号
|
|
|
|
|
|
if withoutDate != model {
|
2025-12-31 08:50:12 +08:00
|
|
|
|
if matches := openAIModelBasePattern.FindStringSubmatch(withoutDate); len(matches) > 1 {
|
2025-12-23 13:58:56 +08:00
|
|
|
|
addVariant(matches[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return variants
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetStatus 获取服务状态
|
2025-12-20 16:19:40 +08:00
|
|
|
|
func (s *PricingService) GetStatus() map[string]any {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
s.mu.RLock()
|
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
2025-12-20 16:19:40 +08:00
|
|
|
|
return map[string]any{
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// isNumeric 检查字符串是否为纯数字
|
|
|
|
|
|
func isNumeric(s string) bool {
|
|
|
|
|
|
for _, c := range s {
|
|
|
|
|
|
if c < '0' || c > '9' {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|