package service import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "go.uber.org/zap" ) var ( openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`) openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`) openAIGPT54FallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2.5e-06, // $2.5 per MTok OutputCostPerToken: 1.5e-05, // $15 per MTok CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok LongContextInputTokenThreshold: 272000, LongContextInputCostMultiplier: 2.0, LongContextOutputCostMultiplier: 1.5, LiteLLMProvider: "openai", Mode: "chat", SupportsPromptCaching: true, } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, OutputCostPerToken: 3e-05, OutputCostPerTokenPriority: 6e-05, CacheCreationInputTokenCost: 6.25e-06, CacheCreationInputTokenCostPriority: 1.25e-05, CacheReadInputTokenCost: 5e-07, CacheReadInputTokenCostPriority: 1e-06, LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", SupportsPromptCaching: true, } openAIGPT56TerraFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2e-06, InputCostPerTokenPriority: 4e-06, OutputCostPerToken: 1.2e-05, OutputCostPerTokenPriority: 2.4e-05, CacheCreationInputTokenCost: 2.5e-06, CacheCreationInputTokenCostPriority: 5e-06, CacheReadInputTokenCost: 2e-07, CacheReadInputTokenCostPriority: 4e-07, LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", SupportsPromptCaching: true, } openAIGPT56LunaFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2e-07, InputCostPerTokenPriority: 4e-07, OutputCostPerToken: 1.2e-06, OutputCostPerTokenPriority: 2.4e-06, CacheCreationInputTokenCost: 2.5e-07, CacheCreationInputTokenCostPriority: 5e-07, CacheReadInputTokenCost: 2e-08, CacheReadInputTokenCostPriority: 4e-08, LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", SupportsPromptCaching: true, } openAIGPT54MiniFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 7.5e-07, OutputCostPerToken: 4.5e-06, CacheReadInputTokenCost: 7.5e-08, LiteLLMProvider: "openai", Mode: "chat", SupportsPromptCaching: true, } openAIGPT54NanoFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2e-07, OutputCostPerToken: 1.25e-06, CacheReadInputTokenCost: 2e-08, LiteLLMProvider: "openai", Mode: "chat", SupportsPromptCaching: true, } ) // LiteLLMModelPricing LiteLLM价格数据结构 // 只保留我们需要的字段,使用指针来处理可能缺失的值 type LiteLLMModelPricing struct { InputCostPerToken float64 `json:"input_cost_per_token"` InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"` OutputCostPerToken float64 `json:"output_cost_per_token"` OutputCostPerTokenPriority float64 `json:"output_cost_per_token_priority"` CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"` CacheCreationInputTokenCostPriority float64 `json:"cache_creation_input_token_cost_priority"` CacheCreationInputTokenCostAbove1hr float64 `json:"cache_creation_input_token_cost_above_1hr"` CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"` CacheReadInputTokenCostPriority float64 `json:"cache_read_input_token_cost_priority"` LongContextInputTokenThreshold int `json:"long_context_input_token_threshold,omitempty"` LongContextInputCostMultiplier float64 `json:"long_context_input_cost_multiplier,omitempty"` LongContextOutputCostMultiplier float64 `json:"long_context_output_cost_multiplier,omitempty"` SupportsServiceTier bool `json:"supports_service_tier"` LiteLLMProvider string `json:"litellm_provider"` Mode string `json:"mode"` SupportsPromptCaching bool `json:"supports_prompt_caching"` OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格 OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格 InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑) // TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。 // 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed, // 否则 token 流量会被按 $0 计费。零值(false)表示条目具备 token 价格。 TokenPricingAbsent bool `json:"-"` } // PricingRemoteClient 远程价格数据获取接口 type PricingRemoteClient interface { FetchPricingJSON(ctx context.Context, url string) ([]byte, error) FetchHashText(ctx context.Context, url string) (string, error) } // LiteLLMRawEntry 用于解析原始JSON数据 type LiteLLMRawEntry struct { InputCostPerToken *float64 `json:"input_cost_per_token"` InputCostPerTokenPriority *float64 `json:"input_cost_per_token_priority"` OutputCostPerToken *float64 `json:"output_cost_per_token"` OutputCostPerTokenPriority *float64 `json:"output_cost_per_token_priority"` CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"` CacheCreationInputTokenCostPriority *float64 `json:"cache_creation_input_token_cost_priority"` CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"` CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"` CacheReadInputTokenCostPriority *float64 `json:"cache_read_input_token_cost_priority"` LongContextInputTokenThreshold *int `json:"long_context_input_token_threshold"` LongContextInputCostMultiplier *float64 `json:"long_context_input_cost_multiplier"` LongContextOutputCostMultiplier *float64 `json:"long_context_output_cost_multiplier"` SupportsServiceTier bool `json:"supports_service_tier"` LiteLLMProvider string `json:"litellm_provider"` Mode string `json:"mode"` SupportsPromptCaching bool `json:"supports_prompt_caching"` OutputCostPerImage *float64 `json:"output_cost_per_image"` OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"` InputCostPerImageToken *float64 `json:"input_cost_per_image_token"` } // PricingService 动态价格服务 type PricingService struct { cfg *config.Config remoteClient PricingRemoteClient mu sync.RWMutex pricingData map[string]*LiteLLMModelPricing lastUpdated time.Time localHash string // 停止信号 stopCh chan struct{} wg sync.WaitGroup } // NewPricingService 创建价格服务 func NewPricingService(cfg *config.Config, remoteClient PricingRemoteClient) *PricingService { s := &PricingService{ cfg: cfg, remoteClient: remoteClient, pricingData: make(map[string]*LiteLLMModelPricing), stopCh: make(chan struct{}), } return s } // Initialize 初始化价格服务 func (s *PricingService) Initialize() error { // 确保数据目录存在 if err := os.MkdirAll(s.cfg.Pricing.DataDir, 0755); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to create data directory: %v", err) } // 首次加载价格数据 if err := s.checkAndUpdatePricing(); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Initial load failed, using fallback: %v", err) if err := s.useFallbackPricing(); err != nil { return fmt.Errorf("failed to load pricing data: %w", err) } } // 启动定时更新 s.startUpdateScheduler() logger.LegacyPrintf("service.pricing", "[Pricing] Service initialized with %d models", len(s.pricingData)) return nil } // Stop 停止价格服务 func (s *PricingService) Stop() { close(s.stopCh) s.wg.Wait() logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped") } // startUpdateScheduler 启动定时更新调度器 func (s *PricingService) startUpdateScheduler() { if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty") return } // 定期检查哈希更新 hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute if hashInterval < time.Minute { hashInterval = 10 * time.Minute } s.wg.Add(1) go func() { defer s.wg.Done() ticker := time.NewTicker(hashInterval) defer ticker.Stop() for { select { case <-ticker.C: if err := s.syncWithRemote(); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) } case <-s.stopCh: return } } }() logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v)", hashInterval) } // checkAndUpdatePricing 检查并更新价格数据 func (s *PricingService) checkAndUpdatePricing() error { pricingFile := s.getPricingFilePath() // 检查本地文件是否存在 if _, err := os.Stat(pricingFile); os.IsNotExist(err) { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Local pricing file not found, downloading...") return s.downloadPricingData() } // 先加载本地文件(确保服务可用),再检查是否需要更新 if err := s.loadPricingData(pricingFile); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to load local file, downloading: %v", err) return s.downloadPricingData() } // 如果配置了哈希URL,通过远程哈希检查是否有更新 if s.cfg.Pricing.HashURL != "" { remoteHash, err := s.fetchRemoteHash() if err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash on startup: %v", err) return nil // 已加载本地文件,哈希获取失败不影响启动 } s.mu.RLock() localHash := s.localHash s.mu.RUnlock() if localHash == "" || remoteHash != localHash { logger.LegacyPrintf("service.pricing", "[Pricing] Remote hash differs on startup (local=%s remote=%s), downloading...", localHash[:min(8, len(localHash))], remoteHash[:min(8, len(remoteHash))]) if err := s.downloadPricingData(); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err) } } return nil } // 没有哈希URL时,基于文件年龄检查 info, err := os.Stat(pricingFile) if err != nil { return nil // 已加载本地文件 } fileAge := time.Since(info.ModTime()) maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour if fileAge > maxAge { logger.LegacyPrintf("service.pricing", "[Pricing] Local file is %v old, updating...", fileAge.Round(time.Hour)) if err := s.downloadPricingData(); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Download failed, using existing file: %v", err) } } return nil } // syncWithRemote 与远程同步(基于哈希校验) func (s *PricingService) syncWithRemote() error { // 如果配置了哈希URL,从远程获取哈希进行比对 if s.cfg.Pricing.HashURL != "" { remoteHash, err := s.fetchRemoteHash() if err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash: %v", err) return nil // 哈希获取失败不影响正常使用 } s.mu.RLock() localHash := s.localHash s.mu.RUnlock() if localHash == "" || remoteHash != localHash { logger.LegacyPrintf("service.pricing", "[Pricing] Remote hash differs (local=%s remote=%s), downloading new version...", localHash[:min(8, len(localHash))], remoteHash[:min(8, len(remoteHash))]) return s.downloadPricingData() } logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Hash check passed, no update needed") return nil } // 没有哈希URL时,基于时间检查 pricingFile := s.getPricingFilePath() info, err := os.Stat(pricingFile) if err != nil { return s.downloadPricingData() } fileAge := time.Since(info.ModTime()) maxAge := time.Duration(s.cfg.Pricing.UpdateIntervalHours) * time.Hour if fileAge > maxAge { logger.LegacyPrintf("service.pricing", "[Pricing] File is %v old, downloading...", fileAge.Round(time.Hour)) return s.downloadPricingData() } return nil } // downloadPricingData 从远程下载价格数据 func (s *PricingService) downloadPricingData() error { remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL) if err != nil { return err } logger.LegacyPrintf("service.pricing", "[Pricing] Downloading from %s", remoteURL) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 获取远程哈希(用于同步锚点,不作为完整性校验) var remoteHash string if strings.TrimSpace(s.cfg.Pricing.HashURL) != "" { remoteHash, err = s.fetchRemoteHash() if err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash (continuing): %v", err) } } body, err := s.remoteClient.FetchPricingJSON(ctx, remoteURL) if err != nil { return fmt.Errorf("download failed: %w", err) } // 哈希校验:不匹配时仅告警,不阻止更新 // 远程哈希文件可能与数据文件不同步(如维护者更新了数据但未更新哈希文件) dataHash := sha256.Sum256(body) dataHashStr := hex.EncodeToString(dataHash[:]) if remoteHash != "" && !strings.EqualFold(remoteHash, dataHashStr) { logger.LegacyPrintf("service.pricing", "[Pricing] Hash mismatch warning: remote=%s data=%s (hash file may be out of sync)", remoteHash[:min(8, len(remoteHash))], dataHashStr[:8]) } // 解析JSON数据(使用灵活的解析方式) data, err := s.parsePricingData(body) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } data = s.mergeFallbackPricingData(data) // 保存到本地文件 pricingFile := s.getPricingFilePath() if err := os.WriteFile(pricingFile, body, 0644); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save file: %v", err) } // 使用远程哈希作为同步锚点,防止重复下载 // 当远程哈希不可用时,回退到数据本身的哈希 syncHash := dataHashStr if remoteHash != "" { syncHash = remoteHash } hashFile := s.getHashFilePath() if err := os.WriteFile(hashFile, []byte(syncHash+"\n"), 0644); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save hash: %v", err) } // 更新内存数据 s.mu.Lock() s.pricingData = data s.lastUpdated = time.Now() s.localHash = syncHash s.mu.Unlock() logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data)) return nil } // parsePricingData 解析价格数据(处理各种格式) func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModelPricing, error) { // 首先解析为 map[string]json.RawMessage var rawData map[string]json.RawMessage if err := json.Unmarshal(body, &rawData); err != nil { return nil, fmt.Errorf("parse raw JSON: %w", err) } 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 && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil && entry.InputCostPerImageToken == nil { continue } pricing := &LiteLLMModelPricing{ LiteLLMProvider: entry.LiteLLMProvider, Mode: entry.Mode, SupportsPromptCaching: entry.SupportsPromptCaching, SupportsServiceTier: entry.SupportsServiceTier, TokenPricingAbsent: entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil, } if entry.InputCostPerToken != nil { pricing.InputCostPerToken = *entry.InputCostPerToken } if entry.InputCostPerTokenPriority != nil { pricing.InputCostPerTokenPriority = *entry.InputCostPerTokenPriority } if entry.OutputCostPerToken != nil { pricing.OutputCostPerToken = *entry.OutputCostPerToken } if entry.OutputCostPerTokenPriority != nil { pricing.OutputCostPerTokenPriority = *entry.OutputCostPerTokenPriority } if entry.CacheCreationInputTokenCost != nil { pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost } if entry.CacheCreationInputTokenCostPriority != nil { pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority } if entry.CacheCreationInputTokenCostAbove1hr != nil { pricing.CacheCreationInputTokenCostAbove1hr = *entry.CacheCreationInputTokenCostAbove1hr } if entry.CacheReadInputTokenCost != nil { pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost } if entry.CacheReadInputTokenCostPriority != nil { pricing.CacheReadInputTokenCostPriority = *entry.CacheReadInputTokenCostPriority } if entry.LongContextInputTokenThreshold != nil { pricing.LongContextInputTokenThreshold = *entry.LongContextInputTokenThreshold } if entry.LongContextInputCostMultiplier != nil { pricing.LongContextInputCostMultiplier = *entry.LongContextInputCostMultiplier } if entry.LongContextOutputCostMultiplier != nil { pricing.LongContextOutputCostMultiplier = *entry.LongContextOutputCostMultiplier } if entry.OutputCostPerImage != nil { pricing.OutputCostPerImage = *entry.OutputCostPerImage } if entry.OutputCostPerImageToken != nil { pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken } if entry.InputCostPerImageToken != nil { pricing.InputCostPerImageToken = *entry.InputCostPerImageToken } result[modelName] = pricing } if skipped > 0 { logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped) } 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) } pricingData = s.mergeFallbackPricingData(pricingData) // 计算哈希 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() logger.LegacyPrintf("service.pricing", "[Pricing] Loaded %d models from %s", len(pricingData), filePath) return nil } func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing { if data == nil { data = make(map[string]*LiteLLMModelPricing) } if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.FallbackFile) == "" { return data } fallbackBody, err := os.ReadFile(s.cfg.Pricing.FallbackFile) if err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Fallback merge skipped: %v", err) return data } fallbackData, err := s.parsePricingData(fallbackBody) if err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Fallback merge parse skipped: %v", err) return data } merged := 0 for modelName, pricing := range fallbackData { if _, ok := data[modelName]; ok { continue } data[modelName] = pricing merged++ } if merged > 0 { logger.LegacyPrintf("service.pricing", "[Pricing] Merged %d fallback-only models", merged) } return data } // useFallbackPricing 使用回退价格文件 func (s *PricingService) useFallbackPricing() error { fallbackFile := s.cfg.Pricing.FallbackFile if _, err := os.Stat(fallbackFile); os.IsNotExist(err) { return fmt.Errorf("fallback file not found: %s", fallbackFile) } logger.LegacyPrintf("service.pricing", "[Pricing] Using fallback file: %s", fallbackFile) // 复制到数据目录 data, err := os.ReadFile(fallbackFile) if err != nil { return fmt.Errorf("read fallback failed: %w", err) } pricingFile := s.getPricingFilePath() if err := os.WriteFile(pricingFile, data, 0644); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err) } return s.loadPricingData(fallbackFile) } // fetchRemoteHash 从远程获取哈希值 func (s *PricingService) fetchRemoteHash() (string, error) { hashURL, err := s.validatePricingURL(s.cfg.Pricing.HashURL) if err != nil { return "", err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() hash, err := s.remoteClient.FetchHashText(ctx, hashURL) if err != nil { return "", err } return strings.TrimSpace(hash), nil } func (s *PricingService) validatePricingURL(raw string) (string, error) { if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) if err != nil { return "", fmt.Errorf("invalid pricing url: %w", err) } return normalized, nil } normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ AllowedHosts: s.cfg.Security.URLAllowlist.PricingHosts, RequireAllowlist: true, AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, }) if err != nil { return "", fmt.Errorf("invalid pricing url: %w", err) } return normalized, nil } // GetModelPricing 获取模型价格(带模糊匹配) func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing { s.mu.RLock() defer s.mu.RUnlock() if modelName == "" { return nil } // 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀) modelLower := strings.ToLower(strings.TrimSpace(modelName)) lookupCandidates := s.buildModelLookupCandidates(modelLower) // 1~3. 确定性识别(精确名 / 已知拼写变体 / 去掉日期版本后缀) if pricing := s.lookupIdentifiedModelPricingLocked(lookupCandidates); pricing != nil { return pricing } // 4. 基于模型系列匹配(Claude) if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil { return pricing } // 5. OpenAI 模型回退策略 if strings.HasPrefix(lookupCandidates[0], "gpt-") { return s.matchOpenAIModel(lookupCandidates[0]) } return nil } // lookupIdentifiedModelPricingLocked 只做"确定性识别"的三步查找:精确键、已知拼写 // 变体、去掉日期/版本后缀后的同名条目。它刻意不包含 matchByModelFamily / // matchOpenAIModel 这类按子串猜系列的兜底——那些兜底会给任意名字都返回一个价格。 // 调用方必须持有 s.mu 读锁。 func (s *PricingService) lookupIdentifiedModelPricingLocked(lookupCandidates []string) *LiteLLMModelPricing { if len(lookupCandidates) == 0 { return nil } // 1. 精确匹配 for _, candidate := range lookupCandidates { if candidate == "" { continue } if pricing, ok := s.pricingData[candidate]; ok { return pricing } } // 2. 处理常见的模型名称变体 // claude-opus-4-5-20251101 -> claude-opus-4.5-20251101 for _, candidate := range lookupCandidates { normalized := strings.ReplaceAll(candidate, "-4-5-", "-4.5-") if pricing, ok := s.pricingData[normalized]; ok { return pricing } } // 3. 尝试模糊匹配(去掉版本号后缀) // claude-opus-4-5-20251101 -> claude-opus-4.5 baseName := s.extractBaseName(lookupCandidates[0]) for key, pricing := range s.pricingData { keyBase := s.extractBaseName(strings.ToLower(key)) if keyBase == baseName { return pricing } } return nil } // GetIdentifiedModelPricing 在价格表中确定性地识别模型,识别不到时返回 nil。 // 与 GetModelPricing 的区别:不会退化成按 "opus"/"haiku" 之类子串猜出的系列兜底价。 // 用于必须区分"这是价格表里已知的模型"和"这只是名字里带某个关键词"的场景。 func (s *PricingService) GetIdentifiedModelPricing(modelName string) *LiteLLMModelPricing { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() modelLower := strings.ToLower(strings.TrimSpace(modelName)) if modelLower == "" { return nil } return s.lookupIdentifiedModelPricingLocked(s.buildModelLookupCandidates(modelLower)) } func (s *PricingService) buildModelLookupCandidates(modelLower string) []string { rawCandidates := []string{ modelLower, strings.TrimPrefix(modelLower, "models/"), lastSegment(modelLower), lastSegment(strings.TrimPrefix(modelLower, "models/")), } normalized := normalizeModelNameForPricing(modelLower) // A tier-specific entry should take precedence when the pricing catalog gains // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, // so the normalized base remains the fallback after the exact aliases. candidates := rawCandidates if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { candidates = append(candidates, normalized) } else { // Prefer canonical model names for all other aliases (including models/xxx). candidates = append([]string{normalized}, candidates...) } seen := make(map[string]struct{}, len(candidates)) out := make([]string, 0, len(candidates)) for _, c := range candidates { c = strings.TrimSpace(c) if c == "" { continue } if _, ok := seen[c]; ok { continue } seen[c] = struct{}{} out = append(out, c) } if len(out) == 0 { return []string{modelLower} } return out } func normalizeModelNameForPricing(model string) string { // Common Gemini/VertexAI forms: // - models/gemini-2.0-flash-exp // - publishers/google/models/gemini-2.5-pro // - projects/.../locations/.../publishers/google/models/gemini-2.5-pro model = strings.TrimSpace(model) model = strings.TrimLeft(model, "/") model = strings.TrimPrefix(model, "models/") model = strings.TrimPrefix(model, "publishers/google/models/") if idx := strings.LastIndex(model, "/publishers/google/models/"); idx != -1 { model = model[idx+len("/publishers/google/models/"):] } if idx := strings.LastIndex(model, "/models/"); idx != -1 { model = model[idx+len("/models/"):] } model = strings.TrimLeft(model, "/") if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" { if canonical == "gpt-5.6" { return "gpt-5.6-sol" } if suffix, ok := strings.CutPrefix(canonical, "gpt-5.6-"); ok && (suffix == "max" || isKnownCodexModelSuffix(suffix)) { return "gpt-5.6-sol" } return canonical } return normalizeGeminiThinkingTierAlias(model) } // normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash // thinking-tier model IDs to the public base model. The tier controls reasoning // behavior, not the published token rate, so this keeps -high/-low/-medium and // -tiered requests on the same price card as gemini-3.6-flash. func normalizeGeminiThinkingTierAlias(model string) string { const baseModel = "gemini-3.6-flash" for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { if model == baseModel+tier { return baseModel } } return model } func lastSegment(model string) string { if idx := strings.LastIndex(model, "/"); idx != -1 { return model[idx+1:] } return model } // extractBaseName 提取基础模型名称(去掉日期版本号) func (s *PricingService) extractBaseName(model string) string { // 移除日期后缀 (如 -20251101, -20241022) parts := strings.Split(model, "-") result := make([]string, 0, len(parts)) for _, part := range parts { // 跳过看起来像日期的部分(8位数字) if len(part) == 8 && isNumeric(part) { continue } // 跳过版本号(如 v1:0) if strings.Contains(part, ":") { continue } result = append(result, part) } return strings.Join(result, "-") } // matchByModelFamily 基于模型系列匹配 func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { // modelFamily 定义一个模型系列的匹配和定价查找规则。 type modelFamily struct { name string // 系列名称 match []string // 用于将模型归类到此系列的模式(strings.Contains 匹配) pricing []string // 用于在定价数据中查找价格的模式(nil 则复用 match;可包含低版本 fallback) } // 按特异性降序排列:高版本号在前,避免 "claude-opus-4"(opus-4 系列) // 因子串关系误匹配 "claude-opus-4-7"(opus-4.7 系列)。 // 注意:原 map 实现存在 Go map 迭代随机性导致的同类 bug,此处改为有序切片修复。 families := []modelFamily{ // Opus 5 与 Opus 4.8 同价($5/$25 per MTok)。定价数据缺失 claude-opus-5 时 // 必须回退到 4.8,否则会掉进 "opus-4" 系列按 $15/$75 计费(3 倍超收)。 {name: "opus-5", match: []string{"claude-opus-5"}, pricing: []string{"claude-opus-5", "claude-opus-4-8"}}, {name: "opus-4.8", match: []string{"claude-opus-4-8", "claude-opus-4.8"}, pricing: []string{"claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7"}}, {name: "opus-4.7", match: []string{"claude-opus-4-7", "claude-opus-4.7"}, pricing: []string{"claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-6"}}, {name: "opus-4.6", match: []string{"claude-opus-4-6", "claude-opus-4.6"}}, {name: "opus-4.5", match: []string{"claude-opus-4-5", "claude-opus-4.5"}}, {name: "opus-4", match: []string{"claude-opus-4", "claude-3-opus"}}, {name: "sonnet-4.5", match: []string{"claude-sonnet-4-5", "claude-sonnet-4.5"}}, {name: "sonnet-4", match: []string{"claude-sonnet-4", "claude-3-5-sonnet"}}, {name: "sonnet-3.5", match: []string{"claude-3-5-sonnet", "claude-3.5-sonnet"}}, {name: "sonnet-3", match: []string{"claude-3-sonnet"}}, {name: "haiku-3.5", match: []string{"claude-3-5-haiku", "claude-3.5-haiku"}}, {name: "haiku-3", match: []string{"claude-3-haiku"}}, } // Phase 1: 按有序切片归类(最具体的系列优先匹配) var matched *modelFamily for i := range families { for _, pattern := range families[i].match { if strings.Contains(model, pattern) || strings.Contains(model, strings.ReplaceAll(pattern, "-", "")) { matched = &families[i] break } } if matched != nil { break } } // Phase 2: 二次兜底——当模型 ID 不含已知模式串时,按关键字粗分 if matched == nil { var fallbackName string switch { case strings.Contains(model, "opus"): switch { // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 case strings.Contains(model, "opus-5") || strings.Contains(model, "opus5"): fallbackName = "opus-5" case strings.Contains(model, "4.8") || strings.Contains(model, "4-8"): fallbackName = "opus-4.8" case strings.Contains(model, "4.7") || strings.Contains(model, "4-7"): fallbackName = "opus-4.7" case strings.Contains(model, "4.6") || strings.Contains(model, "4-6"): fallbackName = "opus-4.6" case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"): fallbackName = "opus-4.5" default: fallbackName = "opus-4" } case strings.Contains(model, "sonnet"): switch { case strings.Contains(model, "4.5") || strings.Contains(model, "4-5"): fallbackName = "sonnet-4.5" case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"): fallbackName = "sonnet-3.5" default: fallbackName = "sonnet-4" } case strings.Contains(model, "haiku"): switch { case strings.Contains(model, "3-5") || strings.Contains(model, "3.5"): fallbackName = "haiku-3.5" default: fallbackName = "haiku-3" } } if fallbackName != "" { for i := range families { if families[i].name == fallbackName { matched = &families[i] break } } } } if matched == nil { return nil } // Phase 3: 在定价数据中查找该系列的价格 lookups := matched.pricing if lookups == nil { lookups = matched.match } for _, pattern := range lookups { for key, pricing := range s.pricingData { keyLower := strings.ToLower(key) if strings.Contains(keyLower, pattern) { logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key) return pricing } } } return nil } // matchOpenAIModel OpenAI 模型回退匹配策略 // 回退顺序: // 1. gpt-5.3-codex-spark* -> gpt-5.1-codex(按业务要求固定计费) // 2. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等) // 3. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号) // 4. gpt-5.3-codex -> gpt-5.2-codex // 5. gpt-5.4* -> 业务静态兜底价 // 6. 最终回退到 DefaultTestModel (gpt-5.1-codex) func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { if strings.HasPrefix(model, "gpt-5.3-codex-spark") { if pricing, ok := s.pricingData["gpt-5.1-codex"]; ok { logger.LegacyPrintf("service.pricing", "[Pricing][SparkBilling] %s -> %s billing", model, "gpt-5.1-codex") logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.1-codex")) return pricing } } // 尝试的回退变体 variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern) for _, variant := range variants { if pricing, ok := s.pricingData[variant]; ok { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, variant)) return pricing } } if strings.HasPrefix(model, "gpt-5.3-codex") { if pricing, ok := s.pricingData["gpt-5.2-codex"]; ok { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.2-codex")) return pricing } } if strings.HasPrefix(model, "gpt-5.6-sol") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-sol(static)")) return openAIGPT56SolFallbackPricing } if strings.HasPrefix(model, "gpt-5.6-terra") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-terra(static)")) return openAIGPT56TerraFallbackPricing } if strings.HasPrefix(model, "gpt-5.6-luna") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-luna(static)")) return openAIGPT56LunaFallbackPricing } // GPT-5.5 回退到 GPT-5.4 定价 if strings.HasPrefix(model, "gpt-5.5") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)")) return openAIGPT54FallbackPricing } if strings.HasPrefix(model, "gpt-5.4-mini") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-mini(static)")) return openAIGPT54MiniFallbackPricing } if strings.HasPrefix(model, "gpt-5.4-nano") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-nano(static)")) return openAIGPT54NanoFallbackPricing } if strings.HasPrefix(model, "gpt-5.4") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)")) return openAIGPT54FallbackPricing } if isOpenAIImageGenerationModel(model) { for _, candidate := range []string{"gpt-image-2", "gpt-image-1.5", "gpt-image-1"} { if pricing, ok := s.pricingData[candidate]; ok { logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI image fallback matched %s -> %s", model, candidate) return pricing } } return nil } // 最终回退到 DefaultTestModel defaultModel := strings.ToLower(openai.DefaultTestModel) if pricing, ok := s.pricingData[defaultModel]; ok { logger.LegacyPrintf("service.pricing", "[Pricing] OpenAI fallback to default model %s -> %s", model, defaultModel) return pricing } return nil } // generateOpenAIModelVariants 生成 OpenAI 模型的回退变体列表 func (s *PricingService) generateOpenAIModelVariants(model string, datePattern *regexp.Regexp) []string { seen := make(map[string]bool) var variants []string addVariant := func(v string) { if v != model && !seen[v] { seen[v] = true variants = append(variants, v) } } // 1. 去掉日期版本号: gpt-5.2-20251222 -> gpt-5.2 withoutDate := datePattern.ReplaceAllString(model, "") if withoutDate != model { addVariant(withoutDate) } // 2. 提取基础版本号: gpt-5.2-codex -> gpt-5.2 // 只匹配纯数字版本号格式 gpt-X 或 gpt-X.Y,不匹配 gpt-4o 这种带字母后缀的 if matches := openAIModelBasePattern.FindStringSubmatch(model); len(matches) > 1 { addVariant(matches[1]) } // 3. 同时去掉日期后再提取基础版本号 if withoutDate != model { if matches := openAIModelBasePattern.FindStringSubmatch(withoutDate); len(matches) > 1 { addVariant(matches[1]) } } return variants } // GetStatus 获取服务状态 func (s *PricingService) GetStatus() map[string]any { s.mu.RLock() defer s.mu.RUnlock() return map[string]any{ "model_count": len(s.pricingData), "last_updated": s.lastUpdated, "local_hash": s.localHash[:min(8, len(s.localHash))], } } // ForceUpdate 强制更新 func (s *PricingService) ForceUpdate() error { return s.downloadPricingData() } // getPricingFilePath 获取价格文件路径 func (s *PricingService) getPricingFilePath() string { return filepath.Join(s.cfg.Pricing.DataDir, "model_pricing.json") } // getHashFilePath 获取哈希文件路径 func (s *PricingService) getHashFilePath() string { return filepath.Join(s.cfg.Pricing.DataDir, "model_pricing.sha256") } // ListModelNamesByProvider returns all model names in the catalog whose // LiteLLMProvider matches the given provider string (case-insensitive). // The returned slice is sorted alphabetically. func (s *PricingService) ListModelNamesByProvider(provider string) []string { s.mu.RLock() defer s.mu.RUnlock() provider = strings.ToLower(strings.TrimSpace(provider)) names := make([]string, 0) for name, p := range s.pricingData { if strings.ToLower(p.LiteLLMProvider) == provider { names = append(names, name) } } sort.Strings(names) return names } // isNumeric 检查字符串是否为纯数字 func isNumeric(s string) bool { for _, c := range s { if c < '0' || c > '9' { return false } } return true }