mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
2133 lines
85 KiB
Go
2133 lines
85 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||
)
|
||
|
||
// 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)
|
||
ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken
|
||
OutputPricePerToken float64 // 每token输出价格 (USD)
|
||
OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD)
|
||
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
|
||
CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD)
|
||
CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退)
|
||
CacheReadPricePerToken float64 // 缓存读取每token价格 (USD)
|
||
CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD)
|
||
FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为
|
||
FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为
|
||
CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD)
|
||
CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD)
|
||
SupportsCacheBreakdown bool // 是否支持详细的缓存分类
|
||
LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格
|
||
LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型
|
||
LongContextInputMultiplier float64 // 长上下文整次会话输入倍率
|
||
LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率
|
||
ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD)
|
||
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
|
||
}
|
||
|
||
func normalizeBillingServiceTier(serviceTier string) string {
|
||
return strings.ToLower(strings.TrimSpace(serviceTier))
|
||
}
|
||
|
||
func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bool {
|
||
if pricing == nil {
|
||
return false
|
||
}
|
||
tier := normalizeBillingServiceTier(serviceTier)
|
||
if tier != "priority" && tier != "fast" {
|
||
return false
|
||
}
|
||
if pricing.FastMultiplier != nil {
|
||
return false
|
||
}
|
||
return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 ||
|
||
pricing.CacheCreationPricePerTokenPriority > 0 || pricing.CacheReadPricePerTokenPriority > 0
|
||
}
|
||
|
||
func serviceTierCostMultiplier(serviceTier string) float64 {
|
||
switch normalizeBillingServiceTier(serviceTier) {
|
||
case "priority", "fast":
|
||
return 2.0
|
||
case "flex":
|
||
return 0.5
|
||
default:
|
||
return 1.0
|
||
}
|
||
}
|
||
|
||
func configuredServiceTierMultiplier(serviceTier string, pricing *ModelPricing) float64 {
|
||
if pricing != nil {
|
||
switch normalizeBillingServiceTier(serviceTier) {
|
||
case "priority", "fast":
|
||
if pricing.FastMultiplier != nil {
|
||
return *pricing.FastMultiplier
|
||
}
|
||
case "flex":
|
||
if pricing.FlexMultiplier != nil {
|
||
return *pricing.FlexMultiplier
|
||
}
|
||
}
|
||
}
|
||
return serviceTierCostMultiplier(serviceTier)
|
||
}
|
||
|
||
func pricingWithPriorityMultiplier(base *ModelPricing, multiplier float64) *ModelPricing {
|
||
if base == nil {
|
||
return nil
|
||
}
|
||
cloned := *base
|
||
cloned.InputPricePerTokenPriority = cloned.InputPricePerToken * multiplier
|
||
cloned.OutputPricePerTokenPriority = cloned.OutputPricePerToken * multiplier
|
||
cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * multiplier
|
||
cloned.CacheReadPricePerTokenPriority = cloned.CacheReadPricePerToken * multiplier
|
||
return &cloned
|
||
}
|
||
|
||
// UsageTokens 使用的token数量
|
||
type UsageTokens struct {
|
||
InputTokens int
|
||
ImageInputTokens int
|
||
OutputTokens int
|
||
CacheCreationTokens int
|
||
CacheReadTokens int
|
||
CacheCreation5mTokens int
|
||
CacheCreation1hTokens int
|
||
ImageOutputTokens int
|
||
}
|
||
|
||
// CostBreakdown 费用明细
|
||
type CostBreakdown struct {
|
||
InputCost float64 // 文本输入费用(不含图片输入,图片输入单独记入 ImageInputCost)
|
||
ImageInputCost float64 // 图片输入 token 费用(如 gpt-image-2 图片编辑)
|
||
OutputCost float64
|
||
ImageOutputCost float64
|
||
CacheCreationCost float64
|
||
CacheReadCost float64
|
||
TotalCost float64
|
||
ActualCost float64 // 应用倍率后的实际费用
|
||
BillingMode string // 计费模式("token"/"per_request"/"image"),由 CalculateCostUnified 填充
|
||
LongContextBillingApplied bool
|
||
}
|
||
|
||
func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) {
|
||
if cost == nil || multiplier == 1 {
|
||
return
|
||
}
|
||
cost.InputCost *= multiplier
|
||
cost.ImageInputCost *= multiplier
|
||
cost.OutputCost *= multiplier
|
||
cost.ImageOutputCost *= multiplier
|
||
cost.CacheCreationCost *= multiplier
|
||
cost.CacheReadCost *= multiplier
|
||
cost.TotalCost *= multiplier
|
||
cost.ActualCost *= multiplier
|
||
}
|
||
|
||
func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 {
|
||
if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil {
|
||
return 1
|
||
}
|
||
return resolved.channelPricing.TimePricing.MultiplierAt(at)
|
||
}
|
||
|
||
// ErrModelPricingUnavailable indicates that none of the configured pricing
|
||
// sources can price the requested model.
|
||
var ErrModelPricingUnavailable = errors.New("pricing not found")
|
||
|
||
// ---- DeepSeek 官方低谷价($/token,2026-08-23 起生效)----
|
||
// Source: https://api-docs.deepseek.com/quick_start/pricing
|
||
// 高峰价 = 2× 低谷价;高峰时段 01:00–04:00 与 06:00–10:00 UTC(仅工作日),
|
||
// 北京时间周六/周日全天低谷。时段判定见 deepseekPeakMultiplierAt。
|
||
const (
|
||
deepseekFlashOffPeakInputPrice = 2.2e-7 // $0.22 per MTok (cache miss)
|
||
deepseekFlashOffPeakOutputPrice = 6.6e-7 // $0.66 per MTok
|
||
deepseekFlashOffPeakCacheRead = 7e-9 // $0.007 per MTok (cache hit)
|
||
deepseekProOffPeakInputPrice = 6.6e-7 // $0.66 per MTok (cache miss)
|
||
deepseekProOffPeakOutputPrice = 1.98e-6 // $1.98 per MTok
|
||
deepseekProOffPeakCacheRead = 2.2e-8 // $0.022 per MTok (cache hit)
|
||
)
|
||
|
||
// isDeepSeekModel 判断模型名是否为 DeepSeek 模型(大小写不敏感)。
|
||
// 任意 deepseek- 前缀均视为 DeepSeek 模型:官方模型(v4-flash / v4-pro /
|
||
// v4-flash-vision-exp)按各自价卡计价,其余 deepseek-*(含已停服的
|
||
// deepseek-chat / deepseek-reasoner 与未知型号)统一按 flash 价兜底,
|
||
// 避免计费中断;新名字由 fallback warn 日志(每模型每进程一条)暴露,
|
||
// 运营者据此更新价卡。
|
||
func isDeepSeekModel(model string) bool {
|
||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "deepseek-")
|
||
}
|
||
|
||
// deepseekPeakMultiplierAt 返回指定时刻的 DeepSeek 官方峰谷定价因子。
|
||
// 官方口径(2026-08-23 起生效):高峰价 = 2× 低谷价;高峰时段为
|
||
// 01:00–04:00 与 06:00–10:00 UTC(半开区间),仅工作日;
|
||
// 周末(北京时间周六/周日)全天低谷。北京时间用固定 +8 偏移(无夏令时)。
|
||
func deepseekPeakMultiplierAt(now time.Time) float64 {
|
||
beijing := now.In(time.FixedZone("Asia/Shanghai", 8*3600))
|
||
switch beijing.Weekday() {
|
||
case time.Saturday, time.Sunday:
|
||
return 1.0
|
||
}
|
||
switch h := now.UTC().Hour(); {
|
||
case h >= 1 && h < 4, h >= 6 && h < 10:
|
||
return 2.0
|
||
}
|
||
return 1.0
|
||
}
|
||
|
||
// BillingService 计费服务
|
||
type BillingService struct {
|
||
cfg *config.Config
|
||
pricingService *PricingService
|
||
fallbackPrices map[string]*ModelPricing // 硬编码回退价格
|
||
|
||
// fallbackWarnSeen 记录已打过 fallback 警告日志的(已小写化)模型名,
|
||
// 让 "[Billing] Using fallback pricing" 每个模型每进程最多打一条,
|
||
// 避免热路径上每请求刷屏(issue #3394)。零值即可用,无需在构造函数初始化。
|
||
fallbackWarnSeen sync.Map
|
||
}
|
||
|
||
// 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"]
|
||
|
||
// Claude 4.8 Opus / Claude Opus 5(标准 $5/$25,Fast $10/$50 per MTok)。
|
||
// 缺少这两条时 getFallbackPricing 会掉到 claude-3-opus($15/$75),造成 3 倍超收。
|
||
s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2)
|
||
s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2)
|
||
|
||
// Claude Fable 5.x uses the same input/output and cache-write prices, while
|
||
// Fable 5.1 reduces cache reads from $1 to $0.25 per MTok.
|
||
s.fallbackPrices["claude-fable-5"] = &ModelPricing{
|
||
InputPricePerToken: 10e-6,
|
||
OutputPricePerToken: 50e-6,
|
||
CacheCreationPricePerToken: 12.5e-6,
|
||
CacheCreation5mPrice: 12.5e-6,
|
||
CacheCreation1hPrice: 20e-6,
|
||
CacheReadPricePerToken: 1e-6,
|
||
SupportsCacheBreakdown: true,
|
||
}
|
||
s.fallbackPrices["claude-fable-5-1"] = &ModelPricing{
|
||
InputPricePerToken: 10e-6,
|
||
OutputPricePerToken: 50e-6,
|
||
CacheCreationPricePerToken: 12.5e-6,
|
||
CacheCreation5mPrice: 12.5e-6,
|
||
CacheCreation1hPrice: 20e-6,
|
||
CacheReadPricePerToken: 0.25e-6,
|
||
SupportsCacheBreakdown: true,
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
|
||
// Gemini 3.6 Flash (Google AI pricing: $1.50 input / $7.50 output /
|
||
// $0.15 cached input per MTok). Antigravity's -high/-low/-medium/-tiered
|
||
// aliases are matched below so unavailable remote pricing never records
|
||
// token-bearing requests at $0.
|
||
s.fallbackPrices["gemini-3.6-flash"] = &ModelPricing{
|
||
InputPricePerToken: 1.5e-6,
|
||
OutputPricePerToken: 7.5e-6,
|
||
CacheReadPricePerToken: 0.15e-6,
|
||
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,
|
||
}
|
||
// OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。
|
||
// Source: https://platform.openai.com/docs/pricing
|
||
s.fallbackPrices["gpt-5.5"] = pricingWithPriorityMultiplier(&ModelPricing{
|
||
InputPricePerToken: 5e-6,
|
||
OutputPricePerToken: 30e-6,
|
||
// 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。
|
||
CacheCreationPricePerToken: 5e-6,
|
||
CacheReadPricePerToken: 0.5e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}, 2.5)
|
||
// GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。
|
||
s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{
|
||
InputPricePerToken: 30e-6,
|
||
OutputPricePerToken: 180e-6,
|
||
// 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。
|
||
CacheCreationPricePerToken: 30e-6,
|
||
CacheReadPricePerToken: 30e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
|
||
s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{
|
||
InputPricePerToken: 5e-6,
|
||
InputPricePerTokenPriority: 10e-6,
|
||
OutputPricePerToken: 30e-6,
|
||
OutputPricePerTokenPriority: 60e-6,
|
||
CacheCreationPricePerToken: 6.25e-6,
|
||
CacheCreationPricePerTokenPriority: 12.5e-6,
|
||
CacheReadPricePerToken: 0.5e-6,
|
||
CacheReadPricePerTokenPriority: 1e-6,
|
||
}
|
||
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
|
||
InputPricePerToken: 2e-6,
|
||
InputPricePerTokenPriority: 4e-6,
|
||
OutputPricePerToken: 12e-6,
|
||
OutputPricePerTokenPriority: 24e-6,
|
||
CacheCreationPricePerToken: 2.5e-6,
|
||
CacheCreationPricePerTokenPriority: 5e-6,
|
||
CacheReadPricePerToken: 0.2e-6,
|
||
CacheReadPricePerTokenPriority: 0.4e-6,
|
||
}
|
||
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
|
||
InputPricePerToken: 0.2e-6,
|
||
InputPricePerTokenPriority: 0.4e-6,
|
||
OutputPricePerToken: 1.2e-6,
|
||
OutputPricePerTokenPriority: 2.4e-6,
|
||
CacheCreationPricePerToken: 0.25e-6,
|
||
CacheCreationPricePerTokenPriority: 0.5e-6,
|
||
CacheReadPricePerToken: 0.02e-6,
|
||
CacheReadPricePerTokenPriority: 0.04e-6,
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
// ============================================================
|
||
// 国产 LLM 兜底定价(数据源:各家官方定价页/USD 口径)
|
||
// 顺序:DeepSeek → 智谱 GLM → 月之暗面 Kimi → MiniMax
|
||
// 覆盖逻辑见同文件 getFallbackPricing()
|
||
// ============================================================
|
||
|
||
// ---- DeepSeek 系列 ----
|
||
// Source: https://api-docs.deepseek.com/quick_start/pricing
|
||
// 官方口径(2026-08-23 起生效):现行模型为 deepseek-v4-flash /
|
||
// deepseek-v4-pro / deepseek-v4-flash-vision-exp;deepseek-chat /
|
||
// deepseek-reasoner 已停止服务,其余 deepseek-*(含未知型号)统一按
|
||
// flash 价兜底(见 getFallbackPricing),避免计费中断。
|
||
// 以下均为官方低谷价;高峰价 = 2× 低谷价(高峰时段 01:00–04:00
|
||
// 与 06:00–10:00 UTC,仅工作日;北京时间周六/周日全天低谷),见 deepseekPeakMultiplierAt。
|
||
s.fallbackPrices["deepseek-v4-pro"] = &ModelPricing{
|
||
InputPricePerToken: deepseekProOffPeakInputPrice, // $0.66 per MTok (cache miss, off-peak)
|
||
OutputPricePerToken: deepseekProOffPeakOutputPrice, // $1.98 per MTok
|
||
CacheReadPricePerToken: deepseekProOffPeakCacheRead, // $0.022 per MTok (cache hit)
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["deepseek-v4-flash"] = &ModelPricing{
|
||
InputPricePerToken: deepseekFlashOffPeakInputPrice, // $0.22 per MTok (cache miss, off-peak)
|
||
OutputPricePerToken: deepseekFlashOffPeakOutputPrice, // $0.66 per MTok
|
||
CacheReadPricePerToken: deepseekFlashOffPeakCacheRead, // $0.007 per MTok (cache hit)
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["deepseek-v4-flash-vision-exp"] = &ModelPricing{
|
||
InputPricePerToken: deepseekFlashOffPeakInputPrice,
|
||
OutputPricePerToken: deepseekFlashOffPeakOutputPrice,
|
||
CacheReadPricePerToken: deepseekFlashOffPeakCacheRead,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// ---- 智谱 GLM(Z.AI)----
|
||
// Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens)
|
||
// 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。
|
||
// GLM-4.6 与 GLM-4.5 在 z.ai 国际版上定价一致;GLM-4.5 国内按 ¥0.8/¥2,汇率换算后约 $0.112/$0.28,与国际版 $0.6/$2.2 不同,本分支采用国际版 USD 口径与现有 Claude/GPT 一致。
|
||
// GLM-5.2 与 GLM-5.1 在 z.ai 上同价。
|
||
s.fallbackPrices["glm-5.2"] = &ModelPricing{
|
||
InputPricePerToken: 1.4e-6, // $1.40 per MTok
|
||
OutputPricePerToken: 4.4e-6, // $4.40 per MTok
|
||
CacheReadPricePerToken: 0.26e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-5.1"] = &ModelPricing{
|
||
InputPricePerToken: 1.4e-6, // $1.40 per MTok
|
||
OutputPricePerToken: 4.4e-6, // $4.40 per MTok
|
||
CacheReadPricePerToken: 0.26e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-5"] = &ModelPricing{
|
||
InputPricePerToken: 1e-6, // $1.00 per MTok
|
||
OutputPricePerToken: 3.2e-6,
|
||
CacheReadPricePerToken: 0.2e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-5-turbo"] = &ModelPricing{
|
||
InputPricePerToken: 1.2e-6,
|
||
OutputPricePerToken: 4e-6,
|
||
CacheReadPricePerToken: 0.24e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.7"] = &ModelPricing{
|
||
InputPricePerToken: 0.6e-6, // $0.60 per MTok
|
||
OutputPricePerToken: 2.2e-6,
|
||
CacheReadPricePerToken: 0.11e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.7-flashx"] = &ModelPricing{
|
||
InputPricePerToken: 0.07e-6, // $0.07 per MTok
|
||
OutputPricePerToken: 0.4e-6,
|
||
CacheReadPricePerToken: 0.01e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.6"] = &ModelPricing{
|
||
InputPricePerToken: 0.6e-6, // $0.60 per MTok
|
||
OutputPricePerToken: 2.2e-6,
|
||
CacheReadPricePerToken: 0.11e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.5"] = &ModelPricing{
|
||
InputPricePerToken: 0.6e-6, // $0.60 per MTok
|
||
OutputPricePerToken: 2.2e-6,
|
||
CacheReadPricePerToken: 0.11e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.5-x"] = &ModelPricing{
|
||
InputPricePerToken: 2.2e-6, // $2.20 per MTok
|
||
OutputPricePerToken: 8.9e-6,
|
||
CacheReadPricePerToken: 0.45e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.5-air"] = &ModelPricing{
|
||
InputPricePerToken: 0.2e-6, // $0.20 per MTok
|
||
OutputPricePerToken: 1.1e-6,
|
||
CacheReadPricePerToken: 0.03e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.5-airx"] = &ModelPricing{
|
||
InputPricePerToken: 1.1e-6,
|
||
OutputPricePerToken: 4.5e-6,
|
||
CacheReadPricePerToken: 0.22e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4-32b-0414-128k"] = &ModelPricing{
|
||
InputPricePerToken: 0.1e-6, // $0.10 per MTok
|
||
OutputPricePerToken: 0.1e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
// GLM-4.5-Flash / GLM-4.7-Flash 在 z.ai 上为 Free,保留 zero-cost entry 防止未知 alias 误计费。
|
||
s.fallbackPrices["glm-4.5-flash"] = &ModelPricing{
|
||
InputPricePerToken: 0,
|
||
OutputPricePerToken: 0,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["glm-4.7-flash"] = &ModelPricing{
|
||
InputPricePerToken: 0,
|
||
OutputPricePerToken: 0,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// ---- 月之暗面 Kimi(K 系列)----
|
||
// Source: https://platform.moonshot.cn/docs/pricing/overview (元/百万 tokens 口径)
|
||
// 交叉验证:https://www.tmtpost.com/7961404.html (USD 口径)
|
||
// Moonshot V1 (¥2/¥5/¥10 多 tier) 公开页未直接标注 USD 价,本分支不覆盖,避免误计价。
|
||
// K2-0905 / K2-0711 官方页面未保留定价,不覆盖。
|
||
// Kimi K3 国际站 USD 价目:https://platform.kimi.ai/docs/pricing/chat-k3.md
|
||
// Kimi Code bare aliases(k3 / k3-256k)官方无按 token 价目;复用 API Platform
|
||
// kimi-k3 档位作代理计费 fallback(同 kimi-for-coding 对 K2.6 的处理口径)。
|
||
s.fallbackPrices["kimi-k3"] = &ModelPricing{
|
||
InputPricePerToken: 3e-6, // $3.00 per MTok (cache miss)
|
||
OutputPricePerToken: 15e-6, // $15.00 per MTok
|
||
CacheReadPricePerToken: 0.30e-6, // $0.30 per MTok (cache hit)
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["kimi-k2.6"] = &ModelPricing{
|
||
InputPricePerToken: 0.95e-6, // $0.95 per MTok (cache miss)
|
||
OutputPricePerToken: 4e-6, // $4.00 per MTok
|
||
CacheReadPricePerToken: 0.15e-6, // $0.15 per MTok (cache hit, ¥1.10)
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
// kimi-for-coding 走 Kimi Coding endpoint,按当前 K2.6 coding 档位兜底计费。
|
||
s.fallbackPrices["kimi-for-coding"] = &ModelPricing{
|
||
InputPricePerToken: 0.95e-6,
|
||
OutputPricePerToken: 4e-6,
|
||
CacheReadPricePerToken: 0.15e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["kimi-k2.5"] = &ModelPricing{
|
||
InputPricePerToken: 0.60e-6, // $0.60 per MTok
|
||
OutputPricePerToken: 3e-6, // $3.00 per MTok
|
||
CacheReadPricePerToken: 0.098e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["kimi-k2-thinking"] = &ModelPricing{
|
||
InputPricePerToken: 0.56e-6, // ¥4/百万 ≈ $0.56
|
||
OutputPricePerToken: 2.24e-6, // ¥16/百万
|
||
CacheReadPricePerToken: 0.14e-6, // ¥1/百万
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["kimi-k2"] = &ModelPricing{
|
||
InputPricePerToken: 0.56e-6, // ¥4/百万
|
||
OutputPricePerToken: 2.24e-6, // ¥16/百万
|
||
CacheReadPricePerToken: 0.14e-6, // ¥1/百万
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// ---- MiniMax M 系列 ----
|
||
// Source: https://platform.minimax.io/docs/guides/pricing-paygo
|
||
// 注意:MiniMax M3 在 >512K context 时价格翻倍,本兜底采用 ≤512K 标准 tier(保守口径,对用户有利)。
|
||
// 如需支持长上下文 multiplier,可后续参考 GPT-5.4 模式扩展 LongContextXxx 字段。
|
||
s.fallbackPrices["minimax-m3"] = &ModelPricing{
|
||
InputPricePerToken: 0.60e-6, // $0.60 per MTok (≤512K standard tier, 含 50% 永久折扣前原价 $1.20)
|
||
OutputPricePerToken: 2.40e-6,
|
||
CacheReadPricePerToken: 0.12e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["minimax-m2.7"] = &ModelPricing{
|
||
InputPricePerToken: 0.30e-6, // $0.30 per MTok
|
||
OutputPricePerToken: 1.20e-6,
|
||
CacheReadPricePerToken: 0.06e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["minimax-m2.7-highspeed"] = &ModelPricing{
|
||
InputPricePerToken: 0.60e-6,
|
||
OutputPricePerToken: 2.40e-6,
|
||
CacheReadPricePerToken: 0.06e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["minimax-m2.5"] = &ModelPricing{
|
||
InputPricePerToken: 0.30e-6,
|
||
OutputPricePerToken: 1.20e-6,
|
||
CacheReadPricePerToken: 0.03e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["minimax-m2.1"] = &ModelPricing{
|
||
InputPricePerToken: 0.30e-6,
|
||
OutputPricePerToken: 1.20e-6,
|
||
CacheReadPricePerToken: 0.03e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["minimax-m2"] = &ModelPricing{
|
||
InputPricePerToken: 0.30e-6,
|
||
OutputPricePerToken: 1.20e-6,
|
||
CacheReadPricePerToken: 0.03e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// ---- 火山方舟 豆包 Embedding(多模态向量化)----
|
||
// doubao-embedding-vision 图文向量化:上游 usage 回传 prompt_tokens_details.{text_tokens,image_tokens},
|
||
// 按量付费官方价 文本 ¥0.7/MTok、图片 ¥1.8/MTok;汇率口径 ÷7.14(与本表其他国产模型一致,¥1≈$0.14)。
|
||
// embedding 无 output,OutputPricePerToken 置 0。
|
||
s.fallbackPrices["doubao-embedding-vision"] = &ModelPricing{
|
||
InputPricePerToken: 0.098e-6, // ¥0.7/MTok ≈ $0.098(文本输入)
|
||
ImageInputPricePerToken: 0.252e-6, // ¥1.8/MTok ≈ $0.252(图片输入)
|
||
OutputPricePerToken: 0,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
|
||
// xAI Grok 4.5: $2 input / $0.30 cached input / $6 output below 200k;
|
||
// long-context rates are $4 / $0.60 / $12 (>=200k prompt tokens).
|
||
s.fallbackPrices["grok-4.5"] = &ModelPricing{
|
||
InputPricePerToken: 2e-6,
|
||
OutputPricePerToken: 6e-6,
|
||
CacheReadPricePerToken: 0.3e-6,
|
||
SupportsCacheBreakdown: false,
|
||
LongContextInputThreshold: 200000,
|
||
LongContextThresholdInclusive: true,
|
||
LongContextInputMultiplier: 2,
|
||
LongContextOutputMultiplier: 2,
|
||
}
|
||
|
||
// xAI Grok 4.6: $2 input / $0.50 cached input / $6 output below 200k;
|
||
// long-context rates are $4 / $1 / $12 (>=200k prompt tokens).
|
||
s.fallbackPrices["grok-4.6"] = &ModelPricing{
|
||
InputPricePerToken: 2e-6,
|
||
OutputPricePerToken: 6e-6,
|
||
CacheReadPricePerToken: 0.5e-6,
|
||
SupportsCacheBreakdown: false,
|
||
LongContextInputThreshold: 200000,
|
||
LongContextThresholdInclusive: true,
|
||
LongContextInputMultiplier: 2,
|
||
LongContextOutputMultiplier: 2,
|
||
}
|
||
|
||
// xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k;
|
||
// long-context rates are $2.50 / $0.40 / $5.
|
||
s.fallbackPrices["grok-4.3"] = &ModelPricing{
|
||
InputPricePerToken: 1.25e-6,
|
||
OutputPricePerToken: 2.5e-6,
|
||
CacheReadPricePerToken: 0.2e-6,
|
||
SupportsCacheBreakdown: false,
|
||
LongContextInputThreshold: 200000,
|
||
LongContextThresholdInclusive: true,
|
||
LongContextInputMultiplier: 2,
|
||
LongContextOutputMultiplier: 2,
|
||
}
|
||
// Grok 4.20 variants share the official $1.25 / $0.20 / $2.50 card
|
||
// (and $2.50 / $0.40 / $5 long-context rates) with Grok 4.3.
|
||
s.fallbackPrices["grok-4.20"] = &ModelPricing{
|
||
InputPricePerToken: 1.25e-6,
|
||
OutputPricePerToken: 2.5e-6,
|
||
CacheReadPricePerToken: 0.2e-6,
|
||
SupportsCacheBreakdown: false,
|
||
LongContextInputThreshold: 200000,
|
||
LongContextThresholdInclusive: true,
|
||
LongContextInputMultiplier: 2,
|
||
LongContextOutputMultiplier: 2,
|
||
}
|
||
|
||
// Keep legacy Grok 3 Mini requests on their own historical xAI price card;
|
||
// otherwise the generic Grok fallback bills them as Grok 4.5.
|
||
s.fallbackPrices["grok-3-mini"] = &ModelPricing{
|
||
InputPricePerToken: 0.30e-6,
|
||
OutputPricePerToken: 0.50e-6,
|
||
CacheReadPricePerToken: 0.075e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["grok-3-mini-fast"] = &ModelPricing{
|
||
InputPricePerToken: 0.60e-6,
|
||
OutputPricePerToken: 4e-6,
|
||
CacheReadPricePerToken: 0.15e-6,
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
// xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input /
|
||
// $2 output per MTok). Composer is available only through Grok Build and
|
||
// has no standalone public API rate card, so its aliases use this coding
|
||
// model rate instead of silently billing at zero.
|
||
s.fallbackPrices["grok-build-0.1"] = &ModelPricing{
|
||
InputPricePerToken: 1e-6,
|
||
OutputPricePerToken: 2e-6,
|
||
CacheReadPricePerToken: 0.2e-6,
|
||
SupportsCacheBreakdown: false,
|
||
LongContextInputThreshold: 200000,
|
||
LongContextThresholdInclusive: true,
|
||
LongContextInputMultiplier: 2,
|
||
LongContextOutputMultiplier: 2,
|
||
}
|
||
}
|
||
|
||
// getFallbackPricing 根据模型系列获取回退价格
|
||
func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||
modelLower := strings.ToLower(model)
|
||
|
||
// 按模型系列匹配
|
||
if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") ||
|
||
strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") {
|
||
return s.fallbackPrices["claude-fable-5-1"]
|
||
}
|
||
if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") {
|
||
return s.fallbackPrices["claude-fable-5"]
|
||
}
|
||
if strings.Contains(modelLower, "opus") {
|
||
// "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
|
||
if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") {
|
||
return s.fallbackPrices["claude-opus-5"]
|
||
}
|
||
if strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8") {
|
||
return s.fallbackPrices["claude-opus-4.8"]
|
||
}
|
||
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"]
|
||
}
|
||
if strings.Contains(modelLower, "gemini-3.6-flash") || strings.Contains(modelLower, "gemini-3-6-flash") {
|
||
return s.fallbackPrices["gemini-3.6-flash"]
|
||
}
|
||
|
||
// DeepSeek 系列:官方模型 V4 Pro/Flash(含 vision-exp)按各自价卡;
|
||
// 其余 deepseek-*(含已停服的 deepseek-chat / deepseek-reasoner 与未知型号)
|
||
// 统一按 flash 价兜底,避免计费中断。新名字由 fallback warn 日志
|
||
// (每模型每进程一条)暴露,运营者据此更新价卡。
|
||
// "deepseek-v4-flash-vision-exp" 含 "deepseek-v4-flash" 子串,显式分支置于 flash 之前,语义清晰。
|
||
if strings.Contains(modelLower, "deepseek-v4-flash-vision-exp") {
|
||
return s.fallbackPrices["deepseek-v4-flash-vision-exp"]
|
||
}
|
||
if strings.Contains(modelLower, "deepseek-v4-flash") {
|
||
return s.fallbackPrices["deepseek-v4-flash"]
|
||
}
|
||
if strings.Contains(modelLower, "deepseek-v4-pro") {
|
||
return s.fallbackPrices["deepseek-v4-pro"]
|
||
}
|
||
if strings.HasPrefix(modelLower, "deepseek-") {
|
||
return s.fallbackPrices["deepseek-v4-flash"]
|
||
}
|
||
|
||
// ---- 国产 LLM 兜底匹配 ----
|
||
// 匹配策略:长 key 优先(具体模型 → 系列 / 厂商),未知型号不回退以避免误计价。
|
||
// 与 DeepSeek 一样采用"白名单"语义:未在本表命中的国产模型 alias 一律不返回兜底价。
|
||
|
||
// 智谱 GLM(z.ai 公开 SKU:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等)
|
||
// 匹配顺序:先判别最高 tier,再依次降级。
|
||
// 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走。
|
||
if strings.Contains(modelLower, "glm-5.2") {
|
||
return s.fallbackPrices["glm-5.2"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-5.1") {
|
||
return s.fallbackPrices["glm-5.1"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-5-turbo") || strings.Contains(modelLower, "glm-5turbo") {
|
||
return s.fallbackPrices["glm-5-turbo"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-5") {
|
||
return s.fallbackPrices["glm-5"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.7-flashx") {
|
||
return s.fallbackPrices["glm-4.7-flashx"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.7-flash") {
|
||
return s.fallbackPrices["glm-4.7-flash"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.7") {
|
||
return s.fallbackPrices["glm-4.7"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.6") {
|
||
return s.fallbackPrices["glm-4.6"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.5-flash") {
|
||
return s.fallbackPrices["glm-4.5-flash"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.5-x") || strings.Contains(modelLower, "glm-4.5x") {
|
||
return s.fallbackPrices["glm-4.5-x"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.5-airx") || strings.Contains(modelLower, "glm-4.5airx") {
|
||
return s.fallbackPrices["glm-4.5-airx"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.5-air") || strings.Contains(modelLower, "glm-4.5air") {
|
||
return s.fallbackPrices["glm-4.5-air"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4.5") {
|
||
return s.fallbackPrices["glm-4.5"]
|
||
}
|
||
if strings.Contains(modelLower, "glm-4-32b") {
|
||
return s.fallbackPrices["glm-4-32b-0414-128k"]
|
||
}
|
||
|
||
// 月之暗面 Kimi(kimi-k3 / k3 / k3-256k / kimi-k2.6 / kimi-for-coding / kimi-k2.5 / kimi-k2-thinking / kimi-k2)
|
||
// K2-0905 / K2-0711 官方未保留定价,不进入 fallback。
|
||
// K3 规则置于 K2 前:API Platform 仅官方 kimi-k3(及 / 路径后缀);
|
||
// Code bare aliases 仅精确 k3 / k3-256k 或 /k3|/k3-256k 后缀,避免 kimi-k30 等未知型号误命中。
|
||
// 注意:kimi-k3[1m] 是 Claude Code 上下文选择语法,不是 Kimi API 模型 ID,不进入 fallback。
|
||
if strings.Contains(modelLower, "kimi-for-coding") {
|
||
return s.fallbackPrices["kimi-for-coding"]
|
||
}
|
||
if modelLower == "kimi-k3" || strings.HasSuffix(modelLower, "/kimi-k3") ||
|
||
modelLower == "k3" || modelLower == "k3-256k" ||
|
||
strings.HasSuffix(modelLower, "/k3") || strings.HasSuffix(modelLower, "/k3-256k") {
|
||
return s.fallbackPrices["kimi-k3"]
|
||
}
|
||
if strings.Contains(modelLower, "kimi-k2.6") || strings.Contains(modelLower, "kimi-k2-6") {
|
||
return s.fallbackPrices["kimi-k2.6"]
|
||
}
|
||
if strings.Contains(modelLower, "kimi-k2.5") || strings.Contains(modelLower, "kimi-k2-5") {
|
||
return s.fallbackPrices["kimi-k2.5"]
|
||
}
|
||
if strings.Contains(modelLower, "kimi-k2-thinking") || strings.Contains(modelLower, "kimi-k2-thinking-") {
|
||
return s.fallbackPrices["kimi-k2-thinking"]
|
||
}
|
||
if strings.Contains(modelLower, "kimi-k2") || strings.Contains(modelLower, "kimi/k2") {
|
||
return s.fallbackPrices["kimi-k2"]
|
||
}
|
||
|
||
// MiniMax M 系列(M3 / M2.7 / M2.5 / M2.1 / M2;含 highspeed 变体)
|
||
if strings.Contains(modelLower, "minimax-m3") {
|
||
return s.fallbackPrices["minimax-m3"]
|
||
}
|
||
if strings.Contains(modelLower, "minimax-m2.7-highspeed") || strings.Contains(modelLower, "minimax-m2-7-highspeed") {
|
||
return s.fallbackPrices["minimax-m2.7-highspeed"]
|
||
}
|
||
if strings.Contains(modelLower, "minimax-m2.7") || strings.Contains(modelLower, "minimax-m2-7") {
|
||
return s.fallbackPrices["minimax-m2.7"]
|
||
}
|
||
if strings.Contains(modelLower, "minimax-m2.5") || strings.Contains(modelLower, "minimax-m2-5") {
|
||
return s.fallbackPrices["minimax-m2.5"]
|
||
}
|
||
if strings.Contains(modelLower, "minimax-m2.1") || strings.Contains(modelLower, "minimax-m2-1") {
|
||
return s.fallbackPrices["minimax-m2.1"]
|
||
}
|
||
if strings.Contains(modelLower, "minimax-m2") || strings.Contains(modelLower, "minimax-m-2") {
|
||
return s.fallbackPrices["minimax-m2"]
|
||
}
|
||
|
||
// 火山方舟 豆包 Embedding(多模态向量化)。
|
||
// most-specific-first:放在未来任何 doubao-embedding / doubao 宽匹配之前。
|
||
// 覆盖带版本后缀的别名(如 doubao-embedding-vision-251215)。
|
||
if strings.Contains(modelLower, "doubao-embedding-vision") {
|
||
return s.fallbackPrices["doubao-embedding-vision"]
|
||
}
|
||
|
||
// OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。
|
||
if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" {
|
||
switch normalized {
|
||
case "gpt-5.6-sol":
|
||
return s.fallbackPrices["gpt-5.6-sol"]
|
||
case "gpt-5.6-terra":
|
||
return s.fallbackPrices["gpt-5.6-terra"]
|
||
case "gpt-5.6-luna":
|
||
return s.fallbackPrices["gpt-5.6-luna"]
|
||
case "gpt-5.5-pro":
|
||
return s.fallbackPrices["gpt-5.5-pro"]
|
||
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"]
|
||
}
|
||
}
|
||
|
||
switch modelLower {
|
||
case "grok", "grok-latest", "grok-4.6", "grok-4.6-latest":
|
||
return s.fallbackPrices["grok-4.6"]
|
||
case "grok-4.5", "grok-4.5-latest":
|
||
return s.fallbackPrices["grok-4.5"]
|
||
case "grok-3-mini":
|
||
return s.fallbackPrices["grok-3-mini"]
|
||
case "grok-3-mini-fast":
|
||
return s.fallbackPrices["grok-3-mini-fast"]
|
||
case "grok-4.3":
|
||
return s.fallbackPrices["grok-4.3"]
|
||
case "grok-4.20-0309-reasoning",
|
||
"grok-4.20-0309-non-reasoning",
|
||
"grok-4.20-multi-agent-0309",
|
||
"grok-4.20-reasoning",
|
||
"grok-4.20-non-reasoning":
|
||
return s.fallbackPrices["grok-4.20"]
|
||
case "grok-build", "grok-build-latest", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5":
|
||
return s.fallbackPrices["grok-build-0.1"]
|
||
}
|
||
|
||
// Unknown Grok text IDs (grok-5, dated snapshots, provider-prefixed) inherit
|
||
// the current default text card so a new model cannot ship unbilled.
|
||
if pricing := s.grokUnknownTextFamilyFallback(modelLower); pricing != nil {
|
||
return pricing
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *BillingService) grokUnknownTextFamilyFallback(model string) *ModelPricing {
|
||
if s == nil || !isGrokUnknownTextFamilyModel(model) {
|
||
return nil
|
||
}
|
||
return s.fallbackPrices["grok-4.6"]
|
||
}
|
||
|
||
func isGrokUnknownTextFamilyModel(model string) bool {
|
||
native := strings.ToLower(strings.TrimSpace(xai.StripGrokProviderPrefix(model)))
|
||
if isGrokMediaFamilyModel(native) {
|
||
return false
|
||
}
|
||
switch {
|
||
case native == "grok", native == "grok-latest":
|
||
return true
|
||
case strings.HasPrefix(native, "grok-build"),
|
||
strings.HasPrefix(native, "grok-composer"),
|
||
strings.HasPrefix(native, "composer-"):
|
||
return true
|
||
case len(native) > 5 && strings.HasPrefix(native, "grok-"):
|
||
rest := native[len("grok-"):]
|
||
return rest[0] >= '0' && rest[0] <= '9'
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// isGrokMediaFamilyModel matches ids that are billed per image/video/audio unit
|
||
// rather than per token, so version-numbered media ids (grok-2-image-1212,
|
||
// grok-5-video) cannot slip into the unknown-text fallback and pick up a token
|
||
// card. "vision" is deliberately absent: multimodal chat models are token billed.
|
||
func isGrokMediaFamilyModel(native string) bool {
|
||
for _, marker := range []string{"imagine", "image", "video", "audio", "speech", "tts", "transcribe", "realtime"} {
|
||
if strings.Contains(native, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasIdentifiedTokenPricing 判断模型能否在价格表中被"确定性识别"出 token 价格。
|
||
//
|
||
// 与 GetModelPricing 的关键区别:本函数拒绝按子串猜系列的兜底。GetModelPricing 会
|
||
// 让任意含 "haiku"/"opus"/"claude" 的名字(哪怕是不存在的型号)落到 getFallbackPricing
|
||
// 的系列兜底价上,因此凡是模型名来自外部、且"能查到价"会直接影响计费金额的场景
|
||
// (如按上游响应自报模型计费),都必须用本函数而不是 GetModelPricing 做准入判断。
|
||
func (s *BillingService) HasIdentifiedTokenPricing(model string) bool {
|
||
if s == nil {
|
||
return false
|
||
}
|
||
model = strings.ToLower(strings.TrimSpace(model))
|
||
if model == "" {
|
||
return false
|
||
}
|
||
if s.pricingService != nil {
|
||
// 仅有图片价的条目不能用于 token 计费,口径与 GetModelPricing 保持一致。
|
||
if pricing := s.pricingService.GetIdentifiedModelPricing(model); pricing != nil && !pricing.TokenPricingAbsent {
|
||
return true
|
||
}
|
||
}
|
||
pricing, ok := s.fallbackPrices[model]
|
||
return ok && pricing != nil
|
||
}
|
||
|
||
// GetModelPricing 获取模型价格配置
|
||
func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||
// 标准化模型名称(转小写)
|
||
model = strings.ToLower(model)
|
||
|
||
// 1. 优先从动态价格服务获取
|
||
if s.pricingService != nil {
|
||
litellmPricing := s.pricingService.GetModelPricing(model)
|
||
// 仅有图片价、无 token 价的条目(如 LiteLLM 的 imagen 类模型)不能用于
|
||
// token 计费:直接返回会把 token 流量按 $0 计费。跳过后走 fallback,
|
||
// 无 fallback 则 fail-closed(ErrModelPricingUnavailable)。
|
||
// 图片计费路径(getDefaultImagePrice / getImageUnitPrice)直接读
|
||
// PricingService,不受影响。
|
||
if litellmPricing != nil && litellmPricing.TokenPricingAbsent {
|
||
litellmPricing = nil
|
||
}
|
||
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,
|
||
CacheCreationPricePerTokenPriority: litellmPricing.CacheCreationInputTokenCostPriority,
|
||
CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost,
|
||
CacheReadPricePerTokenPriority: litellmPricing.CacheReadInputTokenCostPriority,
|
||
CacheCreation5mPrice: price5m,
|
||
CacheCreation1hPrice: price1h,
|
||
SupportsCacheBreakdown: enableBreakdown,
|
||
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
||
// xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。
|
||
LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"),
|
||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||
}), nil
|
||
}
|
||
}
|
||
|
||
// 2. 使用硬编码回退价格
|
||
fallback := s.getFallbackPricing(model)
|
||
if fallback != nil {
|
||
// 按模型名去重:每个模型每进程最多打一条 warn,避免热路径每请求刷屏(issue #3394)。
|
||
// model 在函数入口已 ToLower,故 GLM-5.2 / glm-5.2 视为同一条目。
|
||
if _, seen := s.fallbackWarnSeen.LoadOrStore(model, struct{}{}); !seen {
|
||
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
|
||
}
|
||
// 防止修改 fallbackPrices 中的共享指针
|
||
cloned := *pricing
|
||
pricing = &cloned
|
||
applyChannelTokenPriceOverrides(pricing, channelPricing)
|
||
pricing.FastMultiplier = channelPricing.FastMultiplier
|
||
pricing.FlexMultiplier = channelPricing.FlexMultiplier
|
||
if channelPricing.ImageOutputPrice != nil {
|
||
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
|
||
} else {
|
||
pricing.ImageOutputPricePerToken = 0
|
||
}
|
||
pricing.ImageOutputPriceExplicit = true
|
||
applyChannelImageInputPrice(channelPricing, pricing)
|
||
return pricing, nil
|
||
}
|
||
|
||
// channelTierOverridePrice applies a Standard-tier override while preserving
|
||
// an explicit model-catalog Fast/Priority ratio. If the catalog has no tier
|
||
// price, generic service-tier defaults remain responsible for the fallback.
|
||
func channelTierOverridePrice(baseStandard, baseTier, channelStandard float64) float64 {
|
||
if baseStandard > 0 && baseTier > 0 {
|
||
return channelStandard * (baseTier / baseStandard)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func applyChannelTokenPriceOverrides(pricing *ModelPricing, channelPricing *ChannelModelPricing) {
|
||
if pricing == nil || channelPricing == nil {
|
||
return
|
||
}
|
||
if channelPricing.InputPrice != nil {
|
||
priority := channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *channelPricing.InputPrice)
|
||
pricing.InputPricePerToken = *channelPricing.InputPrice
|
||
pricing.InputPricePerTokenPriority = priority
|
||
}
|
||
if channelPricing.OutputPrice != nil {
|
||
priority := channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *channelPricing.OutputPrice)
|
||
pricing.OutputPricePerToken = *channelPricing.OutputPrice
|
||
pricing.OutputPricePerTokenPriority = priority
|
||
}
|
||
if channelPricing.CacheWritePrice != nil {
|
||
priority := channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *channelPricing.CacheWritePrice)
|
||
pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice
|
||
pricing.CacheCreationPricePerTokenPriority = priority
|
||
pricing.CacheCreationPriceExplicit = true
|
||
pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice
|
||
if channelPricing.CacheWrite1hPrice == nil {
|
||
// Preserve the pre-split behavior for existing configurations: a lone
|
||
// cache_write_price continues to override both TTL tiers.
|
||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||
}
|
||
}
|
||
if channelPricing.CacheWrite1hPrice != nil {
|
||
pricing.CacheCreation1hPrice = *channelPricing.CacheWrite1hPrice
|
||
pricing.SupportsCacheBreakdown = true
|
||
}
|
||
if channelPricing.CacheReadPrice != nil {
|
||
priority := channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *channelPricing.CacheReadPrice)
|
||
pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice
|
||
pricing.CacheReadPricePerTokenPriority = priority
|
||
}
|
||
}
|
||
|
||
// --- 统一计费入口 ---
|
||
|
||
// CostInput 统一计费输入
|
||
type CostInput struct {
|
||
Ctx context.Context
|
||
Model string
|
||
GroupID *int64 // 用于渠道定价查找
|
||
Group *Group
|
||
Tokens UsageTokens
|
||
RequestCount int // 按次计费时使用
|
||
UsageUnits float64 // 音频等连续计量单位(分钟/小时/百万字符)
|
||
SizeTier string // 按次/图片模式的层级标签("1K","2K","4K","HD" 等)
|
||
RateMultiplier float64
|
||
PricingAt time.Time // 渠道分时定价使用的计费时刻
|
||
ServiceTier string // "priority","flex","" 等
|
||
Resolver *ModelPricingResolver // 定价解析器
|
||
Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用)
|
||
LongContextBillingEnabled *bool
|
||
}
|
||
|
||
// CalculateCostUnified 统一计费入口,支持三种计费模式。
|
||
// 使用 ModelPricingResolver 解析定价,然后根据 BillingMode 分发计算。
|
||
func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, error) {
|
||
if input.Resolver == nil {
|
||
// 无 Resolver,回退到旧路径
|
||
applyLongContextBilling := true
|
||
if input.LongContextBillingEnabled != nil {
|
||
applyLongContextBilling = *input.LongContextBillingEnabled
|
||
}
|
||
return s.calculateCostInternalWithPolicy(
|
||
input.Model,
|
||
input.Tokens,
|
||
input.RateMultiplier,
|
||
input.ServiceTier,
|
||
nil,
|
||
applyLongContextBilling,
|
||
)
|
||
}
|
||
|
||
// 优先使用预解析结果,避免重复 Resolve 调用
|
||
resolved := input.Resolved
|
||
if resolved == nil {
|
||
resolved = input.Resolver.Resolve(input.Ctx, PricingInput{
|
||
Model: input.Model,
|
||
GroupID: input.GroupID,
|
||
Group: input.Group,
|
||
})
|
||
}
|
||
|
||
// 保存时强制 > 0;若仍有负数泄漏(缓存/迁移残留),按 0 处理避免按 1x 误扣。
|
||
if input.RateMultiplier < 0 {
|
||
input.RateMultiplier = 0
|
||
}
|
||
|
||
var breakdown *CostBreakdown
|
||
var err error
|
||
switch resolved.Mode {
|
||
case BillingModePerRequest, BillingModeImage, BillingModeVideo:
|
||
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.CacheCreationTokens + input.Tokens.CacheReadTokens
|
||
|
||
// 分组开关是统一入口;账号 API 开关保留为额外开启能力,但 false 不否决分组配置。
|
||
contextTierPricingEnabled := resolved.longContextPricingEnabled
|
||
if input.LongContextBillingEnabled != nil && *input.LongContextBillingEnabled {
|
||
contextTierPricingEnabled = true
|
||
}
|
||
|
||
pricingContext := totalContext
|
||
if !contextTierPricingEnabled {
|
||
// 渠道可能显式配置了第一档,也可能只配置高上下文档。用 1 token
|
||
// 选择最低档;未命中时自然回退到渠道基础价。
|
||
pricingContext = 1
|
||
}
|
||
pricing := input.Resolver.GetIntervalPricing(resolved, pricingContext)
|
||
if pricing == nil {
|
||
return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable)
|
||
}
|
||
|
||
// 默认价卡(Source=LiteLLM)应用 DeepSeek 官方价强制覆盖(幂等,GetModelPricing
|
||
// 内部已强制过);分组/渠道自定义定价保留运营者配置,不强制覆盖官方价。
|
||
pricing = s.applyModelSpecificPricingPolicyEx(input.Model, pricing, resolved.Source == PricingSourceLiteLLM)
|
||
|
||
// DeepSeek 模型默认价卡按官方峰谷口径调整:高峰时段(01:00–04:00 与
|
||
// 06:00–10:00 UTC,仅工作日;北京时间周末全天低谷)按 2× 低谷价计费。
|
||
// 仅作用于默认价卡(Source=LiteLLM,无分组/渠道自定义定价)——分组/渠道
|
||
// 自定义定价保持运营者语义,不叠加。PricingAt 为零值时回退当前时刻。
|
||
// 先克隆再乘,避免污染共享 fallbackPrices 指针。
|
||
if resolved.Source == PricingSourceLiteLLM && isDeepSeekModel(input.Model) {
|
||
pricingAt := input.PricingAt
|
||
if pricingAt.IsZero() {
|
||
pricingAt = timezone.Now()
|
||
}
|
||
if mult := deepseekPeakMultiplierAt(pricingAt); mult > 1 {
|
||
cloned := *pricing
|
||
cloned.InputPricePerToken *= mult
|
||
cloned.OutputPricePerToken *= mult
|
||
cloned.CacheReadPricePerToken *= mult
|
||
pricing = &cloned
|
||
}
|
||
}
|
||
|
||
// 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。
|
||
applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled
|
||
|
||
breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx)
|
||
applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt))
|
||
return breakdown, 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
|
||
cacheCreationPrice := pricing.CacheCreationPricePerToken
|
||
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
|
||
}
|
||
if pricing.CacheCreationPricePerTokenPriority > 0 {
|
||
cacheCreationPrice = pricing.CacheCreationPricePerTokenPriority
|
||
}
|
||
} else {
|
||
tierMultiplier = configuredServiceTierMultiplier(serviceTier, pricing)
|
||
}
|
||
|
||
longContextPricingEligible := applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing)
|
||
var baselineCost *CostBreakdown
|
||
if longContextPricingEligible {
|
||
baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false)
|
||
// 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧),
|
||
// 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。
|
||
longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier)
|
||
inputPrice *= longCtxInputMultiplier
|
||
outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier)
|
||
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
||
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
||
cacheReadPrice *= longCtxInputMultiplier
|
||
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
||
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
||
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
||
cacheCreationMultiplier = longCtxInputMultiplier
|
||
}
|
||
|
||
bd := &CostBreakdown{}
|
||
// 分离图片输入 token 与文本输入 token(多模态 embedding、图片编辑等图文不同价场景)。
|
||
// InputCost 仅计文本输入,图片输入费用单独记入 ImageInputCost,便于对账;总额不变。
|
||
// ImageInputTokens 为 0 时(绝大多数 chat/vision 流量)走原始单价路径,行为不变。
|
||
if tokens.ImageInputTokens > 0 {
|
||
imageInputTokens := tokens.ImageInputTokens
|
||
textInputTokens := tokens.InputTokens - imageInputTokens
|
||
if textInputTokens < 0 {
|
||
textInputTokens = 0
|
||
imageInputTokens = tokens.InputTokens
|
||
}
|
||
imageInputPrice := pricing.ImageInputPricePerToken
|
||
if imageInputPrice == 0 {
|
||
// 未配置图片输入档时回退到文本 input 价(已含 priority / 长上下文调整)
|
||
imageInputPrice = inputPrice
|
||
}
|
||
bd.InputCost = float64(textInputTokens) * inputPrice
|
||
bd.ImageInputCost = float64(imageInputTokens) * imageInputPrice
|
||
} else {
|
||
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, cacheCreationPrice, cacheCreationMultiplier)
|
||
|
||
bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice
|
||
|
||
if tierMultiplier != 1.0 {
|
||
bd.InputCost *= tierMultiplier
|
||
bd.ImageInputCost *= tierMultiplier
|
||
bd.OutputCost *= tierMultiplier
|
||
bd.ImageOutputCost *= tierMultiplier
|
||
bd.CacheCreationCost *= tierMultiplier
|
||
bd.CacheReadCost *= tierMultiplier
|
||
}
|
||
|
||
bd.TotalCost = bd.InputCost + bd.ImageInputCost + bd.OutputCost + bd.ImageOutputCost +
|
||
bd.CacheCreationCost + bd.CacheReadCost
|
||
bd.ActualCost = bd.TotalCost * rateMultiplier
|
||
bd.LongContextBillingApplied = baselineCost != nil && bd.ActualCost > baselineCost.ActualCost
|
||
|
||
return bd
|
||
}
|
||
|
||
// computeCacheCreationCost 计算缓存创建费用(支持 5m/1h 分类或标准计费)。
|
||
// multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。
|
||
func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 {
|
||
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
||
cacheCreation5mTokens, cacheCreation1hTokens := normalizeCacheCreationBreakdown(tokens)
|
||
if cacheCreation5mTokens == 0 && cacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
|
||
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
|
||
return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier
|
||
}
|
||
return float64(cacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier +
|
||
float64(cacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier
|
||
}
|
||
return float64(tokens.CacheCreationTokens) * price * multiplier
|
||
}
|
||
|
||
// normalizeCacheCreationBreakdown caps contradictory 5m/1h details at an explicitly
|
||
// positive aggregate while retaining their reported ratio as closely as integer tokens allow.
|
||
func normalizeCacheCreationBreakdown(tokens UsageTokens) (int, int) {
|
||
cacheCreation5mTokens := tokens.CacheCreation5mTokens
|
||
cacheCreation1hTokens := tokens.CacheCreation1hTokens
|
||
aggregate := tokens.CacheCreationTokens
|
||
if cacheCreation5mTokens < 0 {
|
||
cacheCreation5mTokens = 0
|
||
}
|
||
if cacheCreation1hTokens < 0 {
|
||
cacheCreation1hTokens = 0
|
||
}
|
||
if aggregate <= 0 || (cacheCreation5mTokens <= aggregate && cacheCreation1hTokens <= aggregate-cacheCreation5mTokens) {
|
||
return cacheCreation5mTokens, cacheCreation1hTokens
|
||
}
|
||
|
||
detailTotal := float64(cacheCreation5mTokens) + float64(cacheCreation1hTokens)
|
||
normalized5mTokens := math.Round(float64(aggregate) * float64(cacheCreation5mTokens) / detailTotal)
|
||
if normalized5mTokens >= float64(aggregate) {
|
||
cacheCreation5mTokens = aggregate
|
||
} else {
|
||
cacheCreation5mTokens = int(normalized5mTokens)
|
||
}
|
||
return cacheCreation5mTokens, aggregate - cacheCreation5mTokens
|
||
}
|
||
|
||
// calculatePerRequestCost 按次/图片计费
|
||
func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) {
|
||
units := input.UsageUnits
|
||
if units <= 0 {
|
||
count := input.RequestCount
|
||
if count <= 0 {
|
||
count = 1
|
||
}
|
||
units = float64(count)
|
||
}
|
||
|
||
var unitPrice float64
|
||
|
||
if input.SizeTier != "" {
|
||
unitPrice = input.Resolver.GetRequestTierPrice(resolved, input.SizeTier)
|
||
}
|
||
|
||
if unitPrice == 0 {
|
||
totalContext := input.Tokens.InputTokens + input.Tokens.CacheCreationTokens + input.Tokens.CacheReadTokens
|
||
unitPrice = input.Resolver.GetRequestTierPriceByContext(resolved, totalContext)
|
||
}
|
||
|
||
// 回退到默认按次价格
|
||
if unitPrice == 0 {
|
||
unitPrice = resolved.DefaultPerRequestPrice
|
||
}
|
||
|
||
totalCost := unitPrice * units
|
||
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) calculateCostWithServiceTierPolicy(
|
||
model string,
|
||
tokens UsageTokens,
|
||
rateMultiplier float64,
|
||
serviceTier string,
|
||
longContextBillingEnabled bool,
|
||
) (*CostBreakdown, error) {
|
||
return s.calculateCostInternalWithPolicy(model, tokens, rateMultiplier, serviceTier, nil, longContextBillingEnabled)
|
||
}
|
||
|
||
func (s *BillingService) calculateCostInternal(model string, tokens UsageTokens, rateMultiplier float64, serviceTier string, channelPricing *ChannelModelPricing) (*CostBreakdown, error) {
|
||
return s.calculateCostInternalWithPolicy(model, tokens, rateMultiplier, serviceTier, channelPricing, true)
|
||
}
|
||
|
||
func (s *BillingService) calculateCostInternalWithPolicy(
|
||
model string,
|
||
tokens UsageTokens,
|
||
rateMultiplier float64,
|
||
serviceTier string,
|
||
channelPricing *ChannelModelPricing,
|
||
longContextBillingEnabled bool,
|
||
) (*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, longContextBillingEnabled), nil
|
||
}
|
||
|
||
// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价
|
||
// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority
|
||
// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文
|
||
// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段)
|
||
// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部)
|
||
// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx
|
||
// 关闭强制,保留运营者配置。
|
||
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
||
return s.applyModelSpecificPricingPolicyEx(model, pricing, true)
|
||
}
|
||
|
||
// applyModelSpecificPricingPolicyEx 与 applyModelSpecificPricingPolicy 相同,
|
||
// 但由调用方控制是否强制 DeepSeek 官方价(forceDeepSeekRates)。
|
||
// calculateTokenCost 对分组/渠道自定义定价(Source 非 LiteLLM)传 false:
|
||
// 强制覆盖会把运营者配置的售价盖回官方价,违反自定义定价语义。
|
||
func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing *ModelPricing, forceDeepSeekRates bool) *ModelPricing {
|
||
if pricing == nil {
|
||
return nil
|
||
}
|
||
// DeepSeek 模型:无论 JSON/远端价格表给什么价,一律强制官方低谷价
|
||
// (2026-08-23 起生效)。这是覆盖远端旧价的关键——远端仓库不可改,生产会先
|
||
// 拉到旧价,必须在此兜底修正;克隆后再覆盖,避免污染共享 fallbackPrices 指针。
|
||
// 档位判定:含 "deepseek-v4-pro" 的版本化名称(如 deepseek-v4-pro-0813)归 pro 档,
|
||
// 其余 deepseek-*(含已停服的 chat/reasoner 与未知型号)统一归 flash 档。
|
||
// 高峰时段倍率不在本函数处理,由 calculateTokenCost 按 deepseekPeakMultiplierAt
|
||
// 对默认价卡另行叠加(分组/渠道自定义定价不叠加)。
|
||
if forceDeepSeekRates && isDeepSeekModel(model) {
|
||
cloned := *pricing
|
||
if strings.Contains(strings.ToLower(strings.TrimSpace(model)), "deepseek-v4-pro") {
|
||
cloned.InputPricePerToken = deepseekProOffPeakInputPrice
|
||
cloned.OutputPricePerToken = deepseekProOffPeakOutputPrice
|
||
cloned.CacheReadPricePerToken = deepseekProOffPeakCacheRead
|
||
} else {
|
||
// deepseek-v4-flash / deepseek-v4-flash-vision-exp 与其余 deepseek-* 共用 flash 价。
|
||
cloned.InputPricePerToken = deepseekFlashOffPeakInputPrice
|
||
cloned.OutputPricePerToken = deepseekFlashOffPeakOutputPrice
|
||
cloned.CacheReadPricePerToken = deepseekFlashOffPeakCacheRead
|
||
}
|
||
return &cloned
|
||
}
|
||
normalized := normalizeKnownOpenAICodexModel(model)
|
||
isGPT56 := isOpenAIGPT56Model(normalized)
|
||
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
|
||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
|
||
fastRatio := openAIModelFastPricingRatio(normalized)
|
||
if !needsCacheCreationPolicy && fastRatio <= 0 {
|
||
return pricing
|
||
}
|
||
cloned := *pricing
|
||
if isGPT56 && !cloned.CacheCreationPriceExplicit {
|
||
if cloned.CacheCreationPricePerToken <= 0 {
|
||
cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25
|
||
}
|
||
if cloned.CacheCreationPricePerTokenPriority <= 0 {
|
||
cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25
|
||
}
|
||
}
|
||
if fastRatio > 0 {
|
||
enforceOpenAIFastPricingRatio(&cloned, fastRatio)
|
||
}
|
||
return &cloned
|
||
}
|
||
|
||
// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority
|
||
// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast
|
||
// 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。
|
||
func openAIModelFastPricingRatio(normalized string) float64 {
|
||
switch normalized {
|
||
case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna":
|
||
return 2.0
|
||
case "gpt-5.5":
|
||
return 2.5
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// enforceOpenAIFastPricingRatio 把 priority 档价格改写为「标准价 × ratio」。
|
||
// 本地/远程 LiteLLM 目录可能只带官方旧口径(如 gpt-5.5 priority 仍标 2x),
|
||
// 直接采用会导致 Fast 模式少计费;这里按业务倍率兜底修正,且对已正确的
|
||
// fallback 条目(2x/2.5x)是幂等的。computeTokenBreakdown 在 priority 价格
|
||
// 存在时走显式档位价、不再叠加通用 tier 倍率,因此不会重复乘价。
|
||
func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) {
|
||
if pricing == nil || ratio <= 0 {
|
||
return
|
||
}
|
||
pricing.InputPricePerTokenPriority = pricing.InputPricePerToken * ratio
|
||
pricing.OutputPricePerTokenPriority = pricing.OutputPricePerToken * ratio
|
||
if pricing.CacheReadPricePerToken > 0 {
|
||
pricing.CacheReadPricePerTokenPriority = pricing.CacheReadPricePerToken * ratio
|
||
}
|
||
if pricing.CacheCreationPricePerToken > 0 {
|
||
pricing.CacheCreationPricePerTokenPriority = pricing.CacheCreationPricePerToken * ratio
|
||
}
|
||
}
|
||
|
||
// longContextMultiplierOrOne 把未配置(≤0)的长上下文倍率归一为 1。
|
||
func longContextMultiplierOrOne(m float64) float64 {
|
||
if m <= 0 {
|
||
return 1
|
||
}
|
||
return m
|
||
}
|
||
|
||
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.CacheCreationTokens + tokens.CacheReadTokens
|
||
if pricing.LongContextThresholdInclusive {
|
||
return totalInputTokens >= pricing.LongContextInputThreshold
|
||
}
|
||
return totalInputTokens > pricing.LongContextInputThreshold
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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 表示使用默认值)
|
||
}
|
||
|
||
// VideoPriceConfig 视频生成计费配置。所有价格均为**每秒**单价(USD/s),与 xAI 官方计费口径一致。
|
||
type VideoPriceConfig struct {
|
||
Price480P *float64 // 480p 每秒价格(nil 表示使用默认值)
|
||
Price720P *float64 // 720p 每秒价格(nil 表示使用默认值)
|
||
Price1080P *float64 // 1080p 每秒价格(nil 表示使用默认值)
|
||
// ModelPrices is optional per-model-family override: family → resolution → USD/s.
|
||
// When set for a model, it wins over Price* flat columns for that model only.
|
||
ModelPrices map[string]map[string]float64
|
||
}
|
||
|
||
const (
|
||
defaultImageGenerationPrice = 0.134
|
||
|
||
defaultGrokImagineImagePrice1K = 0.02
|
||
defaultGrokImagineImagePrice2K = 0.02
|
||
defaultGrokImagineImageQualityPrice1K = 0.05
|
||
defaultGrokImagineImageQualityPrice2K = 0.07
|
||
defaultGrokImagineImage20Price1K = 0.06 // default quality is Medium
|
||
defaultGrokImagineImage20Price2K = 0.08
|
||
|
||
// 视频默认价为 xAI 官方**每秒**输出价格(USD/s),总价 = 每秒价 × 时长(秒)。
|
||
defaultGrokImagineVideoPrice480P = 0.05
|
||
defaultGrokImagineVideoPrice720P = 0.07
|
||
defaultGrokImagineVideo15Price480P = 0.08
|
||
defaultGrokImagineVideo15Price720P = 0.14
|
||
defaultGrokImagineVideo15Price1080P = 0.25
|
||
|
||
// Codex alpha/search 网页搜索单次默认价:OpenAI 官方 web search 定价 $10/1000 次。
|
||
defaultWebSearchPricePerCall = 0.01
|
||
|
||
// xAI server-side web/X search and code execution are $5/1000 calls.
|
||
defaultSearchPricePer1k = 5.0
|
||
|
||
// Generic realtime defaults to think-fast-1.0; think-fast-2.0 can be
|
||
// configured independently through per-model group/channel pricing.
|
||
defaultAudioRealtimePricePerMin = 0.05
|
||
defaultAudioTTSPricePerMillionChars = 15.0
|
||
defaultAudioSTTPricePerHour = 0.10
|
||
)
|
||
|
||
// CalculateWebSearchCost 计算 Codex alpha/search 网页搜索按次费用。
|
||
// callCount: 搜索调用次数(每次请求为 1)
|
||
// groupPrice: 分组配置的单次价格(nil 表示使用默认价 0.01;0 表示免费)
|
||
// rateMultiplier: 分组费率倍数
|
||
func (s *BillingService) CalculateWebSearchCost(callCount int, groupPrice *float64, rateMultiplier float64) *CostBreakdown {
|
||
if callCount <= 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
unitPrice := defaultWebSearchPricePerCall
|
||
if groupPrice != nil && *groupPrice >= 0 {
|
||
unitPrice = *groupPrice
|
||
}
|
||
totalCost := unitPrice * float64(callCount)
|
||
|
||
// 应用倍率(保存时强制 > 0;负数按 0 处理避免按 1x 误扣)
|
||
if rateMultiplier < 0 {
|
||
rateMultiplier = 0
|
||
}
|
||
return &CostBreakdown{
|
||
TotalCost: totalCost,
|
||
ActualCost: totalCost * rateMultiplier,
|
||
BillingMode: string(BillingModePerRequest),
|
||
}
|
||
}
|
||
|
||
// CalculateSearchCost bills search/tool invocations (e.g. web_search) per 1k calls.
|
||
// groupPricePer1k: nil → defaultSearchPricePer1k; explicit 0 → free; >0 → that rate.
|
||
func (s *BillingService) CalculateSearchCost(numCalls int, groupPricePer1k *float64, rateMultiplier float64) *CostBreakdown {
|
||
if numCalls <= 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
pricePer1k := defaultSearchPricePer1k
|
||
if groupPricePer1k != nil {
|
||
if *groupPricePer1k < 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
pricePer1k = *groupPricePer1k
|
||
}
|
||
if pricePer1k == 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
if rateMultiplier < 0 {
|
||
rateMultiplier = 0
|
||
}
|
||
unit := pricePer1k / 1000.0
|
||
total := unit * float64(numCalls)
|
||
return &CostBreakdown{
|
||
TotalCost: total,
|
||
ActualCost: total * rateMultiplier,
|
||
BillingMode: string(BillingModePerRequest),
|
||
}
|
||
}
|
||
|
||
type audioPriceConfig struct {
|
||
RealtimePerMin *float64
|
||
TTSPerMChars *float64
|
||
STTPerHour *float64
|
||
}
|
||
|
||
// CalculateAudioCost supports realtime (per min), tts (per M chars), stt (per hr).
|
||
// Missing group prices use defaults; explicit 0 means free for that mode.
|
||
func (s *BillingService) CalculateAudioCost(mode string, durationOrUnits float64, groupConfig *audioPriceConfig, rateMultiplier float64) *CostBreakdown {
|
||
if durationOrUnits <= 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
var unitPrice float64
|
||
switch strings.ToLower(mode) {
|
||
case "realtime":
|
||
unitPrice = defaultAudioRealtimePricePerMin
|
||
if groupConfig != nil && groupConfig.RealtimePerMin != nil {
|
||
unitPrice = *groupConfig.RealtimePerMin
|
||
}
|
||
case "tts":
|
||
unitPrice = defaultAudioTTSPricePerMillionChars
|
||
if groupConfig != nil && groupConfig.TTSPerMChars != nil {
|
||
unitPrice = *groupConfig.TTSPerMChars
|
||
}
|
||
case "stt":
|
||
unitPrice = defaultAudioSTTPricePerHour
|
||
if groupConfig != nil && groupConfig.STTPerHour != nil {
|
||
unitPrice = *groupConfig.STTPerHour
|
||
}
|
||
default:
|
||
return &CostBreakdown{}
|
||
}
|
||
if unitPrice <= 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
if rateMultiplier < 0 {
|
||
rateMultiplier = 0
|
||
}
|
||
total := unitPrice * durationOrUnits
|
||
return &CostBreakdown{
|
||
TotalCost: total,
|
||
ActualCost: total * rateMultiplier,
|
||
BillingMode: string(BillingModePerRequest),
|
||
}
|
||
}
|
||
|
||
// 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),
|
||
}
|
||
}
|
||
|
||
// CalculateVideoCost 计算视频生成费用(按秒计费,与 xAI 口径一致)。
|
||
// model: 请求的模型名称(用于获取默认价格)
|
||
// resolution: 视频分辨率 "480p", "720p", "1080p"
|
||
// videoCount: 生成的视频数量
|
||
// durationSeconds: 单个视频时长(秒),<=0 时按上游默认时长计
|
||
// groupConfig: 分组配置的每秒价格(可能为 nil,表示使用默认值)
|
||
// rateMultiplier: 费率倍数
|
||
func (s *BillingService) CalculateVideoCost(model string, resolution string, videoCount int, durationSeconds int, groupConfig *VideoPriceConfig, rateMultiplier float64) *CostBreakdown {
|
||
if videoCount <= 0 {
|
||
return &CostBreakdown{}
|
||
}
|
||
resolution = NormalizeVideoBillingResolutionOrDefault(resolution)
|
||
durationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds)
|
||
|
||
perSecondPrice := s.getVideoUnitPrice(model, resolution, groupConfig)
|
||
totalCost := perSecondPrice * float64(durationSeconds) * float64(videoCount)
|
||
|
||
if rateMultiplier < 0 {
|
||
rateMultiplier = 0
|
||
}
|
||
actualCost := totalCost * rateMultiplier
|
||
|
||
return &CostBreakdown{
|
||
TotalCost: totalCost,
|
||
ActualCost: actualCost,
|
||
BillingMode: string(BillingModeVideo),
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
func (s *BillingService) getVideoUnitPrice(model string, resolution string, groupConfig *VideoPriceConfig) float64 {
|
||
// Order: (a) per-model map (b) flat group video_price_* (c) model-aware code defaults.
|
||
if groupConfig != nil {
|
||
if price := LookupVideoModelPrice(groupConfig.ModelPrices, model, resolution); price != nil {
|
||
return *price
|
||
}
|
||
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
|
||
case VideoBillingResolution480P:
|
||
if groupConfig.Price480P != nil {
|
||
return *groupConfig.Price480P
|
||
}
|
||
case VideoBillingResolution720P:
|
||
if groupConfig.Price720P != nil {
|
||
return *groupConfig.Price720P
|
||
}
|
||
case VideoBillingResolution1080P:
|
||
if groupConfig.Price1080P != nil {
|
||
return *groupConfig.Price1080P
|
||
}
|
||
}
|
||
}
|
||
|
||
return s.getDefaultVideoPrice(model, resolution)
|
||
}
|
||
|
||
// getDefaultImagePrice 获取 LiteLLM 默认图片价格
|
||
func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 {
|
||
if price, ok := getDefaultGrokImagineImagePrice(model, imageSize); ok {
|
||
return price
|
||
}
|
||
|
||
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 = defaultImageGenerationPrice
|
||
}
|
||
|
||
// 2K 尺寸 1.5 倍,4K 尺寸翻倍
|
||
if imageSize == "2K" {
|
||
return basePrice * 1.5
|
||
}
|
||
if imageSize == "4K" {
|
||
return basePrice * 2
|
||
}
|
||
|
||
return basePrice
|
||
}
|
||
|
||
func (s *BillingService) getDefaultVideoPrice(model string, resolution string) float64 {
|
||
if price, ok := getDefaultGrokImagineVideoPrice(model, resolution); ok {
|
||
return price
|
||
}
|
||
|
||
// The bundled LiteLLM schema does not expose an output video generation price.
|
||
// Keep the historical model default as the fallback (interpreted as a per-second
|
||
// rate; today only Grok models reach video billing, so this path is a safety net),
|
||
// while letting group-level video prices override it independently from image prices.
|
||
return s.getDefaultImagePrice(model, ImageBillingSize2K)
|
||
}
|
||
|
||
func getDefaultGrokImagineImagePrice(model string, imageSize string) (float64, bool) {
|
||
model = strings.ToLower(strings.TrimSpace(model))
|
||
switch model {
|
||
case "grok-imagine-image-2.0":
|
||
return getGrokImagineImageTierPrice(
|
||
imageSize,
|
||
defaultGrokImagineImage20Price1K,
|
||
defaultGrokImagineImage20Price2K,
|
||
), true
|
||
case "grok-imagine-image-quality":
|
||
return getGrokImagineImageTierPrice(
|
||
imageSize,
|
||
defaultGrokImagineImageQualityPrice1K,
|
||
defaultGrokImagineImageQualityPrice2K,
|
||
), true
|
||
case "grok-imagine", "grok-imagine-image", "grok-imagine-edit":
|
||
return getGrokImagineImageTierPrice(
|
||
imageSize,
|
||
defaultGrokImagineImagePrice1K,
|
||
defaultGrokImagineImagePrice2K,
|
||
), true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func getGrokImagineImageTierPrice(imageSize string, price1K float64, price2K float64) float64 {
|
||
switch NormalizeImageBillingTierOrDefault(imageSize) {
|
||
case ImageBillingSize1K:
|
||
return price1K
|
||
case ImageBillingSize2K, ImageBillingSize4K:
|
||
return price2K
|
||
default:
|
||
return price2K
|
||
}
|
||
}
|
||
|
||
func getDefaultGrokImagineVideoPrice(model string, resolution string) (float64, bool) {
|
||
model = strings.ToLower(strings.TrimSpace(model))
|
||
switch {
|
||
case strings.HasPrefix(model, "grok-imagine-video-1.5"):
|
||
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
|
||
case VideoBillingResolution480P:
|
||
return defaultGrokImagineVideo15Price480P, true
|
||
case VideoBillingResolution720P:
|
||
return defaultGrokImagineVideo15Price720P, true
|
||
case VideoBillingResolution1080P:
|
||
return defaultGrokImagineVideo15Price1080P, true
|
||
default:
|
||
return defaultGrokImagineVideo15Price480P, true
|
||
}
|
||
case strings.HasPrefix(model, "grok-imagine-video"):
|
||
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
|
||
case VideoBillingResolution480P:
|
||
return defaultGrokImagineVideoPrice480P, true
|
||
case VideoBillingResolution720P, VideoBillingResolution1080P:
|
||
return defaultGrokImagineVideoPrice720P, true
|
||
default:
|
||
return defaultGrokImagineVideoPrice480P, true
|
||
}
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|