openai_gateway_responses_empty_completed_test.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "testing"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // TestOpenAIResponsesEmptyCompletedFailsOver verifies that a Responses stream
  14. // ending with an empty response.completed (no output, no usage, no error) is
  15. // turned into a failover error instead of a successful empty reply (issue
  16. // #5009).
  17. func TestOpenAIResponsesEmptyCompletedFailsOver(t *testing.T) {
  18. gin.SetMode(gin.TestMode)
  19. upstream := &httpUpstreamRecorder{resp: &http.Response{
  20. StatusCode: http.StatusOK,
  21. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  22. Body: io.NopCloser(strings.NewReader(
  23. "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" +
  24. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"completed\"}}\n\n",
  25. )),
  26. }}
  27. svc := newOpenAIImageGenerationControlTestService(upstream)
  28. c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  29. account := newOpenAIImageGenerationControlTestAccount()
  30. account.Extra = map[string]any{"openai_passthrough": true}
  31. body := []byte(`{
  32. "model":"gpt-5.6-sol",
  33. "stream":true,
  34. "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]
  35. }`)
  36. _, err := svc.Forward(context.Background(), c, account, body)
  37. require.Error(t, err)
  38. var failoverErr *UpstreamFailoverError
  39. require.True(t, errors.As(err, &failoverErr), "empty completed must produce UpstreamFailoverError, got: %v", err)
  40. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  41. require.Empty(t, recorder.Body.String(), "no empty success stream may reach the client")
  42. }
  43. // TestOpenAIResponsesEmptyCompletedWithOutputSucceeds ensures streams with real
  44. // semantic output are untouched.
  45. func TestOpenAIResponsesEmptyCompletedWithOutputSucceeds(t *testing.T) {
  46. gin.SetMode(gin.TestMode)
  47. upstream := &httpUpstreamRecorder{resp: &http.Response{
  48. StatusCode: http.StatusOK,
  49. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  50. Body: io.NopCloser(strings.NewReader(
  51. "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ok\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" +
  52. "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" +
  53. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n",
  54. )),
  55. }}
  56. svc := newOpenAIImageGenerationControlTestService(upstream)
  57. c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  58. account := newOpenAIImageGenerationControlTestAccount()
  59. account.Extra = map[string]any{"openai_passthrough": true}
  60. body := []byte(`{
  61. "model":"gpt-5.6-sol",
  62. "stream":true,
  63. "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]
  64. }`)
  65. result, err := svc.Forward(context.Background(), c, account, body)
  66. require.NoError(t, err)
  67. require.NotNil(t, result)
  68. require.Contains(t, recorder.Body.String(), "hello")
  69. require.NotNil(t, result.Usage)
  70. require.Equal(t, 10, result.Usage.InputTokens)
  71. require.Equal(t, 5, result.Usage.OutputTokens)
  72. }
  73. // TestOpenAIResponsesEmptyCompletedWithUsageSucceeds ensures a completed event
  74. // carrying usage is not mistaken for a silent refusal even without output.
  75. func TestOpenAIResponsesEmptyCompletedWithUsageSucceeds(t *testing.T) {
  76. gin.SetMode(gin.TestMode)
  77. upstream := &httpUpstreamRecorder{resp: &http.Response{
  78. StatusCode: http.StatusOK,
  79. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  80. Body: io.NopCloser(strings.NewReader(
  81. "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_usage\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" +
  82. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_usage\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":0,\"total_tokens\":3}}}\n\n",
  83. )),
  84. }}
  85. svc := newOpenAIImageGenerationControlTestService(upstream)
  86. c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  87. account := newOpenAIImageGenerationControlTestAccount()
  88. account.Extra = map[string]any{"openai_passthrough": true}
  89. body := []byte(`{
  90. "model":"gpt-5.6-sol",
  91. "stream":true,
  92. "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]
  93. }`)
  94. result, err := svc.Forward(context.Background(), c, account, body)
  95. require.NoError(t, err)
  96. require.NotNil(t, result)
  97. require.NotNil(t, result.Usage)
  98. require.Equal(t, 3, result.Usage.InputTokens)
  99. }
  100. func TestOpenAIResponsesCompletedEventIsEmpty(t *testing.T) {
  101. cases := []struct {
  102. name string
  103. data string
  104. usage *OpenAIUsage
  105. want bool
  106. }{
  107. {
  108. name: "bare completed",
  109. data: `{"type":"response.completed"}`,
  110. want: true,
  111. },
  112. {
  113. name: "completed with empty output array",
  114. data: `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`,
  115. want: true,
  116. },
  117. {
  118. name: "completed with usage",
  119. data: `{"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}`,
  120. want: false,
  121. },
  122. {
  123. name: "completed with error",
  124. data: `{"type":"response.completed","response":{"id":"r1","status":"completed","error":{"code":"x"}}}`,
  125. want: false,
  126. },
  127. {
  128. name: "completed with output item",
  129. data: `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[{"type":"message","id":"msg_1"}]}}`,
  130. want: false,
  131. },
  132. {
  133. name: "accumulated usage",
  134. data: `{"type":"response.completed"}`,
  135. usage: &OpenAIUsage{
  136. InputTokens: 7,
  137. OutputTokens: 2,
  138. },
  139. want: false,
  140. },
  141. {
  142. name: "invalid json",
  143. data: `{"type":`,
  144. want: false,
  145. },
  146. }
  147. for _, tc := range cases {
  148. t.Run(tc.name, func(t *testing.T) {
  149. require.Equal(t, tc.want, openAIResponsesCompletedEventIsEmpty([]byte(tc.data), tc.usage))
  150. })
  151. }
  152. }