mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-06 15:03:54 +08:00
Merge branch 'main' into fix/apikey-namespace-roundtrip
This commit is contained in:
@@ -133,6 +133,17 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
|
||||
return false
|
||||
}
|
||||
|
||||
// Self-built images requests always carry a matching image_generation tool, so a
|
||||
// "tool choice not found in 'tools'" 400 means upstream revoked this account's
|
||||
// image capability. Gated on the self-built marker: passthrough clients control
|
||||
// their own tools/tool_choice and could otherwise poison a healthy account.
|
||||
if isOpenAIImagesSelfBuiltRequest(ctx) && isOpenAIImageCapabilityLossError(statusCode, responseBody) {
|
||||
if s != nil && s.rateLimitService != nil {
|
||||
_ = s.rateLimitService.HandleOpenAIImageCapabilityLoss(stateCtx, account, statusCode, responseBody)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if s == nil || account == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -472,13 +472,29 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
// Responses OAuth 与 Chat 兼容入口保持一致:纯文本 system 可以无损提升后删除,
|
||||
// JSON object 模式仍需在 input 中保留 JSON 指令供上游兼容校验。
|
||||
omitPromotedSystemMessages := !strings.EqualFold(
|
||||
strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()),
|
||||
"json_object",
|
||||
)
|
||||
codexResult := codexTransformResult{}
|
||||
if compatMessagesBridge {
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true})
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
|
||||
IsCodexCLI: isCodexCLI,
|
||||
IsCompact: isCompactRequest,
|
||||
SkipDefaultInstructions: true,
|
||||
PreserveToolCallIDs: true,
|
||||
OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
|
||||
})
|
||||
ensureCodexOAuthInstructionsField(decoded)
|
||||
markDecodedModified()
|
||||
} else {
|
||||
codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest)
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
|
||||
IsCodexCLI: isCodexCLI,
|
||||
IsCompact: isCompactRequest,
|
||||
OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
|
||||
})
|
||||
}
|
||||
if codexResult.Error != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}})
|
||||
|
||||
@@ -37,6 +37,13 @@ type OpenAIImagesUpstreamError struct {
|
||||
Message string
|
||||
Param string
|
||||
UpstreamRequestID string
|
||||
|
||||
// SynthesizedFromModelText marks an error the gateway inferred from the
|
||||
// model's plain-text output instead of reading it off a structured upstream
|
||||
// error frame. Such a verdict describes this one turn ("the model answered
|
||||
// with words instead of an image"), not the account — see
|
||||
// shouldCoolOpenAIImagesToolForError.
|
||||
SynthesizedFromModelText bool
|
||||
}
|
||||
|
||||
func (e *OpenAIImagesUpstreamError) Error() string {
|
||||
@@ -328,6 +335,26 @@ func openAIImageUploadToDataURL(upload OpenAIImagesUpload) (string, error) {
|
||||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(upload.Data), nil
|
||||
}
|
||||
|
||||
// openAIImagesSelfBuiltRequestContextKey marks a request whose upstream body was
|
||||
// fully constructed by buildOpenAIImagesResponsesRequest, i.e. tool_choice and the
|
||||
// matching image_generation tool are always both present and never client-controlled.
|
||||
type openAIImagesSelfBuiltRequestContextKey struct{}
|
||||
|
||||
func withOpenAIImagesSelfBuiltRequest(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, openAIImagesSelfBuiltRequestContextKey{}, true)
|
||||
}
|
||||
|
||||
func isOpenAIImagesSelfBuiltRequest(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
selfBuilt, _ := ctx.Value(openAIImagesSelfBuiltRequestContextKey{}).(bool)
|
||||
return selfBuilt
|
||||
}
|
||||
|
||||
func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string) ([]byte, error) {
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("parsed images request is required")
|
||||
@@ -711,6 +738,10 @@ func openAIImagesTextFallbackErrorForText(text string) *OpenAIImagesUpstreamErro
|
||||
ErrorType: "upstream_error",
|
||||
Code: "image_generation_unavailable",
|
||||
Message: "Upstream did not execute image generation",
|
||||
// Inferred from the model's own words, not from an upstream error frame:
|
||||
// good enough to fail this turn over to another account, not evidence that
|
||||
// this account's image tool is down for the next 30 minutes.
|
||||
SynthesizedFromModelText: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1775,6 +1806,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstreamCtx = withOpenAIImagesSelfBuiltRequest(upstreamCtx)
|
||||
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1922,6 +1954,26 @@ const (
|
||||
openAIImagesOAuthUnavailableReason = "openai_images_oauth_tool_unavailable"
|
||||
)
|
||||
|
||||
// shouldCoolOpenAIImagesToolForError decides whether an image_generation_unavailable
|
||||
// verdict is durable enough to park the account's image tool for
|
||||
// openAIImagesOAuthUnavailableCooldown.
|
||||
//
|
||||
// Only an upstream error frame that names the condition qualifies. A verdict the
|
||||
// gateway synthesized from the model's plain-text reply does not: it merely says
|
||||
// this prompt produced words instead of an image, which is prompt-dependent and
|
||||
// happens on healthy accounts. Writing a 30-minute account-level cooldown from it
|
||||
// is doubly wrong because the very same error is classified retryable
|
||||
// (IsOpenAIImagesRetryableUpstreamError: status >= 500) and drives
|
||||
// newOpenAIAccountFailoverError — so one such reply walks the pool and cools every
|
||||
// account the retry touches.
|
||||
//
|
||||
// This mirrors the rule the alpha/search path already states in words: a
|
||||
// tool-endpoint failure "仍允许本次请求换号,但不修改任何账号状态"
|
||||
// (see shouldApplyOpenAIAlphaSearchAccountErrorSideEffects).
|
||||
func shouldCoolOpenAIImagesToolForError(upstreamErr *OpenAIImagesUpstreamError) bool {
|
||||
return upstreamErr != nil && !upstreamErr.SynthesizedFromModelText
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) coolOpenAIImagesOAuthTool(ctx context.Context, account *Account) {
|
||||
if s == nil || s.accountRepo == nil || account == nil || account.Platform != PlatformOpenAI {
|
||||
return
|
||||
@@ -2017,7 +2069,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
|
||||
|
||||
responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr)
|
||||
if upstreamErr.Code == "image_generation_unavailable" {
|
||||
s.coolOpenAIImagesOAuthTool(ctx, account)
|
||||
if shouldCoolOpenAIImagesToolForError(upstreamErr) {
|
||||
s.coolOpenAIImagesOAuthTool(ctx, account)
|
||||
}
|
||||
if responseWritten {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//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),
|
||||
"文字兜底判据是可重试的——正因如此,写账号冷却会沿号池级联")
|
||||
}
|
||||
@@ -132,6 +132,44 @@ func TestOpenAIGatewayService_ResponsesUnknownModelDoesNotFallbackToGPT54(t *tes
|
||||
require.True(t, rec.Code >= http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthResponsesPromotesSystemMessageWithoutDuplication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const systemPrompt = "Unique system prefix for Responses token accounting."
|
||||
const existingInstructions = "Existing instructions."
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"instructions":"` + existingInstructions + `","input":[{"role":"system","content":"` + systemPrompt + `"},{"role":"user","content":"hello"}]}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 124,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.NotEmpty(t, upstream.lastBody)
|
||||
require.Equal(t, systemPrompt+"\n\n"+existingInstructions, gjson.GetBytes(upstream.lastBody, "instructions").String())
|
||||
require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
|
||||
require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
|
||||
require.Equal(t, 1, strings.Count(string(upstream.lastBody), systemPrompt))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_NativeResponsesBodyModificationPreservesHTMLChars(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ const (
|
||||
const (
|
||||
openAIImageRateLimitDefaultCooldown = time.Minute
|
||||
openAIImageRateLimitReason = "openai_image_rate_limited"
|
||||
openAIImageCapabilityLossCooldown = 30 * time.Minute
|
||||
openAIImageCapabilityLossReason = "openai_image_capability_lost"
|
||||
)
|
||||
|
||||
var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`)
|
||||
@@ -2190,6 +2192,44 @@ func (s *RateLimitService) HandleOpenAIImageRateLimit(ctx context.Context, accou
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *RateLimitService) HandleOpenAIImageCapabilityLoss(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool {
|
||||
if s == nil || account == nil || s.accountRepo == nil {
|
||||
return false
|
||||
}
|
||||
if account.Platform != PlatformOpenAI {
|
||||
return false
|
||||
}
|
||||
if !account.ShouldHandleErrorCode(statusCode) {
|
||||
slog.Info("openai_image_capability_loss_skipped_by_error_code_policy", "account_id", account.ID, "status_code", statusCode)
|
||||
return false
|
||||
}
|
||||
if !isOpenAIImageCapabilityLossError(statusCode, responseBody) {
|
||||
return false
|
||||
}
|
||||
|
||||
resetAt := time.Now().Add(openAIImageCapabilityLossCooldown)
|
||||
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, openAIImageGenerationRateLimitKey, resetAt, openAIImageCapabilityLossReason); err != nil {
|
||||
slog.Warn("openai_image_capability_loss_set_model_rate_limit_failed", "account_id", account.ID, "scope", openAIImageGenerationRateLimitKey, "error", err)
|
||||
return true
|
||||
}
|
||||
slog.Info("openai_image_capability_lost", "account_id", account.ID, "scope", openAIImageGenerationRateLimitKey, "reset_at", resetAt, "reset_in", time.Until(resetAt).Truncate(time.Second))
|
||||
return true
|
||||
}
|
||||
|
||||
// isOpenAIImageCapabilityLossError reports whether upstream rejected the
|
||||
// image_generation tool choice that sub2api itself put into the request body.
|
||||
// Only meaningful for self-built images requests, where tools always carries a
|
||||
// matching image_generation entry — upstream saying otherwise means the account
|
||||
// lost the capability.
|
||||
func isOpenAIImageCapabilityLossError(statusCode int, body []byte) bool {
|
||||
if statusCode != http.StatusBadRequest || len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(string(body))
|
||||
return strings.Contains(lower, "image_generation") &&
|
||||
strings.Contains(lower, "not found in 'tools' parameter")
|
||||
}
|
||||
|
||||
func isOpenAIImageRateLimitError(statusCode int, body []byte) bool {
|
||||
if statusCode != http.StatusTooManyRequests || len(body) == 0 {
|
||||
return false
|
||||
|
||||
@@ -120,7 +120,11 @@ func TestOpenAIGatewayServiceForwardImages_ImageRateLimitReturnsFailoverAndCools
|
||||
require.Equal(t, openAIImageGenerationRateLimitKey, repo.modelRateLimitCalls[0].scope)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *testing.T) {
|
||||
// issue #6171:上游"回文字没回图"是**这一轮**的结果(模型选择了说话),不是账号能力
|
||||
// 失效。它同时被判为可重试(502)并驱动 failover,若还写 30 分钟账号级冷却,一次闲聊
|
||||
// 回复就会沿号池把每个被重试到的账号依次冷却掉。冷却仍保留给结构化上游证据,见
|
||||
// TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability。
|
||||
func TestOpenAIGatewayServiceForwardImages_TextFallbackDoesNotCoolImageCapability(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
|
||||
@@ -154,7 +158,6 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
@@ -162,6 +165,56 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
// 换号行为不变:该判据仍足以放弃本账号重试这一次请求……
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
// ……但不再写任何账号级状态,否则重试会把冷却一路刷到整个号池。
|
||||
require.Empty(t, repo.modelRateLimitCalls,
|
||||
"模型回文字只说明这一轮没出图,不构成账号 30 分钟不可用的证据")
|
||||
}
|
||||
|
||||
// 对照不变式:上游 error 帧点名 image_generation_unavailable 时仍写冷却,
|
||||
// 保证 #6171 的修复没有把这项能力保护整个废掉。
|
||||
func TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
|
||||
upstreamSSE := "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"r\",\"error\":" +
|
||||
"{\"type\":\"upstream_error\",\"code\":\"image_generation_unavailable\"," +
|
||||
"\"message\":\"image generation tool is not available for this account\"}}}\n\n"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: repo,
|
||||
httpUpstream: &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
},
|
||||
}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
account := &Account{
|
||||
ID: 206,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
call := repo.modelRateLimitCalls[0]
|
||||
require.Equal(t, account.ID, call.accountID)
|
||||
@@ -169,3 +222,120 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t
|
||||
require.Equal(t, openAIImagesOAuthUnavailableReason, call.reason)
|
||||
require.WithinDuration(t, before.Add(openAIImagesOAuthUnavailableCooldown), call.resetAt, time.Second)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_CapabilityLossCoolsImageScope(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
|
||||
errorBody := `{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
rateLimitService: &RateLimitService{accountRepo: repo},
|
||||
httpUpstream: &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"X-Request-Id": []string{"req_img_capability_lost"}},
|
||||
Body: io.NopCloser(strings.NewReader(errorBody)),
|
||||
},
|
||||
},
|
||||
}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
account := &Account{
|
||||
ID: 205,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
call := repo.modelRateLimitCalls[0]
|
||||
require.Equal(t, account.ID, call.accountID)
|
||||
require.Equal(t, openAIImageGenerationRateLimitKey, call.scope)
|
||||
require.Equal(t, openAIImageCapabilityLossReason, call.reason)
|
||||
require.WithinDuration(t, before.Add(openAIImageCapabilityLossCooldown), call.resetAt, time.Second)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceHandleUpstreamError_PassthroughCapabilityLossDoesNotCool(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
|
||||
account := &Account{ID: 206, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
body := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`)
|
||||
|
||||
disabled := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, http.Header{}, body, "gpt-5.5")
|
||||
|
||||
require.False(t, disabled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
_, wholeAccountBlocked := svc.openaiAccountRuntimeBlockUntil.Load(account.ID)
|
||||
require.False(t, wholeAccountBlocked)
|
||||
}
|
||||
|
||||
func TestRateLimitServiceHandleOpenAIImageCapabilityLoss_IgnoresGenericBadRequest(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &RateLimitService{accountRepo: repo}
|
||||
account := &Account{ID: 207, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
body := []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`)
|
||||
|
||||
handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body)
|
||||
|
||||
require.False(t, handled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
}
|
||||
|
||||
func TestRateLimitServiceHandleOpenAIImageCapabilityLoss_RespectsPlatformAndErrorCodePolicy(t *testing.T) {
|
||||
body := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`)
|
||||
|
||||
t.Run("non_openai_platform", func(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &RateLimitService{accountRepo: repo}
|
||||
account := &Account{ID: 208, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
|
||||
|
||||
handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body)
|
||||
|
||||
require.False(t, handled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
})
|
||||
|
||||
t.Run("custom_error_code_policy_excludes_400", func(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &RateLimitService{accountRepo: repo}
|
||||
account := &Account{
|
||||
ID: 209,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"custom_error_codes_enabled": true,
|
||||
"custom_error_codes": []any{float64(http.StatusTooManyRequests)},
|
||||
},
|
||||
}
|
||||
|
||||
require.False(t, account.ShouldHandleErrorCode(http.StatusBadRequest))
|
||||
handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body)
|
||||
|
||||
require.False(t, handled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsOpenAIImageCapabilityLossError(t *testing.T) {
|
||||
capabilityLossBody := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`)
|
||||
genericBadRequestBody := []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`)
|
||||
|
||||
require.True(t, isOpenAIImageCapabilityLossError(http.StatusBadRequest, capabilityLossBody))
|
||||
require.False(t, isOpenAIImageCapabilityLossError(http.StatusBadRequest, genericBadRequestBody))
|
||||
require.False(t, isOpenAIImageCapabilityLossError(http.StatusTooManyRequests, capabilityLossBody))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user