From b3cae8fc4365053bb3b70ad18e64db092f59ed81 Mon Sep 17 00:00:00 2001 From: Ethan Wang <3242822589@qq.COM> Date: Fri, 8 May 2026 00:29:09 +0800 Subject: [PATCH] feat(billing): map gpt-image-2 quality (low/medium/high) to 1K/2K/4K tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing layer already reads Group.ImagePrice1K/2K/4K via BillingService.CalculateImageCost, but normalizeOpenAIImageSizeTier classified strictly by pixel size, so the 4K price slot was unreachable for typical 1024x1024 requests even when callers asked for high quality. For gpt-image-2, image_tokens scale primarily with `quality` (low ≈ 1k tokens, medium ≈ 2k, high ≈ 4k), not pixel dimensions: $ curl … -d '{"size":"1024x1024","quality":"low"}' → output_tokens=196 (≈ 1K tier) $ curl … -d '{"size":"1024x1024","quality":"high"}' → output_tokens=1756 (≈ 4K tier) This change makes normalizeOpenAIImageSizeTier accept (size, quality) and prefer quality-based classification. When quality is empty, "auto", or unrecognized it falls back to the existing size-dimension classification, preserving behaviour for older gpt-image-1 / dall-e flows and for the OpenAI Responses API image_generation tool path (image_generation_intent.go now also threads tool["quality"] through). Tests: - go build ./... - go test -tags unit ./internal/service/... - existing JSON parse case (size=1024x1024 + quality=high) updated to expect "4K" — its previous "1K" was the billing bug Verified end-to-end against a live deployment (HTTP 200, usage_logs shows image_size="4K" total_cost=0.6 for quality=high; image_size="1K" total_cost=0.2 for quality=low when Group.ImagePrice1K/2K/4K are set). --- .../service/image_generation_intent.go | 7 ++++++- backend/internal/service/openai_images.go | 21 +++++++++++++++++-- .../internal/service/openai_images_test.go | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index b6ef106509d..4b278481a01 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -173,6 +173,7 @@ func cloneRequestMapForImageIntent(body []byte) map[string]any { func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackModel string) (string, string, error) { imageModel := "" imageSize := "" + imageQuality := "" hasImageTool := false if reqBody != nil { rawTools, _ := reqBody["tools"].([]any) @@ -184,11 +185,15 @@ func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackMo hasImageTool = true imageModel = strings.TrimSpace(firstNonEmptyString(toolMap["model"])) imageSize = strings.TrimSpace(firstNonEmptyString(toolMap["size"])) + imageQuality = strings.TrimSpace(firstNonEmptyString(toolMap["quality"])) break } if imageSize == "" { imageSize = strings.TrimSpace(firstNonEmptyString(reqBody["size"])) } + if imageQuality == "" { + imageQuality = strings.TrimSpace(firstNonEmptyString(reqBody["quality"])) + } } if imageModel == "" && reqBody != nil { bodyModel := strings.TrimSpace(firstNonEmptyString(reqBody["model"])) @@ -202,7 +207,7 @@ func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackMo if imageModel == "" { imageModel = strings.TrimSpace(fallbackModel) } - sizeTier := normalizeOpenAIImageSizeTier(imageSize) + sizeTier := normalizeOpenAIImageSizeTier(imageSize, imageQuality) return imageModel, sizeTier, nil } diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index afa94156867..9b7f5aeaba7 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -218,7 +218,7 @@ func (s *OpenAIGatewayService) ParseOpenAIImagesRequest(c *gin.Context, body []b if err := validateOpenAIImagesModel(req.Model); err != nil { return nil, err } - req.SizeTier = normalizeOpenAIImageSizeTier(req.Size) + req.SizeTier = normalizeOpenAIImageSizeTier(req.Size, req.Quality) req.RequiredCapability = classifyOpenAIImagesCapability(req) return req, nil } @@ -531,7 +531,24 @@ func isOpenAINativeImageOption(name string) bool { } } -func normalizeOpenAIImageSizeTier(size string) string { +// normalizeOpenAIImageSizeTier maps an image request to a billing tier +// ("1K"/"2K"/"4K") consumed by BillingService.CalculateImageCost and the +// Group.ImagePrice1K/2K/4K subscription pricing fields. +// +// gpt-image-2's image_tokens scale primarily with `quality`, not pixel size +// (low ≈ 1k tokens, medium ≈ 2k, high ≈ 4k), so an explicit quality value +// takes precedence. When quality is empty / "auto" / unrecognized we fall +// back to size-dimension classification for legacy callers (older +// gpt-image-1, dall-e flows). +func normalizeOpenAIImageSizeTier(size, quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "low": + return "1K" + case "medium": + return "2K" + case "high": + return "4K" + } trimmed := strings.TrimSpace(size) normalized := strings.ToLower(trimmed) switch normalized { diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 45fb24e975e..28eea8c5e37 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -51,7 +51,7 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) { require.Equal(t, "draw a cat", parsed.Prompt) require.True(t, parsed.Stream) require.Equal(t, "1024x1024", parsed.Size) - require.Equal(t, "1K", parsed.SizeTier) + require.Equal(t, "4K", parsed.SizeTier) require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability) require.False(t, parsed.Multipart) }