From ca5d029e7cc6b3bc6d7dd7f3543da754634990c9 Mon Sep 17 00:00:00 2001 From: VitalyR Date: Tue, 28 Apr 2026 04:53:29 +0800 Subject: [PATCH] fix(openai): honor versioned image base URLs --- .../internal/service/account_test_service.go | 2 +- .../account_test_service_openai_image_test.go | 40 +++++ .../internal/service/openai_images_test.go | 138 ++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index c0bbc6dc..cb418550 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -1227,7 +1227,7 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } - apiURL := strings.TrimSuffix(normalizedBaseURL, "/") + "/v1/images/generations" + apiURL := buildOpenAIImagesURL(normalizedBaseURL, openAIImagesGenerationsEndpoint) // Set SSE headers c.Writer.Header().Set("Content-Type", "text/event-stream") diff --git a/backend/internal/service/account_test_service_openai_image_test.go b/backend/internal/service/account_test_service_openai_image_test.go index 80a2fc31..257159c4 100644 --- a/backend/internal/service/account_test_service_openai_image_test.go +++ b/backend/internal/service/account_test_service_openai_image_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -48,3 +49,42 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=") require.Contains(t, rec.Body.String(), "\"success\":true") } + +func TestAccountTestService_OpenAIImageAPIKeyUsesConfiguredV1BaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"data":[{"b64_json":"aGVsbG8=","revised_prompt":"draw a cat"}]}`)), + }, + } + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{}, + } + account := &Account{ + ID: 54, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-api-key", + "base_url": "https://image-upstream.example/v1", + }, + } + + err := svc.testOpenAIImageAPIKey(c, context.Background(), account, "gpt-image-2", "draw a cat") + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://image-upstream.example/v1/images/generations", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer test-api-key", upstream.lastReq.Header.Get("Authorization")) + require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=") + require.Contains(t, rec.Body.String(), "\"success\":true") +} diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 200547d4..47113d4d 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -258,6 +259,25 @@ func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T) require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative)) } +func TestBuildOpenAIImagesURL_HandlesVersionedBaseURL(t *testing.T) { + require.Equal(t, + "https://image-upstream.example/v1/images/generations", + buildOpenAIImagesURL("https://image-upstream.example/v1", openAIImagesGenerationsEndpoint), + ) + require.Equal(t, + "https://image-upstream.example/v1/images/edits", + buildOpenAIImagesURL("https://image-upstream.example/v1/", openAIImagesEditsEndpoint), + ) + require.Equal(t, + "https://image-upstream.example/v1/images/generations", + buildOpenAIImagesURL("https://image-upstream.example", openAIImagesGenerationsEndpoint), + ) + require.Equal(t, + "https://image-upstream.example/v1/images/generations", + buildOpenAIImagesURL("https://image-upstream.example/v1/images/generations", openAIImagesGenerationsEndpoint), + ) +} + type openAIImageTestSSEEvent struct { Name string Data string @@ -371,6 +391,124 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) { require.Equal(t, "draw a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String()) } +func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`) + + 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{ + cfg: &config.Config{}, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_img_apikey"}, + }, + Body: io.NopCloser(strings.NewReader(`{"created":1710000007,"data":[{"b64_json":"aGVsbG8=","revised_prompt":"draw a cat"}]}`)), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + account := &Account{ + ID: 6, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-api-key", + "base_url": "https://image-upstream.example/v1", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, "gpt-image-2", result.Model) + require.Equal(t, "gpt-image-2", result.UpstreamModel) + + upstream, ok := svc.httpUpstream.(*httpUpstreamRecorder) + require.True(t, ok) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://image-upstream.example/v1/images/generations", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer test-api-key", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) + require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String()) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyEditUsesConfiguredV1BaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("model", "gpt-image-2")) + require.NoError(t, writer.WriteField("prompt", "replace background")) + imagePart, err := writer.CreateFormFile("image", "source.png") + require.NoError(t, err) + _, err = imagePart.Write([]byte("png-image-content")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body.Bytes())) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_img_edit_apikey"}, + }, + Body: io.NopCloser(strings.NewReader(`{"created":1710000008,"data":[{"b64_json":"ZWRpdGVk","revised_prompt":"replace background"}]}`)), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body.Bytes()) + require.NoError(t, err) + + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-api-key", + "base_url": "https://image-upstream.example/v1/", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body.Bytes(), parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + + upstream, ok := svc.httpUpstream.(*httpUpstreamRecorder) + require.True(t, ok) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://image-upstream.example/v1/images/edits", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer test-api-key", upstream.lastReq.Header.Get("Authorization")) + require.Contains(t, upstream.lastReq.Header.Get("Content-Type"), "multipart/form-data") + require.Contains(t, string(upstream.lastBody), `name="model"`) + require.Contains(t, string(upstream.lastBody), "gpt-image-2") + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "ZWRpdGVk", gjson.Get(rec.Body.String(), "data.0.b64_json").String()) +} + func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)