Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1992 lines
78 KiB
Go
1992 lines
78 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"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 也不回退)
|
||
}
|
||
|
||
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 {
|
||
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")
|
||
|
||
// 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)
|
||
|
||
// 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,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}
|
||
// 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,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}, 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,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}
|
||
|
||
// 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,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}
|
||
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,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}
|
||
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,
|
||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||
}
|
||
|
||
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 V4 系列 ----
|
||
// Source: https://api-docs.deepseek.com/quick_start/pricing
|
||
// (deepseek-chat / deepseek-reasoner 为 deepseek-v4-flash 的兼容别名,2026/07/24 弃用)
|
||
s.fallbackPrices["deepseek-v4-pro"] = &ModelPricing{
|
||
InputPricePerToken: 4.35e-7, // $0.435 per MTok (cache miss)
|
||
OutputPricePerToken: 8.7e-7, // $0.87 per MTok
|
||
CacheReadPricePerToken: 3.625e-9, // $0.003625 per MTok (cache hit)
|
||
SupportsCacheBreakdown: false,
|
||
}
|
||
s.fallbackPrices["deepseek-v4-flash"] = &ModelPricing{
|
||
InputPricePerToken: 1.4e-7, // $0.14 per MTok (cache miss)
|
||
OutputPricePerToken: 2.8e-7, // $0.28 per MTok
|
||
CacheReadPricePerToken: 2.8e-9, // $0.0028 per MTok (cache hit)
|
||
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.
|
||
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 (docs.x.ai/developers/models: $2 input / $0.50 cached input /
|
||
// $6 output per MTok under 200k prompt tokens; ≥200k is 2× on input,
|
||
// cached input, and output).
|
||
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.
|
||
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,
|
||
}
|
||
// 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, "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 系列:仅匹配已知 V4 Pro/Flash 与官方兼容别名
|
||
// (deepseek-chat / deepseek-reasoner → V4 Flash),未知 deepseek-* 型号不回退,避免误计价。
|
||
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.Contains(modelLower, "deepseek-chat") || strings.Contains(modelLower, "deepseek-reasoner") {
|
||
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.5", "grok-4.5-latest":
|
||
return s.fallbackPrices["grok-4.5"]
|
||
case "grok-4.6", "grok-4.6-latest":
|
||
return s.fallbackPrices["grok-4.6"]
|
||
case "grok-4.3",
|
||
"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.3"]
|
||
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.5"]
|
||
}
|
||
|
||
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,
|
||
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
|
||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||
}
|
||
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)
|
||
}
|
||
|
||
pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing)
|
||
|
||
// 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。
|
||
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)
|
||
inputPrice *= pricing.LongContextInputMultiplier
|
||
outputPrice *= pricing.LongContextOutputMultiplier
|
||
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
||
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
||
cacheReadPrice *= pricing.LongContextInputMultiplier
|
||
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
||
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
||
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
||
cacheCreationMultiplier = pricing.LongContextInputMultiplier
|
||
}
|
||
|
||
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) {
|
||
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) * price * multiplier
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
||
if pricing == nil {
|
||
return nil
|
||
}
|
||
normalized := normalizeKnownOpenAICodexModel(model)
|
||
isGPT56 := isOpenAIGPT56Model(normalized)
|
||
usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized)
|
||
if !isGPT56 && !usesLegacyLongContextPricing {
|
||
return pricing
|
||
}
|
||
needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) &&
|
||
(pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0)
|
||
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
|
||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
|
||
if !needsLongContextPolicy && !needsCacheCreationPolicy {
|
||
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 isGPT56 || usesLegacyLongContextPricing {
|
||
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.CacheCreationTokens + tokens.CacheReadTokens
|
||
if pricing.LongContextThresholdInclusive {
|
||
return totalInputTokens >= pricing.LongContextInputThreshold
|
||
}
|
||
return totalInputTokens > pricing.LongContextInputThreshold
|
||
}
|
||
|
||
func usesOpenAILegacyLongContextPricing(normalized string) bool {
|
||
return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
|
||
}
|
||
|
||
// 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,
|
||
ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost,
|
||
OutputCost: inRangeCost.OutputCost,
|
||
ImageOutputCost: inRangeCost.ImageOutputCost,
|
||
CacheCreationCost: inRangeCost.CacheCreationCost,
|
||
CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost,
|
||
TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost,
|
||
ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost,
|
||
LongContextBillingApplied: outRangeCost.ActualCost > 0,
|
||
}, 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 表示使用默认值)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|