| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- //go:build unit
- package service
- import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- // issue #6171:v0.1.181 起,/v1/images/generations 只要上游"回文字没回图",账号就被
- // 写 30 分钟 openai:image_generation 模型级冷却。该判据是**请求级**的(这个 prompt
- // 这一轮模型选择了说话),却被当成**账号级**能力失效;又因为同一个错误被判为
- // 可重试(502)并驱动 failover,一次闲聊回复会沿着号池逐个把账号冷却掉。
- // countingModelRateLimitRepo 记录 SetModelRateLimit 调用,用于断言"没写账号状态"。
- type countingModelRateLimitRepo struct {
- accountRepoStub
- calls int
- scopes []string
- }
- func (r *countingModelRateLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, _ time.Time, _ ...string) error {
- r.calls++
- r.scopes = append(r.scopes, scope)
- return nil
- }
- func newImagesCooldownContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
- t.Helper()
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- return c, rec
- }
- func imagesCooldownAccount() *Account {
- return &Account{ID: 77, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "img-oauth"}
- }
- func TestShouldCoolOpenAIImagesToolForError(t *testing.T) {
- cases := []struct {
- name string
- err *OpenAIImagesUpstreamError
- want bool
- }{
- {
- name: "nil_error",
- err: nil,
- want: false,
- },
- {
- // 网关从模型文字里推断出来的判据:只说明这一轮没出图。
- name: "synthesized_from_model_text",
- err: &OpenAIImagesUpstreamError{
- StatusCode: http.StatusBadGateway,
- Code: "image_generation_unavailable",
- SynthesizedFromModelText: true,
- },
- want: false,
- },
- {
- // 上游自己在 error 帧里点名该状态:这才是账号级证据,保持冷却。
- name: "structured_upstream_error_frame",
- err: &OpenAIImagesUpstreamError{
- StatusCode: http.StatusBadGateway,
- Code: "image_generation_unavailable",
- },
- want: true,
- },
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- require.Equal(t, tc.want, shouldCoolOpenAIImagesToolForError(tc.err))
- })
- }
- }
- // 主复现:文字兜底判据不得写账号级冷却。
- func TestHandleOpenAIImagesOAuthResponseError_TextFallbackDoesNotCoolAccount(t *testing.T) {
- c, _ := newImagesCooldownContext(t)
- repo := &countingModelRateLimitRepo{}
- svc := &OpenAIGatewayService{accountRepo: repo}
- account := imagesCooldownAccount()
- upstreamErr := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
- require.NotNil(t, upstreamErr)
- require.Equal(t, "image_generation_unavailable", upstreamErr.Code)
- err := svc.handleOpenAIImagesOAuthResponseError(
- context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses",
- &http.Response{StatusCode: http.StatusOK, Header: http.Header{}},
- OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr,
- )
- require.Zero(t, repo.calls, "模型闲聊不构成账号级证据,不得写 30 分钟冷却")
- // 换号行为必须原样保留:本 PR 只撤销账号状态写入,不动 failover。
- var failover *UpstreamFailoverError
- require.True(t, errors.As(err, &failover), "仍应触发换号,got %T", err)
- }
- // 对照不变式:上游 error 帧点名该状态时仍然冷却,否则等于把功能整个废掉。
- func TestHandleOpenAIImagesOAuthResponseError_StructuredUnavailableStillCoolsAccount(t *testing.T) {
- c, _ := newImagesCooldownContext(t)
- repo := &countingModelRateLimitRepo{}
- svc := &OpenAIGatewayService{accountRepo: repo}
- account := imagesCooldownAccount()
- upstreamErr := &OpenAIImagesUpstreamError{
- StatusCode: http.StatusBadGateway,
- ErrorType: "upstream_error",
- Code: "image_generation_unavailable",
- Message: "image generation tool is not available for this account",
- }
- _ = svc.handleOpenAIImagesOAuthResponseError(
- context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses",
- &http.Response{StatusCode: http.StatusOK, Header: http.Header{}},
- OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr,
- )
- require.Equal(t, 1, repo.calls, "结构化上游证据仍须写冷却")
- require.Equal(t, []string{openAIImageGenerationRateLimitKey}, repo.scopes)
- }
- // 标记必须打在文字兜底的两个入口上,且不影响违规拦截分支的判定。
- func TestOpenAIImagesTextFallback_MarksSynthesizedVerdicts(t *testing.T) {
- t.Run("plain_text_reply_is_synthesized", func(t *testing.T) {
- err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
- require.NotNil(t, err)
- require.True(t, err.SynthesizedFromModelText)
- require.Equal(t, "image_generation_unavailable", err.Code)
- require.Equal(t, http.StatusBadGateway, err.StatusCode)
- })
- t.Run("body_entrypoint_is_synthesized", func(t *testing.T) {
- body := []byte("event: response.completed\n" +
- `data: {"type":"response.completed","response":{"id":"r","status":"completed",` +
- `"output":[{"type":"message","content":[{"type":"output_text","text":"I drafted a prompt for you."}]}]}}` +
- "\n\n")
- err := openAIImagesTextFallbackError(body)
- require.NotNil(t, err)
- require.True(t, err.SynthesizedFromModelText)
- })
- t.Run("content_policy_branch_unchanged", func(t *testing.T) {
- err := openAIImagesTextFallbackErrorForText("Blocked by our content policy.")
- require.NotNil(t, err)
- require.Equal(t, "content_policy_violation", err.Code)
- require.Equal(t, http.StatusBadRequest, err.StatusCode)
- // 该分支本来就不走冷却(Code 不匹配),标记与否都不改变行为;
- // 断言它没有被顺手打标,避免语义漂移。
- require.False(t, err.SynthesizedFromModelText)
- })
- t.Run("empty_text_yields_no_error", func(t *testing.T) {
- require.Nil(t, openAIImagesTextFallbackErrorForText(" "))
- })
- }
- // 级联的前提条件:该错误确实是可重试的,所以会带着"已写冷却"的副作用换号。
- // 这条用例把前提钉死,避免以后有人把 502 改成非重试后误以为本修复多余。
- func TestOpenAIImagesTextFallback_RemainsRetryableAndThusCascades(t *testing.T) {
- err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
- require.NotNil(t, err)
- require.True(t, IsOpenAIImagesRetryableUpstreamError(err),
- "文字兜底判据是可重试的——正因如此,写账号冷却会沿号池级联")
- }
|