openai_ws_forwarder_test.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // TestIsOpenAIWSTokenEvent_TerminalEventsExcluded 覆盖 isOpenAIWSTokenEvent 的回归用例。
  13. // 重点验证终止事件(response.completed / response.done)不再被当作 token event,
  14. // 否则当上游没有可识别的 delta 时,firstTokenMs 会被填到终止时刻,
  15. // 等于把"总耗时"误报为"首 token 延迟"(issue #2651)。
  16. func TestIsOpenAIWSTokenEvent_TerminalEventsExcluded(t *testing.T) {
  17. cases := []struct {
  18. name string
  19. eventType string
  20. want bool
  21. }{
  22. {name: "empty", eventType: "", want: false},
  23. {name: "whitespace_trimmed_empty", eventType: " ", want: false},
  24. {name: "response.created", eventType: "response.created", want: false},
  25. {name: "response.in_progress", eventType: "response.in_progress", want: false},
  26. {name: "response.output_item.added", eventType: "response.output_item.added", want: false},
  27. {name: "response.output_item.done", eventType: "response.output_item.done", want: false},
  28. {name: "terminal_response.completed", eventType: "response.completed", want: false},
  29. {name: "terminal_response.done", eventType: "response.done", want: false},
  30. {name: "terminal_response.completed_padded", eventType: " response.completed ", want: false},
  31. {name: "terminal_response.done_padded", eventType: " response.done ", want: false},
  32. {name: "delta_text", eventType: "response.output_text.delta", want: true},
  33. {name: "delta_audio_transcript", eventType: "response.audio_transcript.delta", want: true},
  34. {name: "delta_function_call_arguments", eventType: "response.function_call_arguments.delta", want: true},
  35. {name: "output_text_done", eventType: "response.output_text.done", want: true},
  36. {name: "output_text_annotation_added", eventType: "response.output_text.annotation.added", want: true},
  37. {name: "output_audio_done", eventType: "response.output_audio.done", want: true},
  38. {name: "reasoning_summary_delta", eventType: "response.reasoning_summary_text.delta", want: true},
  39. {name: "unrelated_event_error", eventType: "error", want: false},
  40. {name: "unknown_event_without_match", eventType: "response.reasoning_summary_part.added", want: false},
  41. }
  42. for _, tc := range cases {
  43. tc := tc
  44. t.Run(tc.name, func(t *testing.T) {
  45. got := isOpenAIWSTokenEvent(tc.eventType)
  46. require.Equal(t, tc.want, got, "isOpenAIWSTokenEvent(%q)", tc.eventType)
  47. })
  48. }
  49. }
  50. // TestOpenAIWSCyberPolicyMark_ResponseFailed 验证 WS 路径 response.failed cyber_policy 标记逻辑。
  51. //
  52. // 全量转发循环(forwardOpenAIWSV2 / sendAndRelay)依赖真实 WebSocket 连接,
  53. // 无法在单元测试中驱动。本测试通过直接调用转发循环内使用的两个函数
  54. // detectOpenAICyberPolicy + MarkOpsCyberPolicy,覆盖「从 response.failed 帧
  55. // 到 gin context 写入」的完整调用序列,等同于循环体内对应代码段的逻辑验证。
  56. // 全量 WS 端到端覆盖由后续集成测试(Task 12 handler 编排)承担。
  57. func TestOpenAIWSCyberPolicyMark_ResponseFailed(t *testing.T) {
  58. // 构造一个真实的 response.failed 帧(cyber_policy 命中路径)。
  59. payload := []byte(`{"type":"response.failed","response":{"id":"resp_abc","status":"failed","error":{"code":"cyber_policy","message":"Request blocked by content policy."}}}`)
  60. // 验证 detectOpenAICyberPolicy 能从 response.error.code 路径识别。
  61. hit, code, msg := detectOpenAICyberPolicy(payload)
  62. require.True(t, hit, "detectOpenAICyberPolicy should return true for cyber_policy payload")
  63. require.Equal(t, "cyber_policy", code)
  64. require.Equal(t, "Request blocked by content policy.", msg)
  65. // 构造 gin test context,模拟转发循环调用 MarkOpsCyberPolicy。
  66. rec := httptest.NewRecorder()
  67. c, _ := gin.CreateTestContext(rec)
  68. usage := OpenAIUsage{InputTokens: 42, OutputTokens: 7}
  69. MarkOpsCyberPolicy(c, CyberPolicyMark{
  70. Code: code,
  71. Message: msg,
  72. Body: truncateString(string(payload), 4096),
  73. UpstreamStatus: 200,
  74. UpstreamInTok: usage.InputTokens,
  75. UpstreamOutTok: usage.OutputTokens,
  76. })
  77. mark := GetOpsCyberPolicy(c)
  78. require.NotNil(t, mark, "GetOpsCyberPolicy should return non-nil after MarkOpsCyberPolicy")
  79. require.Equal(t, "cyber_policy", mark.Code)
  80. require.Equal(t, "Request blocked by content policy.", mark.Message)
  81. require.Equal(t, 200, mark.UpstreamStatus)
  82. require.Equal(t, 42, mark.UpstreamInTok)
  83. require.Equal(t, 7, mark.UpstreamOutTok)
  84. // 验证幂等性:再次标记不覆盖首个。
  85. MarkOpsCyberPolicy(c, CyberPolicyMark{Code: "cyber_policy", Message: "second call"})
  86. require.Equal(t, "Request blocked by content policy.", GetOpsCyberPolicy(c).Message, "second MarkOpsCyberPolicy call must not overwrite first")
  87. }
  88. // TestOpenAIWSCyberPolicyMark_NonCyberPayload 验证非 cyber_policy 的 response.failed 不触发标记。
  89. func TestOpenAIWSCyberPolicyMark_NonCyberPayload(t *testing.T) {
  90. payload := []byte(`{"type":"response.failed","response":{"id":"resp_xyz","status":"failed","error":{"code":"server_error","message":"Internal error"}}}`)
  91. hit, _, _ := detectOpenAICyberPolicy(payload)
  92. require.False(t, hit, "detectOpenAICyberPolicy should return false for non-cyber_policy error code")
  93. }
  94. func TestOpenAIForwardResultSucceededForScheduling_TerminalEvents(t *testing.T) {
  95. tests := []struct {
  96. name string
  97. result *OpenAIForwardResult
  98. expected bool
  99. }{
  100. {name: "nil legacy result", result: nil, expected: true},
  101. {name: "non websocket zero value", result: &OpenAIForwardResult{}, expected: true},
  102. {name: "websocket legacy empty terminal", result: &OpenAIForwardResult{OpenAIWSMode: true}, expected: true},
  103. {name: "completed", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.completed"}, expected: true},
  104. {name: "done", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.done"}, expected: true},
  105. {name: "failed", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.failed"}, expected: false},
  106. {name: "incomplete", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.incomplete"}, expected: false},
  107. {name: "cancelled", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.cancelled"}, expected: false},
  108. }
  109. for _, tt := range tests {
  110. t.Run(tt.name, func(t *testing.T) {
  111. require.Equal(t, tt.expected, tt.result.SucceededForScheduling())
  112. })
  113. }
  114. }
  115. func TestOpenAIWSTerminalEvent_ResponseFailedRecordsModelTransient(t *testing.T) {
  116. svc := &OpenAIGatewayService{}
  117. svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
  118. account := &Account{ID: 5201, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  119. payload := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error","message":"Internal error"}}}`)
  120. for range 2 {
  121. terminalEvent := svc.handleOpenAIWSTerminalTransientFailure(context.Background(), account, "gpt-5.5", http.Header{}, payload)
  122. require.Equal(t, "response.failed", terminalEvent)
  123. }
  124. require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
  125. }
  126. func TestOpenAIWSErrorEvent_ServerErrorRecordsModelTransient(t *testing.T) {
  127. svc := &OpenAIGatewayService{}
  128. svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
  129. account := &Account{ID: 5203, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  130. payload := []byte(`{"type":"error","error":{"code":"server_error","type":"server_error","message":"Internal error"}}`)
  131. for range 2 {
  132. svc.handleOpenAIWSErrorEventTransientFailure(context.Background(), account, "gpt-5.5", http.Header{}, payload)
  133. }
  134. require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
  135. }
  136. func TestOpenAIWSPayloadTransientStatus_Explicit529IsNotModelTransient(t *testing.T) {
  137. payload := []byte(`{"type":"response.failed","response":{"error":{"status_code":529,"code":"server_error","message":"overloaded"}}}`)
  138. require.Zero(t, openAIWSPayloadTransientStatus(payload))
  139. }
  140. func TestOpenAIWSDial5xxRecordsModelTransient(t *testing.T) {
  141. svc := &OpenAIGatewayService{}
  142. svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
  143. account := &Account{ID: 5202, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  144. dialErr := &openAIWSDialError{
  145. StatusCode: http.StatusBadGateway,
  146. ResponseHeaders: http.Header{"X-Request-Id": []string{"req-ws-502"}},
  147. ResponseBody: []byte(`{"error":{"message":"bad gateway"}}`),
  148. }
  149. for range 2 {
  150. svc.handleOpenAIWSDialTransientFailure(context.Background(), account, "gpt-5.5", dialErr)
  151. }
  152. require.Eventually(t, func() bool {
  153. return svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")
  154. }, time.Second, 10*time.Millisecond)
  155. }
  156. // TestIsOpenAIWSTokenEvent_DisjointWithTerminal 守护「token 事件集合与终止事件集合互斥」的不变量。
  157. // firstTokenMs 的计算依赖于 isTokenEvent && !isTerminalEvent;
  158. // 若两者再次出现交集,则 issue #2651 描述的 latency 误报会重现。
  159. func TestIsOpenAIWSTokenEvent_DisjointWithTerminal(t *testing.T) {
  160. terminalEvents := []string{
  161. "response.completed",
  162. "response.done",
  163. "response.failed",
  164. "response.incomplete",
  165. "response.cancelled",
  166. "response.canceled",
  167. }
  168. for _, ev := range terminalEvents {
  169. ev := ev
  170. t.Run(ev, func(t *testing.T) {
  171. require.True(t, isOpenAIWSTerminalEvent(ev), "expected terminal event %q to be classified as terminal", ev)
  172. require.False(t, isOpenAIWSTokenEvent(ev), "terminal event %q must NOT be classified as token event (issue #2651)", ev)
  173. })
  174. }
  175. }