Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/internal/service/openai_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody)
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
}
defer func() { _ = resp.Body.Close() }()

Expand Down
172 changes: 171 additions & 1 deletion backend/internal/service/openai_images_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,176 @@ func openAIImagesUpstreamErrorFromGJSON(errorObj gjson.Result, upstreamRequestID
}
}

// openAIImagesErrorTypeForStatus returns an OpenAI-style error type when the
// upstream body does not provide one of its own.
func openAIImagesErrorTypeForStatus(status int) string {
switch {
case status == http.StatusBadRequest:
return "invalid_request_error"
case status == http.StatusUnauthorized:
return "authentication_error"
case status == http.StatusForbidden:
return "permission_error"
case status == http.StatusNotFound:
return "not_found_error"
case status == http.StatusTooManyRequests:
return "rate_limit_error"
case status >= 500:
return "api_error"
default:
return "upstream_error"
}
}

// openAIImagesUpstreamErrorFromHTTP builds an OpenAIImagesUpstreamError from a
// non-2xx upstream HTTP response, preserving the real status code, type, code,
// message and param so the client sees the actual upstream error instead of a
// generic 502.
func openAIImagesUpstreamErrorFromHTTP(statusCode int, header http.Header, body []byte) *OpenAIImagesUpstreamError {
errType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String())
code := strings.TrimSpace(extractUpstreamErrorCode(body))
param := strings.TrimSpace(gjson.GetBytes(body, "error.param").String())
message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body)))
if message == "" {
message = fmt.Sprintf("Upstream request failed (status %d)", statusCode)
}
if errType == "" {
errType = openAIImagesErrorTypeForStatus(statusCode)
}
requestID := ""
if header != nil {
requestID = strings.TrimSpace(header.Get("x-request-id"))
}
return &OpenAIImagesUpstreamError{
StatusCode: statusCode,
ErrorType: errType,
Code: code,
Message: message,
Param: param,
UpstreamRequestID: requestID,
}
}

// handleOpenAIImagesErrorResponse is the non-failover error handler for the
// images endpoints (/v1/images/generations and /v1/images/edits). Unlike the
// generic handleErrorResponse — which collapses every non-failover upstream
// error into a generic 502 "Upstream request failed" — it surfaces the real
// upstream status code and error message/type/code/param to the client. This
// mirrors how the Chat Completions and Messages compat paths use
// handleCompatErrorResponse.
//
// It returns an *OpenAIImagesUpstreamError (already written to the client) so
// the images handler treats it as a terminal user-facing error rather than
// re-writing a fallback response.
func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
ctx context.Context,
resp *http.Response,
c *gin.Context,
account *Account,
requestedModel ...string,
) (*OpenAIForwardResult, error) {
body := s.readUpstreamErrorBody(resp)

upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body)))
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
if maxBytes <= 0 {
maxBytes = 2048
}
upstreamDetail = truncateString(string(body), maxBytes)
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)

if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
logger.LegacyPrintf("service.openai_gateway",
"OpenAI images upstream error %d (account=%d platform=%s type=%s): %s",
resp.StatusCode,
account.ID,
account.Platform,
account.Type,
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
)
}

// Honor admin-configured error passthrough rules first.
if status, errType, errMsg, matched := applyErrorPassthroughRule(
c,
account.Platform,
resp.StatusCode,
body,
http.StatusBadGateway,
"upstream_error",
"Upstream request failed",
); matched {
upErr := &OpenAIImagesUpstreamError{
StatusCode: status,
ErrorType: errType,
Message: errMsg,
UpstreamRequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
}
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
return nil, upErr
}

// If the account is not configured to handle this status code, fall back to
// a generic gateway error without exposing upstream internals (mirrors
// handleCompatErrorResponse).
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "http_error",
Message: upstreamMsg,
Detail: upstreamDetail,
})
upErr := &OpenAIImagesUpstreamError{
StatusCode: http.StatusInternalServerError,
ErrorType: "upstream_error",
Message: "Upstream gateway error",
UpstreamRequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
}
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
return nil, upErr
}

// Track rate limits / decide whether to disable the account (secondary failover).
var modelForCooldown string
if len(requestedModel) > 0 {
modelForCooldown = strings.TrimSpace(requestedModel[0])
}
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, modelForCooldown)
kind := "http_error"
if shouldDisable {
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: kind,
Message: upstreamMsg,
Detail: upstreamDetail,
})
if shouldDisable {
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: body,
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}

// Surface the real upstream error to the client.
upErr := openAIImagesUpstreamErrorFromHTTP(resp.StatusCode, resp.Header, body)
writeOpenAIImagesUpstreamErrorResponse(c, upErr)
return nil, upErr
}

func buildOpenAIImagesAPIResponse(
results []openAIResponsesImageResult,
createdAt int64,
Expand Down Expand Up @@ -1195,7 +1365,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody)
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel)
}
defer func() { _ = resp.Body.Close() }()

Expand Down
58 changes: 58 additions & 0 deletions backend/internal/service/openai_images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,64 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
require.Equal(t, "draw a cat 3", gjson.Get(rec.Body.String(), "data.2.revised_prompt").String())
}

func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)

// The non-failover upstream error path is shared by /generations and /edits;
// use /generations here so the request parses without an uploaded image.
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
c.Set("api_key", &APIKey{ID: 42})

svc := &OpenAIGatewayService{}
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)

svc.httpUpstream = &httpUpstreamRecorder{
resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"req_img_badreq"},
},
Body: io.NopCloser(strings.NewReader(
`{"error":{"message":"Invalid value for 'size': expected one of 1024x1024, 1536x1024.","type":"invalid_request_error","param":"size","code":"unknown_parameter"}}`,
)),
},
}

account := &Account{
ID: 1,
Name: "openai-oauth",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "token-123",
},
}

result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
require.Nil(t, result)

var upstreamErr *OpenAIImagesUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Equal(t, http.StatusBadRequest, upstreamErr.StatusCode)
require.Equal(t, "invalid_request_error", upstreamErr.ErrorType)
require.Equal(t, "unknown_parameter", upstreamErr.Code)

// The client must receive the actual upstream status code and message instead
// of a generic 502 "Upstream request failed".
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, "invalid_request_error", gjson.Get(rec.Body.String(), "error.type").String())
require.Equal(t, "unknown_parameter", gjson.Get(rec.Body.String(), "error.code").String())
require.Equal(t, "size", gjson.Get(rec.Body.String(), "error.param").String())
require.Contains(t, gjson.Get(rec.Body.String(), "error.message").String(), "Invalid value for 'size'")
}

func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamModerationBlockedReturnsClientError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw blocked image","response_format":"b64_json"}`)
Expand Down
Loading