openai_gateway_compact_log_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. package handler
  2. import (
  3. "fmt"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. var handlerStructuredLogCaptureMu sync.Mutex
  15. type handlerInMemoryLogSink struct {
  16. mu sync.Mutex
  17. events []*logger.LogEvent
  18. }
  19. func (s *handlerInMemoryLogSink) WriteLogEvent(event *logger.LogEvent) {
  20. if event == nil {
  21. return
  22. }
  23. cloned := *event
  24. if event.Fields != nil {
  25. cloned.Fields = make(map[string]any, len(event.Fields))
  26. for k, v := range event.Fields {
  27. cloned.Fields[k] = v
  28. }
  29. }
  30. s.mu.Lock()
  31. s.events = append(s.events, &cloned)
  32. s.mu.Unlock()
  33. }
  34. func (s *handlerInMemoryLogSink) ContainsMessageAtLevel(substr, level string) bool {
  35. s.mu.Lock()
  36. defer s.mu.Unlock()
  37. wantLevel := strings.ToLower(strings.TrimSpace(level))
  38. for _, ev := range s.events {
  39. if ev == nil {
  40. continue
  41. }
  42. if strings.Contains(ev.Message, substr) && strings.ToLower(strings.TrimSpace(ev.Level)) == wantLevel {
  43. return true
  44. }
  45. }
  46. return false
  47. }
  48. func (s *handlerInMemoryLogSink) ContainsFieldValue(field, substr string) bool {
  49. s.mu.Lock()
  50. defer s.mu.Unlock()
  51. for _, ev := range s.events {
  52. if ev == nil || ev.Fields == nil {
  53. continue
  54. }
  55. if v, ok := ev.Fields[field]; ok && strings.Contains(fmt.Sprint(v), substr) {
  56. return true
  57. }
  58. }
  59. return false
  60. }
  61. func (s *handlerInMemoryLogSink) FieldValueForMessage(message, field string) (any, bool) {
  62. s.mu.Lock()
  63. defer s.mu.Unlock()
  64. for _, event := range s.events {
  65. if event == nil || event.Message != message || event.Fields == nil {
  66. continue
  67. }
  68. if value, ok := event.Fields[field]; ok {
  69. return value, true
  70. }
  71. }
  72. return nil, false
  73. }
  74. func captureHandlerStructuredLog(t *testing.T) (*handlerInMemoryLogSink, func()) {
  75. t.Helper()
  76. handlerStructuredLogCaptureMu.Lock()
  77. err := logger.Init(logger.InitOptions{
  78. Level: "debug",
  79. Format: "json",
  80. ServiceName: "sub2api",
  81. Environment: "test",
  82. Output: logger.OutputOptions{
  83. ToStdout: true,
  84. ToFile: false,
  85. },
  86. Sampling: logger.SamplingOptions{Enabled: false},
  87. })
  88. require.NoError(t, err)
  89. sink := &handlerInMemoryLogSink{}
  90. logger.SetSink(sink)
  91. return sink, func() {
  92. logger.SetSink(nil)
  93. handlerStructuredLogCaptureMu.Unlock()
  94. }
  95. }
  96. func TestIsOpenAILegacyCompactPath(t *testing.T) {
  97. require.False(t, isOpenAILegacyCompactPath(nil))
  98. gin.SetMode(gin.TestMode)
  99. rec := httptest.NewRecorder()
  100. c, _ := gin.CreateTestContext(rec)
  101. for _, test := range []struct {
  102. path string
  103. want bool
  104. }{
  105. {path: "/v1/responses/compact", want: true},
  106. {path: "/v1/responses/compact/detail", want: true},
  107. {path: "/responses/compact/", want: true},
  108. {path: "/v1/responses", want: false},
  109. {path: "/openai/v1/responses", want: false},
  110. {path: "/responses", want: false},
  111. {path: "/backend-api/codex/responses", want: false},
  112. {path: "/v1/responses/resp_123/cancel", want: false},
  113. } {
  114. c.Request = httptest.NewRequest(http.MethodPost, test.path, nil)
  115. require.Equal(t, test.want, isOpenAILegacyCompactPath(c), test.path)
  116. }
  117. }
  118. func TestLogOpenAIRemoteCompactOutcome_Succeeded(t *testing.T) {
  119. gin.SetMode(gin.TestMode)
  120. logSink, restore := captureHandlerStructuredLog(t)
  121. defer restore()
  122. rec := httptest.NewRecorder()
  123. c, _ := gin.CreateTestContext(rec)
  124. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
  125. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.125.0")
  126. c.Set(opsModelKey, "gpt-5.3-codex")
  127. c.Set(opsAccountIDKey, int64(123))
  128. c.Header("x-request-id", "rid-compact-ok")
  129. c.Status(http.StatusOK)
  130. h := &OpenAIGatewayHandler{}
  131. h.logOpenAIRemoteCompactOutcome(c, time.Now().Add(-8*time.Millisecond))
  132. require.True(t, logSink.ContainsMessageAtLevel("codex.remote_compact.succeeded", "info"))
  133. require.True(t, logSink.ContainsFieldValue("compact_outcome", "succeeded"))
  134. require.True(t, logSink.ContainsFieldValue("status_code", "200"))
  135. require.True(t, logSink.ContainsFieldValue("path", "/v1/responses/compact"))
  136. require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.3-codex"))
  137. require.True(t, logSink.ContainsFieldValue("account_id", "123"))
  138. require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-compact-ok"))
  139. }
  140. func TestLogOpenAIRemoteCompactOutcome_Failed(t *testing.T) {
  141. gin.SetMode(gin.TestMode)
  142. logSink, restore := captureHandlerStructuredLog(t)
  143. defer restore()
  144. rec := httptest.NewRecorder()
  145. c, _ := gin.CreateTestContext(rec)
  146. c.Request = httptest.NewRequest(http.MethodPost, "/responses/compact", nil)
  147. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.125.0")
  148. c.Status(http.StatusBadGateway)
  149. h := &OpenAIGatewayHandler{}
  150. h.logOpenAIRemoteCompactOutcome(c, time.Now())
  151. require.True(t, logSink.ContainsMessageAtLevel("codex.remote_compact.failed", "warn"))
  152. require.True(t, logSink.ContainsFieldValue("compact_outcome", "failed"))
  153. require.True(t, logSink.ContainsFieldValue("status_code", "502"))
  154. require.True(t, logSink.ContainsFieldValue("path", "/responses/compact"))
  155. }
  156. func TestLogOpenAIRemoteCompactOutcome_NonCompactSkips(t *testing.T) {
  157. gin.SetMode(gin.TestMode)
  158. logSink, restore := captureHandlerStructuredLog(t)
  159. defer restore()
  160. rec := httptest.NewRecorder()
  161. c, _ := gin.CreateTestContext(rec)
  162. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  163. c.Status(http.StatusOK)
  164. h := &OpenAIGatewayHandler{}
  165. h.logOpenAIRemoteCompactOutcome(c, time.Now())
  166. require.False(t, logSink.ContainsMessageAtLevel("codex.remote_compact.succeeded", "info"))
  167. require.False(t, logSink.ContainsMessageAtLevel("codex.remote_compact.failed", "warn"))
  168. }
  169. func TestOpenAIResponses_CompactUnauthorizedLogsFailed(t *testing.T) {
  170. gin.SetMode(gin.TestMode)
  171. logSink, restore := captureHandlerStructuredLog(t)
  172. defer restore()
  173. rec := httptest.NewRecorder()
  174. c, _ := gin.CreateTestContext(rec)
  175. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"gpt-5.3-codex"}`))
  176. c.Request.Header.Set("Content-Type", "application/json")
  177. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.125.0")
  178. h := &OpenAIGatewayHandler{}
  179. h.Responses(c)
  180. require.Equal(t, http.StatusUnauthorized, rec.Code)
  181. require.True(t, logSink.ContainsMessageAtLevel("codex.remote_compact.failed", "warn"))
  182. require.True(t, logSink.ContainsFieldValue("status_code", "401"))
  183. require.True(t, logSink.ContainsFieldValue("path", "/v1/responses/compact"))
  184. }