mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
1760 lines
64 KiB
Go
1760 lines
64 KiB
Go
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,
|
||
}
|
||
openAIGPT61SolFallbackPricing = &LiteLLMModelPricing{
|
||
InputCostPerToken: 2e-6,
|
||
InputCostPerTokenPriority: 4e-6,
|
||
OutputCostPerToken: 10e-6,
|
||
OutputCostPerTokenPriority: 20e-6,
|
||
CacheCreationInputTokenCost: 2.5e-6,
|
||
CacheCreationInputTokenCostPriority: 5e-6,
|
||
CacheReadInputTokenCost: 0.1e-6,
|
||
CacheReadInputTokenCostPriority: 0.2e-6,
|
||
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,
|
||
}
|
||
claudeHaiku55FallbackPricing = &LiteLLMModelPricing{
|
||
InputCostPerToken: 1e-7, OutputCostPerToken: 5e-7,
|
||
CacheCreationInputTokenCost: 1.25e-7, CacheCreationInputTokenCostAbove1hr: 2e-7,
|
||
CacheReadInputTokenCost: 1e-8,
|
||
LongContextInputTokenThreshold: 100000,
|
||
LongContextInputCostMultiplier: 5, LongContextOutputCostMultiplier: 5,
|
||
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"`
|
||
// CacheStorageCostPerTokenPerHour 显式上下文缓存存储单价(每 token 每小时,USD),一般由定价覆盖文件提供,
|
||
// 配置后优先于内置价(见 BillingService.CacheStoragePricePerTokenHour)。
|
||
CacheStorageCostPerTokenPerHour float64 `json:"cache_storage_cost_per_token_per_hour"`
|
||
|
||
// 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"`
|
||
CacheStorageCostPerTokenPerHour *float64 `json:"cache_storage_cost_per_token_per_hour"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if entry.CacheStorageCostPerTokenPerHour != nil {
|
||
pricing.CacheStorageCostPerTokenPerHour = *entry.CacheStorageCostPerTokenPerHour
|
||
}
|
||
|
||
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
|
||
// 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", "gemini-4-argon"} {
|
||
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
|
||
}
|
||
if claude.IsHaiku55(model) {
|
||
if pricing, ok := s.pricingData["claude-haiku-5-5"]; ok {
|
||
return pricing
|
||
}
|
||
return claudeHaiku55FallbackPricing
|
||
}
|
||
// 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.IsGPT61SolModelSpelling(model) {
|
||
if pricing, ok := s.pricingData["gpt-6.1-sol"]; ok {
|
||
return pricing
|
||
}
|
||
return openAIGPT61SolFallbackPricing
|
||
}
|
||
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
|
||
}
|