mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-09 00:57:17 +00:00
When channel pricing is configured, image output token price now correctly overrides all other sources (LiteLLM, group config). Add ImageOutputPriceExplicit flag to distinguish intentional zero price from missing price, preventing fallback to output price. Affects Gemini, OpenAI inline image gen, and Images API paths.
943 lines
35 KiB
Go
943 lines
35 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
)
|
||
|
||
// APIKeyRateLimitCacheData holds rate limit usage data cached in Redis.
|
||
type APIKeyRateLimitCacheData struct {
|
||
Usage5h float64 `json:"usage_5h"`
|
||
Usage1d float64 `json:"usage_1d"`
|
||
Usage7d float64 `json:"usage_7d"`
|
||
Window5h int64 `json:"window_5h"` // unix timestamp, 0 = not started
|
||
Window1d int64 `json:"window_1d"`
|
||
Window7d int64 `json:"window_7d"`
|
||
}
|
||
|
||
// UserPlatformQuotaKey 标识一个 user×platform,用于脏集出入与批量读。
|
||
type UserPlatformQuotaKey struct {
|
||
UserID int64
|
||
Platform string
|
||
}
|
||
|
||
// UserPlatformQuotaCacheEntry Redis hash 反序列化结果。
|
||
//
|
||
// SchemaVersion 用于向后兼容:
|
||
// - 0(旧 entry,无 SchemaVersion 字段)→ 视为 cache MISS,强制 refresh
|
||
// - 1(当前版本)→ 包含 limits 和 window_start,可免 DB 查询
|
||
//
|
||
// limit 字段为 nil 表示"无限额"(DB 中对应列为 NULL)。
|
||
const UserPlatformQuotaCacheSchemaV1 = int64(1)
|
||
|
||
type UserPlatformQuotaCacheEntry struct {
|
||
DailyUsageUSD float64
|
||
WeeklyUsageUSD float64
|
||
MonthlyUsageUSD float64
|
||
Version int64
|
||
SchemaVersion int64
|
||
|
||
// 以下字段仅在 SchemaVersion >= 1 时有效
|
||
DailyLimitUSD *float64
|
||
WeeklyLimitUSD *float64
|
||
MonthlyLimitUSD *float64
|
||
|
||
DailyWindowStart *time.Time
|
||
WeeklyWindowStart *time.Time
|
||
MonthlyWindowStart *time.Time
|
||
}
|
||
|
||
// BillingCache defines cache operations for billing service
|
||
type BillingCache interface {
|
||
// Balance operations
|
||
GetUserBalance(ctx context.Context, userID int64) (float64, error)
|
||
SetUserBalance(ctx context.Context, userID int64, balance float64) error
|
||
DeductUserBalance(ctx context.Context, userID int64, amount float64) error
|
||
InvalidateUserBalance(ctx context.Context, userID int64) error
|
||
|
||
// Subscription operations
|
||
GetSubscriptionCache(ctx context.Context, userID, groupID int64) (*SubscriptionCacheData, error)
|
||
SetSubscriptionCache(ctx context.Context, userID, groupID int64, data *SubscriptionCacheData) error
|
||
UpdateSubscriptionUsage(ctx context.Context, userID, groupID int64, cost float64) error
|
||
InvalidateSubscriptionCache(ctx context.Context, userID, groupID int64) error
|
||
|
||
// API Key rate limit operations
|
||
GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*APIKeyRateLimitCacheData, error)
|
||
SetAPIKeyRateLimit(ctx context.Context, keyID int64, data *APIKeyRateLimitCacheData) error
|
||
UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error
|
||
InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error
|
||
|
||
// user × platform quota 缓存
|
||
GetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaCacheEntry, bool, error)
|
||
SetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string, entry *UserPlatformQuotaCacheEntry, ttl time.Duration) error
|
||
DeleteUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) error
|
||
// IncrUserPlatformQuotaUsageCache 在缓存命中时累加用量;缓存未命中(key 不存在)静默返回 nil。
|
||
// markDirty=true 时将该 key 的 member 写入 Redis 脏集,供 flusher 批量回写 DB。
|
||
IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error
|
||
|
||
// 脏集读写,供 flusher 使用。
|
||
PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error)
|
||
ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error
|
||
BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error)
|
||
}
|
||
|
||
// ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致)
|
||
type ModelPricing struct {
|
||
InputPricePerToken float64 // 每token输入价格 (USD)
|
||
InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD)
|
||
OutputPricePerToken float64 // 每token输出价格 (USD)
|
||
OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD)
|
||
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
|
||
CacheReadPricePerToken float64 // 缓存读取每token价格 (USD)
|
||
CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD)
|
||
CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD)
|
||
CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD)
|
||
SupportsCacheBreakdown bool // 是否支持详细的缓存分类
|
||
LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格
|
||
LongContextInputMultiplier float64 // 长上下文整次会话输入倍率
|
||
LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率
|
||
ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD)
|
||
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
|
||
}
|
||
|
||
const (
|
||
openAIGPT54LongContextInputThreshold = 272000
|
||
openAIGPT54LongContextInputMultiplier = 2.0
|
||
openAIGPT54LongContextOutputMultiplier = 1.5
|
||
)
|
||
|
||
func normalizeBillingServiceTier(serviceTier string) string {
|
||
return strings.ToLower(strings.TrimSpace(serviceTier))
|
||
}
|
||
|
||
func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bool {
|
||
if pricing == nil || normalizeBillingServiceTier(serviceTier) != "priority" {
|
||
return false
|
||
}
|
||
return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 || pricing.CacheReadPricePerTokenPriority > 0
|
||
}
|
||
|
||
func serviceTierCostMultiplier(serviceTier string) float64 {
|
||
switch normalizeBillingServiceTier(serviceTier) {
|
||
case "priority":
|
||
return 2.0
|
||
case "flex":
|
||
return 0.5
|
||
default:
|
||
return 1.0
|
||
}
|
||
}
|
||
|
||
// UsageTokens 使用的token数量
|
||
type UsageTokens struct {
|
||
InputTokens int
|
||
OutputTokens int
|
||
CacheCreationTokens int
|
||
CacheReadTokens int
|
||
CacheCreation5mTokens int
|
||
CacheCreation1hTokens int
|
||
ImageOutputTokens int
|
||
}
|
||
|
||
// CostBreakdown 费用明细
|
||
type CostBreakdown struct {
|
||
InputCost float64
|
||
OutputCost float64
|
||
ImageOutputCost float64
|
||
CacheCreationCost float64
|
||
CacheReadCost float64
|
||
TotalCost float64
|
||
ActualCost float64 // 应用倍率后的实际费用
|
||
BillingMode string // 计费模式("token"/"per_request"/"image"),由 CalculateCostUnified 填充
|
||
}
|
||
|
||
// ErrModelPricingUnavailable indicates that none of the configured pricing
|
||
// sources can price the requested model.
|
||
var ErrModelPricingUnavailable = errors.New("pricing not found")
|
||
|
||
// BillingService 计费服务
|
||
type BillingService struct {
|
||
cfg *config.Config
|
||
pricingService *PricingService
|
||
fallbackPrices map[string]*ModelPricing // 硬编码回退价格
|
||
}
|
||
|
||
// NewBillingService 创建计费服务实例
|
||
func NewBillingService(cfg *config.Config, pricingService *PricingService) *BillingService {
|
||
s := &BillingService{
|
||
cfg: cfg,
|
||
pricingService: pricingService,
|
||
fallbackPrices: make(map[string]*ModelPricing),
|
||
}
|
||
|
||
// 初始化硬编码回退价格(当动态价格不可用时使用)
|
||
s.initFallbackPricing()
|
||
|
||
return s
|
||
}
|
||
|
||
// initFallbackPricing 初始化硬编码回退价格(当动态价格不可用时使用)
|
||
// 价格单位:USD per token(与LiteLLM格式一致)
|
||
func (s *BillingService) initFallbackPricing() {
|
||
// Claude 4.5 Opus
|
||
s.fallbackPrices["claude-opus-4.5"] = &ModelPricing{
|
||
InputPricePerToken: 5e-6, // $5 per MTok
|
||
OutputPricePerToken: 25e-6, // $25 per MTok
|
||
CacheCreationPricePerToken: 6.25e-6, // $6.25 per MTok
|
||
CacheReadPricePerToken: 0.5e-6, // $0.50 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// Claude 4 Sonnet
|
||
s.fallbackPrices["claude-sonnet-4"] = &ModelPricing{
|
||
InputPricePerToken: 3e-6, // $3 per MTok
|
||
OutputPricePerToken: 15e-6, // $15 per MTok
|
||
CacheCreationPricePerToken: 3.75e-6, // $3.75 per MTok
|
||
CacheReadPricePerToken: 0.3e-6, // $0.30 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// Claude 3.5 Sonnet
|
||
s.fallbackPrices["claude-3-5-sonnet"] = &ModelPricing{
|
||
InputPricePerToken: 3e-6, // $3 per MTok
|
||
OutputPricePerToken: 15e-6, // $15 per MTok
|
||
CacheCreationPricePerToken: 3.75e-6, // $3.75 per MTok
|
||
CacheReadPricePerToken: 0.3e-6, // $0.30 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// Claude 3.5 Haiku
|
||
s.fallbackPrices["claude-3-5-haiku"] = &ModelPricing{
|
||
InputPricePerToken: 1e-6, // $1 per MTok
|
||
OutputPricePerToken: 5e-6, // $5 per MTok
|
||
CacheCreationPricePerToken: 1.25e-6, // $1.25 per MTok
|
||
CacheReadPricePerToken: 0.1e-6, // $0.10 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// Claude 3 Opus
|
||
s.fallbackPrices["claude-3-opus"] = &ModelPricing{
|
||
InputPricePerToken: 15e-6, // $15 per MTok
|
||
OutputPricePerToken: 75e-6, // $75 per MTok
|
||
CacheCreationPricePerToken: 18.75e-6, // $18.75 per MTok
|
||
CacheReadPricePerToken: 1.5e-6, // $1.50 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// Claude 3 Haiku
|
||
s.fallbackPrices["claude-3-haiku"] = &ModelPricing{
|
||
InputPricePerToken: 0.25e-6, // $0.25 per MTok
|
||
OutputPricePerToken: 1.25e-6, // $1.25 per MTok
|
||
CacheCreationPricePerToken: 0.3e-6, // $0.30 per MTok
|
||
CacheReadPricePerToken: 0.03e-6, // $0.03 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// Claude 4.6 Opus (与4.5同价)
|
||
s.fallbackPrices["claude-opus-4.6"] = s.fallbackPrices["claude-opus-4.5"]
|
||
|
||
// Claude 4.7 Opus (暂与4.6同价,待官方定价更新)
|
||
s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"]
|
||
|
||
// Gemini 3.1 Pro
|
||
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
|
||
InputPricePerToken: 2e-6, // $2 per MTok
|
||
OutputPricePerToken: 12e-6, // $12 per MTok
|
||
CacheCreationPricePerToken: 2e-6, // $2 per MTok
|
||
CacheReadPricePerToken: 0.2e-6, // $0.20 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// OpenAI GPT-5.4(业务指定价格)
|
||
s.fallbackPrices["gpt-5.4"] = &ModelPricing{
|
||
InputPricePerToken: 2.5e-6, // $2.5 per MTok
|
||
InputPricePerTokenPriority: 5e-6, // $5 per MTok
|
||
OutputPricePerToken: 15e-6, // $15 per MTok
|
||
OutputPricePerTokenPriority: 30e-6, // $30 per MTok
|
||
CacheCreationPricePerToken: 2.5e-6, // $2.5 per MTok
|
||
CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok
|
||
CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok
|
||
SupportsCacheBreakdown: false,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}
|
||
// GPT-5.5 暂无独立定价,回退到 GPT-5.4
|
||
s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"]
|
||
|
||
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
|
||
InputPricePerToken: 7.5e-7,
|
||
OutputPricePerToken: 4.5e-6,
|
||
CacheReadPricePerToken: 7.5e-8,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["gpt-5.4-nano"] = &ModelPricing{
|
||
InputPricePerToken: 2e-7,
|
||
OutputPricePerToken: 1.25e-6,
|
||
CacheReadPricePerToken: 2e-8,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
// OpenAI GPT-5.2(本地兜底)
|
||
s.fallbackPrices["gpt-5.2"] = &ModelPricing{
|
||
InputPricePerToken: 1.75e-6,
|
||
InputPricePerTokenPriority: 3.5e-6,
|
||
OutputPricePerToken: 14e-6,
|
||
OutputPricePerTokenPriority: 28e-6,
|
||
CacheCreationPricePerToken: 1.75e-6,
|
||
CacheReadPricePerToken: 0.175e-6,
|
||
CacheReadPricePerTokenPriority: 0.35e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
// Codex 族兜底统一按 GPT-5.3 Codex 价格计费
|
||
s.fallbackPrices["gpt-5.3-codex"] = &ModelPricing{
|
||
InputPricePerToken: 1.5e-6, // $1.5 per MTok
|
||
InputPricePerTokenPriority: 3e-6, // $3 per MTok
|
||
OutputPricePerToken: 12e-6, // $12 per MTok
|
||
OutputPricePerTokenPriority: 24e-6, // $24 per MTok
|
||
CacheCreationPricePerToken: 1.5e-6, // $1.5 per MTok
|
||
CacheReadPricePerToken: 0.15e-6,
|
||
CacheReadPricePerTokenPriority: 0.3e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
}
|
||
|
||
// getFallbackPricing 根据模型系列获取回退价格
|
||
func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||
modelLower := strings.ToLower(model)
|
||
|
||
// 按模型系列匹配
|
||
if strings.Contains(modelLower, "opus") {
|
||
if strings.Contains(modelLower, "4.7") || strings.Contains(modelLower, "4-7") {
|
||
return s.fallbackPrices["claude-opus-4.7"]
|
||
}
|
||
if strings.Contains(modelLower, "4.6") || strings.Contains(modelLower, "4-6") {
|
||
return s.fallbackPrices["claude-opus-4.6"]
|
||
}
|
||
if strings.Contains(modelLower, "4.5") || strings.Contains(modelLower, "4-5") {
|
||
return s.fallbackPrices["claude-opus-4.5"]
|
||
}
|
||
return s.fallbackPrices["claude-3-opus"]
|
||
}
|
||
if strings.Contains(modelLower, "sonnet") {
|
||
if strings.Contains(modelLower, "4") && !strings.Contains(modelLower, "3") {
|
||
return s.fallbackPrices["claude-sonnet-4"]
|
||
}
|
||
return s.fallbackPrices["claude-3-5-sonnet"]
|
||
}
|
||
if strings.Contains(modelLower, "haiku") {
|
||
if strings.Contains(modelLower, "3-5") || strings.Contains(modelLower, "3.5") {
|
||
return s.fallbackPrices["claude-3-5-haiku"]
|
||
}
|
||
return s.fallbackPrices["claude-3-haiku"]
|
||
}
|
||
// Claude 未知型号统一回退到 Sonnet,避免计费中断。
|
||
if strings.Contains(modelLower, "claude") {
|
||
return s.fallbackPrices["claude-sonnet-4"]
|
||
}
|
||
if strings.Contains(modelLower, "gemini-3.1-pro") || strings.Contains(modelLower, "gemini-3-1-pro") {
|
||
return s.fallbackPrices["gemini-3.1-pro"]
|
||
}
|
||
|
||
// OpenAI 仅匹配已知 GPT-5/Codex 族,避免未知 OpenAI 型号误计价。
|
||
if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" {
|
||
switch normalized {
|
||
case "gpt-5.5":
|
||
return s.fallbackPrices["gpt-5.5"]
|
||
case "gpt-5.4-mini":
|
||
return s.fallbackPrices["gpt-5.4-mini"]
|
||
case "gpt-5.4-nano":
|
||
return s.fallbackPrices["gpt-5.4-nano"]
|
||
case "gpt-5.4":
|
||
return s.fallbackPrices["gpt-5.4"]
|
||
case "gpt-5.2":
|
||
return s.fallbackPrices["gpt-5.2"]
|
||
case "gpt-5.3-codex", "gpt-5.3-codex-spark":
|
||
return s.fallbackPrices["gpt-5.3-codex"]
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetModelPricing 获取模型价格配置
|
||
func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||
// 标准化模型名称(转小写)
|
||
model = strings.ToLower(model)
|
||
|
||
// 1. 优先从动态价格服务获取
|
||
if s.pricingService != nil {
|
||
litellmPricing := s.pricingService.GetModelPricing(model)
|
||
if litellmPricing != nil {
|
||
// 启用 5m/1h 分类计费的条件:
|
||
// 1. 存在 1h 价格
|
||
// 2. 1h 价格 > 5m 价格(防止 LiteLLM 数据错误导致少收费)
|
||
price5m := litellmPricing.CacheCreationInputTokenCost
|
||
price1h := litellmPricing.CacheCreationInputTokenCostAbove1hr
|
||
enableBreakdown := price1h > 0 && price1h > price5m
|
||
return s.applyModelSpecificPricingPolicy(model, &ModelPricing{
|
||
InputPricePerToken: litellmPricing.InputCostPerToken,
|
||
InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority,
|
||
OutputPricePerToken: litellmPricing.OutputCostPerToken,
|
||
OutputPricePerTokenPriority: litellmPricing.OutputCostPerTokenPriority,
|
||
CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost,
|
||
CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost,
|
||
CacheReadPricePerTokenPriority: litellmPricing.CacheReadInputTokenCostPriority,
|
||
CacheCreation5mPrice: price5m,
|
||
CacheCreation1hPrice: price1h,
|
||
SupportsCacheBreakdown: enableBreakdown,
|
||
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||
}), nil
|
||
}
|
||
}
|
||
|
||
// 2. 使用硬编码回退价格
|
||
fallback := s.getFallbackPricing(model)
|
||
if fallback != nil {
|
||
log.Printf("[Billing] Using fallback pricing for model: %s", model)
|
||
return s.applyModelSpecificPricingPolicy(model, fallback), nil
|
||
}
|
||
|
||
return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model)
|
||
}
|
||
|
||
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值
|
||
// 渠道存在时,未配置的图片输出价格归零(不回退到 LiteLLM)
|
||
func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) {
|
||
pricing, err := s.GetModelPricing(model)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if channelPricing == nil {
|
||
return pricing, nil
|
||
}
|
||
if channelPricing.InputPrice != nil {
|
||
pricing.InputPricePerToken = *channelPricing.InputPrice
|
||
pricing.InputPricePerTokenPriority = *channelPricing.InputPrice
|
||
}
|
||
if channelPricing.OutputPrice != nil {
|
||
pricing.OutputPricePerToken = *channelPricing.OutputPrice
|
||
pricing.OutputPricePerTokenPriority = *channelPricing.OutputPrice
|
||
}
|
||
if channelPricing.CacheWritePrice != nil {
|
||
pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice
|
||
pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice
|
||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||
}
|
||
if channelPricing.CacheReadPrice != nil {
|
||
pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice
|
||
pricing.CacheReadPricePerTokenPriority = *channelPricing.CacheReadPrice
|
||
}
|
||
if channelPricing.ImageOutputPrice != nil {
|
||
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
|
||
} else {
|
||
pricing.ImageOutputPricePerToken = 0
|
||
}
|
||
pricing.ImageOutputPriceExplicit = true
|
||
return pricing, nil
|
||
}
|
||
|
||
// --- 统一计费入口 ---
|
||
|
||
// CostInput 统一计费输入
|
||
type CostInput struct {
|
||
Ctx context.Context
|
||
Model string
|
||
GroupID *int64 // 用于渠道定价查找
|
||
Tokens UsageTokens
|
||
RequestCount int // 按次计费时使用
|
||
SizeTier string // 按次/图片模式的层级标签("1K","2K","4K","HD" 等)
|
||
RateMultiplier float64
|
||
ServiceTier string // "priority","flex","" 等
|
||
Resolver *ModelPricingResolver // 定价解析器
|
||
Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用)
|
||
}
|
||
|
||
// CalculateCostUnified 统一计费入口,支持三种计费模式。
|
||
// 使用 ModelPricingResolver 解析定价,然后根据 BillingMode 分发计算。
|
||
func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, error) {
|
||
if input.Resolver == nil {
|
||
// 无 Resolver,回退到旧路径
|
||
return s.calculateCostInternal(input.Model, input.Tokens, input.RateMultiplier, input.ServiceTier, nil)
|
||
}
|
||
|
||
// 优先使用预解析结果,避免重复 Resolve 调用
|
||
resolved := input.Resolved
|
||
if resolved == nil {
|
||
resolved = input.Resolver.Resolve(input.Ctx, PricingInput{
|
||
Model: input.Model,
|
||
GroupID: input.GroupID,
|
||
})
|
||
}
|
||
|
||
// 保存时强制 > 0;若仍有负数泄漏(缓存/迁移残留),按 0 处理避免按 1x 误扣。
|
||
if input.RateMultiplier < 0 {
|
||
input.RateMultiplier = 0
|
||
}
|
||
|
||
var breakdown *CostBreakdown
|
||
var err error
|
||
switch resolved.Mode {
|
||
case BillingModePerRequest, BillingModeImage:
|
||
breakdown, err = s.calculatePerRequestCost(resolved, input)
|
||
default: // BillingModeToken
|
||
breakdown, err = s.calculateTokenCost(resolved, input)
|
||
}
|
||
if err == nil && breakdown != nil {
|
||
breakdown.BillingMode = string(resolved.Mode)
|
||
if breakdown.BillingMode == "" {
|
||
breakdown.BillingMode = string(BillingModeToken)
|
||
}
|
||
}
|
||
return breakdown, err
|
||
}
|
||
|
||
// calculateTokenCost 按 token 区间计费
|
||
func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) {
|
||
totalContext := input.Tokens.InputTokens + input.Tokens.CacheReadTokens
|
||
|
||
pricing := input.Resolver.GetIntervalPricing(resolved, totalContext)
|
||
if pricing == nil {
|
||
return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable)
|
||
}
|
||
|
||
pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing)
|
||
|
||
// 长上下文定价仅在无区间定价时应用(区间定价已包含上下文分层)
|
||
applyLongCtx := len(resolved.Intervals) == 0
|
||
|
||
return s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx), nil
|
||
}
|
||
|
||
// computeTokenBreakdown 是 token 计费的核心逻辑,由 calculateTokenCost 和 calculateCostInternal 共用。
|
||
// applyLongCtx 控制是否检查长上下文定价(区间定价已自含上下文分层,不需要额外应用)。
|
||
func (s *BillingService) computeTokenBreakdown(
|
||
pricing *ModelPricing, tokens UsageTokens,
|
||
rateMultiplier float64, serviceTier string,
|
||
applyLongCtx bool,
|
||
) *CostBreakdown {
|
||
// 保存时强制 > 0;若仍有负数泄漏,按 0 处理避免按 1x 误扣。
|
||
if rateMultiplier < 0 {
|
||
rateMultiplier = 0
|
||
}
|
||
|
||
inputPrice := pricing.InputPricePerToken
|
||
outputPrice := pricing.OutputPricePerToken
|
||
cacheReadPrice := pricing.CacheReadPricePerToken
|
||
cacheCreationMultiplier := 1.0
|
||
tierMultiplier := 1.0
|
||
|
||
if usePriorityServiceTierPricing(serviceTier, pricing) {
|
||
if pricing.InputPricePerTokenPriority > 0 {
|
||
inputPrice = pricing.InputPricePerTokenPriority
|
||
}
|
||
if pricing.OutputPricePerTokenPriority > 0 {
|
||
outputPrice = pricing.OutputPricePerTokenPriority
|
||
}
|
||
if pricing.CacheReadPricePerTokenPriority > 0 {
|
||
cacheReadPrice = pricing.CacheReadPricePerTokenPriority
|
||
}
|
||
} else {
|
||
tierMultiplier = serviceTierCostMultiplier(serviceTier)
|
||
}
|
||
|
||
if applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) {
|
||
inputPrice *= pricing.LongContextInputMultiplier
|
||
outputPrice *= pricing.LongContextOutputMultiplier
|
||
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
||
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
||
cacheReadPrice *= pricing.LongContextInputMultiplier
|
||
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
||
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
||
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
||
cacheCreationMultiplier = pricing.LongContextInputMultiplier
|
||
}
|
||
|
||
bd := &CostBreakdown{}
|
||
bd.InputCost = float64(tokens.InputTokens) * inputPrice
|
||
|
||
// 分离图片输出 token 与文本输出 token
|
||
textOutputTokens := tokens.OutputTokens - tokens.ImageOutputTokens
|
||
if textOutputTokens < 0 {
|
||
textOutputTokens = 0
|
||
}
|
||
bd.OutputCost = float64(textOutputTokens) * outputPrice
|
||
|
||
// 图片输出 token 费用(独立费率)
|
||
if tokens.ImageOutputTokens > 0 {
|
||
imgPrice := pricing.ImageOutputPricePerToken
|
||
if imgPrice == 0 && !pricing.ImageOutputPriceExplicit {
|
||
imgPrice = outputPrice
|
||
}
|
||
bd.ImageOutputCost = float64(tokens.ImageOutputTokens) * imgPrice
|
||
}
|
||
|
||
// 缓存创建费用
|
||
bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens, cacheCreationMultiplier)
|
||
|
||
bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice
|
||
|
||
if tierMultiplier != 1.0 {
|
||
bd.InputCost *= tierMultiplier
|
||
bd.OutputCost *= tierMultiplier
|
||
bd.ImageOutputCost *= tierMultiplier
|
||
bd.CacheCreationCost *= tierMultiplier
|
||
bd.CacheReadCost *= tierMultiplier
|
||
}
|
||
|
||
bd.TotalCost = bd.InputCost + bd.OutputCost + bd.ImageOutputCost +
|
||
bd.CacheCreationCost + bd.CacheReadCost
|
||
bd.ActualCost = bd.TotalCost * rateMultiplier
|
||
|
||
return bd
|
||
}
|
||
|
||
// computeCacheCreationCost 计算缓存创建费用(支持 5m/1h 分类或标准计费)。
|
||
// multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。
|
||
func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, multiplier float64) float64 {
|
||
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
||
if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
|
||
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
|
||
return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier
|
||
}
|
||
return float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier +
|
||
float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier
|
||
}
|
||
return float64(tokens.CacheCreationTokens) * pricing.CacheCreationPricePerToken * multiplier
|
||
}
|
||
|
||
// calculatePerRequestCost 按次/图片计费
|
||
func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) {
|
||
count := input.RequestCount
|
||
if count <= 0 {
|
||
count = 1
|
||
}
|
||
|
||
var unitPrice float64
|
||
|
||
if input.SizeTier != "" {
|
||
unitPrice = input.Resolver.GetRequestTierPrice(resolved, input.SizeTier)
|
||
}
|
||
|
||
if unitPrice == 0 {
|
||
totalContext := input.Tokens.InputTokens + input.Tokens.CacheReadTokens
|
||
unitPrice = input.Resolver.GetRequestTierPriceByContext(resolved, totalContext)
|
||
}
|
||
|
||
// 回退到默认按次价格
|
||
if unitPrice == 0 {
|
||
unitPrice = resolved.DefaultPerRequestPrice
|
||
}
|
||
|
||
totalCost := unitPrice * float64(count)
|
||
actualCost := totalCost * input.RateMultiplier
|
||
|
||
return &CostBreakdown{
|
||
TotalCost: totalCost,
|
||
ActualCost: actualCost,
|
||
}, nil
|
||
}
|
||
|
||
// CalculateCost 计算使用费用
|
||
func (s *BillingService) CalculateCost(model string, tokens UsageTokens, rateMultiplier float64) (*CostBreakdown, error) {
|
||
return s.calculateCostInternal(model, tokens, rateMultiplier, "", nil)
|
||
}
|
||
|
||
func (s *BillingService) CalculateCostWithServiceTier(model string, tokens UsageTokens, rateMultiplier float64, serviceTier string) (*CostBreakdown, error) {
|
||
return s.calculateCostInternal(model, tokens, rateMultiplier, serviceTier, nil)
|
||
}
|
||
|
||
func (s *BillingService) calculateCostInternal(model string, tokens UsageTokens, rateMultiplier float64, serviceTier string, channelPricing *ChannelModelPricing) (*CostBreakdown, error) {
|
||
var pricing *ModelPricing
|
||
var err error
|
||
if channelPricing != nil {
|
||
pricing, err = s.GetModelPricingWithChannel(model, channelPricing)
|
||
} else {
|
||
pricing, err = s.GetModelPricing(model)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 旧路径始终检查长上下文定价(无区间定价概念)
|
||
return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, true), nil
|
||
}
|
||
|
||
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
||
if pricing == nil {
|
||
return nil
|
||
}
|
||
if !isOpenAIGPT54Model(model) {
|
||
return pricing
|
||
}
|
||
if pricing.LongContextInputThreshold > 0 && pricing.LongContextInputMultiplier > 0 && pricing.LongContextOutputMultiplier > 0 {
|
||
return pricing
|
||
}
|
||
cloned := *pricing
|
||
if cloned.LongContextInputThreshold <= 0 {
|
||
cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold
|
||
}
|
||
if cloned.LongContextInputMultiplier <= 0 {
|
||
cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier
|
||
}
|
||
if cloned.LongContextOutputMultiplier <= 0 {
|
||
cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier
|
||
}
|
||
return &cloned
|
||
}
|
||
|
||
func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool {
|
||
if pricing == nil || pricing.LongContextInputThreshold <= 0 {
|
||
return false
|
||
}
|
||
if pricing.LongContextInputMultiplier <= 1 && pricing.LongContextOutputMultiplier <= 1 {
|
||
return false
|
||
}
|
||
totalInputTokens := tokens.InputTokens + tokens.CacheReadTokens
|
||
return totalInputTokens > pricing.LongContextInputThreshold
|
||
}
|
||
|
||
func isOpenAIGPT54Model(model string) bool {
|
||
// 仅当模型字符串实际属于已知 GPT-5/Codex 族时才做归一判定,避免
|
||
// normalizeCodexModel 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o)
|
||
// 误识别为 gpt-5.4。
|
||
normalized := normalizeKnownOpenAICodexModel(model)
|
||
return normalized == "gpt-5.4" || normalized == "gpt-5.5"
|
||
}
|
||
|
||
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
|
||
func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) {
|
||
multiplier := s.cfg.Default.RateMultiplier
|
||
if multiplier <= 0 {
|
||
multiplier = 1.0
|
||
}
|
||
return s.CalculateCost(model, tokens, multiplier)
|
||
}
|
||
|
||
// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费
|
||
// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费
|
||
// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍)
|
||
//
|
||
// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0
|
||
// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k)
|
||
// 范围内正常计费,范围外 × 2 计费
|
||
func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) {
|
||
// 未启用长上下文计费,直接走正常计费
|
||
if threshold <= 0 || extraMultiplier <= 1 {
|
||
return s.CalculateCost(model, tokens, rateMultiplier)
|
||
}
|
||
|
||
// 计算总输入 token(缓存读取 + 新输入)
|
||
total := tokens.CacheReadTokens + tokens.InputTokens
|
||
if total <= threshold {
|
||
return s.CalculateCost(model, tokens, rateMultiplier)
|
||
}
|
||
|
||
// 拆分成范围内和范围外
|
||
var inRangeCacheTokens, inRangeInputTokens int
|
||
var outRangeCacheTokens, outRangeInputTokens int
|
||
|
||
if tokens.CacheReadTokens >= threshold {
|
||
// 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入
|
||
inRangeCacheTokens = threshold
|
||
inRangeInputTokens = 0
|
||
outRangeCacheTokens = tokens.CacheReadTokens - threshold
|
||
outRangeInputTokens = tokens.InputTokens
|
||
} else {
|
||
// 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入
|
||
inRangeCacheTokens = tokens.CacheReadTokens
|
||
inRangeInputTokens = threshold - tokens.CacheReadTokens
|
||
outRangeCacheTokens = 0
|
||
outRangeInputTokens = tokens.InputTokens - inRangeInputTokens
|
||
}
|
||
|
||
// 范围内部分:正常计费
|
||
inRangeTokens := UsageTokens{
|
||
InputTokens: inRangeInputTokens,
|
||
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
||
CacheCreationTokens: tokens.CacheCreationTokens,
|
||
CacheReadTokens: inRangeCacheTokens,
|
||
CacheCreation5mTokens: tokens.CacheCreation5mTokens,
|
||
CacheCreation1hTokens: tokens.CacheCreation1hTokens,
|
||
ImageOutputTokens: tokens.ImageOutputTokens,
|
||
}
|
||
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 范围外部分:× extraMultiplier 计费
|
||
outRangeTokens := UsageTokens{
|
||
InputTokens: outRangeInputTokens,
|
||
CacheReadTokens: outRangeCacheTokens,
|
||
}
|
||
outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier)
|
||
if err != nil {
|
||
return inRangeCost, fmt.Errorf("out-range cost: %w", err)
|
||
}
|
||
|
||
// 合并成本
|
||
return &CostBreakdown{
|
||
InputCost: inRangeCost.InputCost + outRangeCost.InputCost,
|
||
OutputCost: inRangeCost.OutputCost,
|
||
ImageOutputCost: inRangeCost.ImageOutputCost,
|
||
CacheCreationCost: inRangeCost.CacheCreationCost,
|
||
CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost,
|
||
TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost,
|
||
ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost,
|
||
}, nil
|
||
}
|
||
|
||
// ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配)
|
||
func (s *BillingService) ListSupportedModels() []string {
|
||
models := make([]string, 0)
|
||
// 返回回退价格支持的模型系列
|
||
for model := range s.fallbackPrices {
|
||
models = append(models, model)
|
||
}
|
||
return models
|
||
}
|
||
|
||
// IsModelSupported 检查模型是否支持(现在总是返回true,因为有模糊匹配回退)
|
||
func (s *BillingService) IsModelSupported(model string) bool {
|
||
// 所有Claude模型都有回退价格支持
|
||
modelLower := strings.ToLower(model)
|
||
return strings.Contains(modelLower, "claude") ||
|
||
strings.Contains(modelLower, "opus") ||
|
||
strings.Contains(modelLower, "sonnet") ||
|
||
strings.Contains(modelLower, "haiku")
|
||
}
|
||
|
||
// GetEstimatedCost 估算费用(用于前端展示)
|
||
func (s *BillingService) GetEstimatedCost(model string, estimatedInputTokens, estimatedOutputTokens int) (float64, error) {
|
||
tokens := UsageTokens{
|
||
InputTokens: estimatedInputTokens,
|
||
OutputTokens: estimatedOutputTokens,
|
||
}
|
||
|
||
breakdown, err := s.CalculateCostWithConfig(model, tokens)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
return breakdown.ActualCost, nil
|
||
}
|
||
|
||
// GetPricingServiceStatus 获取价格服务状态
|
||
func (s *BillingService) GetPricingServiceStatus() map[string]any {
|
||
if s.pricingService != nil {
|
||
return s.pricingService.GetStatus()
|
||
}
|
||
return map[string]any{
|
||
"model_count": len(s.fallbackPrices),
|
||
"last_updated": "using fallback",
|
||
"local_hash": "N/A",
|
||
}
|
||
}
|
||
|
||
// ForceUpdatePricing 强制更新价格数据
|
||
func (s *BillingService) ForceUpdatePricing() error {
|
||
if s.pricingService != nil {
|
||
return s.pricingService.ForceUpdate()
|
||
}
|
||
return fmt.Errorf("pricing service not initialized")
|
||
}
|
||
|
||
// ImagePriceConfig 图片计费配置
|
||
type ImagePriceConfig struct {
|
||
Price1K *float64 // 1K 尺寸价格(nil 表示使用默认值)
|
||
Price2K *float64 // 2K 尺寸价格(nil 表示使用默认值)
|
||
Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值)
|
||
}
|
||
|
||
// CalculateImageCost 计算图片生成费用
|
||
// model: 请求的模型名称(用于获取 LiteLLM 默认价格)
|
||
// imageSize: 图片尺寸 "1K", "2K", "4K"
|
||
// imageCount: 生成的图片数量
|
||
// groupConfig: 分组配置的价格(可能为 nil,表示使用默认值)
|
||
// rateMultiplier: 费率倍数
|
||
func (s *BillingService) CalculateImageCost(model string, imageSize string, imageCount int, groupConfig *ImagePriceConfig, rateMultiplier float64) *CostBreakdown {
|
||
if imageCount <= 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
imageSize = NormalizeImageBillingTierOrDefault(imageSize)
|
||
|
||
// 获取单价
|
||
unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig)
|
||
|
||
// 计算总费用
|
||
totalCost := unitPrice * float64(imageCount)
|
||
|
||
// 应用倍率(保存时强制 > 0;负数按 0 处理避免按 1x 误扣)
|
||
if rateMultiplier < 0 {
|
||
rateMultiplier = 0
|
||
}
|
||
actualCost := totalCost * rateMultiplier
|
||
|
||
return &CostBreakdown{
|
||
TotalCost: totalCost,
|
||
ActualCost: actualCost,
|
||
BillingMode: string(BillingModeImage),
|
||
}
|
||
}
|
||
|
||
// getImageUnitPrice 获取图片单价
|
||
func (s *BillingService) getImageUnitPrice(model string, imageSize string, groupConfig *ImagePriceConfig) float64 {
|
||
// 优先使用分组配置的价格
|
||
if groupConfig != nil {
|
||
switch imageSize {
|
||
case "1K":
|
||
if groupConfig.Price1K != nil {
|
||
return *groupConfig.Price1K
|
||
}
|
||
case "2K":
|
||
if groupConfig.Price2K != nil {
|
||
return *groupConfig.Price2K
|
||
}
|
||
case "4K":
|
||
if groupConfig.Price4K != nil {
|
||
return *groupConfig.Price4K
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回退到 LiteLLM 默认价格
|
||
return s.getDefaultImagePrice(model, imageSize)
|
||
}
|
||
|
||
// getDefaultImagePrice 获取 LiteLLM 默认图片价格
|
||
func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 {
|
||
basePrice := 0.0
|
||
|
||
// 从 PricingService 获取 output_cost_per_image
|
||
if s.pricingService != nil {
|
||
pricing := s.pricingService.GetModelPricing(model)
|
||
if pricing != nil && pricing.OutputCostPerImage > 0 {
|
||
basePrice = pricing.OutputCostPerImage
|
||
}
|
||
}
|
||
|
||
// 如果没有找到价格,使用硬编码默认值($0.134,来自 gemini-3-pro-image-preview)
|
||
if basePrice <= 0 {
|
||
basePrice = 0.134
|
||
}
|
||
|
||
// 2K 尺寸 1.5 倍,4K 尺寸翻倍
|
||
if imageSize == "2K" {
|
||
return basePrice * 1.5
|
||
}
|
||
if imageSize == "4K" {
|
||
return basePrice * 2
|
||
}
|
||
|
||
return basePrice
|
||
}
|