diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index beb34780f3b..6041c4fa612 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -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() }() diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index db9c7b1627f..56f98a999d8 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -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, @@ -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() }() diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index c3efdc931aa..c6083bd0b94 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -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"}`)