Files
sub2api/backend/internal/service/pricing_service_test.go
T
shaw 80a229bce5 fix(batch-image): 修复审计发现的计费死锁、状态机与队列原子性缺陷
修复 PR #3768 批量图像 MVP 合并后审计报告中的全部问题:

结算与计费(高危):
- 所有 SETTLEMENT_* 失败(超冻结/计数非法/manifest 冲突/定价缺失/扣费失败)
  统一计入 retry_count 并在耗尽时释放冻结转 failed,消灭 settling 无限
  requeue 导致的冻结余额永久锁死
- 耗尽出口的释放指纹统一为 RequestHash,与 processor/Cancel/recovery 一致;
  release 遇同 request id 指纹冲突视为幂等成功,治愈历史毒消息
- 管理端校验 hold_multiplier >= discount_multiplier,定价快照对存量脏数据钳制
- 释放前校验 per-job hold claim(dedup+归档表),杜绝幻影释放

索引对账(高危):
- provider 输出与提交 custom_id 集对账:未知条目丢弃并记事件,
  漏项补 PROVIDER_RESULT_MISSING 失败行,保证 success+fail == item_count

提交与恢复(高危):
- 提交前转 uploading 并在 provider.Submit 期间心跳刷新 updated_at;
  恢复扫描改为原子复核(FailStaleUnsubmittedBatchImageJob),
  消灭慢提交被误杀退款而上游任务照常计费的孤儿场景
- 上游任务创建成功但本地状态推进失败时,尽力取消上游并清理输入
- recovery 释放失败时入队交由 worker releaseTerminalHold 兜底重试

队列与并发(中危):
- Enqueue(SetNX+LPush)与 Reserve(BRPop+ZAdd)均改为 Lua 原子脚本,
  消灭崩溃窗口导致 job 脱离队列、被 7 天 inflight 键锁死
- 锁冲突按 LockConflictDelay 重新入队(原直接丢弃需等 10 分钟 stale 恢复)
- 处理期间心跳:active zset 续期(ZAddXX 防幽灵成员)+ 锁 TTL 续期
- ReplaceBatchImageItemsForJob 增加 indexing 状态守卫,防掉队 worker 重写账目

存量回归(中危):
- image-only 定价条目(仅图片价无 token 价)恢复 token 计费 fail-closed,
  不再按 $0 计费;图片计费路径不受影响
- 鉴权余额门槛恢复 balance <= 0 语义,MinimumBalanceReserve 不再作硬 403

加固:
- ZIP max_items 钳制到管理员上限;Submit 补齐 Platform==Gemini 校验;
  gemini downloadUri 跟随前做 host 白名单校验
- 批量客户端改用共享 httpclient(拨号/TLS/响应头超时有界)
- 审计点名的忽略错误(MarkDownloaded/SettlementFailed/AppendEvent 等)改为记日志
2026-07-07 18:53:56 +08:00

384 lines
13 KiB
Go

package service
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) {
svc := &PricingService{}
body := []byte(`{
"gpt-5.4": {
"input_cost_per_token": 0.0000025,
"input_cost_per_token_priority": 0.000005,
"output_cost_per_token": 0.000015,
"output_cost_per_token_priority": 0.00003,
"cache_creation_input_token_cost": 0.0000025,
"cache_read_input_token_cost": 0.00000025,
"cache_read_input_token_cost_priority": 0.0000005,
"supports_service_tier": true,
"supports_prompt_caching": true,
"litellm_provider": "openai",
"mode": "chat"
}
}`)
data, err := svc.parsePricingData(body)
require.NoError(t, err)
pricing := data["gpt-5.4"]
require.NotNil(t, pricing)
require.InDelta(t, 5e-6, pricing.InputCostPerTokenPriority, 1e-12)
require.InDelta(t, 3e-5, pricing.OutputCostPerTokenPriority, 1e-12)
require.InDelta(t, 5e-7, pricing.CacheReadInputTokenCostPriority, 1e-12)
require.True(t, pricing.SupportsServiceTier)
}
func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) {
svc := &PricingService{}
body := []byte(`{
"image-only-model": {
"output_cost_per_image": 0.034,
"litellm_provider": "vertex_ai-language-models",
"mode": "image_generation"
}
}`)
data, err := svc.parsePricingData(body)
require.NoError(t, err)
pricing := data["image-only-model"]
require.NotNil(t, pricing)
require.InDelta(t, 0.034, pricing.OutputCostPerImage, 1e-12)
require.Equal(t, "image_generation", pricing.Mode)
// 仅有图片价的条目必须标记 token 价缺失,供 token 计费路径 fail-closed。
require.True(t, pricing.TokenPricingAbsent)
}
func TestBillingService_GetModelPricing_FailsClosedForImageOnlyEntries(t *testing.T) {
pricingSvc := &PricingService{}
data, err := pricingSvc.parsePricingData([]byte(`{
"imagen-9.0-generate": {
"output_cost_per_image": 0.04,
"litellm_provider": "vertex_ai-image-models",
"mode": "image_generation"
},
"gemini-image-with-token-price": {
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
"output_cost_per_image": 0.034,
"litellm_provider": "vertex_ai-language-models",
"mode": "image_generation"
}
}`))
require.NoError(t, err)
pricingSvc.pricingData = data
billingSvc := NewBillingService(&config.Config{}, pricingSvc)
// image-only 条目不得进入 token 计费(否则 token 流量按 $0 计费),
// 必须落到 fallback / ErrModelPricingUnavailable 的 fail-closed 路径。
_, err = billingSvc.GetModelPricing("imagen-9.0-generate")
require.ErrorIs(t, err, ErrModelPricingUnavailable)
// 显式 0 token 价的免费条目保持历史行为:正常返回。
pricing, err := billingSvc.GetModelPricing("gemini-image-with-token-price")
require.NoError(t, err)
require.Zero(t, pricing.InputPricePerToken)
// 图片计费路径不受影响:仍能读到 image-only 条目的图片单价。
raw := pricingSvc.GetModelPricing("imagen-9.0-generate")
require.NotNil(t, raw)
require.InDelta(t, 0.04, raw.OutputCostPerImage, 1e-12)
}
func TestPricingService_MergesFallbackOnlyModels(t *testing.T) {
dir := t.TempDir()
fallbackFile := filepath.Join(dir, "fallback.json")
require.NoError(t, os.WriteFile(fallbackFile, []byte(`{
"remote-model": {
"input_cost_per_token": 0.000001,
"litellm_provider": "test",
"mode": "chat"
},
"gemini-3.1-flash-lite-image": {
"output_cost_per_image": 0.034,
"litellm_provider": "vertex_ai-language-models",
"mode": "image_generation"
}
}`), 0644))
svc := &PricingService{cfg: &config.Config{}}
svc.cfg.Pricing.FallbackFile = fallbackFile
remoteData, err := svc.parsePricingData([]byte(`{
"remote-model": {
"input_cost_per_token": 0.000002,
"litellm_provider": "test",
"mode": "chat"
}
}`))
require.NoError(t, err)
merged := svc.mergeFallbackPricingData(remoteData)
require.InDelta(t, 0.000002, merged["remote-model"].InputCostPerToken, 1e-12)
require.NotNil(t, merged["gemini-3.1-flash-lite-image"])
require.InDelta(t, 0.034, merged["gemini-3.1-flash-lite-image"].OutputCostPerImage, 1e-12)
}
func TestGetModelPricing_Gpt53CodexSparkUsesGpt51CodexPricing(t *testing.T) {
sparkPricing := &LiteLLMModelPricing{InputCostPerToken: 1}
gpt53Pricing := &LiteLLMModelPricing{InputCostPerToken: 9}
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.1-codex": sparkPricing,
"gpt-5.3": gpt53Pricing,
},
}
got := svc.GetModelPricing("gpt-5.3-codex-spark")
require.Same(t, sparkPricing, got)
}
func TestGetModelPricing_Gpt53CodexFallbackStillUsesGpt52Codex(t *testing.T) {
gpt52CodexPricing := &LiteLLMModelPricing{InputCostPerToken: 2}
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.2-codex": gpt52CodexPricing,
},
}
got := svc.GetModelPricing("gpt-5.3-codex")
require.Same(t, gpt52CodexPricing, got)
}
func TestGetModelPricing_OpenAIFallbackMatchedLoggedAsInfo(t *testing.T) {
logSink, restore := captureStructuredLog(t)
defer restore()
gpt52CodexPricing := &LiteLLMModelPricing{InputCostPerToken: 2}
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.2-codex": gpt52CodexPricing,
},
}
got := svc.GetModelPricing("gpt-5.3-codex")
require.Same(t, gpt52CodexPricing, got)
require.True(t, logSink.ContainsMessageAtLevel("[Pricing] OpenAI fallback matched gpt-5.3-codex -> gpt-5.2-codex", "info"))
require.False(t, logSink.ContainsMessageAtLevel("[Pricing] OpenAI fallback matched gpt-5.3-codex -> gpt-5.2-codex", "warn"))
}
func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.1-codex": &LiteLLMModelPricing{InputCostPerToken: 1.25e-6},
},
}
got := svc.GetModelPricing("gpt-5.4")
require.NotNil(t, got)
require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 1e-12)
require.Equal(t, 272000, got.LongContextInputTokenThreshold)
require.InDelta(t, 2.0, got.LongContextInputCostMultiplier, 1e-12)
require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12)
}
func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
},
}
got := svc.GetModelPricing("openai/gpt5.5")
require.NotNil(t, got)
require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
}
func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
require.NoError(t, err)
svc := &PricingService{}
pricingData, err := svc.parsePricingData(data)
require.NoError(t, err)
svc.pricingData = pricingData
got := svc.GetModelPricing("codex-auto-review")
require.NotNil(t, got)
require.InDelta(t, 5e-6, got.InputCostPerToken, 1e-12)
require.InDelta(t, 3e-5, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 5e-7, got.CacheReadInputTokenCost, 1e-12)
}
func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
},
}
got := svc.GetModelPricing("gpt-5.4-mini")
require.NotNil(t, got)
require.InDelta(t, 7.5e-7, got.InputCostPerToken, 1e-12)
require.InDelta(t, 4.5e-6, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 7.5e-8, got.CacheReadInputTokenCost, 1e-12)
require.Zero(t, got.LongContextInputTokenThreshold)
}
func TestGetModelPricing_Gpt54NanoUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
},
}
got := svc.GetModelPricing("gpt-5.4-nano")
require.NotNil(t, got)
require.InDelta(t, 2e-7, got.InputCostPerToken, 1e-12)
require.InDelta(t, 1.25e-6, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 2e-8, got.CacheReadInputTokenCost, 1e-12)
require.Zero(t, got.LongContextInputTokenThreshold)
}
func TestGetModelPricing_ImageModelDoesNotFallbackToTextModel(t *testing.T) {
imagePricing := &LiteLLMModelPricing{InputCostPerToken: 3}
textPricing := &LiteLLMModelPricing{InputCostPerToken: 9}
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-image-2": imagePricing,
"gpt-5.4": textPricing,
},
}
got := svc.GetModelPricing("gpt-image-3")
require.Same(t, imagePricing, got)
}
func TestParsePricingData_PreservesPriorityAndServiceTierFields(t *testing.T) {
raw := map[string]any{
"gpt-5.4": map[string]any{
"input_cost_per_token": 2.5e-6,
"input_cost_per_token_priority": 5e-6,
"output_cost_per_token": 15e-6,
"output_cost_per_token_priority": 30e-6,
"cache_read_input_token_cost": 0.25e-6,
"cache_read_input_token_cost_priority": 0.5e-6,
"supports_service_tier": true,
"supports_prompt_caching": true,
"litellm_provider": "openai",
"mode": "chat",
},
}
body, err := json.Marshal(raw)
require.NoError(t, err)
svc := &PricingService{}
pricingMap, err := svc.parsePricingData(body)
require.NoError(t, err)
pricing := pricingMap["gpt-5.4"]
require.NotNil(t, pricing)
require.InDelta(t, 2.5e-6, pricing.InputCostPerToken, 1e-12)
require.InDelta(t, 5e-6, pricing.InputCostPerTokenPriority, 1e-12)
require.InDelta(t, 15e-6, pricing.OutputCostPerToken, 1e-12)
require.InDelta(t, 30e-6, pricing.OutputCostPerTokenPriority, 1e-12)
require.InDelta(t, 0.25e-6, pricing.CacheReadInputTokenCost, 1e-12)
require.InDelta(t, 0.5e-6, pricing.CacheReadInputTokenCostPriority, 1e-12)
require.True(t, pricing.SupportsServiceTier)
}
func TestParsePricingData_PreservesServiceTierPriorityFields(t *testing.T) {
svc := &PricingService{}
pricingData, err := svc.parsePricingData([]byte(`{
"gpt-5.4": {
"input_cost_per_token": 0.0000025,
"input_cost_per_token_priority": 0.000005,
"output_cost_per_token": 0.000015,
"output_cost_per_token_priority": 0.00003,
"cache_read_input_token_cost": 0.00000025,
"cache_read_input_token_cost_priority": 0.0000005,
"supports_service_tier": true,
"litellm_provider": "openai",
"mode": "chat"
}
}`))
require.NoError(t, err)
pricing := pricingData["gpt-5.4"]
require.NotNil(t, pricing)
require.InDelta(t, 0.0000025, pricing.InputCostPerToken, 1e-12)
require.InDelta(t, 0.000005, pricing.InputCostPerTokenPriority, 1e-12)
require.InDelta(t, 0.000015, pricing.OutputCostPerToken, 1e-12)
require.InDelta(t, 0.00003, pricing.OutputCostPerTokenPriority, 1e-12)
require.InDelta(t, 0.00000025, pricing.CacheReadInputTokenCost, 1e-12)
require.InDelta(t, 0.0000005, pricing.CacheReadInputTokenCostPriority, 1e-12)
require.True(t, pricing.SupportsServiceTier)
}
// ---------------------------------------------------------------------------
// ListModelNamesByProvider
// ---------------------------------------------------------------------------
func TestListModelNamesByProvider_ReturnsMatchingModels(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"claude-opus-4-5-20251101": {LiteLLMProvider: "anthropic", InputCostPerToken: 1.5e-5},
"claude-sonnet-4-5": {LiteLLMProvider: "anthropic", InputCostPerToken: 3e-6},
"gpt-4o": {LiteLLMProvider: "openai", InputCostPerToken: 5e-6},
"gemini-2.5-pro": {LiteLLMProvider: "google", InputCostPerToken: 1.25e-6},
},
}
got := svc.ListModelNamesByProvider("anthropic")
require.ElementsMatch(t, []string{"claude-opus-4-5-20251101", "claude-sonnet-4-5"}, got)
// Must be sorted
require.Equal(t, "claude-opus-4-5-20251101", got[0])
require.Equal(t, "claude-sonnet-4-5", got[1])
}
func TestListModelNamesByProvider_CaseInsensitive(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-4o": {LiteLLMProvider: "OpenAI", InputCostPerToken: 5e-6},
},
}
got := svc.ListModelNamesByProvider("openai")
require.Equal(t, []string{"gpt-4o"}, got)
got2 := svc.ListModelNamesByProvider("OPENAI")
require.Equal(t, []string{"gpt-4o"}, got2)
}
func TestListModelNamesByProvider_NoMatch(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
"gpt-4o": {LiteLLMProvider: "openai", InputCostPerToken: 5e-6},
},
}
got := svc.ListModelNamesByProvider("anthropic")
require.NotNil(t, got)
require.Empty(t, got)
}
func TestListModelNamesByProvider_EmptyCatalog(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{},
}
got := svc.ListModelNamesByProvider("openai")
require.NotNil(t, got)
require.Empty(t, got)
}