| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- // ---------------------------------------------------------------------------
- // 请求侧:service_tier 校验(fast/priority 等价、非法值拒绝、省略保持现状)
- // ---------------------------------------------------------------------------
- func TestValidateOpenAIServiceTierField(t *testing.T) {
- t.Parallel()
- t.Run("fast normalizes to priority", func(t *testing.T) {
- norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"fast"}`))
- require.NoError(t, err)
- require.Equal(t, "priority", norm)
- })
- t.Run("priority passes through", func(t *testing.T) {
- norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"priority"}`))
- require.NoError(t, err)
- require.Equal(t, "priority", norm)
- })
- t.Run("case and whitespace insensitive", func(t *testing.T) {
- norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":" FAST "}`))
- require.NoError(t, err)
- require.Equal(t, "priority", norm)
- })
- t.Run("official tiers pass through", func(t *testing.T) {
- for _, tier := range []string{"flex", "auto", "default", "scale", "ultrafast"} {
- norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`))
- require.NoError(t, err, "tier %q must be accepted", tier)
- require.Equal(t, tier, norm)
- }
- })
- t.Run("invalid tier rejected", func(t *testing.T) {
- _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"turbo"}`))
- require.Error(t, err)
- var invalid *ErrInvalidOpenAIServiceTier
- require.True(t, errors.As(err, &invalid))
- require.Equal(t, "turbo", invalid.Value)
- require.Contains(t, err.Error(), "invalid service_tier")
- require.Contains(t, err.Error(), "fast", "allowed-value hint must mention fast")
- })
- t.Run("omitted field stays valid", func(t *testing.T) {
- norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","input":"hi"}`))
- require.NoError(t, err)
- require.Empty(t, norm)
- })
- t.Run("null value keeps omission semantics", func(t *testing.T) {
- norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":null}`))
- require.NoError(t, err)
- require.Empty(t, norm)
- })
- t.Run("explicit empty string rejected as invalid enum value", func(t *testing.T) {
- _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":""}`))
- require.Error(t, err)
- var invalid *ErrInvalidOpenAIServiceTier
- require.True(t, errors.As(err, &invalid))
- })
- t.Run("non-string service_tier rejected", func(t *testing.T) {
- // service_tier 必须为字符串;数字/布尔/对象/数组等类型同样按非法值拒绝。
- for _, raw := range []string{
- `{"model":"gpt-5.5","service_tier":123}`,
- `{"model":"gpt-5.5","service_tier":true}`,
- `{"model":"gpt-5.5","service_tier":{}}`,
- `{"model":"gpt-5.5","service_tier":["priority"]}`,
- } {
- _, err := ValidateOpenAIServiceTierField([]byte(raw))
- require.Error(t, err, "raw=%s must be rejected", raw)
- var invalid *ErrInvalidOpenAIServiceTier
- require.True(t, errors.As(err, &invalid), "raw=%s", raw)
- require.Equal(t, "<non-string>", invalid.Value, "raw=%s", raw)
- require.Contains(t, err.Error(), "invalid service_tier")
- }
- })
- t.Run("oversized unknown string is truncated", func(t *testing.T) {
- blob := strings.Repeat("z", 4096)
- _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + blob + `"}`))
- require.Error(t, err)
- require.Contains(t, err.Error(), "invalid service_tier")
- require.NotContains(t, err.Error(), blob)
- require.Less(t, len(err.Error()), 200)
- var invalid *ErrInvalidOpenAIServiceTier
- require.True(t, errors.As(err, &invalid))
- require.Equal(t, strings.Repeat("z", 64)+"...", invalid.Value)
- })
- t.Run("non-string large object/array is not echoed", func(t *testing.T) {
- blob := strings.Repeat("x", 4096)
- payloads := []string{
- `{"model":"gpt-5.5","service_tier":{"blob":"` + blob + `"}}`,
- `{"model":"gpt-5.5","service_tier":["` + blob + `"]}`,
- }
- for _, raw := range payloads {
- _, err := ValidateOpenAIServiceTierField([]byte(raw))
- require.Error(t, err)
- require.Contains(t, err.Error(), "invalid service_tier")
- require.NotContains(t, err.Error(), blob)
- require.Less(t, len(err.Error()), 200)
- var invalid *ErrInvalidOpenAIServiceTier
- require.True(t, errors.As(err, &invalid))
- require.Equal(t, "<non-string>", invalid.Value)
- }
- })
- }
- // ---------------------------------------------------------------------------
- // 计费:gpt-5.6 系列 / gpt-5.4 按标准价 2x,gpt-5.5 按标准价 2.5x
- // ---------------------------------------------------------------------------
- func TestApplyModelSpecificPricingPolicy_EnforcesOpenAIFastRatios(t *testing.T) {
- t.Parallel()
- svc := &BillingService{}
- t.Run("gpt-5.5 catalog 2x priority is corrected to 2.5x", func(t *testing.T) {
- // 模拟本地 LiteLLM 目录仍携带官方旧口径(gpt-5.5 priority = 2x)。
- catalog := &ModelPricing{
- InputPricePerToken: 5e-6,
- InputPricePerTokenPriority: 10e-6,
- OutputPricePerToken: 30e-6,
- OutputPricePerTokenPriority: 60e-6,
- CacheReadPricePerToken: 0.5e-6,
- CacheReadPricePerTokenPriority: 1e-6,
- }
- got := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog)
- require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12)
- require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12)
- require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12)
- // 标准价不被改动。
- require.InDelta(t, 5e-6, got.InputPricePerToken, 1e-12)
- // 原始指针不被污染。
- require.InDelta(t, 10e-6, catalog.InputPricePerTokenPriority, 1e-12)
- })
- t.Run("gpt-5.4 keeps 2x", func(t *testing.T) {
- got := svc.applyModelSpecificPricingPolicy("gpt-5.4", &ModelPricing{
- InputPricePerToken: 2.5e-6,
- InputPricePerTokenPriority: 5e-6,
- OutputPricePerToken: 15e-6,
- OutputPricePerTokenPriority: 30e-6,
- })
- require.InDelta(t, 5e-6, got.InputPricePerTokenPriority, 1e-12)
- require.InDelta(t, 30e-6, got.OutputPricePerTokenPriority, 1e-12)
- })
- t.Run("gpt-5.6 family keeps 2x", func(t *testing.T) {
- 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"} {
- got := svc.applyModelSpecificPricingPolicy(model, &ModelPricing{
- InputPricePerToken: 5e-6,
- InputPricePerTokenPriority: 10e-6,
- OutputPricePerToken: 30e-6,
- OutputPricePerTokenPriority: 60e-6,
- CacheReadPricePerToken: 0.5e-6,
- CacheReadPricePerTokenPriority: 1e-6,
- })
- require.InDelta(t, 10e-6, got.InputPricePerTokenPriority, 1e-12, "model %s", model)
- require.InDelta(t, 60e-6, got.OutputPricePerTokenPriority, 1e-12, "model %s", model)
- }
- })
- t.Run("missing priority prices are backfilled from standard", func(t *testing.T) {
- got := svc.applyModelSpecificPricingPolicy("gpt-5.5", &ModelPricing{
- InputPricePerToken: 5e-6,
- OutputPricePerToken: 30e-6,
- CacheReadPricePerToken: 0.5e-6,
- CacheCreationPricePerToken: 5e-6,
- })
- require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12)
- require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12)
- require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12)
- require.InDelta(t, 12.5e-6, got.CacheCreationPricePerTokenPriority, 1e-12)
- })
- t.Run("gpt-5.5-pro has no mandated fast tier", func(t *testing.T) {
- got := svc.applyModelSpecificPricingPolicy("gpt-5.5-pro", &ModelPricing{
- InputPricePerToken: 30e-6,
- InputPricePerTokenPriority: 60e-6,
- OutputPricePerToken: 180e-6,
- })
- require.InDelta(t, 60e-6, got.InputPricePerTokenPriority, 1e-12)
- })
- t.Run("unrelated models untouched", func(t *testing.T) {
- got := svc.applyModelSpecificPricingPolicy("claude-opus-5", &ModelPricing{InputPricePerToken: 1, OutputPricePerToken: 2})
- require.InDelta(t, 1, got.InputPricePerToken, 1e-12)
- require.Zero(t, got.InputPricePerTokenPriority)
- })
- }
- func TestOpenAIFastBillingMultiplier_2xAnd25x(t *testing.T) {
- t.Parallel()
- // 目录数据携带官方旧口径(gpt-5.5 priority=2x);修正后 fast 必须按 2.5x 计费。
- catalog := map[string]*LiteLLMModelPricing{
- "gpt-5.4": {
- InputCostPerToken: 2.5e-6,
- InputCostPerTokenPriority: 5e-6,
- OutputCostPerToken: 15e-6,
- OutputCostPerTokenPriority: 30e-6,
- CacheReadInputTokenCost: 0.25e-6,
- CacheReadInputTokenCostPriority: 0.5e-6,
- },
- "gpt-5.5": {
- InputCostPerToken: 5e-6,
- InputCostPerTokenPriority: 10e-6,
- OutputCostPerToken: 30e-6,
- OutputCostPerTokenPriority: 60e-6,
- CacheReadInputTokenCost: 0.5e-6,
- CacheReadInputTokenCostPriority: 1e-6,
- },
- "gpt-5.6-sol": {
- InputCostPerToken: 5e-6,
- InputCostPerTokenPriority: 10e-6,
- OutputCostPerToken: 30e-6,
- OutputCostPerTokenPriority: 60e-6,
- CacheReadInputTokenCost: 0.5e-6,
- CacheReadInputTokenCostPriority: 1e-6,
- },
- }
- billing := NewBillingService(&config.Config{}, &PricingService{pricingData: catalog})
- tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}
- standard := func(model string) *CostBreakdown {
- cost, err := billing.CalculateCost(model, tokens, 1)
- require.NoError(t, err)
- return cost
- }
- fast := func(model, tier string) *CostBreakdown {
- cost, err := billing.CalculateCostWithServiceTier(model, tokens, 1, tier)
- require.NoError(t, err)
- return cost
- }
- tests := []struct {
- model string
- ratio float64
- }{
- {model: "gpt-5.4", ratio: 2.0},
- {model: "gpt-5.5", ratio: 2.5},
- {model: "gpt-5.6-sol", ratio: 2.0},
- {model: "gpt-5.6-terra", ratio: 2.0},
- {model: "gpt-5.6-luna", ratio: 2.0},
- }
- for _, tt := range tests {
- t.Run(tt.model+"/fast", func(t *testing.T) {
- base := standard(tt.model)
- fastCost := fast(tt.model, "fast")
- require.InDelta(t, base.TotalCost*tt.ratio, fastCost.TotalCost, 1e-9,
- "fast total must be %.1fx standard", tt.ratio)
- })
- t.Run(tt.model+"/priority_alias", func(t *testing.T) {
- fastCost := fast(tt.model, "fast")
- priorityCost := fast(tt.model, "priority")
- require.InDelta(t, fastCost.TotalCost, priorityCost.TotalCost, 1e-12,
- "client alias fast must bill identically to priority")
- require.InDelta(t, standard(tt.model).TotalCost*tt.ratio, priorityCost.TotalCost, 1e-9)
- })
- t.Run(tt.model+"/no_tier_unchanged", func(t *testing.T) {
- base := standard(tt.model)
- noTier, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "")
- require.NoError(t, err)
- require.InDelta(t, base.TotalCost, noTier.TotalCost, 1e-12)
- })
- t.Run(tt.model+"/default_equals_standard", func(t *testing.T) {
- base := standard(tt.model)
- defaultCost, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "default")
- require.NoError(t, err)
- require.InDelta(t, base.TotalCost, defaultCost.TotalCost, 1e-12)
- require.InDelta(t, base.InputCost, defaultCost.InputCost, 1e-12)
- require.InDelta(t, base.OutputCost, defaultCost.OutputCost, 1e-12)
- require.InDelta(t, base.CacheReadCost, defaultCost.CacheReadCost, 1e-12)
- })
- }
- }
- func TestOpenAIFastBilling_FastMultiplierOverridesEnforcedRatio(t *testing.T) {
- t.Parallel()
- svc := &BillingService{}
- catalog := &ModelPricing{
- InputPricePerToken: 5e-6,
- InputPricePerTokenPriority: 10e-6,
- OutputPricePerToken: 30e-6,
- OutputPricePerTokenPriority: 60e-6,
- CacheReadPricePerToken: 0.5e-6,
- CacheReadPricePerTokenPriority: 1e-6,
- }
- pricing := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog)
- require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12, "enforce must still write 2.5x priority prices")
- require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12)
- multiplier := 1.7
- pricing.FastMultiplier = &multiplier
- tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}
- standard := svc.computeTokenBreakdown(pricing, tokens, 1, "", false)
- fast := svc.computeTokenBreakdown(pricing, tokens, 1, "fast", false)
- priority := svc.computeTokenBreakdown(pricing, tokens, 1, "priority", false)
- require.InDelta(t, standard.TotalCost*1.7, fast.TotalCost, 1e-9)
- require.InDelta(t, fast.TotalCost, priority.TotalCost, 1e-12)
- withoutOverride := *pricing
- withoutOverride.FastMultiplier = nil
- enforced := svc.computeTokenBreakdown(&withoutOverride, tokens, 1, "fast", false)
- require.InDelta(t, standard.TotalCost*2.5, enforced.TotalCost, 1e-9,
- "without FastMultiplier the same enforced prices still bill 2.5x")
- }
- // ---------------------------------------------------------------------------
- // 上游 payload:fast 归一化为 priority 并确实到达上游
- // ---------------------------------------------------------------------------
- func TestForwardAsChatCompletions_ServiceTierFastNormalizedToPriorityUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 21,
- Name: "openai-compatible",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-compatible"},
- Extra: map[string]any{"openai_responses_supported": true},
- }
- _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- require.Error(t, err) // upstream 400 → 错误返回,但请求体已被 recorder 捕获
- require.NotNil(t, upstream.lastBody)
- require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(),
- "client alias fast must reach upstream as priority")
- }
- func TestForwardAsChatCompletions_ServiceTierPriorityPreservedUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"priority","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st2"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 2,
- Name: "openai-compatible",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-compatible"},
- Extra: map[string]any{"openai_responses_supported": true},
- }
- _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- require.Error(t, err)
- require.NotNil(t, upstream.lastBody)
- require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String())
- }
- func TestForward_ResponsesServiceTierFastNormalizedToPriorityUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 7,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastBody)
- require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(),
- "client alias fast must reach the upstream as priority")
- // 计费上下文:result 携带归一化后的 tier。
- require.NotNil(t, result.ServiceTier)
- require.Equal(t, "priority", *result.ServiceTier)
- }
- func TestForward_ResponsesServiceTierOmittedStaysOmitted(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","input":"hello","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st2"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_2","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 7,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastBody)
- require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(),
- "omitted service_tier must stay omitted")
- require.Nil(t, result.ServiceTier)
- }
- // ---------------------------------------------------------------------------
- // 流式计费上下文:service_tier 需要从请求体传到 usage 计费
- // ---------------------------------------------------------------------------
- func TestForwardStreaming_ServiceTierPropagatedToResult(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" +
- "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" +
- "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" +
- "data: [DONE]\n\n"
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-stream-st"}},
- Body: io.NopCloser(strings.NewReader(streamPayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 7,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.ServiceTier)
- require.Equal(t, "priority", *result.ServiceTier, "streaming billing context must carry the normalized tier")
- // /v1/responses 流是上游 SSE 原样透传:上游没回 service_tier 就不该出现;
- // 网关只在计费结果里携带请求侧 tier,不往下游流里注入。
- require.Contains(t, rec.Body.String(), `"delta":"hi"`, "streamed content must reach the client")
- require.NotContains(t, rec.Body.String(), `"service_tier"`, "upstream did not return service_tier, client stream must stay untouched")
- }
- // ---------------------------------------------------------------------------
- // 转发阶段分别保留最终出站 tier 与上游回显 tier
- // ---------------------------------------------------------------------------
- func TestForward_ResponsesKeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- // 上游回显 service_tier=default(例如请求实际被降级)。
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-echo"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","service_tier":"default","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 7,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.ServiceTier)
- require.Equal(t, "priority", *result.ServiceTier)
- require.Equal(t, "default", result.UpstreamResponseServiceTier)
- // 非流式响应原样透传:客户端同样看到 default。
- require.Contains(t, rec.Body.String(), `"service_tier":"default"`)
- require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`)
- }
- func TestForwardStreaming_KeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" +
- "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" +
- "data: [DONE]\n\n"
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-echo-stream"}},
- Body: io.NopCloser(strings.NewReader(streamPayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 7,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.ServiceTier)
- require.Equal(t, "priority", *result.ServiceTier)
- require.Equal(t, "default", result.UpstreamResponseServiceTier)
- // 流式原样透传:客户端在终止事件里看到 default。
- require.Contains(t, rec.Body.String(), `"service_tier":"default"`)
- }
- func TestForwardAsChatCompletions_KeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" +
- "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" +
- "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" +
- "data: [DONE]\n\n"
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-chat-echo"}},
- Body: io.NopCloser(strings.NewReader(streamPayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}),
- }
- account := &Account{
- ID: 21,
- Name: "openai-compatible",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-compatible"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.ServiceTier)
- require.Equal(t, "priority", *result.ServiceTier)
- require.Equal(t, "default", result.UpstreamResponseServiceTier)
- // 缓冲转回 Chat Completions:客户端响应里如实回显 default。
- require.Contains(t, rec.Body.String(), `"service_tier":"default"`)
- require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`)
- }
- // ---------------------------------------------------------------------------
- // policy filter:删除 service_tier 后不得再按原请求 Fast 计费
- // ---------------------------------------------------------------------------
- func TestForward_ServiceTierFilteredByPolicyBillsStandard(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","service_tier":"priority","input":"hello","stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- // 管理员配置 priority → filter:字段在出站前被删除。
- settings := &OpenAIFastPolicySettings{Rules: []OpenAIFastPolicyRule{{
- ServiceTier: OpenAIFastTierPriority,
- Action: BetaPolicyActionFilter,
- Scope: BetaPolicyScopeAll,
- }}}
- raw, err := json.Marshal(settings)
- require.NoError(t, err)
- repo := &openAIFastPolicyRepoStub{values: map[string]string{SettingKeyOpenAIFastPolicySettings: string(raw)}}
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-filter"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- httpUpstream: upstream,
- settingService: NewSettingService(repo, &config.Config{}),
- }
- account := &Account{
- ID: 7,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"openai_responses_supported": true},
- Status: StatusActive,
- Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- // 出站 body 已剥离 service_tier、上游也未回显 → 无 tier → 按标准价计费。
- require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(),
- "policy filter must strip service_tier from the outbound body")
- require.Nil(t, result.ServiceTier, "filtered request must not bill as fast")
- }
- // ---------------------------------------------------------------------------
- // 上游回显观察与解析器单测
- // ---------------------------------------------------------------------------
- func TestUpstreamResponseModelObserver_ObservesServiceTier(t *testing.T) {
- t.Parallel()
- observer := &upstreamResponseModelObserver{}
- // 上游约束:非终止且有类型的事件(response.created)回显的是请求档位而非
- // 实际处理档位,忽略。
- observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5","service_tier":"flex"}}`), "response.created")
- require.Empty(t, observer.ServiceTier())
- // terminal 声明(带 model 帧)优先。
- observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed")
- require.Equal(t, "default", observer.ServiceTier())
- // Chat Completions 顶层 service_tier 按 untyped payload 观察(无 type 字段)。
- ccObserver := &upstreamResponseModelObserver{}
- ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "")
- require.Equal(t, "priority", ccObserver.ServiceTier())
- // 无 model 的帧不触发 tier 观察(上游约束:tier 声明必带 model)。
- modelFree := &upstreamResponseModelObserver{}
- modelFree.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed")
- require.Empty(t, modelFree.ServiceTier())
- }
- func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) {
- t.Parallel()
- priority := func() *string { v := "priority"; return &v }()
- t.Run("upstream echo stays separate from outbound tier", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(nil)
- observer := beginUpstreamResponseModelObservation(c)
- observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed")
- got := resolvedOpenAIUpstreamServiceTier(c, priority)
- require.NotNil(t, got)
- require.Equal(t, "priority", *got)
- require.Equal(t, "default", observedUpstreamResponseServiceTier(c))
- })
- t.Run("no upstream echo falls back to outbound tier", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(nil)
- beginUpstreamResponseModelObservation(c)
- got := resolvedOpenAIUpstreamServiceTier(c, priority)
- require.NotNil(t, got)
- require.Equal(t, "priority", *got)
- })
- t.Run("observed tier never promotes an untiered request", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(nil)
- observer := beginUpstreamResponseModelObservation(c)
- observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"fast"}}`), "response.completed")
- got := resolvedOpenAIUpstreamServiceTier(c, nil)
- require.Nil(t, got)
- require.Equal(t, "priority", observedUpstreamResponseServiceTier(c))
- })
- t.Run("no observer keeps outbound tier", func(t *testing.T) {
- got := resolvedOpenAIUpstreamServiceTier(nil, priority)
- require.NotNil(t, got)
- require.Equal(t, "priority", *got)
- })
- t.Run("no observer and no outbound tier stays nil", func(t *testing.T) {
- require.Nil(t, resolvedOpenAIUpstreamServiceTier(nil, nil))
- })
- t.Run("local observer stays separate from outbound tier", func(t *testing.T) {
- observer := &upstreamResponseModelObserver{}
- observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed")
- got := resolvedOpenAIUpstreamServiceTierFromObserver(observer, priority)
- require.NotNil(t, got)
- require.Equal(t, "priority", *got)
- })
- t.Run("nil local observer falls back to outbound tier", func(t *testing.T) {
- got := resolvedOpenAIUpstreamServiceTierFromObserver(nil, priority)
- require.NotNil(t, got)
- require.Equal(t, "priority", *got)
- })
- }
|