Files
sub2api/backend/internal/service/openai_images_tool_cooldown_test.go
T
li d077002eba fix(openai): 图片工具冷却不再由「模型回文字」这一请求级判据触发
v0.1.181 起,/v1/images/generations 只要上游回了文字而没回图,就给账号写 30 分钟
openai:image_generation 模型级冷却。该判据由 openAIImagesTextFallbackErrorForText
从模型的自然语言输出**推断**而来,只说明这个 prompt 这一轮模型选择了说话——
仓库自己的既有用例 fixture 就是一句「Here's a polished image prompt for your request.」。

更糟的是同一个错误被判为可重试(502 → IsOpenAIImagesRetryableUpstreamError)并驱动
newOpenAIAccountFailoverError:一次闲聊回复会被逐个账号重试,沿途把每个账号都冷却掉,
表现为「开启后没多久账号就全部被限制」。

改为只在上游 error 帧点名该状态时才写冷却;网关自行推断的判据打上
SynthesizedFromModelText 标记并跳过。换号行为一字未动。

依据是仓库既有政策(alpha/search 路径原话):工具端点的单次失败
「仍允许本次请求换号,但不修改任何账号状态」。

Fixes #6171
2026-08-27 13:36:50 +08:00

178 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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),
"文字兜底判据是可重试的——正因如此,写账号冷却会沿号池级联")
}