fix(media): 规范比例与 OpenAI 图像尺寸参数
统一过滤非 x:x 比例,避免默认值参与候选匹配或透传上游。 支持 xK 分辨率归一化,并按 Gemini、Volces 和 OpenAI 的参数规范生成上游请求;OpenAI 显式 WxH 始终保留用户原始尺寸。 验证:在独立临时 worktree 中执行 apps/api 全量 go test ./... -count=1 通过,真实 OpenAI 请求已到达上游但受本地无效凭据阻断。
This commit is contained in:
@@ -272,24 +272,39 @@ func geminiGenerationConfig(body map[string]any, imageResponse bool) map[string]
|
|||||||
if out == nil {
|
if out == nil {
|
||||||
out = map[string]any{}
|
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 {
|
if imageConfig == nil {
|
||||||
imageConfig = map[string]any{}
|
imageConfig = map[string]any{}
|
||||||
}
|
}
|
||||||
imageConfig["aspectRatio"] = aspectRatio
|
imageConfig["aspectRatio"] = aspectRatio
|
||||||
out["imageConfig"] = imageConfig
|
|
||||||
delete(out, "image_config")
|
|
||||||
}
|
}
|
||||||
if imageSize := firstNonEmptyString(body["resolution"], body["imageSize"], body["image_size"], body["size"]); imageSize != "" {
|
if imageSize := normalizedProviderImageResolution(firstNonEmptyString(body["resolution"], body["imageSize"], body["image_size"], body["size"])); imageSize != "" {
|
||||||
imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"])))
|
|
||||||
if imageConfig == nil {
|
if imageConfig == nil {
|
||||||
imageConfig = map[string]any{}
|
imageConfig = map[string]any{}
|
||||||
}
|
}
|
||||||
imageConfig["imageSize"] = imageSize
|
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 {
|
if count := intFromAny(firstPresent(body["n"], body["candidateCount"], body["candidate_count"])); count > 0 {
|
||||||
out["candidateCount"] = count
|
out["candidateCount"] = count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
body["model"] = upstreamModelName(request.Candidate)
|
body["model"] = upstreamModelName(request.Candidate)
|
||||||
normalizeOpenAIImageRequestBody(endpointKind, body)
|
normalizeOpenAIImageRequestBody(endpointKind, body, request.OriginalBody)
|
||||||
stream := openAIEndpointSupportsStream(endpointKind) && (request.Stream || boolValue(body, "stream"))
|
stream := openAIEndpointSupportsStream(endpointKind) && (request.Stream || boolValue(body, "stream"))
|
||||||
ensureOpenAIStreamUsage(body, endpointKind, stream)
|
ensureOpenAIStreamUsage(body, endpointKind, stream)
|
||||||
raw, contentType, err := openAIRequestPayload(endpointKind, body, request.Candidate)
|
raw, contentType, err := openAIRequestPayload(endpointKind, body, request.Candidate)
|
||||||
@@ -172,10 +172,11 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
}, nil
|
}, 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" {
|
if endpointKind != "images.generations" && endpointKind != "images.edits" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
normalizeOfficialOpenAIImageSize(body, originalBody)
|
||||||
// OpenAI image endpoints express output geometry through size. The Gateway
|
// OpenAI image endpoints express output geometry through size. The Gateway
|
||||||
// keeps the generic fields for capability validation and billing, but they
|
// keeps the generic fields for capability validation and billing, but they
|
||||||
// are not valid OpenAI wire parameters.
|
// are not valid OpenAI wire parameters.
|
||||||
@@ -185,6 +186,10 @@ func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any) {
|
|||||||
"resolution",
|
"resolution",
|
||||||
"width",
|
"width",
|
||||||
"height",
|
"height",
|
||||||
|
"platform_id",
|
||||||
|
"platformId",
|
||||||
|
"platform_model_id",
|
||||||
|
"platformModelId",
|
||||||
} {
|
} {
|
||||||
delete(body, key)
|
delete(body, key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Request struct {
|
|||||||
ModelType string
|
ModelType string
|
||||||
Model string
|
Model string
|
||||||
Body map[string]any
|
Body map[string]any
|
||||||
|
OriginalBody map[string]any
|
||||||
Candidate store.RuntimeModelCandidate
|
Candidate store.RuntimeModelCandidate
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
RemoteTaskID string
|
RemoteTaskID string
|
||||||
|
|||||||
@@ -357,16 +357,33 @@ func volcesImageBody(request Request) map[string]any {
|
|||||||
body["seed"] = -1
|
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
|
body["size"] = resolution
|
||||||
}
|
}
|
||||||
if size := widthHeightSize(body); size != "" {
|
for _, key := range []string{"aspect_ratio", "aspectRatio", "ratio", "resolution", "width", "height"} {
|
||||||
body["size"] = size
|
delete(body, key)
|
||||||
}
|
}
|
||||||
normalizeVolcesSequentialImageGeneration(body, request)
|
normalizeVolcesSequentialImageGeneration(body, request)
|
||||||
return body
|
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 {
|
func volcesVideoBody(request Request) map[string]any {
|
||||||
body := cleanProviderBody(request.Body)
|
body := cleanProviderBody(request.Body)
|
||||||
body["model"] = upstreamModelName(request.Candidate)
|
body["model"] = upstreamModelName(request.Candidate)
|
||||||
@@ -397,6 +414,10 @@ func cleanProviderBody(body map[string]any) map[string]any {
|
|||||||
"_gateway_target_protocol",
|
"_gateway_target_protocol",
|
||||||
"_compat_provider",
|
"_compat_provider",
|
||||||
"_kling_compat_version",
|
"_kling_compat_version",
|
||||||
|
"platform_id",
|
||||||
|
"platformId",
|
||||||
|
"platform_model_id",
|
||||||
|
"platformModelId",
|
||||||
} {
|
} {
|
||||||
delete(out, key)
|
delete(out, key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const unsupportedRequestResolutionCode = "unsupported_request_resolution"
|
const unsupportedRequestResolutionCode = "unsupported_request_resolution"
|
||||||
|
const unsupportedRequestAspectRatioCode = "unsupported_request_aspect_ratio"
|
||||||
|
|
||||||
type requestResolutionRequirement struct {
|
type requestResolutionRequirement struct {
|
||||||
Kind string
|
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) {
|
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)
|
requirement, ok := requestResolutionRequirementFor(kind, requestedModel, modelType, body)
|
||||||
if !ok || len(candidates) == 0 {
|
if !ok || len(candidates) == 0 {
|
||||||
return candidates, nil, nil
|
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) {
|
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 {
|
if _, _, ok := parsePixelSizeString(value); ok {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
return value, "resolution"
|
return value, "resolution"
|
||||||
}
|
}
|
||||||
size := normalizedRequestResolution(stringFromAny(body["size"]))
|
size := normalizedRequestResolution(stringFromAny(body["size"]), modelType)
|
||||||
if size == "" {
|
if size == "" {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
@@ -98,7 +108,7 @@ func requestResolutionValue(body map[string]any, modelType string) (string, stri
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedRequestResolution(value string) string {
|
func normalizedRequestResolution(value string, modelType string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" || isEmptyParamString(value) {
|
if value == "" || isEmptyParamString(value) {
|
||||||
return ""
|
return ""
|
||||||
@@ -106,9 +116,11 @@ func normalizedRequestResolution(value string) string {
|
|||||||
switch strings.ToLower(value) {
|
switch strings.ToLower(value) {
|
||||||
case "auto", "automatic", "adaptive", "default":
|
case "auto", "automatic", "adaptive", "default":
|
||||||
return ""
|
return ""
|
||||||
default:
|
}
|
||||||
|
if _, _, ok := parsePixelSizeString(value); ok {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
return normalizeMediaResolution(modelType, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isResolutionFilteredModelType(modelType string) bool {
|
func isResolutionFilteredModelType(modelType string) bool {
|
||||||
@@ -120,10 +132,9 @@ func candidateSupportsRequestResolution(candidate store.RuntimeModelCandidate, r
|
|||||||
capability := capabilityForType(effectiveModelCapability(candidate), modelType)
|
capability := capabilityForType(effectiveModelCapability(candidate), modelType)
|
||||||
detail := candidateResolutionDetail(candidate, requirement, modelType)
|
detail := candidateResolutionDetail(candidate, requirement, modelType)
|
||||||
if capability == nil {
|
if capability == nil {
|
||||||
detail["reason"] = "capability_missing"
|
detail["reason"] = "capability_unrestricted"
|
||||||
detail["message"] = "候选平台模型未配置对应模型类型能力。"
|
detail["message"] = "候选平台模型未配置分辨率白名单,按未限制处理。"
|
||||||
detail["capabilityPath"] = capabilityPath(modelType, "output_resolutions")
|
return true, detail
|
||||||
return false, detail
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed, configured := outputResolutionAllowedValues(capability["output_resolutions"], requirement.Scopes)
|
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["capabilityPath"] = capabilityPath(modelType, "output_resolutions")
|
||||||
detail["capabilityValue"] = cloneAny(capability["output_resolutions"])
|
detail["capabilityValue"] = cloneAny(capability["output_resolutions"])
|
||||||
if !configured {
|
if !configured {
|
||||||
detail["reason"] = "output_resolutions_missing"
|
detail["reason"] = "output_resolutions_unrestricted"
|
||||||
detail["message"] = "候选平台模型未声明 output_resolutions。"
|
detail["message"] = "候选平台模型未声明 output_resolutions,按未限制处理。"
|
||||||
return false, detail
|
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) {
|
if containsResolution(allowed, requirement.Resolution) {
|
||||||
detail["reason"] = "supported"
|
detail["reason"] = "supported"
|
||||||
@@ -162,7 +183,7 @@ func outputResolutionAllowedValues(value any, scopes []string) ([]string, bool)
|
|||||||
for _, raw := range typed {
|
for _, raw := range typed {
|
||||||
values = append(values, stringListFromAny(raw)...)
|
values = append(values, stringListFromAny(raw)...)
|
||||||
}
|
}
|
||||||
return uniqueStringList(values), len(values) > 0
|
return uniqueStringList(values), true
|
||||||
}
|
}
|
||||||
return nil, true
|
return nil, true
|
||||||
default:
|
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 {
|
func containsResolution(values []string, target string) bool {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) {
|
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"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) Name() string { return "ResolutionNormalizeProcessor" }
|
||||||
|
|
||||||
func (resolutionNormalizeProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
func (resolutionNormalizeProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
if stringFromAny(params["resolution"]) != "" {
|
if normalizeMediaResolution(modelType, stringFromAny(params["resolution"])) != "" {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
size := stringFromAny(params["size"])
|
return normalizeMediaResolution(modelType, stringFromAny(params["size"])) != ""
|
||||||
if size == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return isImageResolution(modelType, size) || isVideoResolution(modelType, size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (resolutionNormalizeProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
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"])
|
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")
|
_, capabilityValue := capabilityEvidence(context.modelCapability, modelType, "output_resolutions")
|
||||||
params["resolution"] = size
|
params["resolution"] = resolution
|
||||||
context.resolution = size
|
context.resolution = resolution
|
||||||
context.recordChange(
|
context.recordChange(
|
||||||
"ResolutionNormalizeProcessor",
|
"ResolutionNormalizeProcessor",
|
||||||
"set",
|
"set",
|
||||||
"resolution",
|
"resolution",
|
||||||
nil,
|
nil,
|
||||||
size,
|
resolution,
|
||||||
"size 使用分辨率格式,归一到 resolution 供后续能力校验和计费使用。",
|
"size 使用分辨率格式,归一到 resolution 供后续能力校验和计费使用。",
|
||||||
capabilityPath(modelType, "output_resolutions"),
|
capabilityPath(modelType, "output_resolutions"),
|
||||||
capabilityValue,
|
capabilityValue,
|
||||||
@@ -48,30 +64,69 @@ type aspectRatioProcessor struct{}
|
|||||||
func (aspectRatioProcessor) Name() string { return "AspectRatioProcessor" }
|
func (aspectRatioProcessor) Name() string { return "AspectRatioProcessor" }
|
||||||
|
|
||||||
func (aspectRatioProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
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 {
|
func (aspectRatioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
capability := capabilityForType(context.modelCapability, modelType)
|
aspectRatio, ratioSource, ratioPresent := requestAspectRatio(params)
|
||||||
if capability == nil {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
aspectRatio := stringFromAny(params["aspect_ratio"])
|
capability := capabilityForType(context.modelCapability, modelType)
|
||||||
if isEmptyParamString(aspectRatio) {
|
if capability == nil {
|
||||||
before := params["aspect_ratio"]
|
|
||||||
delete(params, "aspect_ratio")
|
|
||||||
context.aspectRatio = ""
|
|
||||||
context.recordChange(
|
|
||||||
"AspectRatioProcessor",
|
|
||||||
"remove",
|
|
||||||
"aspect_ratio",
|
|
||||||
before,
|
|
||||||
nil,
|
|
||||||
"aspect_ratio 是空值字符串,不能作为有效比例传给上游。",
|
|
||||||
"",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +229,15 @@ func (aspectRatioProcessor) Process(params map[string]any, modelType string, con
|
|||||||
return true
|
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) {
|
func validateExplicitAspectRatio(aspectRatio string, capability map[string]any, allowed []string, modelType string) (string, bool) {
|
||||||
if isVideoModelType(modelType) && len(allowed) > 0 && !containsString(allowed, aspectRatio) {
|
if isVideoModelType(modelType) && len(allowed) > 0 && !containsString(allowed, aspectRatio) {
|
||||||
if aspectRatio == "adaptive" || aspectRatio == "keep_ratio" {
|
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" {
|
if modelType != "image_generate" && modelType != "image_edit" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if _, _, ok := imageDimensionsFromParams(params); !ok {
|
_, _, ok := imageDimensionsFromParams(params)
|
||||||
return false
|
return ok
|
||||||
}
|
|
||||||
capability := capabilityForType(context.modelCapability, modelType)
|
|
||||||
return capability != nil && imageSizeCapabilityConfigured(capability)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (imageSizeProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
func (imageSizeProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
capability := capabilityForType(context.modelCapability, modelType)
|
capability := capabilityForType(context.modelCapability, modelType)
|
||||||
if capability == nil {
|
if capability == nil {
|
||||||
return true
|
capability = map[string]any{}
|
||||||
}
|
}
|
||||||
width, height, ok := imageDimensionsFromParams(params)
|
width, height, ok := imageDimensionsFromParams(params)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -222,7 +283,11 @@ func (imageSizeProcessor) Process(params map[string]any, modelType string, conte
|
|||||||
params["width"] = width
|
params["width"] = width
|
||||||
params["height"] = height
|
params["height"] = height
|
||||||
_, _, sizeHasPixelDimensions := parsePixelSizeString(stringFromAny(params["size"]))
|
_, _, 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)
|
aspectRatio := aspectRatioFromDimensions(width, height)
|
||||||
allowed := aspectRatioAllowed(capability["aspect_ratio_allowed"], firstNonEmptyString(stringFromAny(params["resolution"]), context.resolution))
|
allowed := aspectRatioAllowed(capability["aspect_ratio_allowed"], firstNonEmptyString(stringFromAny(params["resolution"]), context.resolution))
|
||||||
if processed, ok := validateAndAdjustAspectRatio(aspectRatio, capability, allowed); ok && processed != "" {
|
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 {
|
if width > 0 && height > 0 {
|
||||||
return width, height, true
|
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 {
|
func imageSizeCapabilityConfigured(capability map[string]any) bool {
|
||||||
|
|||||||
@@ -393,23 +393,39 @@ func numberPair(value any) ([2]float64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validAspectRatio(value string) bool {
|
func validAspectRatio(value string) bool {
|
||||||
if value == "adaptive" || value == "keep_ratio" {
|
_, ok := normalizedAspectRatio(value)
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, ok := aspectRatioNumber(value)
|
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func aspectRatioNumber(value string) (float64, bool) {
|
func normalizedAspectRatio(value string) (string, bool) {
|
||||||
parts := strings.Split(value, ":")
|
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 {
|
if len(parts) != 2 {
|
||||||
return 0, false
|
return "", false
|
||||||
}
|
}
|
||||||
width := parsePositiveFloat(parts[0])
|
width := parsePositiveFloat(parts[0])
|
||||||
height := parsePositiveFloat(parts[1])
|
height := parsePositiveFloat(parts[1])
|
||||||
if width <= 0 || height <= 0 {
|
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
|
return 0, false
|
||||||
}
|
}
|
||||||
|
parts := strings.Split(normalized, ":")
|
||||||
|
width := parsePositiveFloat(parts[0])
|
||||||
|
height := parsePositiveFloat(parts[1])
|
||||||
return width / height, true
|
return width / height, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,11 +553,53 @@ func isEmptyParamString(value string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isImageResolution(modelType string, 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 {
|
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 {
|
func isVideoModelType(modelType string) bool {
|
||||||
|
|||||||
@@ -1118,6 +1118,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
|||||||
ModelType: candidate.ModelType,
|
ModelType: candidate.ModelType,
|
||||||
Model: task.Model,
|
Model: task.Model,
|
||||||
Body: providerBody,
|
Body: providerBody,
|
||||||
|
OriginalBody: preprocessing.Input,
|
||||||
Candidate: candidate,
|
Candidate: candidate,
|
||||||
HTTPClient: requestHTTPClient,
|
HTTPClient: requestHTTPClient,
|
||||||
RemoteTaskID: task.RemoteTaskID,
|
RemoteTaskID: task.RemoteTaskID,
|
||||||
|
|||||||
Reference in New Issue
Block a user