openai_ws_forwarder_v2_test.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // HTTP POST /v1/responses -> forwardOpenAIWSV2 keeps the canonical outbound
  14. // tier separate from response.completed.service_tier for usage-time billing.
  15. func TestForwardOpenAIWSV2_KeepsOutboundAndObservedServiceTiersSeparate(t *testing.T) {
  16. gin.SetMode(gin.TestMode)
  17. cases := []struct {
  18. name string
  19. requestTier string
  20. stream bool
  21. }{
  22. {name: "priority_nonstream", requestTier: "priority", stream: false},
  23. {name: "fast_stream", requestTier: "fast", stream: true},
  24. }
  25. for _, tc := range cases {
  26. t.Run(tc.name, func(t *testing.T) {
  27. rec := httptest.NewRecorder()
  28. c, _ := gin.CreateTestContext(rec)
  29. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  30. c.Request.Header.Set("User-Agent", "unit-test-agent/1.0")
  31. cfg := &config.Config{}
  32. cfg.Security.URLAllowlist.Enabled = false
  33. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  34. cfg.Gateway.OpenAIWS.Enabled = true
  35. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  36. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  37. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  38. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  39. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  40. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  41. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  42. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  43. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  44. captureConn := &openAIWSCaptureConn{
  45. events: [][]byte{
  46. []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}}}`),
  47. },
  48. }
  49. captureDialer := &openAIWSCaptureDialer{conn: captureConn}
  50. pool := newOpenAIWSConnPool(cfg)
  51. pool.setClientDialerForTest(captureDialer)
  52. svc := &OpenAIGatewayService{
  53. cfg: cfg,
  54. httpUpstream: &httpUpstreamRecorder{},
  55. cache: &stubGatewayCache{},
  56. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  57. toolCorrector: NewCodexToolCorrector(),
  58. openaiWSPool: pool,
  59. }
  60. account := &Account{
  61. ID: 5882,
  62. Name: "openai-ws-v2-tier",
  63. Platform: PlatformOpenAI,
  64. Type: AccountTypeAPIKey,
  65. Status: StatusActive,
  66. Schedulable: true,
  67. Concurrency: 1,
  68. Credentials: map[string]any{"api_key": "sk-test"},
  69. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  70. }
  71. body := []byte(fmt.Sprintf(
  72. `{"model":"gpt-5.5","stream":%t,"service_tier":%q,"input":[{"type":"input_text","text":"hi"}]}`,
  73. tc.stream, tc.requestTier,
  74. ))
  75. result, err := svc.Forward(context.Background(), c, account, body)
  76. require.NoError(t, err)
  77. require.NotNil(t, result)
  78. require.True(t, result.OpenAIWSMode, "must take HTTP POST → forwardOpenAIWSV2, not HTTP fallback")
  79. require.Equal(t, tc.stream, result.Stream)
  80. require.Equal(t, "resp_tier_v2", result.RequestID)
  81. require.NotNil(t, result.ServiceTier)
  82. require.Equal(t, "priority", *result.ServiceTier)
  83. require.Equal(t, "default", result.UpstreamResponseServiceTier)
  84. require.Equal(t, "priority", captureConn.lastWrite["service_tier"],
  85. "outbound WS payload still carries the requested Fast tier")
  86. })
  87. }
  88. }
  89. func TestForwardOpenAIWSV2_MarksCyberPolicyForFailureEventShapes(t *testing.T) {
  90. gin.SetMode(gin.TestMode)
  91. tests := []struct {
  92. name string
  93. upstreamEvent []byte
  94. wantError bool
  95. wantInput int
  96. wantOutput int
  97. }{
  98. {
  99. name: "error_before_return",
  100. 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}}`),
  101. wantError: true,
  102. wantInput: 5,
  103. wantOutput: 1,
  104. },
  105. {
  106. name: "response_failed_terminal",
  107. 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}}}`),
  108. wantInput: 9,
  109. wantOutput: 2,
  110. },
  111. }
  112. for _, tt := range tests {
  113. t.Run(tt.name, func(t *testing.T) {
  114. recorder := httptest.NewRecorder()
  115. c, _ := gin.CreateTestContext(recorder)
  116. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  117. cfg := newOpenAIWSV2TestConfig()
  118. cfg.Security.URLAllowlist.Enabled = false
  119. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  120. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  121. captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}}
  122. pool := newOpenAIWSConnPool(cfg)
  123. pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
  124. svc := &OpenAIGatewayService{
  125. cfg: cfg,
  126. httpUpstream: &httpUpstreamRecorder{},
  127. cache: &stubGatewayCache{},
  128. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  129. toolCorrector: NewCodexToolCorrector(),
  130. openaiWSPool: pool,
  131. }
  132. account := &Account{
  133. ID: 5883, Name: "openai-ws-v2-cyber", Platform: PlatformOpenAI,
  134. Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1,
  135. Credentials: map[string]any{"api_key": "sk-test"},
  136. Extra: map[string]any{"responses_websockets_v2_enabled": true},
  137. }
  138. result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`))
  139. if tt.wantError {
  140. require.Error(t, err)
  141. require.Nil(t, result)
  142. var failoverErr *UpstreamFailoverError
  143. require.False(t, errors.As(err, &failoverErr))
  144. } else {
  145. require.NoError(t, err)
  146. require.NotNil(t, result)
  147. }
  148. mark := GetOpsCyberPolicy(c)
  149. require.NotNil(t, mark)
  150. require.Equal(t, "cyber_policy", mark.Code)
  151. require.Equal(t, tt.wantInput, mark.UpstreamInTok)
  152. require.Equal(t, tt.wantOutput, mark.UpstreamOutTok)
  153. })
  154. }
  155. }