response_model_billing_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "time"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // 夹具模型必须同时满足两个条件,否则测的就不是想测的那条规则:
  10. // 1. 两者价格不同——否则"更便宜才采纳"的断言退化成恒真;
  11. // 2. 两者都能被 HasIdentifiedTokenPricing 确定性识别(即价格表里的精确条目),
  12. // 否则请求会先被"响应模型必须可识别"这道更靠前的门挡掉,成本比较根本走不到。
  13. //
  14. // claude-opus-4 / gpt-5.1 之类的名字不满足条件 2(前者不是 fallback 精确键,
  15. // 后者与 gpt-5.5 共用同一条 gpt-5.4 价格因而也不满足条件 1)。
  16. const (
  17. anthropicCheapFixtureModel = "claude-sonnet-4"
  18. anthropicPriceyFixtureModel = "claude-opus-4.8"
  19. openAICheapFixtureModel = "gpt-5.4-nano"
  20. openAIPriceyFixtureModel = "gpt-5.5"
  21. )
  22. // orderedResponseBillingModels 返回 (cheaper, pricier) 及各自成本,按当前价格表排序,
  23. // 使断言不依赖两个具体模型的价格大小关系(价格表调整时测试仍然自洽)。
  24. func orderedResponseBillingModels(t *testing.T, svc *BillingService, tokens UsageTokens, a, b string) (string, string, *CostBreakdown, *CostBreakdown) {
  25. t.Helper()
  26. costA, err := svc.CalculateCost(a, tokens, 1.1)
  27. require.NoError(t, err)
  28. costB, err := svc.CalculateCost(b, tokens, 1.1)
  29. require.NoError(t, err)
  30. require.NotEqual(t, costA.TotalCost, costB.TotalCost, "fixture prices for %s and %s must differ", a, b)
  31. require.True(t, svc.HasIdentifiedTokenPricing(a), "fixture model %s must be identifiable in the pricing table", a)
  32. require.True(t, svc.HasIdentifiedTokenPricing(b), "fixture model %s must be identifiable in the pricing table", b)
  33. if costA.TotalCost < costB.TotalCost {
  34. return a, b, costA, costB
  35. }
  36. return b, a, costB, costA
  37. }
  38. // --- Anthropic gateway (GatewayService.RecordUsage) ---
  39. func TestGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *testing.T) {
  40. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  41. userRepo := &openAIRecordUsageUserRepoStub{}
  42. svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
  43. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  44. cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
  45. err := svc.RecordUsage(context.Background(), &RecordUsageInput{
  46. Result: &ForwardResult{
  47. RequestID: "gateway_response_model_downgrade",
  48. Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50},
  49. Model: pricier,
  50. UpstreamResponseModel: cheaper, // upstream declared a runtime downgrade
  51. Duration: time.Second,
  52. },
  53. APIKey: &APIKey{ID: 501, Quota: 100},
  54. User: &User{ID: 601},
  55. Account: &Account{ID: 701},
  56. ChannelUsageFields: ChannelUsageFields{
  57. ChannelID: 9,
  58. OriginalModel: pricier,
  59. ChannelMappedModel: pricier,
  60. BillingModelSource: BillingModelSourceResponse,
  61. },
  62. })
  63. require.NoError(t, err)
  64. require.NotNil(t, usageRepo.lastLog)
  65. require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  66. require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12)
  67. require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero")
  68. // 审计链完整保留:请求/发送模型不因计费切换被改写,响应模型与 mismatch 记录在案。
  69. require.Equal(t, pricier, usageRepo.lastLog.Model)
  70. require.Equal(t, pricier, usageRepo.lastLog.RequestedModel)
  71. require.NotNil(t, usageRepo.lastLog.UpstreamResponseModel)
  72. require.Equal(t, cheaper, *usageRepo.lastLog.UpstreamResponseModel)
  73. require.NotNil(t, usageRepo.lastLog.UpstreamModelMismatch)
  74. require.True(t, *usageRepo.lastLog.UpstreamModelMismatch)
  75. }
  76. func TestGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *testing.T) {
  77. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  78. userRepo := &openAIRecordUsageUserRepoStub{}
  79. svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
  80. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  81. cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
  82. err := svc.RecordUsage(context.Background(), &RecordUsageInput{
  83. Result: &ForwardResult{
  84. RequestID: "gateway_response_model_forged_upgrade",
  85. Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50},
  86. Model: cheaper,
  87. UpstreamResponseModel: pricier, // forged/upgraded declaration must not raise cost
  88. Duration: time.Second,
  89. },
  90. APIKey: &APIKey{ID: 501, Quota: 100},
  91. User: &User{ID: 601},
  92. Account: &Account{ID: 701},
  93. ChannelUsageFields: ChannelUsageFields{
  94. ChannelID: 9,
  95. OriginalModel: cheaper,
  96. ChannelMappedModel: cheaper,
  97. BillingModelSource: BillingModelSourceResponse,
  98. },
  99. })
  100. require.NoError(t, err)
  101. require.NotNil(t, usageRepo.lastLog)
  102. require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  103. require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12)
  104. }
  105. func TestGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) {
  106. tests := []struct {
  107. name string
  108. responseModel func(cheaper string) string
  109. conflict bool
  110. source string
  111. }{
  112. {
  113. name: "in_stream_conflict_falls_back_to_baseline",
  114. responseModel: func(cheaper string) string { return cheaper },
  115. conflict: true,
  116. source: BillingModelSourceResponse,
  117. },
  118. {
  119. name: "empty_response_model_falls_back_to_baseline",
  120. responseModel: func(string) string { return "" },
  121. source: BillingModelSourceResponse,
  122. },
  123. {
  124. name: "unpriced_response_model_falls_back_to_baseline",
  125. responseModel: func(string) string { return "zz-unpriced-response-model" },
  126. source: BillingModelSourceResponse,
  127. },
  128. {
  129. name: "default_channel_mapped_mode_ignores_response_model",
  130. responseModel: func(cheaper string) string { return cheaper },
  131. source: BillingModelSourceChannelMapped,
  132. },
  133. }
  134. for _, tt := range tests {
  135. t.Run(tt.name, func(t *testing.T) {
  136. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  137. userRepo := &openAIRecordUsageUserRepoStub{}
  138. svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
  139. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  140. cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
  141. err := svc.RecordUsage(context.Background(), &RecordUsageInput{
  142. Result: &ForwardResult{
  143. RequestID: "gateway_response_model_fallback_" + tt.name,
  144. Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50},
  145. Model: pricier,
  146. UpstreamResponseModel: tt.responseModel(cheaper),
  147. UpstreamResponseModelConflict: tt.conflict,
  148. Duration: time.Second,
  149. },
  150. APIKey: &APIKey{ID: 501, Quota: 100},
  151. User: &User{ID: 601},
  152. Account: &Account{ID: 701},
  153. ChannelUsageFields: ChannelUsageFields{
  154. ChannelID: 9,
  155. OriginalModel: pricier,
  156. ChannelMappedModel: pricier,
  157. BillingModelSource: tt.source,
  158. },
  159. })
  160. require.NoError(t, err)
  161. require.NotNil(t, usageRepo.lastLog)
  162. require.InDelta(t, pricierCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  163. require.InDelta(t, pricierCost.ActualCost, userRepo.lastAmount, 1e-12)
  164. })
  165. }
  166. }
  167. // --- OpenAI gateway (OpenAIGatewayService.RecordUsage) ---
  168. func TestOpenAIGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *testing.T) {
  169. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  170. userRepo := &openAIRecordUsageUserRepoStub{}
  171. svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
  172. tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
  173. cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
  174. err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  175. Result: &OpenAIForwardResult{
  176. RequestID: "openai_response_model_downgrade",
  177. Model: pricier,
  178. UpstreamModel: pricier,
  179. UpstreamResponseModel: cheaper,
  180. Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
  181. Duration: time.Second,
  182. },
  183. APIKey: &APIKey{ID: 10},
  184. User: &User{ID: 20},
  185. Account: &Account{ID: 30},
  186. ChannelUsageFields: ChannelUsageFields{
  187. ChannelID: 9,
  188. OriginalModel: pricier,
  189. ChannelMappedModel: pricier,
  190. BillingModelSource: BillingModelSourceResponse,
  191. },
  192. })
  193. require.NoError(t, err)
  194. require.NotNil(t, usageRepo.lastLog)
  195. require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  196. require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12)
  197. require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero")
  198. // 审计链完整保留。
  199. require.Equal(t, pricier, usageRepo.lastLog.Model)
  200. require.NotNil(t, usageRepo.lastLog.UpstreamResponseModel)
  201. require.Equal(t, cheaper, *usageRepo.lastLog.UpstreamResponseModel)
  202. require.NotNil(t, usageRepo.lastLog.UpstreamModelMismatch)
  203. require.True(t, *usageRepo.lastLog.UpstreamModelMismatch)
  204. }
  205. func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *testing.T) {
  206. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  207. userRepo := &openAIRecordUsageUserRepoStub{}
  208. svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
  209. tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
  210. cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
  211. err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  212. Result: &OpenAIForwardResult{
  213. RequestID: "openai_response_model_forged_upgrade",
  214. Model: cheaper,
  215. UpstreamModel: cheaper,
  216. UpstreamResponseModel: pricier,
  217. Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
  218. Duration: time.Second,
  219. },
  220. APIKey: &APIKey{ID: 10},
  221. User: &User{ID: 20},
  222. Account: &Account{ID: 30},
  223. ChannelUsageFields: ChannelUsageFields{
  224. ChannelID: 9,
  225. OriginalModel: cheaper,
  226. ChannelMappedModel: cheaper,
  227. BillingModelSource: BillingModelSourceResponse,
  228. },
  229. })
  230. require.NoError(t, err)
  231. require.NotNil(t, usageRepo.lastLog)
  232. require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  233. require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12)
  234. }
  235. func TestOpenAIGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) {
  236. tests := []struct {
  237. name string
  238. responseModel func(cheaper string) string
  239. conflict bool
  240. source string
  241. }{
  242. {
  243. name: "in_stream_conflict_falls_back_to_baseline",
  244. responseModel: func(cheaper string) string { return cheaper },
  245. conflict: true,
  246. source: BillingModelSourceResponse,
  247. },
  248. {
  249. name: "empty_response_model_falls_back_to_baseline",
  250. responseModel: func(string) string { return "" },
  251. source: BillingModelSourceResponse,
  252. },
  253. {
  254. name: "unpriced_response_model_falls_back_to_baseline",
  255. responseModel: func(string) string { return "zz-unpriced-response-model" },
  256. source: BillingModelSourceResponse,
  257. },
  258. {
  259. name: "default_channel_mapped_mode_ignores_response_model",
  260. responseModel: func(cheaper string) string { return cheaper },
  261. source: BillingModelSourceChannelMapped,
  262. },
  263. }
  264. for _, tt := range tests {
  265. t.Run(tt.name, func(t *testing.T) {
  266. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  267. userRepo := &openAIRecordUsageUserRepoStub{}
  268. svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
  269. tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
  270. cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
  271. err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  272. Result: &OpenAIForwardResult{
  273. RequestID: "openai_response_model_fallback_" + tt.name,
  274. Model: pricier,
  275. UpstreamModel: pricier,
  276. UpstreamResponseModel: tt.responseModel(cheaper),
  277. UpstreamResponseModelConflict: tt.conflict,
  278. Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
  279. Duration: time.Second,
  280. },
  281. APIKey: &APIKey{ID: 10},
  282. User: &User{ID: 20},
  283. Account: &Account{ID: 30},
  284. ChannelUsageFields: ChannelUsageFields{
  285. ChannelID: 9,
  286. OriginalModel: pricier,
  287. ChannelMappedModel: pricier,
  288. BillingModelSource: tt.source,
  289. },
  290. })
  291. require.NoError(t, err)
  292. require.NotNil(t, usageRepo.lastLog)
  293. require.InDelta(t, pricierCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  294. require.InDelta(t, pricierCost.ActualCost, userRepo.lastAmount, 1e-12)
  295. })
  296. }
  297. }
  298. // --- 准入规则本身 ---
  299. func TestResponseModelBillingDeclaration(t *testing.T) {
  300. t.Parallel()
  301. tests := []struct {
  302. name string
  303. source string
  304. model string
  305. conflict bool
  306. mediaBilled bool
  307. want string
  308. }{
  309. {name: "opted_in_and_clean", source: BillingModelSourceResponse, model: " claude-sonnet-4 ", want: "claude-sonnet-4"},
  310. {name: "other_source_never_looks_at_response", source: BillingModelSourceChannelMapped, model: "claude-sonnet-4"},
  311. {name: "empty_source_never_looks_at_response", source: "", model: "claude-sonnet-4"},
  312. {name: "upstream_source_never_looks_at_response", source: BillingModelSourceUpstream, model: "claude-sonnet-4"},
  313. {name: "in_stream_conflict_rejected", source: BillingModelSourceResponse, model: "claude-sonnet-4", conflict: true},
  314. {name: "media_billed_request_rejected", source: BillingModelSourceResponse, model: "claude-sonnet-4", mediaBilled: true},
  315. {name: "blank_declaration_rejected", source: BillingModelSourceResponse, model: " "},
  316. }
  317. for _, tt := range tests {
  318. t.Run(tt.name, func(t *testing.T) {
  319. t.Parallel()
  320. require.Equal(t, tt.want, responseModelBillingDeclaration(tt.source, tt.model, tt.conflict, tt.mediaBilled))
  321. })
  322. }
  323. }
  324. // 上游自报的模型名是外部输入。GetModelPricing 的系列兜底会给任意含 "haiku" 的名字
  325. // 返回最便宜的系列价,因此计费准入必须走"确定性识别",否则上游随手编一个名字就能
  326. // 把账单压到地板价。本用例把这个差异钉死。
  327. func TestBillingServiceHasIdentifiedTokenPricing_RejectsFamilyGuesses(t *testing.T) {
  328. t.Parallel()
  329. billing := newGatewayRecordUsageServiceForTest(
  330. &openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{},
  331. ).billingService
  332. require.True(t, billing.HasIdentifiedTokenPricing("claude-sonnet-4"))
  333. require.True(t, billing.HasIdentifiedTokenPricing(" CLAUDE-SONNET-4 "), "识别应当忽略大小写与空白")
  334. require.True(t, billing.HasIdentifiedTokenPricing("gpt-5.4-nano"))
  335. const forged = "totally-made-up-haiku-v9"
  336. if _, err := billing.GetModelPricing(forged); err == nil {
  337. // 这正是本函数存在的理由:宽松查价对编造的名字也会成功。
  338. require.False(t, billing.HasIdentifiedTokenPricing(forged),
  339. "family-guessed pricing must not qualify a model as a billing basis")
  340. }
  341. require.False(t, billing.HasIdentifiedTokenPricing(""))
  342. require.False(t, billing.HasIdentifiedTokenPricing("zz-unpriced-response-model"))
  343. // Versioned media ids may inherit a text card via GetModelPricing; the
  344. // identified-token gate must still reject them so response-model billing
  345. // cannot adopt grok-4.5 rates for image/audio/video ids.
  346. require.False(t, billing.HasIdentifiedTokenPricing("grok-2-image-1212"))
  347. require.False(t, billing.HasIdentifiedTokenPricing("grok-2-audio"))
  348. require.False(t, billing.HasIdentifiedTokenPricing("grok-5-video"))
  349. }
  350. func TestGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) {
  351. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  352. userRepo := &openAIRecordUsageUserRepoStub{}
  353. svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
  354. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  355. const forged = "totally-made-up-haiku-v9"
  356. baselineCost, err := svc.billingService.CalculateCost(anthropicPriceyFixtureModel, tokens, 1.1)
  357. require.NoError(t, err)
  358. // 前提:这个编造的名字确实能被宽松查价算出更低的费用——正是必须被拒绝的那条路径。
  359. forgedCost, err := svc.billingService.CalculateCost(forged, tokens, 1.1)
  360. require.NoError(t, err)
  361. require.Less(t, forgedCost.TotalCost, baselineCost.TotalCost)
  362. err = svc.RecordUsage(context.Background(), &RecordUsageInput{
  363. Result: &ForwardResult{
  364. RequestID: "gateway_response_model_forged_family_name",
  365. Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50},
  366. Model: anthropicPriceyFixtureModel,
  367. UpstreamResponseModel: forged,
  368. Duration: time.Second,
  369. },
  370. APIKey: &APIKey{ID: 501, Quota: 100},
  371. User: &User{ID: 601},
  372. Account: &Account{ID: 701},
  373. ChannelUsageFields: ChannelUsageFields{
  374. ChannelID: 9,
  375. OriginalModel: anthropicPriceyFixtureModel,
  376. ChannelMappedModel: anthropicPriceyFixtureModel,
  377. BillingModelSource: BillingModelSourceResponse,
  378. },
  379. })
  380. require.NoError(t, err)
  381. require.NotNil(t, usageRepo.lastLog)
  382. require.InDelta(t, baselineCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  383. require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12)
  384. }
  385. func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) {
  386. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  387. userRepo := &openAIRecordUsageUserRepoStub{}
  388. svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
  389. tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
  390. const forged = "totally-made-up-haiku-v9"
  391. baselineCost, err := svc.billingService.CalculateCost(openAIPriceyFixtureModel, tokens, 1.1)
  392. require.NoError(t, err)
  393. forgedCost, err := svc.billingService.CalculateCost(forged, tokens, 1.1)
  394. require.NoError(t, err)
  395. require.Less(t, forgedCost.TotalCost, baselineCost.TotalCost)
  396. err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  397. Result: &OpenAIForwardResult{
  398. RequestID: "openai_response_model_forged_family_name",
  399. Model: openAIPriceyFixtureModel,
  400. UpstreamModel: openAIPriceyFixtureModel,
  401. UpstreamResponseModel: forged,
  402. Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
  403. Duration: time.Second,
  404. },
  405. APIKey: &APIKey{ID: 10},
  406. User: &User{ID: 20},
  407. Account: &Account{ID: 30},
  408. ChannelUsageFields: ChannelUsageFields{
  409. ChannelID: 9,
  410. OriginalModel: openAIPriceyFixtureModel,
  411. ChannelMappedModel: openAIPriceyFixtureModel,
  412. BillingModelSource: BillingModelSourceResponse,
  413. },
  414. })
  415. require.NoError(t, err)
  416. require.NotNil(t, usageRepo.lastLog)
  417. require.InDelta(t, baselineCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
  418. require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12)
  419. }
  420. // --- 成本准入的三条不变式 ---
  421. func TestResponseModelBillingAdoptable(t *testing.T) {
  422. t.Parallel()
  423. cost := func(total float64) *CostBreakdown {
  424. return &CostBreakdown{TotalCost: total, ActualCost: total}
  425. }
  426. tests := []struct {
  427. name string
  428. baseline *CostBreakdown
  429. response *CostBreakdown
  430. baselineChannelPriced bool
  431. responseChannelPriced bool
  432. want bool
  433. }{
  434. // 1. 不得更贵
  435. {name: "cheaper_adopted", baseline: cost(1), response: cost(0.5), want: true},
  436. {name: "equal_adopted", baseline: cost(1), response: cost(1), want: true},
  437. {name: "float_noise_within_epsilon_adopted", baseline: cost(1), response: cost(1 + 1e-13), want: true},
  438. {name: "pricier_rejected", baseline: cost(1), response: cost(1.0001)},
  439. // 2. 不得把一笔本应计费的请求归零(价格表里有显式写 0 的条目,能通过确定性识别)
  440. {name: "zeroing_a_billable_request_rejected", baseline: cost(1), response: cost(0)},
  441. {name: "negative_cost_rejected_as_zeroing", baseline: cost(1), response: cost(-1)},
  442. {name: "already_zero_baseline_unaffected", baseline: cost(0), response: cost(0), want: true},
  443. // 3. 不得从渠道定价跨到全局价格表(否则渠道加价被带日期的自报模型名绕过)
  444. {name: "channel_priced_baseline_to_global_rejected", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true},
  445. {name: "channel_priced_on_both_sides_adopted", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true, responseChannelPriced: true, want: true},
  446. {name: "global_baseline_to_channel_priced_adopted", baseline: cost(1), response: cost(0.5), responseChannelPriced: true, want: true},
  447. {name: "nil_baseline_rejected", response: cost(0.5)},
  448. {name: "nil_response_rejected", baseline: cost(1)},
  449. }
  450. for _, tt := range tests {
  451. t.Run(tt.name, func(t *testing.T) {
  452. t.Parallel()
  453. require.Equal(t, tt.want, responseModelBillingAdoptable(
  454. tt.baseline, tt.response, tt.baselineChannelPriced, tt.responseChannelPriced,
  455. ))
  456. })
  457. }
  458. }
  459. // --- 按次/按量计费请求一律不采纳(门的调用点接线) ---
  460. //
  461. // 搜索附加费是叠加在 token 成本之上的,所以"采纳与否"会体现在最终金额上,本用例因此
  462. // 能真正区分两条分支。语音(AudioUsage)走的是与模型无关的按量单价,采纳与否金额相同,
  463. // 无法用金额断言区分,故只由 TestResponseModelBillingDeclaration 覆盖门本身。
  464. func TestGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) {
  465. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  466. userRepo := &openAIRecordUsageUserRepoStub{}
  467. svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
  468. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  469. cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
  470. const searchCalls = 2
  471. searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1)
  472. require.NotNil(t, searchCost)
  473. require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支")
  474. err := svc.RecordUsage(context.Background(), &RecordUsageInput{
  475. Result: &ForwardResult{
  476. RequestID: "gateway_response_model_search_surcharge",
  477. Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50},
  478. Model: pricier,
  479. UpstreamResponseModel: cheaper,
  480. SearchCount: searchCalls,
  481. Duration: time.Second,
  482. },
  483. APIKey: &APIKey{ID: 501, Quota: 100},
  484. User: &User{ID: 601},
  485. Account: &Account{ID: 701},
  486. ChannelUsageFields: ChannelUsageFields{
  487. ChannelID: 9,
  488. OriginalModel: pricier,
  489. ChannelMappedModel: pricier,
  490. BillingModelSource: BillingModelSourceResponse,
  491. },
  492. })
  493. require.NoError(t, err)
  494. require.NotNil(t, usageRepo.lastLog)
  495. want := pricierCost.ActualCost + searchCost.ActualCost
  496. require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12)
  497. require.InDelta(t, want, userRepo.lastAmount, 1e-12)
  498. }
  499. func TestOpenAIGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) {
  500. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  501. userRepo := &openAIRecordUsageUserRepoStub{}
  502. svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
  503. tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
  504. cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
  505. const searchCalls = 3
  506. searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1)
  507. require.NotNil(t, searchCost)
  508. require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支")
  509. err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  510. Result: &OpenAIForwardResult{
  511. RequestID: "openai_response_model_search_surcharge",
  512. Model: pricier,
  513. UpstreamModel: pricier,
  514. UpstreamResponseModel: cheaper,
  515. SearchCount: searchCalls,
  516. Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
  517. Duration: time.Second,
  518. },
  519. APIKey: &APIKey{ID: 10},
  520. User: &User{ID: 20},
  521. Account: &Account{ID: 30},
  522. ChannelUsageFields: ChannelUsageFields{
  523. ChannelID: 9,
  524. OriginalModel: pricier,
  525. ChannelMappedModel: pricier,
  526. BillingModelSource: BillingModelSourceResponse,
  527. },
  528. })
  529. require.NoError(t, err)
  530. require.NotNil(t, usageRepo.lastLog)
  531. want := pricierCost.ActualCost + searchCost.ActualCost
  532. require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12)
  533. require.InDelta(t, want, userRepo.lastAmount, 1e-12)
  534. }
  535. // --- 渠道配置透传 ---
  536. func TestToUsageFields_ResponseModelSourcePassesThrough(t *testing.T) {
  537. r := ChannelMappingResult{
  538. MappedModel: "claude-fable-5",
  539. ChannelID: 4,
  540. Mapped: false,
  541. BillingModelSource: BillingModelSourceResponse,
  542. }
  543. fields := r.ToUsageFields("claude-fable-5", "claude-fable-5")
  544. require.Equal(t, int64(4), fields.ChannelID)
  545. require.Equal(t, BillingModelSourceResponse, fields.BillingModelSource)
  546. }