package service import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) // HTTP POST /v1/responses -> forwardOpenAIWSV2 keeps the canonical outbound // tier separate from response.completed.service_tier for usage-time billing. func TestForwardOpenAIWSV2_KeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) { gin.SetMode(gin.TestMode) cases := []struct { name string requestTier string stream bool }{ {name: "priority_nonstream", requestTier: "priority", stream: false}, {name: "fast_stream", requestTier: "fast", stream: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false cfg.Security.URLAllowlist.AllowInsecureHTTP = true cfg.Gateway.OpenAIWS.Enabled = true cfg.Gateway.OpenAIWS.APIKeyEnabled = true cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 captureConn := &openAIWSCaptureConn{ events: [][]byte{ []byte(`{"type":"response.completed","response":{"id":"resp_tier_v2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`), }, } captureDialer := &openAIWSCaptureDialer{conn: captureConn} pool := newOpenAIWSConnPool(cfg) pool.setClientDialerForTest(captureDialer) svc := &OpenAIGatewayService{ cfg: cfg, httpUpstream: &httpUpstreamRecorder{}, cache: &stubGatewayCache{}, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), openaiWSPool: pool, } account := &Account{ ID: 5882, Name: "openai-ws-v2-tier", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test"}, Extra: map[string]any{"responses_websockets_v2_enabled": true}, } body := []byte(fmt.Sprintf( `{"model":"gpt-5.5","stream":%t,"service_tier":%q,"input":[{"type":"input_text","text":"hi"}]}`, tc.stream, tc.requestTier, )) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) require.True(t, result.OpenAIWSMode, "must take HTTP POST → forwardOpenAIWSV2, not HTTP fallback") require.Equal(t, tc.stream, result.Stream) require.Equal(t, "resp_tier_v2", result.RequestID) require.NotNil(t, result.ServiceTier) require.Equal(t, "priority", *result.ServiceTier) require.Equal(t, "default", result.UpstreamResponseServiceTier) require.Equal(t, "priority", captureConn.lastWrite["service_tier"], "outbound WS payload still carries the requested Fast tier") }) } } func TestForwardOpenAIWSV2_MarksCyberPolicyForFailureEventShapes(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string upstreamEvent []byte wantError bool wantInput int wantOutput int }{ { name: "error_before_return", upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`), wantError: true, wantInput: 5, wantOutput: 1, }, { name: "response_failed_terminal", upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`), wantInput: 9, wantOutput: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) cfg := newOpenAIWSV2TestConfig() cfg.Security.URLAllowlist.Enabled = false cfg.Security.URLAllowlist.AllowInsecureHTTP = true cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}} pool := newOpenAIWSConnPool(cfg) pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) svc := &OpenAIGatewayService{ cfg: cfg, httpUpstream: &httpUpstreamRecorder{}, cache: &stubGatewayCache{}, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), openaiWSPool: pool, } account := &Account{ ID: 5883, Name: "openai-ws-v2-cyber", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test"}, Extra: map[string]any{"responses_websockets_v2_enabled": true}, } result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`)) if tt.wantError { require.Error(t, err) require.Nil(t, result) var failoverErr *UpstreamFailoverError require.False(t, errors.As(err, &failoverErr)) } else { require.NoError(t, err) require.NotNil(t, result) } mark := GetOpsCyberPolicy(c) require.NotNil(t, mark) require.Equal(t, "cyber_policy", mark.Code) require.Equal(t, tt.wantInput, mark.UpstreamInTok) require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) }) } }