Files
sub2api/backend/internal/service/billing_service.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1992 lines
78 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"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/$25Fast $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,
}
// ---- 智谱 GLMZ.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,
}
// ---- 月之暗面 KimiK 系列)----
// 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 aliasesk3 / 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 无 outputOutputPricePerToken 置 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 一律不返回兜底价。
// 智谱 GLMz.ai 公开 SKUglm-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"]
}
// 月之暗面 Kimikimi-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"]
}
// OpenAIGPT-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-closedErrModelPricingUnavailable)。
// 图片计费路径(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
}
}