openai_fast_service_tier_test.go 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. "github.com/tidwall/gjson"
  16. )
  17. // ---------------------------------------------------------------------------
  18. // 请求侧:service_tier 校验(fast/priority 等价、非法值拒绝、省略保持现状)
  19. // ---------------------------------------------------------------------------
  20. func TestValidateOpenAIServiceTierField(t *testing.T) {
  21. t.Parallel()
  22. t.Run("fast normalizes to priority", func(t *testing.T) {
  23. norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"fast"}`))
  24. require.NoError(t, err)
  25. require.Equal(t, "priority", norm)
  26. })
  27. t.Run("priority passes through", func(t *testing.T) {
  28. norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"priority"}`))
  29. require.NoError(t, err)
  30. require.Equal(t, "priority", norm)
  31. })
  32. t.Run("case and whitespace insensitive", func(t *testing.T) {
  33. norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":" FAST "}`))
  34. require.NoError(t, err)
  35. require.Equal(t, "priority", norm)
  36. })
  37. t.Run("official tiers pass through", func(t *testing.T) {
  38. for _, tier := range []string{"flex", "auto", "default", "scale", "ultrafast"} {
  39. norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`))
  40. require.NoError(t, err, "tier %q must be accepted", tier)
  41. require.Equal(t, tier, norm)
  42. }
  43. })
  44. t.Run("invalid tier rejected", func(t *testing.T) {
  45. _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"turbo"}`))
  46. require.Error(t, err)
  47. var invalid *ErrInvalidOpenAIServiceTier
  48. require.True(t, errors.As(err, &invalid))
  49. require.Equal(t, "turbo", invalid.Value)
  50. require.Contains(t, err.Error(), "invalid service_tier")
  51. require.Contains(t, err.Error(), "fast", "allowed-value hint must mention fast")
  52. })
  53. t.Run("omitted field stays valid", func(t *testing.T) {
  54. norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","input":"hi"}`))
  55. require.NoError(t, err)
  56. require.Empty(t, norm)
  57. })
  58. t.Run("null value keeps omission semantics", func(t *testing.T) {
  59. norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":null}`))
  60. require.NoError(t, err)
  61. require.Empty(t, norm)
  62. })
  63. t.Run("explicit empty string rejected as invalid enum value", func(t *testing.T) {
  64. _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":""}`))
  65. require.Error(t, err)
  66. var invalid *ErrInvalidOpenAIServiceTier
  67. require.True(t, errors.As(err, &invalid))
  68. })
  69. t.Run("non-string service_tier rejected", func(t *testing.T) {
  70. // service_tier 必须为字符串;数字/布尔/对象/数组等类型同样按非法值拒绝。
  71. for _, raw := range []string{
  72. `{"model":"gpt-5.5","service_tier":123}`,
  73. `{"model":"gpt-5.5","service_tier":true}`,
  74. `{"model":"gpt-5.5","service_tier":{}}`,
  75. `{"model":"gpt-5.5","service_tier":["priority"]}`,
  76. } {
  77. _, err := ValidateOpenAIServiceTierField([]byte(raw))
  78. require.Error(t, err, "raw=%s must be rejected", raw)
  79. var invalid *ErrInvalidOpenAIServiceTier
  80. require.True(t, errors.As(err, &invalid), "raw=%s", raw)
  81. require.Equal(t, "<non-string>", invalid.Value, "raw=%s", raw)
  82. require.Contains(t, err.Error(), "invalid service_tier")
  83. }
  84. })
  85. t.Run("oversized unknown string is truncated", func(t *testing.T) {
  86. blob := strings.Repeat("z", 4096)
  87. _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + blob + `"}`))
  88. require.Error(t, err)
  89. require.Contains(t, err.Error(), "invalid service_tier")
  90. require.NotContains(t, err.Error(), blob)
  91. require.Less(t, len(err.Error()), 200)
  92. var invalid *ErrInvalidOpenAIServiceTier
  93. require.True(t, errors.As(err, &invalid))
  94. require.Equal(t, strings.Repeat("z", 64)+"...", invalid.Value)
  95. })
  96. t.Run("non-string large object/array is not echoed", func(t *testing.T) {
  97. blob := strings.Repeat("x", 4096)
  98. payloads := []string{
  99. `{"model":"gpt-5.5","service_tier":{"blob":"` + blob + `"}}`,
  100. `{"model":"gpt-5.5","service_tier":["` + blob + `"]}`,
  101. }
  102. for _, raw := range payloads {
  103. _, err := ValidateOpenAIServiceTierField([]byte(raw))
  104. require.Error(t, err)
  105. require.Contains(t, err.Error(), "invalid service_tier")
  106. require.NotContains(t, err.Error(), blob)
  107. require.Less(t, len(err.Error()), 200)
  108. var invalid *ErrInvalidOpenAIServiceTier
  109. require.True(t, errors.As(err, &invalid))
  110. require.Equal(t, "<non-string>", invalid.Value)
  111. }
  112. })
  113. }
  114. // ---------------------------------------------------------------------------
  115. // 计费:gpt-5.6 系列 / gpt-5.4 按标准价 2x,gpt-5.5 按标准价 2.5x
  116. // ---------------------------------------------------------------------------
  117. func TestApplyModelSpecificPricingPolicy_EnforcesOpenAIFastRatios(t *testing.T) {
  118. t.Parallel()
  119. svc := &BillingService{}
  120. t.Run("gpt-5.5 catalog 2x priority is corrected to 2.5x", func(t *testing.T) {
  121. // 模拟本地 LiteLLM 目录仍携带官方旧口径(gpt-5.5 priority = 2x)。
  122. catalog := &ModelPricing{
  123. InputPricePerToken: 5e-6,
  124. InputPricePerTokenPriority: 10e-6,
  125. OutputPricePerToken: 30e-6,
  126. OutputPricePerTokenPriority: 60e-6,
  127. CacheReadPricePerToken: 0.5e-6,
  128. CacheReadPricePerTokenPriority: 1e-6,
  129. }
  130. got := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog)
  131. require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12)
  132. require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12)
  133. require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12)
  134. // 标准价不被改动。
  135. require.InDelta(t, 5e-6, got.InputPricePerToken, 1e-12)
  136. // 原始指针不被污染。
  137. require.InDelta(t, 10e-6, catalog.InputPricePerTokenPriority, 1e-12)
  138. })
  139. t.Run("gpt-5.4 keeps 2x", func(t *testing.T) {
  140. got := svc.applyModelSpecificPricingPolicy("gpt-5.4", &ModelPricing{
  141. InputPricePerToken: 2.5e-6,
  142. InputPricePerTokenPriority: 5e-6,
  143. OutputPricePerToken: 15e-6,
  144. OutputPricePerTokenPriority: 30e-6,
  145. })
  146. require.InDelta(t, 5e-6, got.InputPricePerTokenPriority, 1e-12)
  147. require.InDelta(t, 30e-6, got.OutputPricePerTokenPriority, 1e-12)
  148. })
  149. t.Run("gpt-5.6 family keeps 2x", func(t *testing.T) {
  150. for _, model := range []string{"gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-max", "gpt-5.6-sol-preview"} {
  151. got := svc.applyModelSpecificPricingPolicy(model, &ModelPricing{
  152. InputPricePerToken: 5e-6,
  153. InputPricePerTokenPriority: 10e-6,
  154. OutputPricePerToken: 30e-6,
  155. OutputPricePerTokenPriority: 60e-6,
  156. CacheReadPricePerToken: 0.5e-6,
  157. CacheReadPricePerTokenPriority: 1e-6,
  158. })
  159. require.InDelta(t, 10e-6, got.InputPricePerTokenPriority, 1e-12, "model %s", model)
  160. require.InDelta(t, 60e-6, got.OutputPricePerTokenPriority, 1e-12, "model %s", model)
  161. }
  162. })
  163. t.Run("missing priority prices are backfilled from standard", func(t *testing.T) {
  164. got := svc.applyModelSpecificPricingPolicy("gpt-5.5", &ModelPricing{
  165. InputPricePerToken: 5e-6,
  166. OutputPricePerToken: 30e-6,
  167. CacheReadPricePerToken: 0.5e-6,
  168. CacheCreationPricePerToken: 5e-6,
  169. })
  170. require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12)
  171. require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12)
  172. require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12)
  173. require.InDelta(t, 12.5e-6, got.CacheCreationPricePerTokenPriority, 1e-12)
  174. })
  175. t.Run("gpt-5.5-pro has no mandated fast tier", func(t *testing.T) {
  176. got := svc.applyModelSpecificPricingPolicy("gpt-5.5-pro", &ModelPricing{
  177. InputPricePerToken: 30e-6,
  178. InputPricePerTokenPriority: 60e-6,
  179. OutputPricePerToken: 180e-6,
  180. })
  181. require.InDelta(t, 60e-6, got.InputPricePerTokenPriority, 1e-12)
  182. })
  183. t.Run("unrelated models untouched", func(t *testing.T) {
  184. got := svc.applyModelSpecificPricingPolicy("claude-opus-5", &ModelPricing{InputPricePerToken: 1, OutputPricePerToken: 2})
  185. require.InDelta(t, 1, got.InputPricePerToken, 1e-12)
  186. require.Zero(t, got.InputPricePerTokenPriority)
  187. })
  188. }
  189. func TestOpenAIFastBillingMultiplier_2xAnd25x(t *testing.T) {
  190. t.Parallel()
  191. // 目录数据携带官方旧口径(gpt-5.5 priority=2x);修正后 fast 必须按 2.5x 计费。
  192. catalog := map[string]*LiteLLMModelPricing{
  193. "gpt-5.4": {
  194. InputCostPerToken: 2.5e-6,
  195. InputCostPerTokenPriority: 5e-6,
  196. OutputCostPerToken: 15e-6,
  197. OutputCostPerTokenPriority: 30e-6,
  198. CacheReadInputTokenCost: 0.25e-6,
  199. CacheReadInputTokenCostPriority: 0.5e-6,
  200. },
  201. "gpt-5.5": {
  202. InputCostPerToken: 5e-6,
  203. InputCostPerTokenPriority: 10e-6,
  204. OutputCostPerToken: 30e-6,
  205. OutputCostPerTokenPriority: 60e-6,
  206. CacheReadInputTokenCost: 0.5e-6,
  207. CacheReadInputTokenCostPriority: 1e-6,
  208. },
  209. "gpt-5.6-sol": {
  210. InputCostPerToken: 5e-6,
  211. InputCostPerTokenPriority: 10e-6,
  212. OutputCostPerToken: 30e-6,
  213. OutputCostPerTokenPriority: 60e-6,
  214. CacheReadInputTokenCost: 0.5e-6,
  215. CacheReadInputTokenCostPriority: 1e-6,
  216. },
  217. }
  218. billing := NewBillingService(&config.Config{}, &PricingService{pricingData: catalog})
  219. tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}
  220. standard := func(model string) *CostBreakdown {
  221. cost, err := billing.CalculateCost(model, tokens, 1)
  222. require.NoError(t, err)
  223. return cost
  224. }
  225. fast := func(model, tier string) *CostBreakdown {
  226. cost, err := billing.CalculateCostWithServiceTier(model, tokens, 1, tier)
  227. require.NoError(t, err)
  228. return cost
  229. }
  230. tests := []struct {
  231. model string
  232. ratio float64
  233. }{
  234. {model: "gpt-5.4", ratio: 2.0},
  235. {model: "gpt-5.5", ratio: 2.5},
  236. {model: "gpt-5.6-sol", ratio: 2.0},
  237. {model: "gpt-5.6-terra", ratio: 2.0},
  238. {model: "gpt-5.6-luna", ratio: 2.0},
  239. }
  240. for _, tt := range tests {
  241. t.Run(tt.model+"/fast", func(t *testing.T) {
  242. base := standard(tt.model)
  243. fastCost := fast(tt.model, "fast")
  244. require.InDelta(t, base.TotalCost*tt.ratio, fastCost.TotalCost, 1e-9,
  245. "fast total must be %.1fx standard", tt.ratio)
  246. })
  247. t.Run(tt.model+"/priority_alias", func(t *testing.T) {
  248. fastCost := fast(tt.model, "fast")
  249. priorityCost := fast(tt.model, "priority")
  250. require.InDelta(t, fastCost.TotalCost, priorityCost.TotalCost, 1e-12,
  251. "client alias fast must bill identically to priority")
  252. require.InDelta(t, standard(tt.model).TotalCost*tt.ratio, priorityCost.TotalCost, 1e-9)
  253. })
  254. t.Run(tt.model+"/no_tier_unchanged", func(t *testing.T) {
  255. base := standard(tt.model)
  256. noTier, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "")
  257. require.NoError(t, err)
  258. require.InDelta(t, base.TotalCost, noTier.TotalCost, 1e-12)
  259. })
  260. t.Run(tt.model+"/default_equals_standard", func(t *testing.T) {
  261. base := standard(tt.model)
  262. defaultCost, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "default")
  263. require.NoError(t, err)
  264. require.InDelta(t, base.TotalCost, defaultCost.TotalCost, 1e-12)
  265. require.InDelta(t, base.InputCost, defaultCost.InputCost, 1e-12)
  266. require.InDelta(t, base.OutputCost, defaultCost.OutputCost, 1e-12)
  267. require.InDelta(t, base.CacheReadCost, defaultCost.CacheReadCost, 1e-12)
  268. })
  269. }
  270. }
  271. func TestOpenAIFastBilling_FastMultiplierOverridesEnforcedRatio(t *testing.T) {
  272. t.Parallel()
  273. svc := &BillingService{}
  274. catalog := &ModelPricing{
  275. InputPricePerToken: 5e-6,
  276. InputPricePerTokenPriority: 10e-6,
  277. OutputPricePerToken: 30e-6,
  278. OutputPricePerTokenPriority: 60e-6,
  279. CacheReadPricePerToken: 0.5e-6,
  280. CacheReadPricePerTokenPriority: 1e-6,
  281. }
  282. pricing := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog)
  283. require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12, "enforce must still write 2.5x priority prices")
  284. require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12)
  285. multiplier := 1.7
  286. pricing.FastMultiplier = &multiplier
  287. tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}
  288. standard := svc.computeTokenBreakdown(pricing, tokens, 1, "", false)
  289. fast := svc.computeTokenBreakdown(pricing, tokens, 1, "fast", false)
  290. priority := svc.computeTokenBreakdown(pricing, tokens, 1, "priority", false)
  291. require.InDelta(t, standard.TotalCost*1.7, fast.TotalCost, 1e-9)
  292. require.InDelta(t, fast.TotalCost, priority.TotalCost, 1e-12)
  293. withoutOverride := *pricing
  294. withoutOverride.FastMultiplier = nil
  295. enforced := svc.computeTokenBreakdown(&withoutOverride, tokens, 1, "fast", false)
  296. require.InDelta(t, standard.TotalCost*2.5, enforced.TotalCost, 1e-9,
  297. "without FastMultiplier the same enforced prices still bill 2.5x")
  298. }
  299. // ---------------------------------------------------------------------------
  300. // 上游 payload:fast 归一化为 priority 并确实到达上游
  301. // ---------------------------------------------------------------------------
  302. func TestForwardAsChatCompletions_ServiceTierFastNormalizedToPriorityUpstream(t *testing.T) {
  303. gin.SetMode(gin.TestMode)
  304. rec := httptest.NewRecorder()
  305. c, _ := gin.CreateTestContext(rec)
  306. body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`)
  307. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  308. c.Request.Header.Set("Content-Type", "application/json")
  309. upstream := &httpUpstreamRecorder{resp: &http.Response{
  310. StatusCode: http.StatusBadRequest,
  311. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st"}},
  312. Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)),
  313. }}
  314. svc := &OpenAIGatewayService{
  315. cfg: &config.Config{},
  316. httpUpstream: upstream,
  317. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  318. }
  319. account := &Account{
  320. ID: 21,
  321. Name: "openai-compatible",
  322. Platform: PlatformOpenAI,
  323. Type: AccountTypeAPIKey,
  324. Concurrency: 1,
  325. Credentials: map[string]any{"api_key": "sk-compatible"},
  326. Extra: map[string]any{"openai_responses_supported": true},
  327. }
  328. _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
  329. require.Error(t, err) // upstream 400 → 错误返回,但请求体已被 recorder 捕获
  330. require.NotNil(t, upstream.lastBody)
  331. require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(),
  332. "client alias fast must reach upstream as priority")
  333. }
  334. func TestForwardAsChatCompletions_ServiceTierPriorityPreservedUpstream(t *testing.T) {
  335. gin.SetMode(gin.TestMode)
  336. rec := httptest.NewRecorder()
  337. c, _ := gin.CreateTestContext(rec)
  338. body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"priority","stream":false}`)
  339. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  340. c.Request.Header.Set("Content-Type", "application/json")
  341. upstream := &httpUpstreamRecorder{resp: &http.Response{
  342. StatusCode: http.StatusBadRequest,
  343. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st2"}},
  344. Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)),
  345. }}
  346. svc := &OpenAIGatewayService{
  347. cfg: &config.Config{},
  348. httpUpstream: upstream,
  349. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  350. }
  351. account := &Account{
  352. ID: 2,
  353. Name: "openai-compatible",
  354. Platform: PlatformOpenAI,
  355. Type: AccountTypeAPIKey,
  356. Concurrency: 1,
  357. Credentials: map[string]any{"api_key": "sk-compatible"},
  358. Extra: map[string]any{"openai_responses_supported": true},
  359. }
  360. _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
  361. require.Error(t, err)
  362. require.NotNil(t, upstream.lastBody)
  363. require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String())
  364. }
  365. func TestForward_ResponsesServiceTierFastNormalizedToPriorityUpstream(t *testing.T) {
  366. gin.SetMode(gin.TestMode)
  367. rec := httptest.NewRecorder()
  368. c, _ := gin.CreateTestContext(rec)
  369. body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`)
  370. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  371. c.Request.Header.Set("Content-Type", "application/json")
  372. upstream := &httpUpstreamRecorder{resp: &http.Response{
  373. StatusCode: http.StatusOK,
  374. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st"}},
  375. Body: io.NopCloser(strings.NewReader(
  376. `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
  377. )),
  378. }}
  379. svc := &OpenAIGatewayService{
  380. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  381. httpUpstream: upstream,
  382. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  383. }
  384. account := &Account{
  385. ID: 7,
  386. Name: "openai-apikey",
  387. Platform: PlatformOpenAI,
  388. Type: AccountTypeAPIKey,
  389. Concurrency: 1,
  390. Credentials: map[string]any{"api_key": "sk-test"},
  391. Extra: map[string]any{"openai_responses_supported": true},
  392. Status: StatusActive,
  393. Schedulable: true,
  394. }
  395. result, err := svc.Forward(context.Background(), c, account, body)
  396. require.NoError(t, err)
  397. require.NotNil(t, result)
  398. require.NotNil(t, upstream.lastBody)
  399. require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(),
  400. "client alias fast must reach the upstream as priority")
  401. // 计费上下文:result 携带归一化后的 tier。
  402. require.NotNil(t, result.ServiceTier)
  403. require.Equal(t, "priority", *result.ServiceTier)
  404. }
  405. func TestForward_ResponsesServiceTierOmittedStaysOmitted(t *testing.T) {
  406. gin.SetMode(gin.TestMode)
  407. rec := httptest.NewRecorder()
  408. c, _ := gin.CreateTestContext(rec)
  409. body := []byte(`{"model":"gpt-5.5","input":"hello","stream":false}`)
  410. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  411. c.Request.Header.Set("Content-Type", "application/json")
  412. upstream := &httpUpstreamRecorder{resp: &http.Response{
  413. StatusCode: http.StatusOK,
  414. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st2"}},
  415. Body: io.NopCloser(strings.NewReader(
  416. `{"id":"resp_2","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
  417. )),
  418. }}
  419. svc := &OpenAIGatewayService{
  420. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  421. httpUpstream: upstream,
  422. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  423. }
  424. account := &Account{
  425. ID: 7,
  426. Name: "openai-apikey",
  427. Platform: PlatformOpenAI,
  428. Type: AccountTypeAPIKey,
  429. Concurrency: 1,
  430. Credentials: map[string]any{"api_key": "sk-test"},
  431. Extra: map[string]any{"openai_responses_supported": true},
  432. Status: StatusActive,
  433. Schedulable: true,
  434. }
  435. result, err := svc.Forward(context.Background(), c, account, body)
  436. require.NoError(t, err)
  437. require.NotNil(t, result)
  438. require.NotNil(t, upstream.lastBody)
  439. require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(),
  440. "omitted service_tier must stay omitted")
  441. require.Nil(t, result.ServiceTier)
  442. }
  443. // ---------------------------------------------------------------------------
  444. // 流式计费上下文:service_tier 需要从请求体传到 usage 计费
  445. // ---------------------------------------------------------------------------
  446. func TestForwardStreaming_ServiceTierPropagatedToResult(t *testing.T) {
  447. gin.SetMode(gin.TestMode)
  448. rec := httptest.NewRecorder()
  449. c, _ := gin.CreateTestContext(rec)
  450. body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`)
  451. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  452. c.Request.Header.Set("Content-Type", "application/json")
  453. streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" +
  454. "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" +
  455. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
  456. "data: [DONE]\n\n"
  457. upstream := &httpUpstreamRecorder{resp: &http.Response{
  458. StatusCode: http.StatusOK,
  459. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-stream-st"}},
  460. Body: io.NopCloser(strings.NewReader(streamPayload)),
  461. }}
  462. svc := &OpenAIGatewayService{
  463. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  464. httpUpstream: upstream,
  465. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  466. }
  467. account := &Account{
  468. ID: 7,
  469. Name: "openai-apikey",
  470. Platform: PlatformOpenAI,
  471. Type: AccountTypeAPIKey,
  472. Concurrency: 1,
  473. Credentials: map[string]any{"api_key": "sk-test"},
  474. Extra: map[string]any{"openai_responses_supported": true},
  475. Status: StatusActive,
  476. Schedulable: true,
  477. }
  478. result, err := svc.Forward(context.Background(), c, account, body)
  479. require.NoError(t, err)
  480. require.NotNil(t, result)
  481. require.NotNil(t, result.ServiceTier)
  482. require.Equal(t, "priority", *result.ServiceTier, "streaming billing context must carry the normalized tier")
  483. // /v1/responses 流是上游 SSE 原样透传:上游没回 service_tier 就不该出现;
  484. // 网关只在计费结果里携带请求侧 tier,不往下游流里注入。
  485. require.Contains(t, rec.Body.String(), `"delta":"hi"`, "streamed content must reach the client")
  486. require.NotContains(t, rec.Body.String(), `"service_tier"`, "upstream did not return service_tier, client stream must stay untouched")
  487. }
  488. // ---------------------------------------------------------------------------
  489. // 转发阶段分别保留最终出站 tier 与上游回显 tier
  490. // ---------------------------------------------------------------------------
  491. func TestForward_ResponsesKeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
  492. gin.SetMode(gin.TestMode)
  493. rec := httptest.NewRecorder()
  494. c, _ := gin.CreateTestContext(rec)
  495. body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`)
  496. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  497. c.Request.Header.Set("Content-Type", "application/json")
  498. // 上游回显 service_tier=default(例如请求实际被降级)。
  499. upstream := &httpUpstreamRecorder{resp: &http.Response{
  500. StatusCode: http.StatusOK,
  501. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-echo"}},
  502. Body: io.NopCloser(strings.NewReader(
  503. `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","service_tier":"default","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
  504. )),
  505. }}
  506. svc := &OpenAIGatewayService{
  507. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  508. httpUpstream: upstream,
  509. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  510. }
  511. account := &Account{
  512. ID: 7,
  513. Name: "openai-apikey",
  514. Platform: PlatformOpenAI,
  515. Type: AccountTypeAPIKey,
  516. Concurrency: 1,
  517. Credentials: map[string]any{"api_key": "sk-test"},
  518. Extra: map[string]any{"openai_responses_supported": true},
  519. Status: StatusActive,
  520. Schedulable: true,
  521. }
  522. result, err := svc.Forward(context.Background(), c, account, body)
  523. require.NoError(t, err)
  524. require.NotNil(t, result)
  525. require.NotNil(t, result.ServiceTier)
  526. require.Equal(t, "priority", *result.ServiceTier)
  527. require.Equal(t, "default", result.UpstreamResponseServiceTier)
  528. // 非流式响应原样透传:客户端同样看到 default。
  529. require.Contains(t, rec.Body.String(), `"service_tier":"default"`)
  530. require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`)
  531. }
  532. func TestForwardStreaming_KeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
  533. gin.SetMode(gin.TestMode)
  534. rec := httptest.NewRecorder()
  535. c, _ := gin.CreateTestContext(rec)
  536. body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`)
  537. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  538. c.Request.Header.Set("Content-Type", "application/json")
  539. streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" +
  540. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
  541. "data: [DONE]\n\n"
  542. upstream := &httpUpstreamRecorder{resp: &http.Response{
  543. StatusCode: http.StatusOK,
  544. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-echo-stream"}},
  545. Body: io.NopCloser(strings.NewReader(streamPayload)),
  546. }}
  547. svc := &OpenAIGatewayService{
  548. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  549. httpUpstream: upstream,
  550. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  551. }
  552. account := &Account{
  553. ID: 7,
  554. Name: "openai-apikey",
  555. Platform: PlatformOpenAI,
  556. Type: AccountTypeAPIKey,
  557. Concurrency: 1,
  558. Credentials: map[string]any{"api_key": "sk-test"},
  559. Extra: map[string]any{"openai_responses_supported": true},
  560. Status: StatusActive,
  561. Schedulable: true,
  562. }
  563. result, err := svc.Forward(context.Background(), c, account, body)
  564. require.NoError(t, err)
  565. require.NotNil(t, result)
  566. require.NotNil(t, result.ServiceTier)
  567. require.Equal(t, "priority", *result.ServiceTier)
  568. require.Equal(t, "default", result.UpstreamResponseServiceTier)
  569. // 流式原样透传:客户端在终止事件里看到 default。
  570. require.Contains(t, rec.Body.String(), `"service_tier":"default"`)
  571. }
  572. func TestForwardAsChatCompletions_KeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
  573. gin.SetMode(gin.TestMode)
  574. rec := httptest.NewRecorder()
  575. c, _ := gin.CreateTestContext(rec)
  576. body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`)
  577. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  578. c.Request.Header.Set("Content-Type", "application/json")
  579. streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" +
  580. "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" +
  581. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
  582. "data: [DONE]\n\n"
  583. upstream := &httpUpstreamRecorder{resp: &http.Response{
  584. StatusCode: http.StatusOK,
  585. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-chat-echo"}},
  586. Body: io.NopCloser(strings.NewReader(streamPayload)),
  587. }}
  588. svc := &OpenAIGatewayService{
  589. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  590. httpUpstream: upstream,
  591. settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
  592. }
  593. account := &Account{
  594. ID: 21,
  595. Name: "openai-compatible",
  596. Platform: PlatformOpenAI,
  597. Type: AccountTypeAPIKey,
  598. Concurrency: 1,
  599. Credentials: map[string]any{"api_key": "sk-compatible"},
  600. Extra: map[string]any{"openai_responses_supported": true},
  601. Status: StatusActive,
  602. Schedulable: true,
  603. }
  604. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
  605. require.NoError(t, err)
  606. require.NotNil(t, result)
  607. require.NotNil(t, result.ServiceTier)
  608. require.Equal(t, "priority", *result.ServiceTier)
  609. require.Equal(t, "default", result.UpstreamResponseServiceTier)
  610. // 缓冲转回 Chat Completions:客户端响应里如实回显 default。
  611. require.Contains(t, rec.Body.String(), `"service_tier":"default"`)
  612. require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`)
  613. }
  614. // ---------------------------------------------------------------------------
  615. // policy filter:删除 service_tier 后不得再按原请求 Fast 计费
  616. // ---------------------------------------------------------------------------
  617. func TestForward_ServiceTierFilteredByPolicyBillsStandard(t *testing.T) {
  618. gin.SetMode(gin.TestMode)
  619. rec := httptest.NewRecorder()
  620. c, _ := gin.CreateTestContext(rec)
  621. body := []byte(`{"model":"gpt-5.5","service_tier":"priority","input":"hello","stream":false}`)
  622. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  623. c.Request.Header.Set("Content-Type", "application/json")
  624. // 管理员配置 priority → filter:字段在出站前被删除。
  625. settings := &OpenAIFastPolicySettings{Rules: []OpenAIFastPolicyRule{{
  626. ServiceTier: OpenAIFastTierPriority,
  627. Action: BetaPolicyActionFilter,
  628. Scope: BetaPolicyScopeAll,
  629. }}}
  630. raw, err := json.Marshal(settings)
  631. require.NoError(t, err)
  632. repo := &openAIFastPolicyRepoStub{values: map[string]string{SettingKeyOpenAIFastPolicySettings: string(raw)}}
  633. upstream := &httpUpstreamRecorder{resp: &http.Response{
  634. StatusCode: http.StatusOK,
  635. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-filter"}},
  636. Body: io.NopCloser(strings.NewReader(
  637. `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
  638. )),
  639. }}
  640. svc := &OpenAIGatewayService{
  641. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  642. httpUpstream: upstream,
  643. settingService: NewSettingService(repo, &config.Config{}),
  644. }
  645. account := &Account{
  646. ID: 7,
  647. Name: "openai-apikey",
  648. Platform: PlatformOpenAI,
  649. Type: AccountTypeAPIKey,
  650. Concurrency: 1,
  651. Credentials: map[string]any{"api_key": "sk-test"},
  652. Extra: map[string]any{"openai_responses_supported": true},
  653. Status: StatusActive,
  654. Schedulable: true,
  655. }
  656. result, err := svc.Forward(context.Background(), c, account, body)
  657. require.NoError(t, err)
  658. require.NotNil(t, result)
  659. // 出站 body 已剥离 service_tier、上游也未回显 → 无 tier → 按标准价计费。
  660. require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(),
  661. "policy filter must strip service_tier from the outbound body")
  662. require.Nil(t, result.ServiceTier, "filtered request must not bill as fast")
  663. }
  664. // ---------------------------------------------------------------------------
  665. // 上游回显观察与解析器单测
  666. // ---------------------------------------------------------------------------
  667. func TestUpstreamResponseModelObserver_ObservesServiceTier(t *testing.T) {
  668. t.Parallel()
  669. observer := &upstreamResponseModelObserver{}
  670. // 上游约束:非终止且有类型的事件(response.created)回显的是请求档位而非
  671. // 实际处理档位,忽略。
  672. observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5","service_tier":"flex"}}`), "response.created")
  673. require.Empty(t, observer.ServiceTier())
  674. // terminal 声明(带 model 帧)优先。
  675. observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed")
  676. require.Equal(t, "default", observer.ServiceTier())
  677. // Chat Completions 顶层 service_tier 按 untyped payload 观察(无 type 字段)。
  678. ccObserver := &upstreamResponseModelObserver{}
  679. ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "")
  680. require.Equal(t, "priority", ccObserver.ServiceTier())
  681. // 无 model 的帧不触发 tier 观察(上游约束:tier 声明必带 model)。
  682. modelFree := &upstreamResponseModelObserver{}
  683. modelFree.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed")
  684. require.Empty(t, modelFree.ServiceTier())
  685. }
  686. func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) {
  687. t.Parallel()
  688. priority := func() *string { v := "priority"; return &v }()
  689. t.Run("upstream echo stays separate from outbound tier", func(t *testing.T) {
  690. gin.SetMode(gin.TestMode)
  691. c, _ := gin.CreateTestContext(nil)
  692. observer := beginUpstreamResponseModelObservation(c)
  693. observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed")
  694. got := resolvedOpenAIUpstreamServiceTier(c, priority)
  695. require.NotNil(t, got)
  696. require.Equal(t, "priority", *got)
  697. require.Equal(t, "default", observedUpstreamResponseServiceTier(c))
  698. })
  699. t.Run("no upstream echo falls back to outbound tier", func(t *testing.T) {
  700. gin.SetMode(gin.TestMode)
  701. c, _ := gin.CreateTestContext(nil)
  702. beginUpstreamResponseModelObservation(c)
  703. got := resolvedOpenAIUpstreamServiceTier(c, priority)
  704. require.NotNil(t, got)
  705. require.Equal(t, "priority", *got)
  706. })
  707. t.Run("observed tier never promotes an untiered request", func(t *testing.T) {
  708. gin.SetMode(gin.TestMode)
  709. c, _ := gin.CreateTestContext(nil)
  710. observer := beginUpstreamResponseModelObservation(c)
  711. observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"fast"}}`), "response.completed")
  712. got := resolvedOpenAIUpstreamServiceTier(c, nil)
  713. require.Nil(t, got)
  714. require.Equal(t, "priority", observedUpstreamResponseServiceTier(c))
  715. })
  716. t.Run("no observer keeps outbound tier", func(t *testing.T) {
  717. got := resolvedOpenAIUpstreamServiceTier(nil, priority)
  718. require.NotNil(t, got)
  719. require.Equal(t, "priority", *got)
  720. })
  721. t.Run("no observer and no outbound tier stays nil", func(t *testing.T) {
  722. require.Nil(t, resolvedOpenAIUpstreamServiceTier(nil, nil))
  723. })
  724. t.Run("local observer stays separate from outbound tier", func(t *testing.T) {
  725. observer := &upstreamResponseModelObserver{}
  726. observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed")
  727. got := resolvedOpenAIUpstreamServiceTierFromObserver(observer, priority)
  728. require.NotNil(t, got)
  729. require.Equal(t, "priority", *got)
  730. })
  731. t.Run("nil local observer falls back to outbound tier", func(t *testing.T) {
  732. got := resolvedOpenAIUpstreamServiceTierFromObserver(nil, priority)
  733. require.NotNil(t, got)
  734. require.Equal(t, "priority", *got)
  735. })
  736. }