openai_images_tool_cooldown_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // issue #6171:v0.1.181 起,/v1/images/generations 只要上游"回文字没回图",账号就被
  14. // 写 30 分钟 openai:image_generation 模型级冷却。该判据是**请求级**的(这个 prompt
  15. // 这一轮模型选择了说话),却被当成**账号级**能力失效;又因为同一个错误被判为
  16. // 可重试(502)并驱动 failover,一次闲聊回复会沿着号池逐个把账号冷却掉。
  17. // countingModelRateLimitRepo 记录 SetModelRateLimit 调用,用于断言"没写账号状态"。
  18. type countingModelRateLimitRepo struct {
  19. accountRepoStub
  20. calls int
  21. scopes []string
  22. }
  23. func (r *countingModelRateLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, _ time.Time, _ ...string) error {
  24. r.calls++
  25. r.scopes = append(r.scopes, scope)
  26. return nil
  27. }
  28. func newImagesCooldownContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
  29. t.Helper()
  30. gin.SetMode(gin.TestMode)
  31. rec := httptest.NewRecorder()
  32. c, _ := gin.CreateTestContext(rec)
  33. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  34. return c, rec
  35. }
  36. func imagesCooldownAccount() *Account {
  37. return &Account{ID: 77, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "img-oauth"}
  38. }
  39. func TestShouldCoolOpenAIImagesToolForError(t *testing.T) {
  40. cases := []struct {
  41. name string
  42. err *OpenAIImagesUpstreamError
  43. want bool
  44. }{
  45. {
  46. name: "nil_error",
  47. err: nil,
  48. want: false,
  49. },
  50. {
  51. // 网关从模型文字里推断出来的判据:只说明这一轮没出图。
  52. name: "synthesized_from_model_text",
  53. err: &OpenAIImagesUpstreamError{
  54. StatusCode: http.StatusBadGateway,
  55. Code: "image_generation_unavailable",
  56. SynthesizedFromModelText: true,
  57. },
  58. want: false,
  59. },
  60. {
  61. // 上游自己在 error 帧里点名该状态:这才是账号级证据,保持冷却。
  62. name: "structured_upstream_error_frame",
  63. err: &OpenAIImagesUpstreamError{
  64. StatusCode: http.StatusBadGateway,
  65. Code: "image_generation_unavailable",
  66. },
  67. want: true,
  68. },
  69. }
  70. for _, tc := range cases {
  71. t.Run(tc.name, func(t *testing.T) {
  72. require.Equal(t, tc.want, shouldCoolOpenAIImagesToolForError(tc.err))
  73. })
  74. }
  75. }
  76. // 主复现:文字兜底判据不得写账号级冷却。
  77. func TestHandleOpenAIImagesOAuthResponseError_TextFallbackDoesNotCoolAccount(t *testing.T) {
  78. c, _ := newImagesCooldownContext(t)
  79. repo := &countingModelRateLimitRepo{}
  80. svc := &OpenAIGatewayService{accountRepo: repo}
  81. account := imagesCooldownAccount()
  82. upstreamErr := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
  83. require.NotNil(t, upstreamErr)
  84. require.Equal(t, "image_generation_unavailable", upstreamErr.Code)
  85. err := svc.handleOpenAIImagesOAuthResponseError(
  86. context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses",
  87. &http.Response{StatusCode: http.StatusOK, Header: http.Header{}},
  88. OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr,
  89. )
  90. require.Zero(t, repo.calls, "模型闲聊不构成账号级证据,不得写 30 分钟冷却")
  91. // 换号行为必须原样保留:本 PR 只撤销账号状态写入,不动 failover。
  92. var failover *UpstreamFailoverError
  93. require.True(t, errors.As(err, &failover), "仍应触发换号,got %T", err)
  94. }
  95. // 对照不变式:上游 error 帧点名该状态时仍然冷却,否则等于把功能整个废掉。
  96. func TestHandleOpenAIImagesOAuthResponseError_StructuredUnavailableStillCoolsAccount(t *testing.T) {
  97. c, _ := newImagesCooldownContext(t)
  98. repo := &countingModelRateLimitRepo{}
  99. svc := &OpenAIGatewayService{accountRepo: repo}
  100. account := imagesCooldownAccount()
  101. upstreamErr := &OpenAIImagesUpstreamError{
  102. StatusCode: http.StatusBadGateway,
  103. ErrorType: "upstream_error",
  104. Code: "image_generation_unavailable",
  105. Message: "image generation tool is not available for this account",
  106. }
  107. _ = svc.handleOpenAIImagesOAuthResponseError(
  108. context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses",
  109. &http.Response{StatusCode: http.StatusOK, Header: http.Header{}},
  110. OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr,
  111. )
  112. require.Equal(t, 1, repo.calls, "结构化上游证据仍须写冷却")
  113. require.Equal(t, []string{openAIImageGenerationRateLimitKey}, repo.scopes)
  114. }
  115. // 标记必须打在文字兜底的两个入口上,且不影响违规拦截分支的判定。
  116. func TestOpenAIImagesTextFallback_MarksSynthesizedVerdicts(t *testing.T) {
  117. t.Run("plain_text_reply_is_synthesized", func(t *testing.T) {
  118. err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
  119. require.NotNil(t, err)
  120. require.True(t, err.SynthesizedFromModelText)
  121. require.Equal(t, "image_generation_unavailable", err.Code)
  122. require.Equal(t, http.StatusBadGateway, err.StatusCode)
  123. })
  124. t.Run("body_entrypoint_is_synthesized", func(t *testing.T) {
  125. body := []byte("event: response.completed\n" +
  126. `data: {"type":"response.completed","response":{"id":"r","status":"completed",` +
  127. `"output":[{"type":"message","content":[{"type":"output_text","text":"I drafted a prompt for you."}]}]}}` +
  128. "\n\n")
  129. err := openAIImagesTextFallbackError(body)
  130. require.NotNil(t, err)
  131. require.True(t, err.SynthesizedFromModelText)
  132. })
  133. t.Run("content_policy_branch_unchanged", func(t *testing.T) {
  134. err := openAIImagesTextFallbackErrorForText("Blocked by our content policy.")
  135. require.NotNil(t, err)
  136. require.Equal(t, "content_policy_violation", err.Code)
  137. require.Equal(t, http.StatusBadRequest, err.StatusCode)
  138. // 该分支本来就不走冷却(Code 不匹配),标记与否都不改变行为;
  139. // 断言它没有被顺手打标,避免语义漂移。
  140. require.False(t, err.SynthesizedFromModelText)
  141. })
  142. t.Run("empty_text_yields_no_error", func(t *testing.T) {
  143. require.Nil(t, openAIImagesTextFallbackErrorForText(" "))
  144. })
  145. }
  146. // 级联的前提条件:该错误确实是可重试的,所以会带着"已写冷却"的副作用换号。
  147. // 这条用例把前提钉死,避免以后有人把 502 改成非重试后误以为本修复多余。
  148. func TestOpenAIImagesTextFallback_RemainsRetryableAndThusCascades(t *testing.T) {
  149. err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
  150. require.NotNil(t, err)
  151. require.True(t, IsOpenAIImagesRetryableUpstreamError(err),
  152. "文字兜底判据是可重试的——正因如此,写账号冷却会沿号池级联")
  153. }