From 95776c84f63bf854b6006fc96d72ee742622a202 Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 27 Jul 2026 10:31:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20=E8=A7=84=E8=8C=83=E6=AF=94?= =?UTF-8?q?=E4=BE=8B=E4=B8=8E=20OpenAI=20=E5=9B=BE=E5=83=8F=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一过滤非 x:x 比例,避免默认值参与候选匹配或透传上游。 支持 xK 分辨率归一化,并按 Gemini、Volces 和 OpenAI 的参数规范生成上游请求;OpenAI 显式 WxH 始终保留用户原始尺寸。 验证:在独立临时 worktree 中执行 apps/api 全量 go test ./... -count=1 通过,真实 OpenAI 请求已到达上游但受本地无效凭据阻断。 --- apps/api/internal/clients/gemini.go | 31 ++- .../media_parameter_normalization_test.go | 232 ++++++++++++++++ apps/api/internal/clients/media_parameters.go | 251 ++++++++++++++++++ apps/api/internal/clients/openai.go | 9 +- apps/api/internal/clients/types.go | 1 + apps/api/internal/clients/volces.go | 27 +- apps/api/internal/runner/candidate_filter.go | 187 ++++++++++++- .../media_parameter_normalization_test.go | 173 ++++++++++++ .../internal/runner/param_processor_media.go | 169 +++++++++--- .../internal/runner/param_processor_utils.go | 76 +++++- apps/api/internal/runner/service.go | 1 + 11 files changed, 1086 insertions(+), 71 deletions(-) create mode 100644 apps/api/internal/clients/media_parameter_normalization_test.go create mode 100644 apps/api/internal/clients/media_parameters.go create mode 100644 apps/api/internal/runner/media_parameter_normalization_test.go diff --git a/apps/api/internal/clients/gemini.go b/apps/api/internal/clients/gemini.go index a6a51a2..b3a0e25 100644 --- a/apps/api/internal/clients/gemini.go +++ b/apps/api/internal/clients/gemini.go @@ -272,24 +272,39 @@ func geminiGenerationConfig(body map[string]any, imageResponse bool) map[string] if out == nil { out = map[string]any{} } - if aspectRatio := firstNonEmptyString(body["aspect_ratio"], body["aspectRatio"]); aspectRatio != "" { - imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"]))) + imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"]))) + if imageConfig != nil { + aspectRatio := normalizedProviderAspectRatio(firstNonEmptyString(imageConfig["aspectRatio"], imageConfig["aspect_ratio"])) + imageSize := normalizedProviderImageResolution(firstNonEmptyString(imageConfig["imageSize"], imageConfig["image_size"])) + delete(imageConfig, "aspectRatio") + delete(imageConfig, "aspect_ratio") + delete(imageConfig, "imageSize") + delete(imageConfig, "image_size") + if aspectRatio != "" { + imageConfig["aspectRatio"] = aspectRatio + } + if imageSize != "" { + imageConfig["imageSize"] = imageSize + } + } + if aspectRatio := normalizedProviderAspectRatio(firstNonEmptyString(body["aspect_ratio"], body["aspectRatio"], body["ratio"])); aspectRatio != "" { if imageConfig == nil { imageConfig = map[string]any{} } imageConfig["aspectRatio"] = aspectRatio - out["imageConfig"] = imageConfig - delete(out, "image_config") } - if imageSize := firstNonEmptyString(body["resolution"], body["imageSize"], body["image_size"], body["size"]); imageSize != "" { - imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"]))) + if imageSize := normalizedProviderImageResolution(firstNonEmptyString(body["resolution"], body["imageSize"], body["image_size"], body["size"])); imageSize != "" { if imageConfig == nil { imageConfig = map[string]any{} } imageConfig["imageSize"] = imageSize - out["imageConfig"] = imageConfig - delete(out, "image_config") } + if len(imageConfig) > 0 { + out["imageConfig"] = imageConfig + } else { + delete(out, "imageConfig") + } + delete(out, "image_config") if count := intFromAny(firstPresent(body["n"], body["candidateCount"], body["candidate_count"])); count > 0 { out["candidateCount"] = count } diff --git a/apps/api/internal/clients/media_parameter_normalization_test.go b/apps/api/internal/clients/media_parameter_normalization_test.go new file mode 100644 index 0000000..68d6887 --- /dev/null +++ b/apps/api/internal/clients/media_parameter_normalization_test.go @@ -0,0 +1,232 @@ +package clients + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestGeminiGenerationConfigDropsDefaultRatioTokensAndNormalizesKSize(t *testing.T) { + config := geminiGenerationConfig(map[string]any{ + "aspect_ratio": "auto", + "size": "2k", + "generationConfig": map[string]any{ + "imageConfig": map[string]any{ + "aspectRatio": "adaptive", + "imageSize": "2k", + }, + }, + }, true) + + imageConfig := mapFromAny(config["imageConfig"]) + if _, ok := imageConfig["aspectRatio"]; ok { + t.Fatalf("non-ratio tokens must not be sent to Gemini: %+v", config) + } + if imageConfig["imageSize"] != "2K" { + t.Fatalf("Gemini imageSize should use canonical K format: %+v", config) + } +} + +func TestNormalizeOfficialOpenAIImageSizeUsesModelSpecificWireFormat(t *testing.T) { + legacy := map[string]any{ + "model": "gpt-image-1", + "size": "2K", + "width": 2048, + "height": 1152, + "aspect_ratio": "16:9", + } + normalizeOfficialOpenAIImageSize(legacy, map[string]any{ + "size": "2K", + "aspect_ratio": "16:9", + }) + if legacy["size"] != "1536x1024" { + t.Fatalf("legacy GPT Image should use an official orientation size: %+v", legacy) + } + + flexible := map[string]any{ + "model": "gpt-image-2", + "size": "2K", + "resolution": "2k", + "aspect_ratio": "16:9", + } + normalizeOfficialOpenAIImageSize(flexible, map[string]any{ + "size": "2K", + "aspect_ratio": "16:9", + }) + if flexible["size"] != "2048x1152" { + t.Fatalf("GPT Image 2 should receive flexible pixel dimensions: %+v", flexible) + } + + rounded := map[string]any{ + "model": "gpt-image-2", + "size": "2K", + "resolution": "2K", + "aspect_ratio": "3:2", + } + normalizeOfficialOpenAIImageSize(rounded, map[string]any{ + "size": "2K", + "aspect_ratio": "3:2", + }) + if rounded["size"] != "2048x1360" { + t.Fatalf("GPT Image 2 dimensions should satisfy the official 16-pixel multiple: %+v", rounded) + } + + explicit := map[string]any{ + "model": "gpt-image-2", + "size": "1232x768", + "width": 1232, + "height": 768, + } + normalizeOfficialOpenAIImageSize(explicit, map[string]any{ + "size": "1234x777", + }) + if explicit["size"] != "1234x777" { + t.Fatalf("an explicit WxH size must be sent unchanged to OpenAI: %+v", explicit) + } + + explicitLegacy := map[string]any{ + "model": "gpt-image-1", + "size": "2048x1152", + } + normalizeOfficialOpenAIImageSize(explicitLegacy, map[string]any{ + "size": "2048x1152", + }) + if explicitLegacy["size"] != "2048x1152" { + t.Fatalf("explicit WxH must also win for legacy OpenAI image models: %+v", explicitLegacy) + } +} + +func TestOpenAIClientUsesOriginalWxHAndConvertsKResolution(t *testing.T) { + received := make([]map[string]any, 0, 3) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode OpenAI image request: %v", err) + } + received = append(received, body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []any{map[string]any{"b64_json": "aW1hZ2U="}}, + }) + })) + defer server.Close() + + candidate := store.RuntimeModelCandidate{ + BaseURL: server.URL, + Provider: "openai", + ProviderModelName: "gpt-image-2", + ModelType: "image_generate", + Credentials: map[string]any{"apiKey": "openai-key"}, + } + requests := []Request{ + { + Kind: "images.generations", + ModelType: "image_generate", + Body: map[string]any{ + "prompt": "explicit dimensions", + "size": "1232x768", + "width": 1232, + "height": 768, + }, + OriginalBody: map[string]any{ + "size": "1234x777", + }, + Candidate: candidate, + }, + { + Kind: "images.generations", + ModelType: "image_generate", + Body: map[string]any{ + "prompt": "resolution conversion", + "size": "2048x1365", + "resolution": "2K", + "aspect_ratio": "3:2", + "width": 2048, + "height": 1365, + }, + OriginalBody: map[string]any{ + "size": "2K", + "aspect_ratio": "3:2", + }, + Candidate: candidate, + }, + { + Kind: "images.generations", + ModelType: "image_generate", + Body: map[string]any{ + "prompt": "resolution field conversion", + "size": "1024x1024", + "resolution": "1K", + "aspect_ratio": "1:1", + "width": 1024, + "height": 1024, + }, + OriginalBody: map[string]any{ + "resolution": "1k", + "aspect_ratio": "1:1", + }, + Candidate: candidate, + }, + } + for _, request := range requests { + if _, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), request); err != nil { + t.Fatalf("run OpenAI image request: %v", err) + } + } + + if len(received) != 3 { + t.Fatalf("unexpected request count: %d", len(received)) + } + if received[0]["size"] != "1234x777" { + t.Fatalf("explicit WxH should win over preprocessed dimensions: %+v", received[0]) + } + if received[1]["size"] != "2048x1360" { + t.Fatalf("2K with 3:2 should use normalized OpenAI dimensions: %+v", received[1]) + } + if received[2]["size"] != "1024x1024" { + t.Fatalf("resolution=1K should be converted to OpenAI dimensions: %+v", received[2]) + } + for _, body := range received { + for _, key := range []string{"resolution", "aspect_ratio", "width", "height"} { + if _, ok := body[key]; ok { + t.Fatalf("generic geometry field %q must not reach OpenAI: %+v", key, body) + } + } + } +} + +func TestVolcesImageBodySelectsConfiguredSizeFormat(t *testing.T) { + body := volcesImageBody(Request{ + Kind: "images.generations", + ModelType: "image_generate", + Body: map[string]any{ + "model": "Seedream-5.0-Pro", + "resolution": "2k", + "size": "2048x1152", + "width": 2048, + "height": 1152, + "aspect_ratio": "16:9", + "platformId": "gateway-internal-platform", + }, + Candidate: store.RuntimeModelCandidate{ + ProviderModelName: "doubao-seedream-5-0-pro-260628", + ModelType: "image_generate", + Capabilities: map[string]any{ + "image_generate": map[string]any{ + "size_param_format": "resolution", + }, + }, + }, + }) + if body["size"] != "2K" { + t.Fatalf("resolution-only Volces model should receive canonical K size: %+v", body) + } + for _, key := range []string{"resolution", "width", "height", "aspect_ratio", "platformId"} { + if _, ok := body[key]; ok { + t.Fatalf("generic geometry field %q must not reach Volces: %+v", key, body) + } + } +} diff --git a/apps/api/internal/clients/media_parameters.go b/apps/api/internal/clients/media_parameters.go new file mode 100644 index 0000000..39f9a3f --- /dev/null +++ b/apps/api/internal/clients/media_parameters.go @@ -0,0 +1,251 @@ +package clients + +import ( + "math" + "strconv" + "strings" +) + +const ( + openAIGPTImage2MaxEdge = 3840 + openAIGPTImage2MinPixels = 655360 + openAIGPTImage2MaxPixels = 8294400 + openAIGPTImage2MaxRatio = 3 + openAIGPTImage2Multiple = 16 +) + +type providerImageDimensions struct { + width int + height int +} + +func normalizedProviderAspectRatio(value any) string { + compact := strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '\r', '\n': + return -1 + default: + return r + } + }, strings.TrimSpace(stringFromAny(value))) + parts := strings.Split(compact, ":") + if len(parts) != 2 { + return "" + } + width, widthErr := strconv.ParseFloat(parts[0], 64) + height, heightErr := strconv.ParseFloat(parts[1], 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return "" + } + return parts[0] + ":" + parts[1] +} + +func normalizedProviderImageResolution(value any) string { + text := strings.ToLower(strings.TrimSpace(stringFromAny(value))) + if len(text) < 2 || text[len(text)-1] != 'k' { + return "" + } + magnitude, err := strconv.Atoi(text[:len(text)-1]) + if err != nil || magnitude <= 0 { + return "" + } + return strconv.Itoa(magnitude) + "K" +} + +func normalizeOfficialOpenAIImageSize(body map[string]any, originalBody map[string]any) { + if originalBody == nil { + originalBody = body + } + if width, height, ok := providerPixelDimensions(stringFromAny(originalBody["size"])); ok { + body["size"] = strconv.Itoa(width) + "x" + strconv.Itoa(height) + return + } + + model := strings.ToLower(strings.TrimSpace(stringFromAny(body["model"]))) + resolution := normalizedProviderImageResolution(firstNonEmptyString( + originalBody["resolution"], + originalBody["size"], + body["resolution"], + body["size"], + )) + if resolution != "" { + width, height, ok := providerBodyPixelDimensions(body) + if !ok { + width, height, ok = providerResolutionDimensions( + resolution, + firstNonEmptyString(originalBody["aspect_ratio"], originalBody["aspectRatio"], originalBody["ratio"]), + ) + } + if !ok { + delete(body, "size") + return + } + switch { + case strings.HasPrefix(model, "gpt-image-2"): + dimensions := normalizeOpenAIGPTImage2Dimensions(width, height) + body["size"] = strconv.Itoa(dimensions.width) + "x" + strconv.Itoa(dimensions.height) + case strings.HasPrefix(model, "gpt-image-"): + body["size"] = officialOpenAILegacyImageSize(width, height) + default: + body["size"] = strconv.Itoa(width) + "x" + strconv.Itoa(height) + } + return + } + + if !strings.HasPrefix(model, "gpt-image-") { + return + } + size := strings.ToLower(strings.TrimSpace(stringFromAny(body["size"]))) + if size == "auto" { + body["size"] = "auto" + return + } + if strings.HasPrefix(model, "gpt-image-2") { + if width, height, ok := providerPixelDimensions(size); ok { + body["size"] = strconv.Itoa(width) + "x" + strconv.Itoa(height) + } + return + } + + switch size { + case "1024x1024", "1536x1024", "1024x1536": + body["size"] = size + return + } + width, height, ok := requestedProviderDimensions(body) + if !ok { + body["size"] = "auto" + return + } + body["size"] = officialOpenAILegacyImageSize(width, height) +} + +func officialOpenAILegacyImageSize(width int, height int) string { + switch { + case width > height: + return "1536x1024" + case height > width: + return "1024x1536" + default: + return "1024x1024" + } +} + +func providerBodyPixelDimensions(body map[string]any) (int, int, bool) { + if width, height, ok := providerPixelDimensions(stringFromAny(body["size"])); ok { + return width, height, true + } + width := intFromAny(body["width"]) + height := intFromAny(body["height"]) + return width, height, width > 0 && height > 0 +} + +func providerResolutionDimensions(resolution string, aspectRatio any) (int, int, bool) { + resolution = normalizedProviderImageResolution(resolution) + if resolution == "" { + return 0, 0, false + } + magnitude, err := strconv.Atoi(strings.TrimSuffix(resolution, "K")) + if err != nil || magnitude <= 0 { + return 0, 0, false + } + baseSide := magnitude * 1024 + ratio := providerAspectRatioNumber(aspectRatio) + width, height := baseSide, baseSide + if ratio > 1 { + height = max(1, int(math.Round(float64(baseSide)/ratio))) + } else if ratio > 0 && ratio < 1 { + width = max(1, int(math.Round(float64(baseSide)*ratio))) + } + return width, height, true +} + +func requestedProviderDimensions(body map[string]any) (int, int, bool) { + if width, height, ok := providerPixelDimensions(stringFromAny(body["size"])); ok { + return width, height, true + } + width := intFromAny(body["width"]) + height := intFromAny(body["height"]) + if width > 0 && height > 0 { + return width, height, true + } + ratio := providerAspectRatioNumber(firstNonEmptyString(body["aspect_ratio"], body["aspectRatio"], body["ratio"])) + if ratio > 1 { + return 2, 1, true + } + if ratio > 0 && ratio < 1 { + return 1, 2, true + } + if ratio == 1 { + return 1, 1, true + } + return 0, 0, false +} + +func providerPixelDimensions(value string) (int, int, bool) { + parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "x") + if len(parts) != 2 { + return 0, 0, false + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +func providerAspectRatioNumber(value any) float64 { + normalized := normalizedProviderAspectRatio(value) + if normalized == "" { + return 0 + } + parts := strings.Split(normalized, ":") + width, _ := strconv.ParseFloat(parts[0], 64) + height, _ := strconv.ParseFloat(parts[1], 64) + return width / height +} + +func normalizeOpenAIGPTImage2Dimensions(width int, height int) providerImageDimensions { + if width <= 0 || height <= 0 { + return providerImageDimensions{} + } + rawRatio := float64(width) / float64(height) + ratio := rawRatio + if ratio < 1 { + ratio = 1 / ratio + } + ratio = min(ratio, float64(openAIGPTImage2MaxRatio)) + landscape := rawRatio >= 1 + longEdge := min(max(width, height), openAIGPTImage2MaxEdge) + dimensions := buildOpenAIGPTImage2Dimensions(longEdge, ratio, landscape, false) + for dimensions.width*dimensions.height > openAIGPTImage2MaxPixels { + longEdge = max(openAIGPTImage2Multiple, max(dimensions.width, dimensions.height)-openAIGPTImage2Multiple) + dimensions = buildOpenAIGPTImage2Dimensions(longEdge, ratio, landscape, false) + } + for dimensions.width*dimensions.height < openAIGPTImage2MinPixels && max(dimensions.width, dimensions.height) < openAIGPTImage2MaxEdge { + longEdge = min(openAIGPTImage2MaxEdge, max(dimensions.width, dimensions.height)+openAIGPTImage2Multiple) + dimensions = buildOpenAIGPTImage2Dimensions(longEdge, ratio, landscape, true) + } + return dimensions +} + +func buildOpenAIGPTImage2Dimensions(longEdge int, ratio float64, landscape bool, roundUp bool) providerImageDimensions { + normalizedLongEdge := roundOpenAIGPTImage2Multiple(float64(longEdge), roundUp) + normalizedShortEdge := roundOpenAIGPTImage2Multiple(float64(normalizedLongEdge)/ratio, roundUp) + if float64(normalizedLongEdge)/float64(normalizedShortEdge) > openAIGPTImage2MaxRatio { + normalizedShortEdge += openAIGPTImage2Multiple + } + if landscape { + return providerImageDimensions{width: normalizedLongEdge, height: normalizedShortEdge} + } + return providerImageDimensions{width: normalizedShortEdge, height: normalizedLongEdge} +} + +func roundOpenAIGPTImage2Multiple(value float64, roundUp bool) int { + rounded := math.Round(value / openAIGPTImage2Multiple) + if roundUp { + rounded = math.Ceil(value / openAIGPTImage2Multiple) + } + return max(openAIGPTImage2Multiple, int(rounded)*openAIGPTImage2Multiple) +} diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index 9c0ff52..1be5776 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -65,7 +65,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error } } body["model"] = upstreamModelName(request.Candidate) - normalizeOpenAIImageRequestBody(endpointKind, body) + normalizeOpenAIImageRequestBody(endpointKind, body, request.OriginalBody) stream := openAIEndpointSupportsStream(endpointKind) && (request.Stream || boolValue(body, "stream")) ensureOpenAIStreamUsage(body, endpointKind, stream) raw, contentType, err := openAIRequestPayload(endpointKind, body, request.Candidate) @@ -172,10 +172,11 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error }, nil } -func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any) { +func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any, originalBody map[string]any) { if endpointKind != "images.generations" && endpointKind != "images.edits" { return } + normalizeOfficialOpenAIImageSize(body, originalBody) // OpenAI image endpoints express output geometry through size. The Gateway // keeps the generic fields for capability validation and billing, but they // are not valid OpenAI wire parameters. @@ -185,6 +186,10 @@ func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any) { "resolution", "width", "height", + "platform_id", + "platformId", + "platform_model_id", + "platformModelId", } { delete(body, key) } diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 599deef..78fdbad 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -15,6 +15,7 @@ type Request struct { ModelType string Model string Body map[string]any + OriginalBody map[string]any Candidate store.RuntimeModelCandidate HTTPClient *http.Client RemoteTaskID string diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index 17c4c68..4b770d8 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -357,16 +357,33 @@ func volcesImageBody(request Request) map[string]any { body["seed"] = -1 } } - if resolution := strings.TrimSpace(stringFromAny(body["resolution"])); resolution != "" { + resolution := normalizedProviderImageResolution(body["resolution"]) + size := widthHeightSize(body) + if volcesImageUsesResolutionSize(request) && resolution != "" { + body["size"] = resolution + } else if size != "" { + body["size"] = size + } else if resolution != "" { body["size"] = resolution } - if size := widthHeightSize(body); size != "" { - body["size"] = size + for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio", "resolution", "width", "height"} { + delete(body, key) } normalizeVolcesSequentialImageGeneration(body, request) return body } +func volcesImageUsesResolutionSize(request Request) bool { + modelType := firstNonEmpty(request.ModelType, request.Candidate.ModelType) + if capability, ok := request.Candidate.Capabilities[modelType].(map[string]any); ok { + if strings.EqualFold(strings.TrimSpace(stringFromAny(capability["size_param_format"])), "resolution") { + return true + } + } + model := strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) + return strings.HasPrefix(model, "doubao-seedream-5-0") +} + func volcesVideoBody(request Request) map[string]any { body := cleanProviderBody(request.Body) body["model"] = upstreamModelName(request.Candidate) @@ -397,6 +414,10 @@ func cleanProviderBody(body map[string]any) map[string]any { "_gateway_target_protocol", "_compat_provider", "_kling_compat_version", + "platform_id", + "platformId", + "platform_model_id", + "platformModelId", } { delete(out, key) } diff --git a/apps/api/internal/runner/candidate_filter.go b/apps/api/internal/runner/candidate_filter.go index c8549f0..1c9b8ac 100644 --- a/apps/api/internal/runner/candidate_filter.go +++ b/apps/api/internal/runner/candidate_filter.go @@ -8,6 +8,7 @@ import ( ) const unsupportedRequestResolutionCode = "unsupported_request_resolution" +const unsupportedRequestAspectRatioCode = "unsupported_request_aspect_ratio" type requestResolutionRequirement struct { Kind string @@ -29,6 +30,15 @@ type videoResolutionReferenceStats struct { } func filterRuntimeCandidatesByRequest(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) { + filtered, resolutionSummary, err := filterRuntimeCandidatesByRequestResolution(kind, requestedModel, modelType, body, candidates) + if err != nil { + return filtered, resolutionSummary, err + } + filtered, aspectRatioSummary, err := filterRuntimeCandidatesByRequestAspectRatio(kind, requestedModel, modelType, body, filtered) + return filtered, mergeCandidateFilterSummaries(resolutionSummary, aspectRatioSummary), err +} + +func filterRuntimeCandidatesByRequestResolution(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) { requirement, ok := requestResolutionRequirementFor(kind, requestedModel, modelType, body) if !ok || len(candidates) == 0 { return candidates, nil, nil @@ -82,13 +92,13 @@ func requestResolutionRequirementFor(kind string, requestedModel string, modelTy } func requestResolutionValue(body map[string]any, modelType string) (string, string) { - if value := normalizedRequestResolution(stringFromAny(body["resolution"])); value != "" { + if value := normalizedRequestResolution(stringFromAny(body["resolution"]), modelType); value != "" { if _, _, ok := parsePixelSizeString(value); ok { return "", "" } return value, "resolution" } - size := normalizedRequestResolution(stringFromAny(body["size"])) + size := normalizedRequestResolution(stringFromAny(body["size"]), modelType) if size == "" { return "", "" } @@ -98,7 +108,7 @@ func requestResolutionValue(body map[string]any, modelType string) (string, stri return "", "" } -func normalizedRequestResolution(value string) string { +func normalizedRequestResolution(value string, modelType string) string { value = strings.TrimSpace(value) if value == "" || isEmptyParamString(value) { return "" @@ -106,9 +116,11 @@ func normalizedRequestResolution(value string) string { switch strings.ToLower(value) { case "auto", "automatic", "adaptive", "default": return "" - default: + } + if _, _, ok := parsePixelSizeString(value); ok { return value } + return normalizeMediaResolution(modelType, value) } func isResolutionFilteredModelType(modelType string) bool { @@ -120,10 +132,9 @@ func candidateSupportsRequestResolution(candidate store.RuntimeModelCandidate, r capability := capabilityForType(effectiveModelCapability(candidate), modelType) detail := candidateResolutionDetail(candidate, requirement, modelType) if capability == nil { - detail["reason"] = "capability_missing" - detail["message"] = "候选平台模型未配置对应模型类型能力。" - detail["capabilityPath"] = capabilityPath(modelType, "output_resolutions") - return false, detail + detail["reason"] = "capability_unrestricted" + detail["message"] = "候选平台模型未配置分辨率白名单,按未限制处理。" + return true, detail } allowed, configured := outputResolutionAllowedValues(capability["output_resolutions"], requirement.Scopes) @@ -131,9 +142,19 @@ func candidateSupportsRequestResolution(candidate store.RuntimeModelCandidate, r detail["capabilityPath"] = capabilityPath(modelType, "output_resolutions") detail["capabilityValue"] = cloneAny(capability["output_resolutions"]) if !configured { - detail["reason"] = "output_resolutions_missing" - detail["message"] = "候选平台模型未声明 output_resolutions。" - return false, detail + detail["reason"] = "output_resolutions_unrestricted" + detail["message"] = "候选平台模型未声明 output_resolutions,按未限制处理。" + return true, detail + } + if len(allowed) == 0 { + if _, scoped := capability["output_resolutions"].(map[string]any); scoped && len(requirement.Scopes) > 0 && !outputResolutionScopeConfigured(capability["output_resolutions"], requirement.Scopes) { + detail["reason"] = "resolution_scope_not_allowed" + detail["message"] = "候选平台模型未声明当前请求场景的 output_resolutions。" + return false, detail + } + detail["reason"] = "output_resolutions_empty" + detail["message"] = "候选平台模型声明了空分辨率白名单,按未限制处理。" + return true, detail } if containsResolution(allowed, requirement.Resolution) { detail["reason"] = "supported" @@ -162,7 +183,7 @@ func outputResolutionAllowedValues(value any, scopes []string) ([]string, bool) for _, raw := range typed { values = append(values, stringListFromAny(raw)...) } - return uniqueStringList(values), len(values) > 0 + return uniqueStringList(values), true } return nil, true default: @@ -170,6 +191,148 @@ func outputResolutionAllowedValues(value any, scopes []string) ([]string, bool) } } +func outputResolutionScopeConfigured(value any, scopes []string) bool { + typed, ok := value.(map[string]any) + if !ok { + return false + } + for _, scope := range append(append([]string(nil), scopes...), "default", "*", "all") { + if scope == "" { + continue + } + if _, exists := typed[scope]; exists { + return true + } + } + return false +} + +type requestAspectRatioRequirement struct { + Kind string + RequestedModel string + ModelType string + AspectRatio string + Source string + Resolution string +} + +func filterRuntimeCandidatesByRequestAspectRatio(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) { + requirement, ok := requestAspectRatioRequirementFor(kind, requestedModel, modelType, body) + if !ok || len(candidates) == 0 { + return candidates, nil, nil + } + + filtered := make([]store.RuntimeModelCandidate, 0, len(candidates)) + rejected := make([]map[string]any, 0) + supportedRatios := make([]string, 0) + for _, candidate := range candidates { + modelType := firstNonEmptyString(candidate.ModelType, requirement.ModelType) + capability := capabilityForType(effectiveModelCapability(candidate), modelType) + detail := candidateAspectRatioDetail(candidate, requirement, modelType) + if capability == nil { + detail["reason"] = "capability_unrestricted" + filtered = append(filtered, candidate) + continue + } + allowed, configured := outputResolutionAllowedValues(capability["aspect_ratio_allowed"], []string{requirement.Resolution}) + detail["allowedAspectRatios"] = allowed + detail["capabilityPath"] = capabilityPath(modelType, "aspect_ratio_allowed") + detail["capabilityValue"] = cloneAny(capability["aspect_ratio_allowed"]) + for _, value := range allowed { + appendUniqueString(&supportedRatios, value) + } + if !configured || len(allowed) == 0 || containsResolution(allowed, requirement.AspectRatio) { + detail["reason"] = "supported" + filtered = append(filtered, candidate) + continue + } + detail["reason"] = "aspect_ratio_not_allowed" + detail["message"] = "请求比例不在候选平台模型 aspect_ratio_allowed 中。" + rejected = append(rejected, detail) + } + + summary := map[string]any{ + "code": unsupportedRequestAspectRatioCode, + "filter": "request_aspect_ratio", + "kind": requirement.Kind, + "requestedModel": requirement.RequestedModel, + "modelType": requirement.ModelType, + "requestedAspectRatio": requirement.AspectRatio, + "aspectRatioSource": requirement.Source, + "resolution": requirement.Resolution, + "capabilityPath": capabilityPath(requirement.ModelType, "aspect_ratio_allowed"), + "candidateCount": len(candidates), + "supportedCandidateCount": len(filtered), + "filteredCandidateCount": len(rejected), + "supportedAspectRatios": uniqueStringList(supportedRatios), + "rejectedCandidates": rejected, + } + if len(filtered) == 0 { + return nil, summary, &store.ModelCandidateUnavailableError{ + Code: unsupportedRequestAspectRatioCode, + Message: fmt.Sprintf("请求的%s比例 %s 没有可用平台模型支持,已过滤 %d 个候选平台模型", mediaResourceName(requirement.ModelType), requirement.AspectRatio, len(rejected)), + Details: summary, + } + } + return filtered, summary, nil +} + +func requestAspectRatioRequirementFor(kind string, requestedModel string, modelType string, body map[string]any) (requestAspectRatioRequirement, bool) { + if !isResolutionFilteredModelType(modelType) { + return requestAspectRatioRequirement{}, false + } + for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio"} { + raw, exists := body[key] + if !exists { + continue + } + aspectRatio, ok := normalizedAspectRatio(stringFromAny(raw)) + if !ok { + return requestAspectRatioRequirement{}, false + } + resolution, _ := requestResolutionValue(body, modelType) + return requestAspectRatioRequirement{ + Kind: kind, + RequestedModel: requestedModel, + ModelType: modelType, + AspectRatio: aspectRatio, + Source: key, + Resolution: resolution, + }, true + } + return requestAspectRatioRequirement{}, false +} + +func candidateAspectRatioDetail(candidate store.RuntimeModelCandidate, requirement requestAspectRatioRequirement, modelType string) map[string]any { + return map[string]any{ + "platformId": candidate.PlatformID, + "platformKey": candidate.PlatformKey, + "platformName": candidate.PlatformName, + "provider": candidate.Provider, + "platformModelId": candidate.PlatformModelID, + "modelName": candidate.ModelName, + "modelAlias": candidate.ModelAlias, + "displayName": candidate.DisplayName, + "providerModelName": candidate.ProviderModelName, + "modelType": modelType, + "requested": map[string]any{ + "aspectRatio": requirement.AspectRatio, + "source": requirement.Source, + "resolution": requirement.Resolution, + }, + } +} + +func mediaResourceName(modelType string) string { + if modelType == "image_generate" || modelType == "image_edit" { + return "图像" + } + if isVideoModelType(modelType) { + return "视频" + } + return "媒体" +} + func containsResolution(values []string, target string) bool { for _, value := range values { if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) { diff --git a/apps/api/internal/runner/media_parameter_normalization_test.go b/apps/api/internal/runner/media_parameter_normalization_test.go new file mode 100644 index 0000000..8c8640f --- /dev/null +++ b/apps/api/internal/runner/media_parameter_normalization_test.go @@ -0,0 +1,173 @@ +package runner + +import ( + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestParamProcessorTreatsNonRatioValuesAsUnspecifiedForImagesAndVideos(t *testing.T) { + for _, modelType := range []string{"image_generate", "video_generate"} { + t.Run(modelType, func(t *testing.T) { + result := preprocessRequestWithLog(mediaKindForTest(modelType), map[string]any{ + "aspect_ratio": "auto", + "aspectRatio": "adaptive", + "ratio": "keep_ratio", + }, store.RuntimeModelCandidate{ + ModelType: modelType, + Capabilities: map[string]any{ + modelType: map[string]any{ + "aspect_ratio_allowed": []any{"adaptive"}, + }, + }, + }) + for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio"} { + if _, ok := result.Body[key]; ok { + t.Fatalf("%s should be removed for non-ratio input: %+v", key, result.Body) + } + } + if result.Err != nil { + t.Fatalf("non-ratio input should use provider default instead of failing: %v", result.Err) + } + }) + } +} + +func TestParamProcessorNormalizesImageKSizeAndPreservesProviderResolutionChoice(t *testing.T) { + tests := []struct { + name string + capability map[string]any + wantSize string + }{ + { + name: "dimension upstream", + capability: map[string]any{ + "output_resolutions": []any{"1K", "2K", "4K"}, + "aspect_ratio_allowed": []any{"16:9"}, + }, + wantSize: "2048x1152", + }, + { + name: "resolution upstream", + capability: map[string]any{ + "output_resolutions": []any{"1K", "2K"}, + "aspect_ratio_allowed": []any{"16:9"}, + "size_param_format": "resolution", + }, + wantSize: "2K", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := preprocessRequestWithLog("images.generations", map[string]any{ + "size": "2k", + "aspect_ratio": "16 : 9", + }, store.RuntimeModelCandidate{ + ModelType: "image_generate", + Capabilities: map[string]any{ + "image_generate": test.capability, + }, + }) + if result.Err != nil { + t.Fatalf("preprocess image size: %v", result.Err) + } + if result.Body["resolution"] != "2K" || result.Body["size"] != test.wantSize { + t.Fatalf("unexpected normalized size: %+v", result.Body) + } + if result.Body["width"] != 2048 || result.Body["height"] != 1152 || result.Body["aspect_ratio"] != "16:9" { + t.Fatalf("unexpected normalized dimensions: %+v", result.Body) + } + }) + } +} + +func TestParamProcessorNormalizesVideo4KSize(t *testing.T) { + result := preprocessRequestWithLog("videos.generations", map[string]any{ + "size": "4K", + "aspect_ratio": "auto", + }, store.RuntimeModelCandidate{ + ModelType: "video_generate", + Capabilities: map[string]any{ + "video_generate": map[string]any{ + "output_resolutions": []any{"2160p"}, + "aspect_ratio_allowed": []any{"16:9"}, + }, + }, + }) + if result.Body["resolution"] != "2160p" { + t.Fatalf("video 4K size should normalize to 2160p: %+v", result.Body) + } + if _, ok := result.Body["aspect_ratio"]; ok { + t.Fatalf("video auto ratio should stay unspecified: %+v", result.Body) + } +} + +func TestCandidateFilterIgnoresNonRatioValuesAndFiltersRealRatios(t *testing.T) { + candidates := []store.RuntimeModelCandidate{ + candidateWithAspectRatios("square", "image_generate", "1:1"), + candidateWithAspectRatios("landscape", "image_generate", "16:9"), + } + + filtered, summary, err := filterRuntimeCandidatesByRequest("images.generations", "demo-image", "image_generate", map[string]any{ + "aspect_ratio": "auto", + }, candidates) + if err != nil || len(filtered) != 2 || summary != nil { + t.Fatalf("auto ratio should not participate in candidate matching: filtered=%+v summary=%+v err=%v", filtered, summary, err) + } + + filtered, summary, err = filterRuntimeCandidatesByRequest("images.generations", "demo-image", "image_generate", map[string]any{ + "aspect_ratio": "16 : 9", + }, candidates) + if err != nil || len(filtered) != 1 || filtered[0].PlatformKey != "landscape" { + t.Fatalf("real ratio should filter candidates: filtered=%+v summary=%+v err=%v", filtered, summary, err) + } +} + +func TestCandidateFilterNormalizesKSizeAndAllowsUnconfiguredResolution(t *testing.T) { + candidates := []store.RuntimeModelCandidate{ + candidateWithImageResolutions("one-k", "1K"), + { + PlatformID: "platform-unrestricted", + PlatformKey: "unrestricted", + PlatformModelID: "model-unrestricted", + ModelName: "demo-image", + ModelType: "image_generate", + Capabilities: map[string]any{ + "image_generate": map[string]any{}, + }, + }, + } + + filtered, _, err := filterRuntimeCandidatesByRequest("images.generations", "demo-image", "image_generate", map[string]any{ + "size": "2k", + }, candidates) + if err != nil { + t.Fatalf("2k size should be accepted and normalized for matching: %v", err) + } + if len(filtered) != 1 || filtered[0].PlatformKey != "unrestricted" { + t.Fatalf("unexpected candidates for normalized 2K size: %+v", filtered) + } +} + +func candidateWithAspectRatios(platformKey string, modelType string, ratios ...string) store.RuntimeModelCandidate { + return store.RuntimeModelCandidate{ + PlatformID: "platform-" + platformKey, + PlatformKey: platformKey, + PlatformModelID: "model-" + platformKey, + ModelName: "demo-media", + ModelType: modelType, + Capabilities: map[string]any{ + modelType: map[string]any{ + "aspect_ratio_allowed": stringsToAny(ratios), + }, + }, + } +} + +func mediaKindForTest(modelType string) string { + if modelType == "image_generate" { + return "images.generations" + } + return "videos.generations" +} diff --git a/apps/api/internal/runner/param_processor_media.go b/apps/api/internal/runner/param_processor_media.go index bf0ffda..4b905ba 100644 --- a/apps/api/internal/runner/param_processor_media.go +++ b/apps/api/internal/runner/param_processor_media.go @@ -3,6 +3,7 @@ package runner import ( "fmt" "math" + "strconv" "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" @@ -13,28 +14,43 @@ type resolutionNormalizeProcessor struct{} func (resolutionNormalizeProcessor) Name() string { return "ResolutionNormalizeProcessor" } func (resolutionNormalizeProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool { - if stringFromAny(params["resolution"]) != "" { - return false + if normalizeMediaResolution(modelType, stringFromAny(params["resolution"])) != "" { + return true } - size := stringFromAny(params["size"]) - if size == "" { - return false - } - return isImageResolution(modelType, size) || isVideoResolution(modelType, size) + return normalizeMediaResolution(modelType, stringFromAny(params["size"])) != "" } func (resolutionNormalizeProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool { + if resolution := normalizeMediaResolution(modelType, stringFromAny(params["resolution"])); resolution != "" { + before := params["resolution"] + params["resolution"] = resolution + context.resolution = resolution + if stringFromAny(before) != resolution { + _, capabilityValue := capabilityEvidence(context.modelCapability, modelType, "output_resolutions") + context.recordChange( + "ResolutionNormalizeProcessor", + "adjust", + "resolution", + before, + resolution, + "resolution 使用了可识别的媒体分辨率格式,已归一为标准大小写和值。", + capabilityPath(modelType, "output_resolutions"), + capabilityValue, + ) + } + return true + } size := stringFromAny(params["size"]) - if stringFromAny(params["resolution"]) == "" && (isImageResolution(modelType, size) || isVideoResolution(modelType, size)) { + if resolution := normalizeMediaResolution(modelType, size); resolution != "" { _, capabilityValue := capabilityEvidence(context.modelCapability, modelType, "output_resolutions") - params["resolution"] = size - context.resolution = size + params["resolution"] = resolution + context.resolution = resolution context.recordChange( "ResolutionNormalizeProcessor", "set", "resolution", nil, - size, + resolution, "size 使用分辨率格式,归一到 resolution 供后续能力校验和计费使用。", capabilityPath(modelType, "output_resolutions"), capabilityValue, @@ -48,30 +64,69 @@ type aspectRatioProcessor struct{} func (aspectRatioProcessor) Name() string { return "AspectRatioProcessor" } func (aspectRatioProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool { - return modelType != "text_generate" && (stringFromAny(params["aspect_ratio"]) != "" || stringFromAny(params["size"]) != "") + if modelType == "text_generate" { + return false + } + for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio"} { + if _, ok := params[key]; ok { + return true + } + } + return stringFromAny(params["size"]) != "" } func (aspectRatioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool { - capability := capabilityForType(context.modelCapability, modelType) - if capability == nil { + aspectRatio, ratioSource, ratioPresent := requestAspectRatio(params) + if ratioPresent { + originalAspectRatio := aspectRatio + normalized, valid := normalizedAspectRatio(aspectRatio) + if !valid { + before := map[string]any{} + for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio"} { + if value, ok := params[key]; ok { + before[key] = cloneAny(value) + delete(params, key) + } + } + aspectRatio = "" + context.aspectRatio = "" + context.recordChange( + "AspectRatioProcessor", + "remove", + ratioSource, + before, + nil, + "比例不是有效的正数 x:x 格式,按未指定比例处理。", + "", + nil, + ) + return true + } else { + aspectRatio = normalized + params["aspect_ratio"] = normalized + for _, key := range []string{"aspectRatio", "ratio"} { + delete(params, key) + } + if ratioSource != "aspect_ratio" || normalized != originalAspectRatio { + context.recordChange( + "AspectRatioProcessor", + "adjust", + "aspect_ratio", + map[string]any{ratioSource: originalAspectRatio}, + normalized, + "比例已归一为标准 x:x 格式。", + "", + nil, + ) + } + } + } + if !ratioPresent { return true } - aspectRatio := stringFromAny(params["aspect_ratio"]) - if isEmptyParamString(aspectRatio) { - before := params["aspect_ratio"] - delete(params, "aspect_ratio") - context.aspectRatio = "" - context.recordChange( - "AspectRatioProcessor", - "remove", - "aspect_ratio", - before, - nil, - "aspect_ratio 是空值字符串,不能作为有效比例传给上游。", - "", - nil, - ) + capability := capabilityForType(context.modelCapability, modelType) + if capability == nil { return true } @@ -174,6 +229,15 @@ func (aspectRatioProcessor) Process(params map[string]any, modelType string, con return true } +func requestAspectRatio(params map[string]any) (string, string, bool) { + for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio"} { + if value, ok := params[key]; ok { + return stringFromAny(value), key, true + } + } + return "", "aspect_ratio", false +} + func validateExplicitAspectRatio(aspectRatio string, capability map[string]any, allowed []string, modelType string) (string, bool) { if isVideoModelType(modelType) && len(allowed) > 0 && !containsString(allowed, aspectRatio) { if aspectRatio == "adaptive" || aspectRatio == "keep_ratio" { @@ -194,17 +258,14 @@ func (imageSizeProcessor) ShouldProcess(params map[string]any, modelType string, if modelType != "image_generate" && modelType != "image_edit" { return false } - if _, _, ok := imageDimensionsFromParams(params); !ok { - return false - } - capability := capabilityForType(context.modelCapability, modelType) - return capability != nil && imageSizeCapabilityConfigured(capability) + _, _, ok := imageDimensionsFromParams(params) + return ok } func (imageSizeProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool { capability := capabilityForType(context.modelCapability, modelType) if capability == nil { - return true + capability = map[string]any{} } width, height, ok := imageDimensionsFromParams(params) if !ok { @@ -222,7 +283,11 @@ func (imageSizeProcessor) Process(params map[string]any, modelType string, conte params["width"] = width params["height"] = height _, _, sizeHasPixelDimensions := parsePixelSizeString(stringFromAny(params["size"])) - if stringFromAny(params["aspect_ratio"]) == "" || sizeHasPixelDimensions { + _, _, resolutionHasPixelDimensions := parsePixelSizeString(stringFromAny(params["resolution"])) + explicitWidthHeight := positiveIntegerFromAny(params["width"]) > 0 && positiveIntegerFromAny(params["height"]) > 0 && + normalizeImageResolution(stringFromAny(params["size"])) == "" && + normalizeImageResolution(stringFromAny(params["resolution"])) == "" + if sizeHasPixelDimensions || resolutionHasPixelDimensions || (stringFromAny(params["aspect_ratio"]) == "" && explicitWidthHeight) { aspectRatio := aspectRatioFromDimensions(width, height) allowed := aspectRatioAllowed(capability["aspect_ratio_allowed"], firstNonEmptyString(stringFromAny(params["resolution"]), context.resolution)) if processed, ok := validateAndAdjustAspectRatio(aspectRatio, capability, allowed); ok && processed != "" { @@ -274,7 +339,37 @@ func imageDimensionsFromParams(params map[string]any) (int, int, bool) { if width > 0 && height > 0 { return width, height, true } - return parsePixelSizeString(stringFromAny(params["resolution"])) + if width, height, ok := parsePixelSizeString(stringFromAny(params["resolution"])); ok { + return width, height, true + } + resolution := firstNonEmptyString( + normalizeImageResolution(stringFromAny(params["resolution"])), + normalizeImageResolution(stringFromAny(params["size"])), + ) + if resolution == "" { + return 0, 0, false + } + return imageDimensionsForResolution(resolution, stringFromAny(params["aspect_ratio"])) +} + +func imageDimensionsForResolution(resolution string, aspectRatio string) (int, int, bool) { + resolution = normalizeImageResolution(resolution) + if resolution == "" { + return 0, 0, false + } + magnitude, err := strconv.Atoi(strings.TrimSuffix(resolution, "K")) + if err != nil || magnitude <= 0 { + return 0, 0, false + } + baseSide := magnitude * 1024 + ratio, validRatio := aspectRatioNumber(aspectRatio) + if !validRatio { + return baseSide, baseSide, true + } + if ratio >= 1 { + return baseSide, max(1, int(math.Round(float64(baseSide)/ratio))), true + } + return max(1, int(math.Round(float64(baseSide)*ratio))), baseSide, true } func imageSizeCapabilityConfigured(capability map[string]any) bool { diff --git a/apps/api/internal/runner/param_processor_utils.go b/apps/api/internal/runner/param_processor_utils.go index fe5f951..d271957 100644 --- a/apps/api/internal/runner/param_processor_utils.go +++ b/apps/api/internal/runner/param_processor_utils.go @@ -393,23 +393,39 @@ func numberPair(value any) ([2]float64, bool) { } func validAspectRatio(value string) bool { - if value == "adaptive" || value == "keep_ratio" { - return true - } - _, ok := aspectRatioNumber(value) + _, ok := normalizedAspectRatio(value) return ok } -func aspectRatioNumber(value string) (float64, bool) { - parts := strings.Split(value, ":") +func normalizedAspectRatio(value string) (string, bool) { + compact := strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '\r', '\n': + return -1 + default: + return r + } + }, strings.TrimSpace(value)) + parts := strings.Split(compact, ":") if len(parts) != 2 { - return 0, false + return "", false } width := parsePositiveFloat(parts[0]) height := parsePositiveFloat(parts[1]) if width <= 0 || height <= 0 { + return "", false + } + return parts[0] + ":" + parts[1], true +} + +func aspectRatioNumber(value string) (float64, bool) { + normalized, ok := normalizedAspectRatio(value) + if !ok { return 0, false } + parts := strings.Split(normalized, ":") + width := parsePositiveFloat(parts[0]) + height := parsePositiveFloat(parts[1]) return width / height, true } @@ -537,11 +553,53 @@ func isEmptyParamString(value string) bool { } func isImageResolution(modelType string, value string) bool { - return (modelType == "image_generate" || modelType == "image_edit") && containsString([]string{"1K", "2K", "3K", "4K", "8K"}, value) + if modelType != "image_generate" && modelType != "image_edit" { + return false + } + return normalizeImageResolution(value) != "" } func isVideoResolution(modelType string, value string) bool { - return isVideoModelType(modelType) && containsString([]string{"480p", "720p", "1080p", "1440p", "2160p"}, value) + return isVideoModelType(modelType) && normalizeVideoResolution(value) != "" +} + +func normalizeMediaResolution(modelType string, value string) string { + if modelType == "image_generate" || modelType == "image_edit" { + return normalizeImageResolution(value) + } + if isVideoModelType(modelType) { + return normalizeVideoResolution(value) + } + return "" +} + +func normalizeImageResolution(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if len(value) < 2 || value[len(value)-1] != 'k' { + return "" + } + magnitude, err := strconv.Atoi(value[:len(value)-1]) + if err != nil || magnitude <= 0 { + return "" + } + return strconv.Itoa(magnitude) + "K" +} + +func normalizeVideoResolution(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "480p": + return "480p" + case "720p": + return "720p" + case "1080p": + return "1080p" + case "1440p": + return "1440p" + case "2160p", "4k": + return "2160p" + default: + return "" + } } func isVideoModelType(modelType string) bool { diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index 9f444d9..b78e751 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -1118,6 +1118,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user ModelType: candidate.ModelType, Model: task.Model, Body: providerBody, + OriginalBody: preprocessing.Input, Candidate: candidate, HTTPClient: requestHTTPClient, RemoteTaskID: task.RemoteTaskID,