Files
sub2api/backend/internal/service/pricing_service.go
T

1716 lines
62 KiB
Go
Raw Blame History

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