diff --git a/backend/internal/app/channel_model_pricing_test.go b/backend/internal/app/channel_model_pricing_test.go index 81058b5af..56828a515 100644 --- a/backend/internal/app/channel_model_pricing_test.go +++ b/backend/internal/app/channel_model_pricing_test.go @@ -219,6 +219,29 @@ func TestRepriceTierFieldValidation(t *testing.T) { } } +func TestNormalizeChannelModelTierSelectorAcceptsLegacyTypoWithoutCollidingWithPixelTier(t *testing.T) { + for _, key := range []string{"resoluton"} { + t.Run(key, func(t *testing.T) { + selector, resolution, videoSeconds, err := normalizeChannelModelTierSelector("image", ChannelModelPriceTierRequest{ + Selector: map[string]string{key: "2K"}, + }) + if err != nil { + t.Fatal(err) + } + if selector["imageResolution"] != "2k" || selector[key] != "" || resolution != "*" || videoSeconds != 0 { + t.Fatalf("selector=%v resolution=%q videoSeconds=%d", selector, resolution, videoSeconds) + } + }) + } +} + +func TestImageTierSelectorDoesNotOverwriteIndependentResolution(t *testing.T) { + selector, _, _, err := normalizeChannelModelTierSelector("image", ChannelModelPriceTierRequest{Selector: map[string]string{"imageResolution": "1k", "resolution": "2k"}}) + if err != nil || selector["imageResolution"] != "1k" || selector["resolution"] != "2k" { + t.Fatalf("selector = %#v, err=%v", selector, err) + } +} + func TestBatchRepricingRollsBackOnConcurrentChange(t *testing.T) { for _, target := range []string{"model", "tier"} { t.Run(target, func(t *testing.T) { @@ -263,7 +286,7 @@ func TestNormalizeChannelModelTierSelectorAcceptsResolutionTypo(t *testing.T) { if err != nil { t.Fatal(err) } - if selector["resolution"] != "2k" || selector["resoluton"] != "" || resolution != "*" || videoSeconds != 0 { + if selector["imageResolution"] != "2k" || selector["resoluton"] != "" || resolution != "*" || videoSeconds != 0 { t.Fatalf("selector=%v resolution=%q videoSeconds=%d", selector, resolution, videoSeconds) } } diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index a69f3fa2c..2d10b099c 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -106,7 +106,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod if decodeErr != nil || config == nil { continue } - normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), config) + normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), config, s.protocolRegistry()) if normalizeErr != nil || normalized == nil { continue } @@ -171,9 +171,11 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型") } if capability == "text" || capability == "image" || capability == "video" { - if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil { - return nil, err + validated, validateErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry()) + if validateErr != nil { + return nil, validateErr } + req.CapabilityConfig = validated } tiers, err := s.normalizeChannelModelPriceTiers(req, capability, protocol, providerModelKey) if err != nil { @@ -216,7 +218,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.Protocol = protocol s.applyChannelModelPriceTierSummary(item, tiers) if capability == "text" || capability == "image" || capability == "video" { - capabilityConfig, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) + capabilityConfig, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry()) if normalizeErr != nil { return nil, normalizeErr } @@ -235,7 +237,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if req.Enabled != nil { item.Enabled = *req.Enabled } - if err := validateChannelModelTierCapabilities(tiers, item.CapabilityConfigJSON, capability); err != nil { + if err := validateChannelModelTierCapabilities(tiers, item.CapabilityConfigJSON, capability, string(protocol)); err != nil { return nil, err } // 渠道模型及所有价格档必须同时落库,不能出现“能力已开放但规格价格尚未更新”的窗口。 diff --git a/backend/internal/app/channel_models_pricing.go b/backend/internal/app/channel_models_pricing.go index 773ce4d68..0406b1c5a 100644 --- a/backend/internal/app/channel_models_pricing.go +++ b/backend/internal/app/channel_models_pricing.go @@ -8,9 +8,44 @@ import ( "strings" "yingce/backend/internal/model" + protocolcontract "yingce/backend/internal/protocol" ) -func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error { +func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string, protocol string) error { + if capability == "image" { + config, err := DecodeModelCapabilityConfig(rawCapabilityConfig) + if err != nil || config == nil || config.Image == nil { + return BadAuthRequest("图片模型能力配置无效,无法校验价格档规格") + } + image := config.Image + if image.Parameters != nil { + for _, tier := range tiers { + if err := validateImageParameterPriceSelector(image.Parameters, tier.Selector); err != nil { + return err + } + } + return nil + } + normalizeLegacyImageResolutionConfig(protocol, image) + for _, tier := range tiers { + if value := strings.TrimSpace(tier.Selector["imageResolution"]); value != "" && value != "*" && !image.Resolution.Supported { + return BadAuthRequest("价格档使用了未启用的图片分辨率条件") + } + if value := strings.TrimSpace(tier.Selector["imageResolution"]); value != "" && value != "*" && !containsCapabilityString(image.Resolution.Values, value) { + return BadAuthRequest("价格档分辨率不在该图片模型支持范围内:" + value) + } + if value := strings.TrimSpace(tier.Selector["quality"]); value != "" && value != "*" { + if image.Quality.Supported { + if !containsCapabilityString(image.Quality.Values, value) { + return BadAuthRequest("价格档质量不在该图片模型支持范围内:" + value) + } + } else if !legacyImageResolutionProtocol(protocol) || !image.Resolution.Supported || !containsCapabilityString(image.Resolution.Values, value) { + return BadAuthRequest("价格档使用了未启用的图片质量条件") + } + } + } + return nil + } if capability != "video" { return nil } @@ -80,7 +115,11 @@ func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capab result := make([]model.ChannelModelPriceTier, 0, len(inputs)) seen := make(map[string]bool, len(inputs)) for _, input := range inputs { - selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input) + var parameters []protocolcontract.ImageParameter + if req.CapabilityConfig != nil && req.CapabilityConfig.Image != nil { + parameters = req.CapabilityConfig.Image.Parameters + } + selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input, parameters) if selectorErr != nil { return nil, selectorErr } @@ -126,7 +165,27 @@ func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capab return result, nil } -func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest) (map[string]string, string, int, error) { +func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest, parameters ...[]protocolcontract.ImageParameter) (map[string]string, string, int, error) { + if capability == "image" && len(parameters) > 0 && parameters[0] != nil { + if input.VideoSeconds != 0 || input.Resolution != "" && input.Resolution != "*" { + return nil, "", 0, BadAuthRequest("图片价格不接受视频规格") + } + selector := make(map[string]string, len(input.Selector)) + for name, value := range input.Selector { + name, value = strings.TrimSpace(name), strings.TrimSpace(value) + if name == "" || value == "" || value == "*" { + continue + } + if _, exists := selector[name]; exists { + return nil, "", 0, BadAuthRequest("价格档存在重复规格条件:" + name) + } + selector[name] = value + } + if err := validateImageParameterPriceSelector(parameters[0], selector); err != nil { + return nil, "", 0, err + } + return selector, "*", 0, nil + } selector := make(map[string]string, len(input.Selector)+3) for rawKey, rawValue := range input.Selector { key := strings.TrimSpace(rawKey) @@ -137,14 +196,21 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric switch key { case "operation": value = strings.ToLower(value) - case "quality", "size": + case "quality", "imageResolution", "size": value = strings.ToLower(value) if value == "auto" || value == "any" { value = "*" } + case "resoluton": + // Normalize the historical misspelling at the input boundary. + key = "imageResolution" + value = strings.ToLower(strings.TrimSpace(value)) + if value == "auto" || value == "any" { + value = "*" + } case "vquality": value = normalizeChannelModelTierResolution(value) - case "resolution", "resoluton": + case "resolution": // 兼容图片价格档的标准字段,以及历史接口误拼写 resoluton。 key = "resolution" value = strings.ToLower(strings.TrimSpace(value)) @@ -178,7 +244,12 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric return nil, "", 0, BadAuthRequest("视频价格档音频条件必须为 true、false 或 *") } default: - return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) + if capability != "image" { + return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) + } + } + if existing, exists := selector[key]; exists && existing != value { + return nil, "", 0, BadAuthRequest("价格档存在重复规格条件:" + key) } selector[key] = value } diff --git a/backend/internal/app/channel_models_test_call.go b/backend/internal/app/channel_models_test_call.go index a1ae55e5c..35b1e53e6 100644 --- a/backend/internal/app/channel_models_test_call.go +++ b/backend/internal/app/channel_models_test_call.go @@ -25,7 +25,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, return nil, err } if capability == "text" || capability == "image" || capability == "video" { - if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil { + if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry()); err != nil { return nil, err } } @@ -58,7 +58,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, var videoProfile *VideoCapabilityConfig switch capability { case "image": - profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) + profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry()) if normalizeErr != nil { return nil, normalizeErr } @@ -67,7 +67,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, case "video": // 视频测试必须带上模型能力画像:声明式协议只按画像里的枚举回填分辨率名(如 480 -> 480p), // 没有画像时会把裸数字发给上游,火山方舟等供应商会直接拒绝。 - profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) + profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry()) if normalizeErr != nil { return nil, normalizeErr } @@ -102,6 +102,15 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, } if capability == "image" { input.ImageCapability = imageProfile + if imageProfile.Parameters != nil { + input.Config.ImageParameters = imageParameterDefaults(imageProfile.Parameters) + } + if imageProfile.Resolution.Supported { + input.Config.ImageResolution = imageProfile.Resolution.Default + } + if err := applyImageParameterValues(&input.Config, imageProfile); err != nil { + return nil, err + } } if capability == "video" { input.VideoCapability = videoProfile diff --git a/backend/internal/app/cloud_agent_approval_settings_test.go b/backend/internal/app/cloud_agent_approval_settings_test.go index 30041adf6..63f0d5b7e 100644 --- a/backend/internal/app/cloud_agent_approval_settings_test.go +++ b/backend/internal/app/cloud_agent_approval_settings_test.go @@ -184,12 +184,12 @@ func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) { t.Fatal(err) } id := state.Approval.ID - settings := CloudAgentMediaSettings{ChannelID: "channel", ChannelModelKey: "grok-image-alternative", Size: "16:9", Quality: "1k"} + settings := CloudAgentMediaSettings{ChannelID: "channel", ChannelModelKey: "grok-image-alternative", Size: "16:9", ImageResolution: "1k"} for _, invalid := range []CloudAgentMediaSettings{ - {ChannelID: "channel", ChannelModelKey: "missing", Size: "16:9", Quality: "1k"}, - {ChannelID: "channel", ChannelModelKey: "text-test", Size: "16:9", Quality: "1k"}, + {ChannelID: "channel", ChannelModelKey: "missing", Size: "16:9", ImageResolution: "1k"}, + {ChannelID: "channel", ChannelModelKey: "text-test", Size: "16:9", ImageResolution: "1k"}, {LogicalModelID: "mixed", ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "16:9"}, - {ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "invalid-size", Quality: "1k"}, + {ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "invalid-size", ImageResolution: "1k"}, {ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "16:9", Quality: "invalid-quality"}, } { if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &invalid); err == nil { @@ -242,7 +242,7 @@ func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) { if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { t.Fatal(err) } - if input.Config.Model != settings.ChannelModelKey || input.Config.Size != settings.Size || input.Config.Quality != settings.Quality || task.Prompt != approvedArgs.Prompt || len(input.ReferenceImages) != 1 { + if input.Config.Model != settings.ChannelModelKey || input.Config.Size != settings.Size || input.Config.ImageResolution != settings.ImageResolution || input.Config.Quality != settings.Quality || task.Prompt != approvedArgs.Prompt || len(input.ReferenceImages) != 1 { t.Fatalf("approved inputs were lost: %+v", input.Config) } var count int64 diff --git a/backend/internal/app/cloud_agent_generation_contract.go b/backend/internal/app/cloud_agent_generation_contract.go index e7793a1cf..cdaf9ea2b 100644 --- a/backend/internal/app/cloud_agent_generation_contract.go +++ b/backend/internal/app/cloud_agent_generation_contract.go @@ -33,6 +33,12 @@ func cloudAgentGenerationSpec(a cloudAgentMediaArgs, refs map[string]any, resolv } else if a.Mode == "image" { count := 1 options.Count = &count + if a.ImageResolution != "" { + options.ImageResolution = &a.ImageResolution + } + if a.ImageParameters != "" { + options.ImageParameters = &a.ImageParameters + } if a.Quality != "" { options.Quality = &a.Quality } diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index f87805aa3..73680577b 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -104,6 +104,8 @@ type cloudAgentMediaArgs struct { ChannelModelKey string `json:"channelModelKey"` Duration int `json:"durationSeconds"` Size string `json:"size"` + ImageResolution string `json:"imageResolution"` + ImageParameters string `json:"imageParameters,omitempty"` Quality string `json:"quality"` VideoGenerateAudio *bool `json:"videoGenerateAudio"` VideoWatermark *bool `json:"videoWatermark"` diff --git a/backend/internal/app/cloud_agent_media_approval.go b/backend/internal/app/cloud_agent_media_approval.go index 6515635b6..cc1be2269 100644 --- a/backend/internal/app/cloud_agent_media_approval.go +++ b/backend/internal/app/cloud_agent_media_approval.go @@ -15,6 +15,8 @@ type CloudAgentMediaSettings struct { ChannelID string `json:"channelId,omitempty"` ChannelModelKey string `json:"channelModelKey,omitempty"` Size string `json:"size"` + ImageResolution string `json:"imageResolution,omitempty"` + ImageParameters string `json:"imageParameters,omitempty"` Quality string `json:"quality"` DurationSeconds *int `json:"durationSeconds,omitempty"` VideoGenerateAudio *bool `json:"videoGenerateAudio,omitempty"` @@ -41,7 +43,8 @@ func (s *Service) updateCloudAgentMediaApproval(repo *repository.Repository, run return BadAuthRequest("仅图片或视频生成审批支持修改模型和规格") } args.LogicalModelID, args.ChannelID, args.ChannelModelKey = settings.LogicalModelID, settings.ChannelID, settings.ChannelModelKey - args.Size, args.Quality = settings.Size, settings.Quality + args.Size, args.ImageResolution, args.Quality = settings.Size, settings.ImageResolution, settings.Quality + args.ImageParameters = settings.ImageParameters if args.Mode == "video" { if settings.DurationSeconds != nil { if *settings.DurationSeconds < 0 { @@ -75,14 +78,12 @@ func (s *Service) updateCloudAgentMediaApproval(repo *repository.Repository, run if err != nil { return err } - var resolved struct { - Config map[string]any `json:"config"` - } + var resolved map[string]any if err := json.Unmarshal([]byte(task.InputJSON), &resolved); err != nil { return err } requested, _ := req.Input["config"].(map[string]any) - if err := validateCloudAgentResolvedMediaOptions(requested, resolved.Config); err != nil { + if err := validateCloudAgentResolvedMediaOptions(requested, resolved); err != nil { return err } name, err := s.cloudAgentMediaModelName(args) @@ -128,9 +129,36 @@ func replaceCloudAgentPreparedLeases(repo *repository.Repository, userID, runID, return repo.ReplaceCloudAgentResourceLeases(userID, runID, approvalID, ids, prepared.Quote.ExpiresAt) } -func validateCloudAgentResolvedMediaOptions(requested, resolved map[string]any) error { - for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload"} { - if value := stringValue(requested[key]); value != "" && !strings.EqualFold(value, stringValue(resolved[key])) { +func validateCloudAgentResolvedMediaOptions(requested, resolvedInput map[string]any) error { + resolvedOptions, _ := resolvedInput["capabilityOptions"].(map[string]any) + resolvedConfig, _ := resolvedInput["config"].(map[string]any) + if raw := requested["imageParameters"]; raw != nil { + var values map[string]any + if encoded, ok := raw.(string); ok { + if err := json.Unmarshal([]byte(encoded), &values); err != nil { + return creationConflict("图片参数格式无效") + } + } else { + values, _ = raw.(map[string]any) + } + for name, value := range values { + if resolvedOptions[name] != value { + return creationConflict("模型解析后的图片参数与审批内容不同") + } + } + } + for _, key := range []string{"size", "imageResolution", "videoSeconds", "vquality", "quality", "count", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload"} { + requestedValue := stringValue(requested[key]) + if key == "quality" && requestedValue != "" { + if _, declared := resolvedOptions[key]; !declared && !strings.EqualFold(requestedValue, stringValue(resolvedOptions["imageResolution"])) { + return creationConflict("所选图片模型未声明质量参数,请使用模型声明的图片分辨率或质量选项") + } + } + resolved := stringValue(resolvedOptions[key]) + if resolved == "" { + resolved = stringValue(resolvedConfig[key]) + } + if requestedValue != "" && !strings.EqualFold(requestedValue, resolved) { return creationConflict("模型解析后的生成规格与审批参数不同,请重新读取目录并审批") } } diff --git a/backend/internal/app/cloud_agent_media_test.go b/backend/internal/app/cloud_agent_media_test.go index 94dd4f703..b9546d59c 100644 --- a/backend/internal/app/cloud_agent_media_test.go +++ b/backend/internal/app/cloud_agent_media_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "testing" @@ -325,7 +326,7 @@ func TestCloudAgentImageCreatesReferencedNode(t *testing.T) { if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil { t.Fatal(err) } - if task.Operation != "image_to_image" || len(input.ReferenceImages) != 1 || input.Config.Count != "1" || input.Config.Quality != "2k" { + if task.Operation != "image_to_image" || len(input.ReferenceImages) != 1 || input.Config.Count != "1" || input.Config.ImageResolution != "2k" || input.Config.Quality != "" { t.Fatal("image generation contract was lost") } canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") @@ -570,6 +571,123 @@ func TestCloudAgentModelListFiltersActualReferences(t *testing.T) { } } +func TestCloudAgentImageCatalogTreatsUnselectedPricedOptionsAsCandidates(t *testing.T) { + s, db, _, _ := creationTestService(t) + imageModel := seedAgentLogicalImageModel(t, s, db) + intent := ModelRequestIntent{Capability: "image", Inputs: map[string]int{}, Options: map[string]any{}} + + models, err := s.PublicLogicalModels(&intent) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != imageModel.ID || !models[0].Available || len(models[0].PriceTiers) != 9 { + t.Fatalf("unselected image options should expose the priced candidate and all 9 tiers: %#v", models) + } + + adminModels, err := s.AdminLogicalModels(&model.User{ID: "admin", Role: model.UserRoleAdmin}) + if err != nil { + t.Fatal(err) + } + if len(adminModels) != 1 || len(adminModels[0].PriceTiers) != 9 { + t.Fatalf("admin projection should expose the route's 9 configured tiers: %#v", adminModels) + } + + catalog, err := s.cloudAgentModelList(&intent) + if err != nil { + t.Fatal(err) + } + var logicalEntry map[string]any + for _, entry := range catalog.(map[string]any)["models"].([]map[string]any) { + if entry["selection"].(map[string]any)["logicalModelId"] == imageModel.ID { + logicalEntry = entry + break + } + } + if logicalEntry == nil || len(logicalEntry["priceTiers"].([]PublicLogicalModelPriceTier)) != 9 { + t.Fatalf("agent catalog did not expose the logical candidate with 9 price tiers: %#v", logicalEntry) + } + defaultRoute, err := s.ResolveLogicalModel(imageModel.ID, intent) + if err != nil || defaultRoute.PriceTier == nil { + t.Fatalf("declared model defaults must resolve a priced specification: %v", err) + } + selectedIntent := ModelRequestIntent{Capability: "image", Inputs: map[string]int{}, Options: map[string]any{"imageResolution": "4k", "quality": "max"}} + routed, err := s.ResolveLogicalModel(imageModel.ID, selectedIntent) + if err != nil || routed.PriceTier == nil || routed.PriceTier.ID != "agent-image-4k-max" { + t.Fatalf("complete selection should resolve only its exact price tier: routed=%#v error=%v", routed, err) + } +} + +func TestCloudAgentImageCatalogRejectsUnpricedExplicitCombination(t *testing.T) { + s, db, _, _ := creationTestService(t) + imageModel := seedAgentLogicalImageModel(t, s, db) + if err := db.Where("channel_model_id = ? AND selector_json = ?", "agent-image-cm", "{\"imageResolution\":\"4k\",\"quality\":\"max\"}").Delete(&model.ChannelModelPriceTier{}).Error; err != nil { + t.Fatal(err) + } + s.invalidateRouteCatalog() + intent := ModelRequestIntent{Capability: "image", Inputs: map[string]int{}, Options: map[string]any{"imageResolution": "4k", "quality": "max"}} + + models, err := s.PublicLogicalModels(&intent) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != imageModel.ID || models[0].Available { + t.Fatalf("supported but unpriced explicit combination must not be available: %#v", models) + } + catalog, err := s.cloudAgentModelList(&intent) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(catalog) + if strings.Contains(string(encoded), "\"logicalModelId\":\""+imageModel.ID+"\"") { + t.Fatalf("agent exposed a model with no price tier for the explicit combination: %s", encoded) + } + if _, err := s.ResolveLogicalModel(imageModel.ID, intent); err == nil { + t.Fatal("strict routing accepted an explicitly unpriced combination") + } +} + +func seedAgentLogicalImageModel(t *testing.T, s *Service, db *gorm.DB) model.LogicalModel { + t.Helper() + if err := db.Create(&model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: "{\"frontendModelsEnabled\":true}"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ModelChannel{ID: "agent-image-channel", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Agent image test"}).Error; err != nil { + t.Fatal(err) + } + config := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceOpenAIImage), "gpt-image-2.5-sunburst") + config.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"1k", "2k", "4k"}, Default: "1k"} + config.Image.Quality = ImageQualityConfig{Supported: true, Values: []string{"medium", "high", "max"}, Default: "medium"} + if err := db.Create(&model.ChannelModel{ID: "agent-image-cm", ChannelID: "agent-image-channel", ModelKey: "gpt-image-2.5-sunburst", DisplayName: "Sunburst", Capability: "image", Protocol: model.ChannelInterfaceOpenAIImage, CapabilityConfigJSON: mustEncodeModelCapabilityConfig(t, config), BillingMode: "fixed_request", PriceConfigured: true, Enabled: true}).Error; err != nil { + t.Fatal(err) + } + for _, resolution := range []string{"1k", "2k", "4k"} { + for _, quality := range []string{"medium", "high", "max"} { + selector := fmt.Sprintf("{\"imageResolution\":%q,\"quality\":%q}", resolution, quality) + if err := db.Create(&model.ChannelModelPriceTier{ID: "agent-image-" + resolution + "-" + quality, ChannelModelID: "agent-image-cm", SelectorKey: selector, SelectorJSON: selector, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}).Error; err != nil { + t.Fatal(err) + } + } + } + spec, err := CapabilitySpecFromModelCapabilityConfig(config, "image") + if err != nil { + t.Fatal(err) + } + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + imageModel := model.LogicalModel{ID: "agent-sunburst", Code: "agent-sunburst", Name: "Sunburst", Capability: "image", Enabled: true, ActiveRevisionID: "agent-sunburst-revision", PricePolicy: "channel", BillingMode: "fixed_request"} + revision := model.LogicalModelRevision{ID: imageModel.ActiveRevisionID, LogicalModelID: imageModel.ID, Version: 1, CapabilitySpecJSON: string(specJSON), DefaultOptionsJSON: "{}"} + route := model.LogicalModelRoute{ID: "agent-sunburst-route", LogicalModelRevisionID: revision.ID, ChannelModelID: "agent-image-cm", Enabled: true, Weight: 1} + for _, row := range []any{&imageModel, &revision, &route} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + s.invalidateRouteCatalog() + return imageModel +} + func TestCloudAgentMediaDraftReuseLifecycle(t *testing.T) { for _, tc := range []struct { name, status, ownerUser, ownerCanvas string diff --git a/backend/internal/app/cloud_agent_prepared_media.go b/backend/internal/app/cloud_agent_prepared_media.go index 655b9c99b..150ec2ee4 100644 --- a/backend/internal/app/cloud_agent_prepared_media.go +++ b/backend/internal/app/cloud_agent_prepared_media.go @@ -156,7 +156,10 @@ func cloudAgentResolvedMediaHash(task *model.Task) (string, error) { } config, _ := input["config"].(map[string]any) public := map[string]any{} - for _, key := range []string{"channelId", "channelModelKey", "providerModelKey", "priceTierId", "model", "apiFormat", "interfaceType", "capabilityConfig", "size", "quality", "transparentBackground", "count", "videoSeconds", "vquality", "videoGenerateAudio", "videoWatermark", "audioVoice", "audioFormat", "audioSpeed", "audioInstructions"} { + if values, exists := config["imageParameters"]; exists { + public["imageParameters"] = values + } + for _, key := range []string{"channelId", "channelModelKey", "providerModelKey", "priceTierId", "model", "apiFormat", "interfaceType", "capabilityConfig", "size", "imageResolution", "quality", "transparentBackground", "count", "videoSeconds", "vquality", "videoGenerateAudio", "videoWatermark", "audioVoice", "audioFormat", "audioSpeed", "audioInstructions"} { if value, exists := config[key]; exists { public[key] = value } diff --git a/backend/internal/app/cloud_agent_runtime_media.go b/backend/internal/app/cloud_agent_runtime_media.go index 7af355e29..de479837e 100644 --- a/backend/internal/app/cloud_agent_runtime_media.go +++ b/backend/internal/app/cloud_agent_runtime_media.go @@ -99,8 +99,7 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c } if media != nil { requested, _ := req.Input["config"].(map[string]any) - resolved, _ := input["config"].(map[string]any) - if err := validateCloudAgentResolvedMediaOptions(requested, resolved); err != nil { + if err := validateCloudAgentResolvedMediaOptions(requested, input); err != nil { return s.cloudAgentMediaError(run, state, "admission", false, false, err) } } diff --git a/backend/internal/app/finance.go b/backend/internal/app/finance.go index 37612b442..ae1540011 100644 --- a/backend/internal/app/finance.go +++ b/backend/internal/app/finance.go @@ -543,7 +543,11 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i } intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) priceTierID, _ := config["priceTierId"].(string) - snapshotCreditCost(order, channelModelPriceTierForBilling(*channelModel, priceTierID, capability, intent), requestedBillingQuantity(capability, config), tokenEstimate) + costTier, err := s.channelModelPriceTierForBilling(*channelModel, priceTierID, capability, intent) + if err != nil { + return nil, err + } + snapshotCreditCost(order, costTier, requestedBillingQuantity(capability, config), tokenEstimate) return order, nil } @@ -587,7 +591,10 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide if err != nil { return nil, err } - tier := channelModelPriceTierForBilling(*item, priceTierID, capability, intents...) + tier, err := s.channelModelPriceTierForBilling(*item, priceTierID, capability, intents...) + if err != nil { + return nil, err + } if tier == nil { return nil, BadAuthRequest("当前模型尚未配置所选规格的用户积分价格") } @@ -657,24 +664,42 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide return order, nil } -func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) *model.ChannelModelPriceTier { +func (s *Service) channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) (*model.ChannelModelPriceTier, error) { + intent := ModelRequestIntent{Capability: capability, Options: map[string]any{}} + if len(intents) > 0 { + intent = intents[0] + if normalizeCapability(intent.Capability) == "" { + intent.Capability = capability + } + } + var specs []CapabilitySpec + if normalizeCapability(capability) == "image" && strings.TrimSpace(channelModel.CapabilityConfigJSON) != "" { + spec, err := channelModelCapabilitySpec(channelModel, s.protocolRegistry()) + if err != nil { + return nil, err + } + specs = append(specs, spec) + intent = normalizeImageSizeIntent(spec, intent) + } if priceTierID != "" { for index := range channelModel.PriceTiers { tier := &channelModel.PriceTiers[index] if tier.ID == priceTierID && tier.Enabled && tier.PriceConfigured { - return tier + if len(specs) > 0 && specs[0].ImageParameters != nil { + selector := model.DecodeSKUSelector(tier.SelectorJSON) + if err := validateImageParameterPriceSelector(specs[0].ImageParameters, selector); err != nil { + return nil, err + } + if matched, _ := matchSKUSelector(selector, imagePriceSelectorForParameters(specs[0].ImageParameters, intent)); !matched { + return nil, BadAuthRequest("价格档与实际图片参数不一致") + } + } + return tier, nil } } - return nil + return nil, nil } - if len(intents) > 0 { - intent := intents[0] - if normalizeCapability(intent.Capability) == "" { - intent.Capability = capability - } - return channelModelPriceTierForIntent(channelModel, intent) - } - return channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: capability, Options: map[string]any{}}) + return channelModelPriceTierForIntent(channelModel, intent, specs...), nil } func estimateTaskTokens(input map[string]any) tokenBillingEstimate { diff --git a/backend/internal/app/image_parameters.go b/backend/internal/app/image_parameters.go new file mode 100644 index 000000000..8b4c14fec --- /dev/null +++ b/backend/internal/app/image_parameters.go @@ -0,0 +1,532 @@ +package app + +import ( + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + + "yingce/backend/internal/model" + "yingce/backend/internal/protocol" +) + +func resolveImageCapabilityParameters(image *ImageCapabilityConfig, definitions []protocol.ImageParameter, model string) error { + configured := image.Parameters + if configured == nil { + // Import existing declarations once. A resolution-only plugin may import + // old quality tiers only when every value is explicitly in its contract. + hasQuality := false + for _, p := range definitions { + if p.Name == "quality" && p.AppliesTo(model) { + hasQuality = true + } + } + for _, definition := range definitions { + if !definition.AppliesTo(model) { + continue + } + p := definition + enabled := p.IsEnabled() + var values []string + var initial string + switch p.Name { + case "size": + enabled = image.Size.Parameter != "none" + values, initial = image.Size.Values, image.Size.Default + case "imageResolution": + enabled = image.Resolution.Supported + values, initial = image.Resolution.Values, image.Resolution.Default + if !enabled && !hasQuality && image.Quality.Supported { + valid := len(image.Quality.Values) > 0 + for _, value := range image.Quality.Values { + valid = valid && definition.Accepts(value) + } + if valid { + enabled, values, initial = true, image.Quality.Values, image.Quality.Default + } + } + case "quality": + enabled, values, initial = image.Quality.Supported, image.Quality.Values, image.Quality.Default + case "transparentBackground": + enabled, p.Default = image.TransparentBackground.Supported, image.TransparentBackground.Default + case "count": + maximum := float64(image.MaxOutputs) + if p.Max == nil || maximum < *p.Max { + p.Max = &maximum + } + } + if len(values) > 0 { + p.Values = nil + for _, value := range values { + if definition.Accepts(value) { + p.Values = append(p.Values, value) + } + } + if len(p.Values) == 0 { + enabled = false + } + if p.Accepts(initial) { + p.Default = initial + } else if len(p.Values) > 0 { + p.Default = p.Values[0] + } + } + p.Enabled = &enabled + configured = append(configured, p) + } + } + parameters, err := protocol.ResolveImageParameters(definitions, configured, model) + if err != nil { + return BadAuthRequest("图片参数配置无效:" + err.Error()) + } + image.Parameters = parameters + return projectImageParameters(image) +} + +// Legacy structs are an execution projection, never a second editable source. +func projectImageParameters(image *ImageCapabilityConfig) error { + if err := protocol.ValidateImageParameters(image.Parameters); err != nil { + return BadAuthRequest(err.Error()) + } + image.Quality = ImageQualityConfig{Default: "auto", Values: []string{}} + image.Resolution = ImageResolutionConfig{Default: "auto", Values: []string{}} + image.TransparentBackground = VideoBooleanConfig{} + image.ResolutionUsesQuality = false + image.MaxOutputs = 1 + sizeEnabled := false + for _, p := range image.Parameters { + if !p.IsEnabled() { + continue + } + values := make([]string, 0, len(p.Values)) + for _, value := range p.Values { + values = append(values, fmt.Sprint(value)) + } + initial := "auto" + if p.Default != nil { + initial = fmt.Sprint(p.Default) + } else if len(values) > 0 { + initial = values[0] + } + switch p.Name { + case "size": + sizeEnabled = true + image.Size.Parameter = "aspect_ratio" + if p.Encoding == "pixels" { + image.Size.Parameter = "size" + for _, value := range values { + if strings.Contains(value, ":") { + image.Size.Parameter = "aspect_ratio" + break + } + } + } + image.Size.Values, image.Size.Default = values, initial + image.Size.AllowCustom = len(p.Values) == 0 + case "imageResolution": + image.Resolution = ImageResolutionConfig{Supported: true, Values: values, Default: initial} + case "quality": + image.Quality = ImageQualityConfig{Supported: true, Values: values, Default: initial} + case "transparentBackground": + image.TransparentBackground = VideoBooleanConfig{Supported: true, Default: p.Default == true} + case "count": + if p.Max != nil { + image.MaxOutputs = int(*p.Max) + } + } + } + if !sizeEnabled { + image.Size = ImageSizeConfig{Parameter: "none", Default: "auto", Values: []string{}} + } + if sizeEnabled { + presets := make([]ImageSizePreset, 0, len(image.Size.Presets)) + for _, preset := range image.Size.Presets { + value := preset.Ratio + if image.Size.Parameter == "size" { + value = preset.Size + } + if (image.Size.AllowCustom || containsCapabilityString(image.Size.Values, value)) && (!image.Resolution.Supported || len(image.Resolution.Values) == 0 || containsCapabilityString(image.Resolution.Values, preset.Tier)) { + presets = append(presets, preset) + } + } + image.Size.Presets = presets + } + return nil +} + +func publicImageParameters(parameters []protocol.ImageParameter) []protocol.ImageParameter { + result := make([]protocol.ImageParameter, 0, len(parameters)) + for _, p := range parameters { + p.Binding, p.Models = "", nil + p.Encoding = "" + result = append(result, p) + } + return result +} + +// Disabled resolution keeps its presets for editing; execution uses one explicit +// default group. Multiple groups without a default must not pick by array order. +func singleImageSizeTier(image *ImageCapabilityConfig) (string, bool) { + if image.Size.DefaultTier != "" { + return image.Size.DefaultTier, true + } + for _, field := range image.Parameters { + if field.Name == "imageResolution" && field.Default != nil && field.Default != "auto" { + return fmt.Sprint(field.Default), true + } + } + for _, preset := range image.Size.Presets { + if preset.Size == image.Size.Default { + return preset.Tier, true + } + } + if len(image.Size.Presets) == 0 { + return "", true + } + tier := image.Size.Presets[0].Tier + for _, preset := range image.Size.Presets { + if preset.Tier != tier { + return "", false + } + } + return tier, true +} + +func activeImageSizePresets(image *ImageCapabilityConfig) []ImageSizePreset { + if image.Parameters == nil || image.Resolution.Supported { + return image.Size.Presets + } + tier, ok := singleImageSizeTier(image) + if !ok { + return nil + } + result := make([]ImageSizePreset, 0, len(image.Size.Presets)) + for _, preset := range image.Size.Presets { + if preset.Tier == tier { + result = append(result, preset) + } + } + return result +} + +func imageParameterConstraints(parameters []protocol.ImageParameter) map[string]OptionConstraint { + result := map[string]OptionConstraint{} + for _, p := range parameters { + if !p.IsEnabled() { + continue + } + constraint := OptionConstraint{Values: p.Values, Min: p.Min, Max: p.Max, Step: p.Step} + if len(p.Values) > 0 { + constraint.Min, constraint.Max, constraint.Step = nil, nil, nil + } + if len(p.Values) == 0 && p.Type == "boolean" { + constraint.Values = []any{false, true} + } + if len(p.Values) == 0 && p.Type == "string" { + constraint.Values = []any{"*"} + } + if len(p.Values) == 0 && (p.Type == "number" || p.Type == "integer") && (p.Min == nil || p.Max == nil) { + constraint = OptionConstraint{Values: []any{"*"}} + } + result[p.Name] = constraint + } + return result +} + +func imageParameterDefaults(parameters []protocol.ImageParameter) map[string]any { + result := map[string]any{} + for _, p := range parameters { + if p.IsEnabled() && p.Default != nil { + result[p.Name] = p.Default + } + } + return result +} + +func imageParameterValue(p protocol.ImageParameter, value any) any { + text, ok := value.(string) + if !ok || p.Type == "string" { + return value + } + switch p.Type { + case "boolean": + if v, err := strconv.ParseBool(text); err == nil { + return v + } + case "number", "integer": + if v, err := strconv.ParseFloat(text, 64); err == nil { + return v + } + } + return value +} + +func resolveImageParameterValues(parameters []protocol.ImageParameter, values map[string]any) (map[string]any, error) { + allowed := map[string]protocol.ImageParameter{} + result := imageParameterDefaults(parameters) + for _, p := range parameters { + if p.IsEnabled() { + allowed[p.Name] = p + } + } + for name, value := range values { + p, ok := allowed[name] + if !ok { + return nil, ModelCapabilityNotSupported("当前图片模型未启用参数:" + name) + } + value = imageParameterValue(p, value) + if !p.Accepts(value) { + return nil, ModelCapabilityNotSupported("图片参数超出支持范围:" + p.Label) + } + if value != nil && value != "" { + result[name] = value + } + } + for _, p := range parameters { + if p.IsEnabled() && p.Required && (result[p.Name] == nil || result[p.Name] == "") { + return nil, ModelCapabilityNotSupported("请填写图片参数:" + p.Label) + } + } + return result, nil +} + +func validateImageParameterPriceSelector(parameters []protocol.ImageParameter, selector map[string]string) error { + allowed := map[string]protocol.ImageParameter{} + for _, p := range parameters { + if p.IsEnabled() && p.Pricing { + allowed[p.Name] = p + } + } + for name, value := range selector { + if name == "operation" { + if value != "text_to_image" && value != "image_to_image" && value != "" && value != "*" { + return BadAuthRequest("图片价格生成方式无效") + } + continue + } + p, ok := allowed[name] + if !ok { + return BadAuthRequest("当前图片模型未声明此计价参数:" + name) + } + if value == "" || value == "*" { + continue + } + if !p.Accepts(imageParameterValue(p, value)) { + return BadAuthRequest("价格档参数不在支持范围内:" + p.Label) + } + } + return nil +} + +func declaredImageParameterValues(config map[string]any, parameters []protocol.ImageParameter) map[string]any { + values := map[string]any{} + for _, p := range parameters { + if !p.IsEnabled() { + continue + } + if value, exists := config[p.Name]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + values[p.Name] = value + } + } + if extras, ok := config["imageParameters"].(map[string]any); ok { + for name, value := range extras { + values[name] = value + } + } + if encoded, ok := config["imageParameters"].(string); ok && encoded != "" { + var extras map[string]any + if json.Unmarshal([]byte(encoded), &extras) == nil { + for name, value := range extras { + values[name] = value + } + } else { + values["invalidImageParameters"] = encoded + } + } + return values +} + +// Admission and worker execution use the same validated values. Core fields are +// only a transport projection; unknown or disabled extension values fail here. +func applyImageParameterValues(config *providerConfig, profile *ImageCapabilityConfig) error { + if profile == nil || profile.Parameters == nil { + return nil + } + values := declaredImageParameterValues(map[string]any{ + "size": config.Size, "imageResolution": config.ImageResolution, "quality": config.Quality, + "count": config.Count, "transparentBackground": config.TransparentBackground, "imageParameters": config.ImageParameters, + }, profile.Parameters) + for _, field := range profile.Parameters { + if field.Name == "size" && !field.Accepts(values["size"]) { + size, resolution := fmt.Sprint(values["size"]), config.ImageResolution + if pixels := resolveImageTaskSize(profile, config.InterfaceType, size, resolution); field.Encoding == "pixels" && field.Accepts(pixels) { + values["size"] = pixels + } else if ratio := imageSizeRatioForDeclaredPreset(profile, size, resolution); ratio != "" && field.Accepts(ratio) { + values["size"] = ratio + } + } + } + resolved, err := resolveImageParameterValues(profile.Parameters, values) + if err != nil { + return err + } + config.ImageParameters = resolved + text := func(name string) string { + if value, exists := resolved[name]; exists { + return fmt.Sprint(value) + } + return "" + } + config.Size, config.ImageResolution, config.Quality = text("size"), text("imageResolution"), text("quality") + config.Count, config.TransparentBackground = text("count"), text("transparentBackground") + if imageSizePixelOutbound(profile, config.InterfaceType) { + config.Size = resolveImageTaskSize(profile, config.InterfaceType, config.Size, config.ImageResolution) + if strings.Contains(config.Size, ":") { + return BadAuthRequest("当前比例与分辨率缺少明确的像素尺寸映射") + } + } + return nil +} + +func imagePriceSelectorForParameters(parameters []protocol.ImageParameter, intent ModelRequestIntent) map[string]string { + selector := map[string]string{"operation": "text_to_image"} + if intent.Inputs["image"] > 0 { + selector["operation"] = "image_to_image" + } + for _, p := range parameters { + if value, ok := intent.Options[p.Name]; ok && p.IsEnabled() && p.Pricing && value != nil { + selector[p.Name] = fmt.Sprint(value) + } + } + return selector +} + +func imagePriceParametersForChannel(channelModel model.ChannelModel, specs ...CapabilitySpec) []protocol.ImageParameter { + if len(specs) > 0 { + return specs[0].ImageParameters + } + config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) + if err == nil && config != nil && config.Image != nil { + return config.Image.Parameters + } + return nil +} + +// Read projection follows live route declarations. Explicit omissions in a new +// frontend schema remain omissions; a legacy snapshot imports available fields +// once. The saved route graph and administrator restrictions remain untouched. +func effectiveImageParameterSpec(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec { + if spec.Capability != "image" { + return spec + } + sources := map[string]protocol.ImageParameter{} + conflicts := map[string]bool{} + order := []string{} + for _, route := range routes { + for _, p := range route.ImageParameters { + if !p.IsEnabled() { + continue + } + previous, exists := sources[p.Name] + if !exists { + sources[p.Name] = p + order = append(order, p.Name) + continue + } + if previous.Type != p.Type { + conflicts[p.Name] = true + continue + } + previous.Pricing = previous.Pricing && p.Pricing + if len(previous.Values) > 0 && len(p.Values) > 0 { + for _, candidate := range p.Values { + found := false + for _, value := range previous.Values { + found = found || protocol.ImageParameterValuesEqual(candidate, value) + } + if !found { + previous.Values = append(previous.Values, candidate) + } + } + } else if p.Min != nil && p.Max != nil && previous.Min != nil && previous.Max != nil { + minimum, maximum := min(*p.Min, *previous.Min), max(*p.Max, *previous.Max) + candidate := OptionConstraint{Min: &minimum, Max: &maximum, Step: previous.Step} + if optionConstraintCovered(candidate, p.Name, routes) { + previous.Min, previous.Max = &minimum, &maximum + } + } + sources[p.Name] = previous + } + } + hasContract := false + for _, route := range routes { + hasContract = hasContract || route.ImageParameters != nil + } + if !hasContract { + return spec + } + result := spec + result.ImageParameters = []protocol.ImageParameter{} + result.Options = map[string]OptionConstraint{} + for _, name := range order { + if conflicts[name] { + log.Printf("image parameter omitted because route types conflict: %s", name) + continue + } + p := sources[name] + constraint, selected := spec.Options[name] + if !selected { + known := false + for _, previous := range spec.ImageParameters { + known = known || previous.Name == name + } + if known { + disabled := false + p.Enabled = &disabled + result.ImageParameters = append(result.ImageParameters, p) + continue + } + } + if selected { + if len(constraint.Values) > 0 && !isWildcardOptionConstraint(constraint) { + values := []any{} + for _, value := range constraint.Values { + if p.Accepts(imageParameterValue(p, value)) { + values = append(values, imageParameterValue(p, value)) + } + } + if len(values) == 0 { + continue + } + p.Values = values + } + if constraint.Min != nil && (p.Min == nil || *constraint.Min > *p.Min) { + p.Min = constraint.Min + } + if constraint.Max != nil && (p.Max == nil || *constraint.Max < *p.Max) { + p.Max = constraint.Max + } + if constraint.Step != nil && (p.Step == nil || *constraint.Step >= *p.Step) { + p.Step = constraint.Step + } + if p.Min != nil && p.Max != nil && *p.Min > *p.Max { + continue + } + } + if p.Default != nil && !p.Accepts(p.Default) { + p.Default = nil + if len(p.Values) > 0 { + p.Default = p.Values[0] + } + } + p.Binding, p.Models, p.Encoding = "", nil, "" + result.ImageParameters = append(result.ImageParameters, p) + } + result.Options = imageParameterConstraints(result.ImageParameters) + result.ImageSize = mergeCapabilityImageSize(routes) + if _, ok := result.Options["size"]; !ok { + result.ImageSize = nil + } + return result +} diff --git a/backend/internal/app/image_parameters_test.go b/backend/internal/app/image_parameters_test.go new file mode 100644 index 000000000..d959b2ca2 --- /dev/null +++ b/backend/internal/app/image_parameters_test.go @@ -0,0 +1,327 @@ +package app + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "yingce/backend/internal/model" + "yingce/backend/internal/protocol" +) + +func imageParameterTestAdapter(t *testing.T, packageID, providerID string) protocol.Adapter { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", packageID+".yingce-plugin")) + if err != nil { + t.Fatal(err) + } + pkg, err := protocol.ParsePluginPackage(data) + if err != nil { + t.Fatal(err) + } + adapters, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil) + if err != nil { + t.Fatal(err) + } + for _, a := range adapters { + if a.Metadata().ID == providerID { + return a + } + } + t.Fatal("provider missing") + return nil +} + +func TestImageParametersResolutionOnlyAndLogicalReconciliation(t *testing.T) { + a := imageParameterTestAdapter(t, "google-gemini-image", "gemini-image") + registry, err := protocol.NewRegistry(a) + if err != nil { + t.Fatal(err) + } + config := DefaultModelCapabilityConfigForModel("gemini-image", "arbitrary-model") + config.Image.Quality = ImageQualityConfig{Supported: true, Values: []string{"2k"}, Default: "2k"} + config.Image.Size.Presets = nil + resolved, err := NormalizeModelCapabilityConfigForModel("image", "gemini-image", "arbitrary-model", config, registry) + if err != nil { + t.Fatal(err) + } + if resolved.Image.Quality.Supported || !resolved.Image.Resolution.Supported || resolved.Image.Resolution.Default != "2k" { + t.Fatalf("resolution contract = %#v", resolved.Image) + } + route, err := CapabilitySpecFromModelCapabilityConfig(resolved, "image") + if err != nil { + t.Fatal(err) + } + old := CapabilitySpec{Version: 1, Capability: "image", Options: map[string]OptionConstraint{"quality": {Values: []any{"2k"}}}} + current := capabilitySpecWithRoutePresets(old, []CapabilitySpec{route}) + if _, exists := current.Options["quality"]; exists { + t.Fatal("stale quality leaked into public model") + } + if _, exists := current.Options["imageResolution"]; !exists { + t.Fatal("resolution lost") + } + defaults, err := decodeLogicalDefaults(`{"quality":"2k"}`, current) + if err != nil || defaults["imageResolution"] != "2k" || defaults["quality"] != nil { + t.Fatalf("defaults = %#v, err=%v", defaults, err) + } +} + +func TestImageParametersAdmissionPricingAndProviderUseSameValues(t *testing.T) { + minimum, maximum := 0.0, 100.0 + parameters := []protocol.ImageParameter{ + {Name: "imageResolution", Label: "Resolution", Type: "string", Values: []any{"small", "large"}, Default: "small", Pricing: true, Binding: "resolution"}, + {Name: "novelAmount", Label: "Novel amount", Type: "number", Min: &minimum, Max: &maximum, Default: 0, Binding: "providerOptions.example.amount"}, + {Name: "rawMode", Label: "Raw mode", Type: "boolean", Default: false, Binding: "providerOptions.example.raw"}, + } + profile := DefaultModelCapabilityConfigForModel("example", "model") + profile.Image.Parameters = parameters + profile.Image.Size = ImageSizeConfig{Parameter: "none", Default: "auto"} + if err := projectImageParameters(profile.Image); err != nil { + t.Fatal(err) + } + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "image") + if err != nil { + t.Fatal(err) + } + intent := ModelRequestIntent{Capability: "image", Options: map[string]any{"imageResolution": "large", "novelAmount": float64(0), "rawMode": false}} + if match := MatchCapability(spec, intent); !match.Matched { + t.Fatal(match.Reasons) + } + for _, invalid := range []map[string]any{{"novelAmount": 101}, {"quality": "4k"}, {"rawMode": 0}} { + if _, err := resolveImageParameterValues(parameters, invalid); err == nil { + t.Fatalf("invalid values accepted: %#v", invalid) + } + } + if err := validateImageParameterPriceSelector(parameters, map[string]string{"novelAmount": "1"}); err == nil { + t.Fatal("nonpricing parameter accepted for billing") + } + if err := validateImageParameterPriceSelector(parameters, map[string]string{"imageResolution": "large"}); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(profile) + cm := model.ChannelModel{Capability: "image", Protocol: "example", CapabilityConfigJSON: string(raw), PriceTiers: []model.ChannelModelPriceTier{newSelectionPriceTier("large", `{"imageResolution":"large"}`, "model", "fixed_request")}} + if tier := channelModelPriceTierForIntent(cm, intent); tier == nil || tier.ID != "large" { + t.Fatal("typed intent failed price matching") + } + request := protocolRequestFromInput(canvasGenerationInput{Mode: "image", Config: providerConfig{Model: "model", InterfaceType: "example", ImageParameters: intent.Options}, Metadata: map[string]any{"providerOptions": map[string]any{"example": map[string]any{"amount": 999}}}}) + request, err = protocol.ApplyImageParameters(request, parameters) + if err != nil { + t.Fatal(err) + } + if request.Resolution != "large" || request.ProviderOptions["example"]["amount"] != float64(0) || request.ProviderOptions["example"]["raw"] != false { + t.Fatalf("provider request drifted: %#v", request) + } +} + +func TestImageParametersPublicIntentDoesNotOverwriteEncodedPixelSize(t *testing.T) { + adapter := imageParameterTestAdapter(t, "openai-images", "openai-image") + request := protocolRequestFromInput(canvasGenerationInput{Mode: "image", Config: providerConfig{ + Model: "arbitrary-model", InterfaceType: "openai-image", Size: "2496x1664", ImageResolution: "2k", Quality: "max", + ImageParameters: map[string]any{"size": "3:2", "imageResolution": "2k", "quality": "max"}, + }}) + spec, err := adapter.BuildCreate(context.Background(), protocol.RequestContext{Request: request}) + if err != nil { + t.Fatal(err) + } + body, ok := spec.Body.(map[string]any) + if !ok { + encoded, _ := json.Marshal(spec.Body) + _ = json.Unmarshal(encoded, &body) + } + if body["size"] != "2496x1664" || body["quality"] != "max" || body["resolution"] != "2k" { + t.Fatalf("encoded request = %#v", body) + } +} + +func TestImageParametersLegacyChannelUsesLiveContractForRouteAndBilling(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("gemini-image", "opaque-model") + profile.Image.Quality = ImageQualityConfig{Supported: true, Values: []string{"2k"}, Default: "2k"} + profile.Image.Resolution = ImageResolutionConfig{} + svc, db, _, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceGeminiImage, profile, []model.ChannelModelPriceTier{ + newSelectionPriceTier("resolution", `{"imageResolution":"2k"}`, "opaque-model", "fixed_request"), + }) + if err := db.AutoMigrate(&model.SystemSetting{}); err != nil { + t.Fatal(err) + } + stored, err := svc.repo.ChannelModel(channelModel.ID) + if err != nil { + t.Fatal(err) + } + spec, err := channelModelCapabilitySpec(*stored, svc.protocolRegistry()) + if err != nil { + t.Fatal(err) + } + if imagePriceParametersForChannel(*stored) != nil { + t.Fatal("fixture must retain legacy persisted config") + } + svc.routeCatalogTTL = time.Hour + svc.routeCatalog = &routeCatalogSnapshot{LoadedAt: time.Now(), Models: map[string]cachedLogicalModel{ + "logical": {Model: model.LogicalModel{ID: "logical", Capability: "image", PricePolicy: "channel"}, ProductSpec: spec, Defaults: imageParameterDefaults(spec.ImageParameters), Routes: []cachedLogicalRoute{ + {Route: model.LogicalModelRoute{ID: "route", Enabled: true, Weight: 1}, CapabilitySpec: spec, ChannelModel: *stored}, + }}, + }} + routed, err := svc.ResolveLogicalModel("logical", ModelRequestIntent{Capability: "image", Options: map[string]any{"imageResolution": "2k"}}) + if err != nil { + t.Fatal(err) + } + input := applyRoutedProviderSelection(map[string]any{"mode": "image", "config": map[string]any{"quality": "stale", "transparentBackground": "true"}}, routed) + if err := svc.ValidateTaskCapability(input); err != nil { + t.Fatal(err) + } + config := input["config"].(map[string]any) + parameters := config["imageParameters"].(map[string]any) + if parameters["imageResolution"] != "2k" || parameters["quality"] != nil || config["quality"] != "" { + t.Fatalf("execution projection = %#v", config) + } + tier, err := svc.channelModelPriceTierForBilling(*stored, "resolution", "image", routed.Intent) + if err != nil || tier == nil || tier.ID != routed.PriceTier.ID { + t.Fatalf("billing projection = %#v, %v", tier, err) + } + quote, err := svc.QuoteLogicalModel("logical", ModelRequestIntent{Capability: "image", Options: map[string]any{"imageResolution": "2k"}}) + if err != nil || quote == nil || quote.AmountMicrocredits != tier.UnitPriceMicrocredits { + t.Fatalf("quote drift: %#v, %v", quote, err) + } + if _, err := svc.channelModelPriceTierForBilling(*stored, "resolution", "image", ModelRequestIntent{Capability: "image", Options: map[string]any{"imageResolution": "4k"}}); err == nil { + t.Fatal("mismatched billing parameters accepted") + } +} + +func TestImageParametersCustomChannelRejectsForgedOrOutOfRangeValues(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("gemini-image", "opaque-model") + enabled, minimum, maximum := true, 0.0, 2.0 + profile.Image.Parameters = []protocol.ImageParameter{{Name: "temperature", Enabled: &enabled, Min: &minimum, Max: &maximum}} + svc := &Service{} + for _, values := range []map[string]any{{"temperature": 3}, {"providerSecret": "value"}, {"temperature": []any{1}}} { + input := map[string]any{"mode": "image", "config": map[string]any{"interfaceType": "gemini-image", "model": "opaque-model", "capabilityConfig": profile, "imageParameters": values}} + if err := svc.ValidateTaskCapability(input); err == nil { + t.Fatalf("invalid values accepted: %#v", values) + } + } +} + +func TestImageParametersDeclaredResolutionNamesAndPixelsAreNotFixedTiers(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("opaque-provider", "opaque-model") + profile.Image.Parameters = []protocol.ImageParameter{ + {Name: "size", Label: "Frame", Type: "string", Values: []any{"2:1"}, Default: "2:1", Binding: "aspectRatio", Encoding: "pixels"}, + {Name: "imageResolution", Label: "Resolution", Type: "string", Values: []any{"panorama"}, Default: "panorama", Binding: "resolution"}, + } + profile.Image.Size.Presets = []ImageSizePreset{{Tier: "panorama", Ratio: "2:1", Width: 8192, Height: 4096, Size: "8192x4096"}} + if err := projectImageParameters(profile.Image); err != nil { + t.Fatal(err) + } + if err := validateImageCapabilityConfig(profile.Image); err != nil { + t.Fatal(err) + } + config := providerConfig{InterfaceType: "opaque-provider", ImageParameters: map[string]any{"size": "2:1", "imageResolution": "panorama"}} + if err := applyImageParameterValues(&config, profile.Image); err != nil { + t.Fatal(err) + } + if config.Size != "8192x4096" || config.ImageParameters["size"] != "2:1" { + t.Fatalf("pixel mapping = %#v", config) + } +} + +func TestImageParametersSaveDifferentRatiosPerResolution(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("opaque-provider", "opaque-model") + profile.Image.Parameters = []protocol.ImageParameter{ + {Name: "size", Label: "Frame", Type: "string", Values: []any{"1:1", "16:9"}, Default: "1:1", Binding: "aspectRatio", Encoding: "pixels"}, + {Name: "imageResolution", Label: "Resolution", Type: "string", Values: []any{"1k", "2k"}, Default: "1k", Binding: "resolution"}, + } + profile.Image.Size.Presets = []ImageSizePreset{ + {Tier: "1k", Ratio: "1:1", Width: 1024, Height: 1024, Size: "1024x1024"}, + {Tier: "2k", Ratio: "16:9", Width: 2560, Height: 1440, Size: "2560x1440"}, + } + if err := projectImageParameters(profile.Image); err != nil { + t.Fatal(err) + } + if err := validateImageSizePresetRequirement("image", "opaque-provider", profile); err != nil { + t.Fatal(err) + } + if err := validateImagePresetSelection(profile.Image, "2k", "1:1"); err == nil { + t.Fatal("unsupported combination accepted") + } + if got := resolveImageTaskSize(profile.Image, "opaque-provider", "16:9", "2k"); got != "2560x1440" { + t.Fatal(got) + } + profile.Image.Size.Presets = profile.Image.Size.Presets[:1] + if err := validateImageSizePresetRequirement("image", "opaque-provider", profile); err == nil { + t.Fatal("unmapped ratio accepted") + } +} + +func TestImageParametersPixelSelectionKeepsExactSizeWithoutResolution(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("opaque-provider", "opaque-model") + profile.Image.Parameters = []protocol.ImageParameter{{Name: "size", Label: "Size", Type: "string", Values: []any{"1024x1024", "2048x2048"}, Default: "1024x1024", Binding: "aspectRatio", Encoding: "pixels"}} + profile.Image.Size.Presets = []ImageSizePreset{{Tier: "1k", Ratio: "1:1", Width: 1024, Height: 1024, Size: "1024x1024"}, {Tier: "2k", Ratio: "1:1", Width: 2048, Height: 2048, Size: "2048x2048"}} + if err := projectImageParameters(profile.Image); err != nil { + t.Fatal(err) + } + config := providerConfig{InterfaceType: "opaque-provider", Size: "2048x2048", ImageParameters: map[string]any{"size": "2048x2048"}} + if err := applyImageParameterValues(&config, profile.Image); err != nil { + t.Fatal(err) + } + if config.Size != "2048x2048" || config.ImageResolution != "" || config.Quality != "" { + t.Fatalf("unexpected encoding: %#v", config) + } +} + +func TestImageParametersDisabledResolutionUsesDefaultAndPreservesGroups(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("opaque-provider", "opaque-model") + disabled := false + profile.Image.Parameters = []protocol.ImageParameter{ + {Name: "size", Label: "Frame", Type: "string", Values: []any{"1:1"}, Default: "1:1", Binding: "aspectRatio", Encoding: "pixels"}, + {Name: "imageResolution", Label: "Resolution", Type: "string", Values: []any{"1k", "2k"}, Default: "2k", Binding: "resolution", Enabled: &disabled}, + } + profile.Image.Size.DefaultTier = "2k" + profile.Image.Size.Presets = []ImageSizePreset{{Tier: "1k", Ratio: "1:1", Width: 1024, Height: 1024, Size: "1024x1024"}, {Tier: "2k", Ratio: "1:1", Width: 2048, Height: 2048, Size: "2048x2048"}} + encoded, err := json.Marshal(profile) + if err != nil { + t.Fatal(err) + } + var reopened ModelCapabilityConfig + if err := json.Unmarshal(encoded, &reopened); err != nil { + t.Fatal(err) + } + if err := projectImageParameters(reopened.Image); err != nil { + t.Fatal(err) + } + if err := validateImageSizePresetRequirement("image", "opaque-provider", &reopened); err != nil { + t.Fatal(err) + } + config := providerConfig{InterfaceType: "opaque-provider", Size: "1:1"} + if err := applyImageParameterValues(&config, reopened.Image); err != nil { + t.Fatal(err) + } + if config.Size != "2048x2048" || config.ImageResolution != "" { + t.Fatalf("disabled resolution changed output: %#v", config) + } + if len(reopened.Image.Size.Presets) != 2 || len(activeImageSizePresets(reopened.Image)) != 1 { + t.Fatal("stored and active presets not separated") + } + reopened.Image.Parameters[1].Enabled = nil + if err := projectImageParameters(reopened.Image); err != nil { + t.Fatal(err) + } + if len(activeImageSizePresets(reopened.Image)) != 2 { + t.Fatal("enabling lost presets") + } +} + +func TestImageParametersDisabledResolutionRejectsAmbiguousPixels(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("opaque-provider", "opaque-model") + profile.Image.Parameters = []protocol.ImageParameter{{Name: "size", Label: "Frame", Type: "string", Values: []any{"1:1"}, Default: "1:1", Binding: "aspectRatio", Encoding: "pixels"}} + profile.Image.Size.Presets = []ImageSizePreset{{Tier: "1k", Ratio: "1:1", Width: 1024, Height: 1024, Size: "1024x1024"}, {Tier: "2k", Ratio: "1:1", Width: 2048, Height: 2048, Size: "2048x2048"}} + if err := projectImageParameters(profile.Image); err != nil { + t.Fatal(err) + } + if err := validateImageSizePresetRequirement("image", "opaque-provider", profile); err == nil { + t.Fatal("ambiguous pixels accepted") + } + config := providerConfig{InterfaceType: "opaque-provider", Size: "1:1"} + if err := applyImageParameterValues(&config, profile.Image); err == nil { + t.Fatal("runtime selected first preset") + } +} diff --git a/backend/internal/app/image_size_presets_test.go b/backend/internal/app/image_size_presets_test.go index 58f18544c..206f01f0c 100644 --- a/backend/internal/app/image_size_presets_test.go +++ b/backend/internal/app/image_size_presets_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "yingce/backend/internal/model" ) func TestImageSizePresetsValidationAndRoundTrip(t *testing.T) { @@ -54,9 +56,50 @@ func TestImageSizePresetsValidationAndRoundTrip(t *testing.T) { } } +func TestImageQualityDoesNotSelectResolutionPresets(t *testing.T) { + profile := DefaultImageCapabilityConfig("openai-image", "test") + profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1"}, Default: "1:1", Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024}, + }} + profile.Resolution = ImageResolutionConfig{Supported: false, Default: "auto"} + profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"medium", "high", "max"}, Default: "medium"} + if err := validateImageCapabilityConfig(profile); err != nil { + t.Fatalf("quality-only capability rejected: %v", err) + } + for _, quality := range profile.Quality.Values { + input := canvasGenerationInput{Mode: "image", Prompt: "local test", Config: providerConfig{InterfaceType: "openai-image", Model: "test", Size: "1:1", Quality: quality, Count: "1"}} + raw := map[string]any{"config": map[string]any{"size": "1:1", "quality": quality}} + if err := validateImageTaskWithResolvedSize(raw, &input, profile); err != nil { + t.Fatalf("quality %q rejected: %v", quality, err) + } + if input.Config.Size != "1024x1024" || input.Config.Quality != quality { + t.Fatalf("quality changed preset or value: %+v", input.Config) + } + } + profile.Resolution.Supported = true + if err := validateImagePresetSelection(profile, "2k", "1:1"); err == nil { + t.Fatal("independent resolution must still reject an undeclared combination") + } +} + +func TestResolveImageTaskSizeUsesRouteEncodingForDeclaredAlias(t *testing.T) { + profile := &ImageCapabilityConfig{ + Size: ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"16:9"}, Default: "16:9", Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "16:9", Size: "1824x1024"}, + }}, + Quality: ImageQualityConfig{Supported: true, Values: []string{"1k"}, Default: "1k"}, + } + if got := resolveImageTaskSize(profile, string(model.ChannelInterfaceGrokImage), "1824x1024", "1k"); got != "16:9" { + t.Fatalf("aspect-ratio route got %q, want canonical ratio 16:9", got) + } + if got := resolveImageTaskSize(profile, string(model.ChannelInterfaceOpenAIImage), "32:18", "1k"); got != "1824x1024" { + t.Fatalf("pixel route got %q, want declared pixel size 1824x1024", got) + } +} + func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) { profile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image") - profile.Quality.Default = "1k" + profile.Resolution.Default = "1k" profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1", Presets: []ImageSizePreset{ {Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024}, {Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536}, @@ -64,12 +107,12 @@ func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) { if err := validateImageCapabilityConfig(profile); err != nil { t.Fatal(err) } - profile.Quality.Default = "2k" + profile.Resolution.Default = "2k" if err := validateImageCapabilityConfig(profile); err == nil || !strings.Contains(err.Error(), "默认图片分辨率") { t.Fatalf("expected invalid default pair error, got %v", err) } - profile.Quality.Default = "1k" - input := canvasGenerationInput{Config: providerConfig{Size: "1:1", Quality: "2k"}} + profile.Resolution.Default = "1k" + input := canvasGenerationInput{Config: providerConfig{Size: "1:1", ImageResolution: "2k"}} if err := validateImageTask(profile, input); err == nil || !strings.Contains(err.Error(), "当前分辨率") { t.Fatalf("expected pair validation error, got %v", err) } @@ -83,3 +126,187 @@ func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) { t.Fatal(err) } } + +func pixelSizeTestProfile() *ImageCapabilityConfig { + profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2.5-4k") + profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"2048x2048", "2752x1536", "2880x2880", "3840x2160"}, Default: "2752x1536", Presets: []ImageSizePreset{ + {Tier: "2k", Ratio: "1:1", Size: "2048x2048", Width: 2048, Height: 2048}, + {Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536}, + {Tier: "4k", Ratio: "1:1", Size: "2880x2880", Width: 2880, Height: 2880}, + {Tier: "4k", Ratio: "16:9", Size: "3840x2160", Width: 3840, Height: 2160}, + }} + profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"} + return profile +} + +func TestResolveImageTaskSizeUsesTierRatioPreset(t *testing.T) { + profile := pixelSizeTestProfile() + for _, tc := range []struct { + name, size, quality, want string + }{ + {"2k ratio preset", "16:9", "2k", "2752x1536"}, + {"4k ratio preset", "1:1", "4k", "2880x2880"}, + {"quality alias", "16:9", "medium", "2752x1536"}, + {"fallback to default tier", "1:1", "", "2048x2048"}, + {"pixel passthrough", "2048x2048", "2k", "2048x2048"}, + {"auto passthrough", "auto", "2k", "auto"}, + {"undeclared ratio stays for validation", "3:2", "2k", "3:2"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := resolveImageTaskSize(profile, "openai-image", tc.size, tc.quality); got != tc.want { + t.Fatalf("resolveImageTaskSize(%q, %q) = %q; want %q", tc.size, tc.quality, got, tc.want) + } + }) + } + aspectProfile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image") + aspectProfile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1"} + if got := resolveImageTaskSize(aspectProfile, "grok-image", "16:9", "2k"); got != "16:9" { + t.Fatalf("aspect_ratio profile size changed: %q", got) + } + if got := resolveImageTaskSize(nil, "openai-image", "16:9", "2k"); got != "16:9" { + t.Fatalf("nil profile size changed: %q", got) + } +} + +func TestValidateTaskCapabilityRewritesRatioSizeToPixelPreset(t *testing.T) { + config := DefaultModelCapabilityConfigForModel("openai-image", "gpt-image-2.5-4k") + config.Image = pixelSizeTestProfile() + config.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"} + config.Image.Quality = ImageQualityConfig{Supported: false} + for _, tc := range []struct { + name, size, resolution, wantSize string + }{ + {"2k preset", "16:9", "2k", "2752x1536"}, + {"4k preset", "1:1", "4k", "2880x2880"}, + } { + t.Run(tc.name, func(t *testing.T) { + input := map[string]any{ + "mode": "image", "prompt": "A simple gray circle on a white background.", + "config": map[string]any{"interfaceType": "openai-image", "model": "gpt-image-2.5-4k", "size": tc.size, "imageResolution": tc.resolution, "count": "1", "capabilityConfig": config}, + } + data, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + var normalized map[string]any + if err := json.Unmarshal(data, &normalized); err != nil { + t.Fatal(err) + } + if err := (&Service{}).ValidateTaskCapability(normalized); err != nil { + t.Fatalf("ValidateTaskCapability() error = %v", err) + } + rewritten := normalized["config"].(map[string]any)["size"] + if rewritten != tc.wantSize { + t.Fatalf("config.size = %v; want %q", rewritten, tc.wantSize) + } + }) + } + t.Run("undeclared ratio fails validation locally", func(t *testing.T) { + input := map[string]any{ + "mode": "image", "prompt": "A simple gray circle on a white background.", + "config": map[string]any{"interfaceType": "openai-image", "model": "gpt-image-2.5-4k", "size": "3:2", "imageResolution": "2k", "count": "1", "capabilityConfig": config}, + } + data, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + var normalized map[string]any + if err := json.Unmarshal(data, &normalized); err != nil { + t.Fatal(err) + } + err = (&Service{}).ValidateTaskCapability(normalized) + if err == nil || !strings.Contains(err.Error(), "支持范围") { + t.Fatalf("expected local size validation error, got %v", err) + } + }) +} + +func TestValidateTaskCapabilityRewritesOpenAIAspectPresetToPixelSize(t *testing.T) { + config := DefaultModelCapabilityConfigForModel("openai-image", "gpt-image-2.5-4k") + config.Image.Size = ImageSizeConfig{ + Parameter: "aspect_ratio", + Values: []string{"9:16"}, + Default: "9:16", + Presets: []ImageSizePreset{ + {Tier: "2k", Ratio: "9:16", Size: "1536x2752", Width: 1536, Height: 2752}, + {Tier: "4k", Ratio: "9:16", Size: "2160x3840", Width: 2160, Height: 3840}, + }, + } + config.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"} + config.Image.Quality = ImageQualityConfig{Supported: false} + input := map[string]any{ + "mode": "image", + "prompt": "A simple gray circle on a white background.", + "config": map[string]any{ + "interfaceType": "openai-image", + "model": "gpt-image-2.5-4k", + "size": "9:16", + "imageResolution": "2k", + "count": "1", + "capabilityConfig": config, + }, + } + data, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + var normalized map[string]any + if err := json.Unmarshal(data, &normalized); err != nil { + t.Fatal(err) + } + if err := (&Service{}).ValidateTaskCapability(normalized); err != nil { + t.Fatalf("ValidateTaskCapability() error = %v", err) + } + if got := normalized["config"].(map[string]any)["size"]; got != "1536x2752" { + t.Fatalf("config.size = %v; want 1536x2752", got) + } +} + +// 比例型线路在任务输入边界把画布比例换算成 preset 像素值后,执行前的 provider 预检会用 +// 同一份能力画像再校验一次;换算结果必须继续被认成合法尺寸,否则任务会在发请求前被本地 +// 拒绝(线上表现为「图片尺寸不在当前模型支持范围内」,出站记录为 provider-preflight)。 +func TestValidateImageTaskAcceptsResolvedPresetPixelSize(t *testing.T) { + profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2.5") + profile.Size = ImageSizeConfig{ + Parameter: "aspect_ratio", + Values: []string{"1:1", "4:3"}, + Default: "1:1", + Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024}, + {Tier: "1k", Ratio: "4:3", Size: "1360x1024", Width: 1360, Height: 1024}, + {Tier: "2k", Ratio: "4:3", Size: "2304x1728", Width: 2304, Height: 1728}, + }, + } + profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "1k"} + + raw := map[string]any{"config": map[string]any{"size": "4:3", "quality": "1k"}} + taskInput := canvasGenerationInput{Mode: "image", Prompt: "a simple circle", Config: providerConfig{InterfaceType: "openai-image", Model: "gpt-image-2.5", Size: "4:3", Quality: "1k", Count: "1"}} + if err := validateImageTaskWithResolvedSize(raw, &taskInput, profile); err != nil { + t.Fatalf("resolve step error = %v", err) + } + if taskInput.Config.Size != "1360x1024" { + t.Fatalf("resolved size = %q; want 1360x1024", taskInput.Config.Size) + } + if got := raw["config"].(map[string]any)["size"]; got != "1360x1024" { + t.Fatalf("persisted config.size = %v; want 1360x1024", got) + } + + // 执行前预检用同一份画像再校验已换算的像素值,必须通过。 + if err := validateImageTask(profile, taskInput); err != nil { + t.Fatalf("provider preflight rejected the resolved pixel size: %v", err) + } + + // 未声明的像素值仍然拒绝。 + undeclared := taskInput + undeclared.Config.Size = "2000x2000" + if err := validateImageTask(profile, undeclared); err == nil || !strings.Contains(err.Error(), "图片尺寸") { + t.Fatalf("undeclared pixel size should stay rejected, got %v", err) + } + + // 其他档位的 preset 像素值不能借低档质量通过。 + wrongTier := taskInput + wrongTier.Config.Size = "2304x1728" + if err := validateImageTask(profile, wrongTier); err == nil || !strings.Contains(err.Error(), "图片尺寸") { + t.Fatalf("other-tier pixel size should stay rejected, got %v", err) + } +} diff --git a/backend/internal/app/logical_model_quote.go b/backend/internal/app/logical_model_quote.go index ad9abbf4d..58a7ee9fc 100644 --- a/backend/internal/app/logical_model_quote.go +++ b/backend/internal/app/logical_model_quote.go @@ -101,7 +101,7 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if routed.PriceTier != nil { priceTierID = routed.PriceTier.ID } - order, billingErr := s.newBillingOrderWithPriceTier("", "", "quote", routed.ChannelModel.ChannelID, routed.ChannelModel.ModelKey, capability, "model_quote", quantity, tokenEstimate, priceTierID) + order, billingErr := s.newBillingOrderWithPriceTier("", "", "quote", routed.ChannelModel.ChannelID, routed.ChannelModel.ModelKey, capability, "model_quote", quantity, tokenEstimate, priceTierID, resolvedIntent) if billingErr != nil { return nil, billingErr } diff --git a/backend/internal/app/logical_models.go b/backend/internal/app/logical_models.go index 4d66c064b..606fec3c7 100644 --- a/backend/internal/app/logical_models.go +++ b/backend/internal/app/logical_models.go @@ -10,6 +10,7 @@ import ( "gorm.io/gorm" "yingce/backend/internal/model" + "yingce/backend/internal/protocol" "yingce/backend/internal/repository" ) @@ -182,17 +183,21 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit channelModelByID[channelModel.ID] = channelModel } admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: map[string]any{}}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}} + projectionRoutes := make([]cachedLogicalRoute, 0, len(graph.Routes)) for _, route := range graph.Routes { channelModel, channelOK := channelModelByID[route.ChannelModelID] if !channelOK { return nil, errors.New("供应线路引用的渠道模型不存在") } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel, s.protocolRegistry()) if specErr != nil { return nil, specErr } _, channelOK = systemChannelByID[channelModel.ChannelID] structurallyAvailable := route.Enabled && route.Weight > 0 && channelModel.Enabled && channelOK + if structurallyAvailable { + projectionRoutes = append(projectionRoutes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) + } billingAvailable := item.PricePolicy != "unified" || item.BillingMode != "token" || supportsTokenBilling(item.Capability, channelModel.Protocol) available := structurallyAvailable && billingAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured) admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, ChannelModelKey: channelModel.ModelKey, ChannelModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: capabilitySpec}) @@ -206,9 +211,8 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit if err != nil { return nil, err } - admin.PublicLogicalModel = publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false) - // publicLogicalModel above has no route list; admin routes are already attached - // and the enriched product spec is the source used by the editor. + admin.PublicLogicalModel = publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults, Routes: projectionRoutes}, false) + // Admin and creator share the sanitized price projection of active routes. admin.CapabilitySpec = productSpec admin.DefaultOptions = defaults structuralRouteSpecs := structuralAdminRouteSpecs(admin.Routes) @@ -381,11 +385,11 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo return nil, nil, nil, false, BadAuthRequest("前台模型只能同步系统渠道模型") } capability = normalizeCapability(source.Capability) - derivedSpec, specErr := channelModelCapabilitySpec(*source) + derivedSpec, specErr := channelModelCapabilitySpec(*source, s.protocolRegistry()) if specErr != nil { return nil, nil, nil, false, specErr } - derivedDefaults, defaultsErr := channelModelDefaultOptions(*source, derivedSpec) + derivedDefaults, defaultsErr := channelModelDefaultOptions(*source, derivedSpec, s.protocolRegistry()) if defaultsErr != nil { return nil, nil, nil, false, defaultsErr } @@ -496,7 +500,7 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if modelErr != nil { return nil, nil, nil, false, BadAuthRequest("供应线路引用的渠道模型不存在") } - capabilitySpec, specErr := channelModelCapabilitySpec(*channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(*channelModel, s.protocolRegistry()) if specErr != nil { return nil, nil, nil, false, specErr } @@ -532,6 +536,22 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本和视频前台模型") } } + if capability == "image" && len(structuralRouteSpecs) > 0 { + // Rebuild labels, types and pricing eligibility from current routes. The + // frontend may restrict values, but cannot invent provider capabilities. + reconciled := effectiveImageParameterSpec(req.CapabilitySpec, structuralRouteSpecs) + if reconciled.ImageParameters != nil { + if err := validateProductSpecWithinRoutes(req.CapabilitySpec, structuralRouteSpecs); err != nil { + return nil, nil, nil, false, err + } + req.CapabilitySpec = reconciled + encodedSpec, encodeErr := json.Marshal(reconciled) + if encodeErr != nil { + return nil, nil, nil, false, encodeErr + } + revision.CapabilitySpecJSON = string(encodedSpec) + } + } // 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。 if req.Enabled { if len(structuralRouteSpecs) == 0 { @@ -563,9 +583,18 @@ func supportsLogicalModelTokenBilling(capability string, enabledRouteProtocols [ } func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map[string]any, error) { + if spec.Capability == "image" && spec.ImageParameters != nil { + parameters := append([]protocol.ImageParameter(nil), spec.ImageParameters...) + for index := range parameters { + parameters[index].Required = false + } + if _, err := resolveImageParameterValues(parameters, defaults); err != nil { + return nil, err + } + } result := make(map[string]any, len(defaults)) for rawName, value := range defaults { - name := canonicalCapabilityOptionName(rawName) + name := canonicalModelOptionName(spec.Capability, rawName) if _, exists := result[name]; exists { return nil, BadAuthRequest("默认参数存在重复别名:" + name) } @@ -594,13 +623,13 @@ func defaultMap(value map[string]any) map[string]any { return value } -func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec, error) { +func channelModelCapabilitySpec(channelModel model.ChannelModel, registries ...*protocol.Registry) (CapabilitySpec, error) { config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) if err != nil { return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") } if config != nil { - config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config) + config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config, registries...) if err != nil { return CapabilitySpec{}, err } @@ -673,18 +702,21 @@ func capabilitySpecWithPriceTiers(spec CapabilitySpec, channelModel model.Channe return result } -func channelModelDefaultOptions(channelModel model.ChannelModel, spec CapabilitySpec) (map[string]any, error) { +func channelModelDefaultOptions(channelModel model.ChannelModel, spec CapabilitySpec, registries ...*protocol.Registry) (map[string]any, error) { config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) if err != nil { return nil, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") } if config != nil { - config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config) + config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config, registries...) if err != nil { return nil, err } } defaults := make(map[string]any) + if spec.Capability == "image" && spec.ImageParameters != nil { + return normalizeLogicalDefaults(spec, imageParameterDefaults(spec.ImageParameters)) + } if config != nil { switch normalizeCapability(channelModel.Capability) { case "image": diff --git a/backend/internal/app/logical_models_catalog.go b/backend/internal/app/logical_models_catalog.go index e0240d390..2a7111a23 100644 --- a/backend/internal/app/logical_models_catalog.go +++ b/backend/internal/app/logical_models_catalog.go @@ -32,7 +32,8 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic available = false if coverageValid { for _, route := range cached.Routes { - if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && (cached.Model.PricePolicy != "channel" || channelModelPriceTierForIntent(route.ChannelModel, resolvedIntent) != nil) { + priceCompatible := cached.Model.PricePolicy != "channel" || channelModelHasCompatiblePriceTier(route.ChannelModel, resolvedIntent, route.CapabilitySpec) + if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && priceCompatible { available = true break } @@ -93,6 +94,12 @@ func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalMode continue } selector := skuSelectorForTier(tier) + if parameters := route.CapabilitySpec.ImageParameters; parameters != nil { + selector = model.DecodeSKUSelector(tier.SelectorJSON) + if validateImageParameterPriceSelector(parameters, selector) != nil { + continue + } + } _, selectorKey, selectorErr := model.CanonicalSKUSelector(selector) if selectorErr != nil { continue @@ -147,6 +154,13 @@ func enabledLogicalRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec { // Callers must pass only currently enabled routes; disabled or zero-weight // routes would otherwise advertise size tiers the public catalog cannot route. func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec { + if spec.Capability == "image" { + for _, route := range routes { + if route.ImageParameters != nil { + return effectiveImageParameterSpec(spec, routes) + } + } + } result := spec result.Options = make(map[string]OptionConstraint, len(spec.Options)) for name, constraint := range spec.Options { diff --git a/backend/internal/app/model_capability.go b/backend/internal/app/model_capability.go index 23a678241..409d1990e 100644 --- a/backend/internal/app/model_capability.go +++ b/backend/internal/app/model_capability.go @@ -7,6 +7,7 @@ import ( "strings" "yingce/backend/internal/model" + protocolcontract "yingce/backend/internal/protocol" ) // ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。 @@ -38,13 +39,18 @@ type TextReferenceConfig struct { } type ImageCapabilityConfig struct { - References ImageReferenceConfig `json:"references"` - Size ImageSizeConfig `json:"size"` - Quality ImageQualityConfig `json:"quality"` - TransparentBackground VideoBooleanConfig `json:"transparentBackground"` - ResponseFormat ParameterSupport `json:"responseFormat"` - OutputFormat ParameterSupport `json:"outputFormat"` - MaxOutputs int `json:"maxOutputs"` + Parameters []protocolcontract.ImageParameter `json:"parameters"` + References ImageReferenceConfig `json:"references"` + Size ImageSizeConfig `json:"size"` + Resolution ImageResolutionConfig `json:"resolution"` + Quality ImageQualityConfig `json:"quality"` + // ResolutionUsesQuality is reserved for protocols whose documented quality + // values are actually resolution tiers (for example Gemini imageSize). + ResolutionUsesQuality bool `json:"resolutionUsesQuality,omitempty"` + TransparentBackground VideoBooleanConfig `json:"transparentBackground"` + ResponseFormat ParameterSupport `json:"responseFormat"` + OutputFormat ParameterSupport `json:"outputFormat"` + MaxOutputs int `json:"maxOutputs"` } type ImageReferenceConfig struct { @@ -60,6 +66,7 @@ type ImageSizeConfig struct { Default string `json:"default"` AllowCustom bool `json:"allowCustom"` Presets []ImageSizePreset `json:"presets,omitempty"` + DefaultTier string `json:"defaultTier,omitempty"` } type ImageSizePreset struct { @@ -76,6 +83,12 @@ type ImageQualityConfig struct { Default string `json:"default"` } +type ImageResolutionConfig struct { + Supported bool `json:"supported"` + Values []string `json:"values"` + Default string `json:"default"` +} + type ParameterSupport struct { Supported bool `json:"supported"` } @@ -147,7 +160,7 @@ func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) { // normalizedChannelModelCapability 从持久化记录恢复渠道模型的权威能力合同。 // 目录读取可以选择隔离损坏记录;任务创建等写路径必须把错误向上返回并失败关闭。 -func normalizedChannelModelCapability(channelModel *model.ChannelModel) (*ModelCapabilityConfig, error) { +func normalizedChannelModelCapability(channelModel *model.ChannelModel, registries ...*protocolcontract.Registry) (*ModelCapabilityConfig, error) { if channelModel == nil { return nil, errors.New("渠道模型为空") } @@ -162,7 +175,7 @@ func normalizedChannelModelCapability(channelModel *model.ChannelModel) (*ModelC if err != nil { return nil, fmt.Errorf("解析渠道模型能力配置失败:%w", err) } - normalized, err := NormalizeModelCapabilityConfigForModel(capability, string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config) + normalized, err := NormalizeModelCapabilityConfigForModel(capability, string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config, registries...) if err != nil { return nil, err } @@ -173,7 +186,7 @@ func NormalizeModelCapabilityConfig(capability string, protocol string, input *M return NormalizeModelCapabilityConfigForModel(capability, protocol, "", input) } -func NormalizeModelCapabilityConfigForModel(capability string, protocol string, modelName string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) { +func NormalizeModelCapabilityConfigForModel(capability string, protocol string, modelName string, input *ModelCapabilityConfig, registries ...*protocolcontract.Registry) (*ModelCapabilityConfig, error) { if capability != "text" && capability != "image" && capability != "video" { return nil, nil } @@ -202,6 +215,27 @@ func NormalizeModelCapabilityConfigForModel(capability string, protocol string, if input == nil || input.Image == nil { return nil, BadAuthRequest("请配置图片模型能力参数") } + if len(registries) > 0 && registries[0] != nil { + if adapter, ok := registries[0].Resolve(protocol); ok && adapter.Metadata().ImageParameters != nil { + if err := resolveImageCapabilityParameters(input.Image, adapter.Metadata().ImageParameters, modelName); err != nil { + return nil, err + } + } else if input.Image.Parameters != nil { + return nil, BadAuthRequest("当前插件未声明图片参数合同,请先更新插件") + } + } + if input.Image.Parameters != nil { + if err := projectImageParameters(input.Image); err != nil { + return nil, err + } + } else { + normalizeLegacyImageResolutionConfig(protocol, input.Image) + input.Image.ResolutionUsesQuality = strings.TrimSpace(protocol) == string(model.ChannelInterfaceGeminiImage) + } + // 旧配置缺换算表时按声明的档位与比例补齐后再校验,保存即把旧线路归一化成当前形态。 + if input.Image.Parameters == nil { + fillMissingImageSizePresets(input.Image, protocol) + } value := &ModelCapabilityConfig{Version: 1, Image: input.Image} if err := validateImageCapabilityConfig(value.Image); err != nil { return nil, err @@ -226,6 +260,71 @@ func NormalizeModelCapabilityConfigForModel(capability string, protocol string, return value, nil } +// normalizeLegacyImageResolutionConfig projects legacy values only for protocols whose +// shipped contracts prove that these exact quality values represented resolution tiers. +// The projection is in-memory; persisted capabilities and historical task inputs are untouched. +func normalizeLegacyImageResolutionConfig(protocol string, image *ImageCapabilityConfig) { + if image == nil || image.Resolution.Supported || !image.Quality.Supported { + return + } + allowed := legacyImageResolutionTiers(protocol) + if len(allowed) == 0 { + return + } + values := make([]string, 0, len(image.Quality.Values)) + for _, value := range image.Quality.Values { + value = strings.ToLower(strings.TrimSpace(value)) + if !allowed[value] { + return + } + values = append(values, value) + } + defaultValue := strings.ToLower(strings.TrimSpace(image.Quality.Default)) + if len(values) == 0 || !allowed[defaultValue] || !containsCapabilityString(values, defaultValue) { + return + } + image.Resolution = ImageResolutionConfig{Supported: true, Values: values, Default: defaultValue} + image.Quality = ImageQualityConfig{Supported: false, Values: []string{}, Default: "auto"} +} + +func legacyImageResolutionProtocol(protocol string) bool { + return len(legacyImageResolutionTiers(protocol)) > 0 +} + +func legacyImageResolutionTiers(protocol string) map[string]bool { + switch strings.TrimSpace(protocol) { + case string(model.ChannelInterfaceGrokImage): + return map[string]bool{"1k": true, "2k": true} + case "agnes-image": + return map[string]bool{"1k": true, "2k": true, "4k": true} + default: + return nil + } +} + +// normalizeLegacyImageResolutionRequest adapts only old request fields for the same +// proven protocols. Other plugins retain their own quality semantics. +func normalizeLegacyImageResolutionRequest(protocol string, capability *ModelCapabilityConfig, config map[string]any) string { + if capability == nil || capability.Image == nil || !capability.Image.Resolution.Supported || capability.Image.Quality.Supported { + return "" + } + imageResolutionProtocol := strings.TrimSpace(protocol) == string(model.ChannelInterfaceGrokImage) || strings.TrimSpace(protocol) == "agnes-image" + if !imageResolutionProtocol { + return "" + } + legacy := strings.TrimSpace(metadataString(config, "quality")) + if !containsCapabilityString(capability.Image.Resolution.Values, legacy) { + return "" + } + if strings.TrimSpace(metadataString(config, "imageResolution")) != "" { + delete(config, "quality") + return "" + } + config["imageResolution"] = legacy + delete(config, "quality") + return legacy +} + func applyModelSpecificVideoCapability(profile *VideoCapabilityConfig, protocol string, modelName string) *VideoCapabilityConfig { if profile == nil || model.ChannelInterfaceType(strings.TrimSpace(protocol)) != model.ChannelInterfaceAgnesVideo { return profile @@ -304,6 +403,14 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa if image.Size.Parameter != "none" { spec.Options["size"] = imageSizeOptionConstraint(image.Size) spec.ImageSize = capabilityImageSizeFromConfig(image.Size) + if image.Parameters != nil && !image.Resolution.Supported { + size := image.Size + size.Presets = activeImageSizePresets(image) + spec.ImageSize = capabilityImageSizeFromConfig(size) + } + } + if image.Resolution.Supported { + spec.Options["imageResolution"] = anyValues(image.Resolution.Values) } if image.Quality.Supported { spec.Options["quality"] = anyValues(image.Quality.Values) @@ -314,6 +421,10 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa spec.Options["transparentBackground"] = boolValues(false) } spec.Options["count"] = numericRange(1, float64(image.MaxOutputs), 1) + if image.Parameters != nil { + spec.Options = imageParameterConstraints(image.Parameters) + spec.ImageParameters = publicImageParameters(image.Parameters) + } case "video": if config.Video == nil { return spec, BadAuthRequest("渠道视频模型尚未配置能力参数") @@ -391,7 +502,7 @@ func capabilityImageSizeFromConfig(size ImageSizeConfig) *CapabilityImageSize { tier := strings.ToLower(strings.TrimSpace(preset.Tier)) ratio := strings.TrimSpace(preset.Ratio) value := strings.TrimSpace(preset.Size) - if tier == "" || ratio == "" || value == "" { + if ratio == "" || value == "" { continue } result.Presets = append(result.Presets, CapabilityImageSizePreset{ diff --git a/backend/internal/app/model_capability_defaults.go b/backend/internal/app/model_capability_defaults.go index 53f516960..85d18d7cc 100644 --- a/backend/internal/app/model_capability_defaults.go +++ b/backend/internal/app/model_capability_defaults.go @@ -3,6 +3,7 @@ package app import ( + "strconv" "strings" "yingce/backend/internal/model" @@ -27,6 +28,10 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab MaxOutputs: 15, } switch model.ChannelInterfaceType(protocol) { + case model.ChannelInterfaceOpenAIImage: + // OpenAI Images 只收 WIDTHxHEIGHT:默认配置声明了 1K/2K/4K,就必须自带档位换算表, + // 否则运行时换算会被静默跳过(见 imageSizePresetMissing),实际出图档位与所选档位不一致。 + image.Size.Presets = openAIImageDefaultSizePresets() case "km-kemei-seedream": image.References.MaxImages = 10 image.References.MaskSupported = false @@ -45,7 +50,8 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab image.References.MaskSupported = false // grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。 image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} - image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + image.Quality = ImageQualityConfig{Supported: false, Default: "auto"} image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} image.ResponseFormat = ParameterSupport{Supported: true} image.OutputFormat = ParameterSupport{Supported: false} @@ -71,12 +77,14 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab image.ResponseFormat.Supported = false image.OutputFormat.Supported = false image.MaxOutputs = 4 + image.ResolutionUsesQuality = true } if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") { image.References.MaxImages = 0 image.References.MaskSupported = false image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} - image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + image.Quality = ImageQualityConfig{Supported: false, Default: "auto"} image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} image.ResponseFormat = ParameterSupport{Supported: true} image.OutputFormat = ParameterSupport{Supported: false} @@ -94,6 +102,45 @@ func defaultImageSizeValues() []string { } } +// openAIImageDefaultSizeTable 是 openai-image 默认能力配置的档位换算表(比例 × 1K/2K/4K)。 +// 像素值与库内 openai-image 线路实际使用的预设逐项一致(gpt-image-2 / 2.5 / 4k 的 1K/2K/4K 全部命中), +// 且都在 defaultImageSizeValues() 里,不引入未声明的尺寸。 +var openAIImageDefaultSizeTable = []struct { + ratio string + tierSizes [3]string +}{ + {"1:1", [3]string{"1024x1024", "2048x2048", "2880x2880"}}, + {"16:9", [3]string{"1824x1024", "2752x1536", "3840x2160"}}, + {"9:16", [3]string{"1024x1824", "1536x2752", "2160x3840"}}, + {"4:3", [3]string{"1360x1024", "2304x1728", "3264x2448"}}, + {"3:4", [3]string{"1024x1360", "1728x2304", "2448x3264"}}, + {"3:2", [3]string{"1536x1024", "2496x1664", "3504x2336"}}, + {"2:3", [3]string{"1024x1536", "1664x2496", "2336x3504"}}, + {"4:5", [3]string{"1024x1280", "1792x2240", "2560x3200"}}, + {"5:4", [3]string{"1280x1024", "2240x1792", "3200x2560"}}, + {"21:9", [3]string{"2048x878", "3136x1344", "3808x1632"}}, +} + +// openAIImageDefaultSizePresets 把上面的表展开成 3 个档位 × 10 个比例的预设。 +// 像素值都来自 defaultImageSizeValues(),validateImageCapabilityConfig 会校验与支持值一致。 +func openAIImageDefaultSizePresets() []ImageSizePreset { + tiers := []string{"1k", "2k", "4k"} + presets := make([]ImageSizePreset, 0, len(openAIImageDefaultSizeTable)*len(tiers)) + for _, entry := range openAIImageDefaultSizeTable { + for index, tier := range tiers { + size := entry.tierSizes[index] + widthText, heightText, found := strings.Cut(size, "x") + width, widthErr := strconv.Atoi(widthText) + height, heightErr := strconv.Atoi(heightText) + if !found || widthErr != nil || heightErr != nil { + continue + } + presets = append(presets, ImageSizePreset{Tier: tier, Ratio: entry.ratio, Size: size, Width: width, Height: height}) + } + } + return presets +} + // legacyImageSizeValues 用于修复旧数据中仅保存了 "*" 的图片尺寸能力。 // 这组值是前后台共同展示的基础预设,不能让历史通配符配置继续污染用户生成参数。 func legacyImageSizeValues() []string { diff --git a/backend/internal/app/model_capability_validate.go b/backend/internal/app/model_capability_validate.go index d9b1b9faf..12fa6851f 100644 --- a/backend/internal/app/model_capability_validate.go +++ b/backend/internal/app/model_capability_validate.go @@ -54,7 +54,7 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { value.Size.Default = "auto" value.Size.AllowCustom = false case "size", "aspect_ratio": - if strings.TrimSpace(value.Size.Default) == "" { + if value.Parameters == nil && strings.TrimSpace(value.Size.Default) == "" { return BadAuthRequest("请配置默认图片尺寸或比例") } if !value.Size.AllowCustom && !containsCapabilityString(value.Size.Values, value.Size.Default) { @@ -65,19 +65,25 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { } seenPresets := make(map[string]bool) for _, preset := range value.Size.Presets { - if preset.Tier != "1k" && preset.Tier != "1.5k" && preset.Tier != "2k" && preset.Tier != "4k" { + if value.Parameters == nil && preset.Tier != "1k" && preset.Tier != "1.5k" && preset.Tier != "2k" && preset.Tier != "4k" { return BadAuthRequest("图片分辨率档位仅支持 1K、1.5K、2K、4K") } + if value.Parameters != nil && value.Resolution.Supported && !containsCapabilityString(value.Resolution.Values, preset.Tier) { + return BadAuthRequest("像素映射使用了未启用的分辨率") + } parts := strings.Split(preset.Ratio, ":") if len(parts) != 2 { return BadAuthRequest("图片预设比例格式无效") } w, ew := strconv.Atoi(parts[0]) h, eh := strconv.Atoi(parts[1]) - if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 { + if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || value.Parameters == nil && max(w, h) > min(w, h)*3 { return BadAuthRequest("图片预设比例无效") } - if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) { + if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 100000 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) { + return BadAuthRequest("图片预设像素尺寸无效") + } + if value.Parameters == nil && (max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400) { return BadAuthRequest("图片预设像素尺寸无效") } // 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。 @@ -106,14 +112,26 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { } } if value.Quality.Supported { - if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) { + if value.Parameters == nil && (len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default)) { return BadAuthRequest("请配置图片质量支持值和默认值") } } else { value.Quality.Values = []string{} value.Quality.Default = "auto" } - if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil { + if value.Resolution.Supported { + if value.Parameters == nil && (len(value.Resolution.Values) == 0 || strings.TrimSpace(value.Resolution.Default) == "" || !containsCapabilityString(value.Resolution.Values, value.Resolution.Default)) { + return BadAuthRequest("请配置图片分辨率支持值和默认值") + } + } else { + value.Resolution.Values = []string{} + value.Resolution.Default = "auto" + } + defaultTier := value.Quality.Default + if value.Resolution.Supported { + defaultTier = value.Resolution.Default + } + if err := validateImagePresetSelection(value, defaultTier, value.Size.Default); err != nil { return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中") } if !value.TransparentBackground.Supported { @@ -215,7 +233,18 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { if taskInput.Config.CapabilityConfig != nil && taskInput.Config.CapabilityConfig.Image != nil { profile = taskInput.Config.CapabilityConfig.Image } - return validateImageTask(profile, taskInput) + normalized, err := NormalizeModelCapabilityConfigForModel("image", taskInput.Config.InterfaceType, taskInput.Config.Model, &ModelCapabilityConfig{Version: 1, Image: profile}, s.protocolRegistry()) + if err != nil { + return err + } + profile = normalized.Image + if profile.Parameters == nil { + fillMissingImageSizePresets(profile, taskInput.Config.InterfaceType) + } + if presetErr := imageSizePresetReadyError(profile, taskInput.Config.InterfaceType); presetErr != nil { + return presetErr + } + return validateImageTaskWithResolvedSize(input, &taskInput, profile) } profile := taskInput.Config.CapabilityConfig if profile == nil || profile.Video == nil { @@ -240,6 +269,7 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { } profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) if taskInput.Mode == "image" { + profile, err = normalizedChannelModelCapability(item, s.protocolRegistry()) if err != nil { return BadAuthRequest("当前图片模型能力参数无效") } @@ -247,7 +277,15 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { if profile != nil && profile.Image != nil { imageProfile = profile.Image } - return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput) + imageProfile = applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat) + // 旧线路没有换算表时按声明的档位补齐;补不齐就失败关闭,不能静默按 1K 出图。 + if imageProfile.Parameters == nil { + fillMissingImageSizePresets(imageProfile, string(item.Protocol)) + } + if presetErr := imageSizePresetReadyError(imageProfile, string(item.Protocol)); presetErr != nil { + return presetErr + } + return validateImageTaskWithResolvedSize(input, &taskInput, imageProfile) } if err != nil || profile == nil || profile.Video == nil { return BadAuthRequest("当前视频模型尚未配置能力参数") @@ -348,6 +386,11 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp if profile == nil { return nil } + if profile.Parameters != nil { + if _, err := resolveImageParameterValues(profile.Parameters, input.Config.ImageParameters); err != nil { + return err + } + } modelName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input.Config.Model)), "models/") if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) && modelName == "grok-imagine-image-quality" { const maxPromptBytes = 8000 @@ -367,19 +410,32 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp if input.Mask != nil && !profile.References.MaskSupported { return BadAuthRequest("当前图片模型不支持蒙版编辑") } - if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) { + quality := strings.TrimSpace(input.Config.Quality) + resolution := strings.TrimSpace(input.Config.ImageResolution) + if profile.Resolution.Supported && resolution != "" && !strings.EqualFold(resolution, "auto") && !strings.EqualFold(resolution, "any") && !containsCapabilityString(profile.Resolution.Values, resolution) { + return BadAuthRequest("图片分辨率不在当前模型支持范围内") + } + selectionTier := quality + if profile.Resolution.Supported { + selectionTier = resolution + } + // 比例型线路会在任务输入边界把画布比例换算成 preset 像素值(OpenAI Images 只收 + // WIDTHxHEIGHT)。换算结果必须继续通过同一套尺寸与分辨率校验:先把已声明的 preset + // 像素值还原成对应比例,否则执行前的 provider 预检会把刚换算好的值判成不支持的尺寸。 + resolvedRatio := imageSizeRatioForDeclaredPreset(profile, input.Config.Size, qualityForImageValidation(selectionTier, profile.Resolution.Default)) + if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && + !containsCapabilityString(profile.Size.Values, input.Config.Size) && resolvedRatio == "" { return BadAuthRequest("图片尺寸不在当前模型支持范围内") } - if profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) { + if profile.Parameters == nil && profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) { if err := validateGPTImage2CustomSize(input.Config.Size); err != nil { return BadAuthRequest(err.Error()) } } - quality := strings.TrimSpace(input.Config.Quality) if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) { return BadAuthRequest("图片质量不在当前模型支持范围内") } - if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { + if err := validateImagePresetSelection(profile, qualityForImageValidation(selectionTier, firstNonEmpty(profile.Resolution.Default, profile.Quality.Default)), firstNonEmpty(resolvedRatio, input.Config.Size, profile.Size.Default)); err != nil { return err } count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) @@ -389,6 +445,29 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func validateImageTaskWithResolvedSize(input map[string]any, taskInput *canvasGenerationInput, profile *ImageCapabilityConfig) error { + if profile.Parameters != nil { + if err := applyImageParameterValues(&taskInput.Config, profile); err != nil { + return err + } + if config, ok := input["config"].(map[string]any); ok { + config["imageParameters"] = taskInput.Config.ImageParameters + config["size"], config["quality"], config["imageResolution"] = taskInput.Config.Size, taskInput.Config.Quality, taskInput.Config.ImageResolution + config["count"], config["transparentBackground"] = taskInput.Config.Count, taskInput.Config.TransparentBackground + } + return validateImageTask(profile, *taskInput) + } + if imageSizeResolutionAfterValidation(profile, taskInput.Config.InterfaceType) { + if err := validateImageTask(profile, *taskInput); err != nil { + return err + } + applyImageSizeResolution(input, taskInput, profile) + return nil + } + applyImageSizeResolution(input, taskInput, profile) + return validateImageTask(profile, *taskInput) +} + func qualityForImageValidation(quality string, fallback string) string { if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") { return fallback @@ -400,7 +479,7 @@ func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { return nil } - tier := imageResolutionTier(quality) + tier := imagePresetSelectionTier(profile, quality) if tier == "" { return nil } @@ -412,6 +491,62 @@ func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio return BadAuthRequest("当前分辨率不支持所选图片宽高比") } +// imageSizeRatioForDeclaredPreset 把已声明的 preset 像素值还原成它对应的比例。比例型线路 +// 会把画布比例换算成 preset 像素值(OpenAI Images 只收 WIDTHxHEIGHT),换算结果必须继续 +// 通过同一套尺寸与分辨率校验。只认当前档位的 preset,避免用低档价格取到高档像素。 +func imageSizeRatioForDeclaredPreset(profile *ImageCapabilityConfig, size string, quality string) string { + if profile == nil { + return "" + } + value := strings.TrimSpace(size) + if value == "" || !strings.ContainsAny(value, "xX") { + return "" + } + if preset, ok := declaredImageSizePreset(profile.Size.Presets, value, imagePresetSelectionTier(profile, quality), imagePresetDefaultTier(profile)); ok { + return normalizeDeclaredImageRatio(preset.Ratio) + } + return "" +} + +func declaredImageSizePreset(presets []ImageSizePreset, value string, quality string, fallbackQuality string) (ImageSizePreset, bool) { + value = strings.TrimSpace(value) + if value == "" || strings.EqualFold(value, "auto") { + return ImageSizePreset{}, false + } + tier := strings.TrimSpace(quality) + if tier == "" { + tier = strings.TrimSpace(fallbackQuality) + } + ratio := "" + if strings.Contains(value, ":") { + ratio = normalizeDeclaredImageRatio(value) + } + for _, preset := range presets { + if tier != "" && !strings.EqualFold(strings.TrimSpace(preset.Tier), tier) { + continue + } + if (ratio != "" && normalizeDeclaredImageRatio(preset.Ratio) == ratio) || strings.EqualFold(strings.TrimSpace(preset.Size), value) { + return preset, true + } + } + return ImageSizePreset{}, false +} + +func normalizeDeclaredImageRatio(value string) string { + ratio := normalizeImageAspectRatio(value) + left, right, found := strings.Cut(ratio, ":") + if !found { + return ratio + } + width, widthErr := strconv.Atoi(left) + height, heightErr := strconv.Atoi(right) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return ratio + } + divisor := imageDimensionGCD(width, height) + return strconv.Itoa(width/divisor) + ":" + strconv.Itoa(height/divisor) +} + func imageResolutionTier(quality string) string { switch strings.ToLower(strings.TrimSpace(quality)) { case "1k", "low": @@ -427,6 +562,322 @@ func imageResolutionTier(quality string) string { } } +func declaredImageResolutionTier(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1k", "1.5k", "2k", "4k": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +// Legacy aliases are meaningful only when the model explicitly declares resolution tiers. +// Ordinary image quality never selects a pixel preset. +func imagePresetSelectionTier(profile *ImageCapabilityConfig, value string) string { + if profile.Parameters != nil { + if profile.Resolution.Supported && containsCapabilityString(profile.Resolution.Values, value) { + return value + } + if !profile.Resolution.Supported { + if tier, ok := singleImageSizeTier(profile); ok { + return tier + } + } + return "" + } + if tier := declaredImageResolutionTier(value); tier != "" { + return tier + } + if profile.Resolution.Supported || (profile.Quality.Supported && profile.ResolutionUsesQuality) { + return imageResolutionTier(value) + } + for _, quality := range profile.Quality.Values { + if declaredImageResolutionTier(quality) != "" { + return imageResolutionTier(value) + } + } + return "" +} + +func imagePresetDefaultTier(profile *ImageCapabilityConfig) string { + if profile.Resolution.Supported { + return imagePresetSelectionTier(profile, profile.Resolution.Default) + } + return imagePresetSelectionTier(profile, profile.Quality.Default) +} + +// resolveImageTaskSize 把画布按比例保存的 size(如 16:9)换算成图片模型 +// 声明的 preset 像素尺寸。声明式 OpenAI Images 协议把统一请求的 aspectRatio +// 原样映射到上游 size 字段,只认 WIDTHxHEIGHT 的上游会拒绝比例字符串,所以 +// 换算必须在任务输入边界完成。未命中 preset 的组合不做猜测,交给能力校验给 +// 出明确错误。 +func resolveImageTaskSize(profile *ImageCapabilityConfig, interfaceType string, size string, quality string) string { + if profile == nil { + return size + } + value := strings.TrimSpace(size) + if value == "" || strings.EqualFold(value, "auto") { + return size + } + preset, ok := declaredImageSizePreset(activeImageSizePresets(profile), value, imagePresetSelectionTier(profile, quality), imagePresetDefaultTier(profile)) + if !ok { + return size + } + if imageSizePixelOutbound(profile, interfaceType) { + return preset.Size + } + if profile.Size.Parameter == "aspect_ratio" { + return normalizeDeclaredImageRatio(preset.Ratio) + } + return size +} + +// imageSizePixelOutbound 表示该线路出网需要像素尺寸,决定“比例 → 像素”换算是否发生。 +// parameter=size 由字段本身要求像素;aspect_ratio 只在声明式 OpenAI Images 上被翻成像素。 +func imageSizePixelOutbound(profile *ImageCapabilityConfig, protocol string) bool { + if profile == nil { + return false + } + if profile.Parameters != nil { + for _, p := range profile.Parameters { + if p.Name == "size" && p.IsEnabled() { + return p.Encoding == "pixels" + } + } + return false + } + if profile.Size.Parameter == "size" { + return true + } + return profile.Size.Parameter == "aspect_ratio" && strings.TrimSpace(protocol) == string(model.ChannelInterfaceOpenAIImage) +} + +// imageSizeClientSubmitsRatio 表示画布会按比例提交尺寸。size 字段里出现比例值属于历史 +// 遗留形态:字段名说“发送像素”,值却是比例,同样需要换算表。 +func imageSizeClientSubmitsRatio(profile *ImageCapabilityConfig) bool { + if profile.Size.Parameter == "aspect_ratio" || profile.Size.AllowCustom { + return true + } + if strings.Contains(profile.Size.Default, ":") { + return true + } + for _, value := range profile.Size.Values { + if strings.Contains(value, ":") { + return true + } + } + return false +} + +// imageSizePresetMissingExact 是规则本体:出网需要像素、客户端按比例提交、没有换算表、 +// 且确实有档位要被编码进尺寸。与 imageSizeResolutionEnabled 共用 imageSizePixelOutbound, +// 保证“需要换算”只有一个判断来源。size + 像素值 + 无预设表 的线路不需要换算,不能误伤。 +func imageSizePresetMissingExact(profile *ImageCapabilityConfig, protocol string) bool { + return imageSizePixelOutbound(profile, protocol) && len(profile.Size.Presets) == 0 && imageSizeClientSubmitsRatio(profile) +} + +// imageSizePresetMissing 在规则本体上再要求“有可解析的档位”:没有档位声明的线路(如 ark、 +// 即梦图片)档位不参与请求,用不着换算表,不能因为缺表就判为配置不完整。 +func imageSizePresetMissing(profile *ImageCapabilityConfig, protocol string) bool { + if !imageSizePresetMissingExact(profile, protocol) { + return false + } + if profile.Parameters != nil { + return true + } + return len(imageSizeDeclaredTiers(profile)) > 0 +} + +// imageSizeDeclaredTiers 取出该线路声明里可解析的分辨率档位。只有"档位要被编码进尺寸"的 +// 线路才需要换算表:没有质量档位声明(例如 ark / 即梦图片)时,档位不参与请求,用不着表。 +func imageSizeDeclaredTiers(profile *ImageCapabilityConfig) []string { + tiers := make([]string, 0, 3) + seen := make(map[string]bool, 3) + add := func(value string) { + tier := imageResolutionTier(value) + if tier == "" || seen[tier] { + return + } + seen[tier] = true + tiers = append(tiers, tier) + } + if profile.Quality.Supported { + for _, value := range profile.Quality.Values { + add(value) + } + } + add(profile.Quality.Default) + return tiers +} + +// imageSizeDeclaredRatios 取出该线路声明里按比例形式的支持值与默认值,归一化后去重。 +func imageSizeDeclaredRatios(profile *ImageCapabilityConfig) []string { + ratios := make([]string, 0, len(profile.Size.Values)) + seen := make(map[string]bool, len(profile.Size.Values)+1) + add := func(value string) { + if !strings.Contains(value, ":") { + return + } + ratio := normalizeImageAspectRatio(value) + if ratio == "" || seen[ratio] { + return + } + seen[ratio] = true + ratios = append(ratios, ratio) + } + add(profile.Size.Default) + for _, value := range profile.Size.Values { + add(value) + } + return ratios +} + +// openAIImageDefaultPixelSize 从默认换算表取「比例 + 档位」对应的像素尺寸。 +func openAIImageDefaultPixelSize(ratio string, tier string) (ImageSizePreset, bool) { + index := -1 + switch tier { + case "1k": + index = 0 + case "2k": + index = 1 + case "4k": + index = 2 + } + if index < 0 { + return ImageSizePreset{}, false + } + for _, entry := range openAIImageDefaultSizeTable { + if entry.ratio != ratio { + continue + } + size := entry.tierSizes[index] + widthText, heightText, found := strings.Cut(size, "x") + width, widthErr := strconv.Atoi(widthText) + height, heightErr := strconv.Atoi(heightText) + if !found || widthErr != nil || heightErr != nil { + return ImageSizePreset{}, false + } + return ImageSizePreset{Tier: tier, Ratio: ratio, Size: size, Width: width, Height: height}, true + } + return ImageSizePreset{}, false +} + +// fillMissingImageSizePresets 为「出网需要像素、按比例提交、却没有换算表」的旧线路补齐 +// 换算表:按线路自己声明的档位与比例,从 openAIImageDefaultSizeTable 取值。这与 +// legacyImageSizeValues 修复旧通配符尺寸是同一套做法 —— 旧数据在读取边界补成当前形态, +// 而不是要求人工逐条重配。补不齐(声明了表里没有的比例)时保持原样,交给调用方拒绝。 +// 返回是否发生了补齐。 +func fillMissingImageSizePresets(profile *ImageCapabilityConfig, protocol string) bool { + if !imageSizePresetMissingExact(profile, protocol) { + return false + } + tiers := imageSizeDeclaredTiers(profile) + ratios := imageSizeDeclaredRatios(profile) + if len(tiers) == 0 || len(ratios) == 0 { + return false + } + presets := make([]ImageSizePreset, 0, len(tiers)*len(ratios)) + for _, tier := range tiers { + for _, ratio := range ratios { + if preset, ok := openAIImageDefaultPixelSize(ratio, tier); ok { + presets = append(presets, preset) + } + } + } + if len(presets) == 0 { + return false + } + profile.Size.Presets = presets + if profile.Size.Parameter == "size" { + // parameter=size 的线路要求预设像素值同时出现在支持值里(validateImageCapabilityConfig), + // 与前端 imageSizeConfigWithPresets 的做法一致。 + for _, preset := range presets { + if !containsCapabilityString(profile.Size.Values, preset.Size) { + profile.Size.Values = append(profile.Size.Values, preset.Size) + } + } + } + return true +} + +// imageSizePresetReadyError 是运行时的失败关闭点:需要把档位换算成像素、又确实没有可用的 +// 换算表(含补不齐的情况)时,宁可报错,也不要静默按 1K 出图。 +func imageSizePresetReadyError(profile *ImageCapabilityConfig, protocol string) error { + if !imageSizePresetMissing(profile, protocol) { + return nil + } + return BadAuthRequest("当前线路缺少像素尺寸换算表,无法把所选比例与档位换算成上游要求的像素尺寸;请在管理端为该线路的每个档位配置像素预设") +} + +// validateImageSizePresetRequirement 是写路径(保存渠道模型)使用的完整性校验: +// 出网需要像素、客户端又按比例提交时必须有换算表。读路径不做这个拦截。 +func validateImageSizePresetRequirement(capability string, protocol string, profile *ModelCapabilityConfig) error { + if normalizeCapability(capability) != "image" || profile == nil { + return nil + } + if image := profile.Image; image != nil && image.Parameters != nil && imageSizePixelOutbound(image, protocol) { + if !image.Resolution.Supported && len(image.Size.Presets) > 0 { + if _, ok := singleImageSizeTier(image); !ok || len(activeImageSizePresets(image)) == 0 { + return BadAuthRequest("关闭分辨率前请设置默认尺寸档位") + } + } + hasRatios := false + for _, ratio := range image.Size.Values { + if !strings.Contains(ratio, ":") { + continue + } + hasRatios = true + if _, ok := declaredImageSizePreset(image.Size.Presets, ratio, "", ""); !ok { + return BadAuthRequest(fmt.Sprintf("请配置比例 %s 对应的像素尺寸", ratio)) + } + } + // Each tier owns its available ratios; the cartesian product is not required. + if hasRatios && image.Resolution.Supported { + for _, tier := range image.Resolution.Values { + found := false + for _, preset := range image.Size.Presets { + if preset.Tier == tier { + found = true + break + } + } + if !found { + return BadAuthRequest(fmt.Sprintf("请为分辨率 %s 至少配置一个比例", tier)) + } + } + } + } + if !imageSizePresetMissing(profile.Image, protocol) { + return nil + } + return BadAuthRequest("该线路按像素尺寸出网但未配置像素预设:请为每个分辨率档位填写对应的像素尺寸,否则实际出图尺寸会与所选档位不一致") +} + +func imageSizeResolutionEnabled(profile *ImageCapabilityConfig, interfaceType string) bool { + return imageSizePixelOutbound(profile, interfaceType) && len(profile.Size.Presets) > 0 +} + +func imageSizeResolutionAfterValidation(profile *ImageCapabilityConfig, interfaceType string) bool { + return profile != nil && profile.Size.Parameter != "size" && imageSizeResolutionEnabled(profile, interfaceType) +} + +// applyImageSizeResolution 把换算结果写回任务输入,与视频的 applyVideoResolution +// 写回 config.vquality 同一机制,保证持久化的任务输入已是可执行值。 +func applyImageSizeResolution(input map[string]any, taskInput *canvasGenerationInput, profile *ImageCapabilityConfig) { + tier := taskInput.Config.Quality + if profile.Resolution.Supported { + tier = taskInput.Config.ImageResolution + } + resolved := resolveImageTaskSize(profile, taskInput.Config.InterfaceType, taskInput.Config.Size, tier) + if resolved == taskInput.Config.Size { + return + } + taskInput.Config.Size = resolved + if config, ok := input["config"].(map[string]any); ok { + config["size"] = resolved + } +} + func validateWorkflowProviderPromptLength(input canvasGenerationInput) error { profile := input.Config.CapabilityConfig if input.Mode != "video" || profile == nil || profile.Video == nil { diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index d4c2ef6ac..965c53e12 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -190,6 +190,10 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod if cm == nil { return PublicChannelModel{}, fmt.Errorf("渠道模型为空") } + normalized, err := normalizedChannelModelCapability(cm, s.protocolRegistry()) + if err != nil { + return PublicChannelModel{}, err + } // 仓储层已预加载价格档;这里只发布当前启用且价格字段自洽的活动档位。 priceTiers := cm.PriceTiers @@ -198,6 +202,11 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod if !tier.Enabled || !tier.PriceConfigured || !ValidatePriceTierPrice(&tier, cm.Capability, cm.Protocol) { continue } + if normalized != nil && normalized.Image != nil && normalized.Image.Parameters != nil { + if validateImageParameterPriceSelector(normalized.Image.Parameters, model.DecodeSKUSelector(tier.SelectorJSON)) != nil { + continue + } + } publicTiers = append(publicTiers, PublicChannelModelPriceTier{ ID: tier.ID, Selector: model.DecodeSKUSelector(tier.SelectorJSON), @@ -215,10 +224,6 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod pricingMode, displayPrice, priceLabel := computeChannelModelPriceDisplay(cm, publicTiers) var capabilityConfig map[string]any - normalized, err := normalizedChannelModelCapability(cm) - if err != nil { - return PublicChannelModel{}, err - } if normalized != nil { capabilityConfig, err = modelCapabilityConfigToMap(normalized) if err != nil { @@ -231,7 +236,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod if specErr != nil { return PublicChannelModel{}, fmt.Errorf("投影渠道模型默认参数失败:%w", specErr) } - defaultOptions, err = channelModelDefaultOptions(*cm, spec) + defaultOptions, err = channelModelDefaultOptions(*cm, spec, s.protocolRegistry()) if err != nil { return PublicChannelModel{}, err } @@ -303,7 +308,7 @@ func (s *Service) channelModelMatchesIntent(cm *model.ChannelModel, intent *Mode if normalizeCapability(cm.Capability) == "audio" { return true, nil } - config, err := normalizedChannelModelCapability(cm) + config, err := normalizedChannelModelCapability(cm, s.protocolRegistry()) if err != nil { return false, err } diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index de6b2fa8c..22c88877c 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -167,7 +167,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { continue } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel, s.protocolRegistry()) if specErr != nil { log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) continue @@ -200,7 +200,12 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest if !ok { return nil, BadAuthRequest("所选模型不可用") } - intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) + intent = resolveModelRequestIntent(cached.ProductSpec, intent, cached.Defaults) + if parameters := cached.ProductSpec.ImageParameters; parameters != nil { + if _, err := resolveImageParameterValues(parameters, intent.Options); err != nil { + return nil, err + } + } if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) } @@ -211,12 +216,12 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest selected := weightedRoute(eligible) var priceTier *model.ChannelModelPriceTier if cached.Model.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent, selected.CapabilitySpec) if priceTier == nil { return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") } } - return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil + return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, CapabilitySpec: selected.CapabilitySpec, PriceTier: priceTier, Defaults: cached.Defaults, Intent: intent}, nil } func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { @@ -229,7 +234,7 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { continue } - if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { + if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent, route.CapabilitySpec) == nil { continue } if route.Route.Priority > maxPriority { @@ -254,8 +259,12 @@ func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { // channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 // 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 -func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { +func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent, specs ...CapabilitySpec) *model.ChannelModelPriceTier { selector := skuSelectorForIntent(intent) + parameters := imagePriceParametersForChannel(channelModel, specs...) + if parameters != nil { + selector = imagePriceSelectorForParameters(parameters, intent) + } bestScore := -1 var best *model.ChannelModelPriceTier for index := range channelModel.PriceTiers { @@ -263,7 +272,14 @@ func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent Mode if !tier.Enabled || !tier.PriceConfigured { continue } - matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) + tierSelector := skuSelectorForTier(*tier) + if parameters != nil { + tierSelector = model.DecodeSKUSelector(tier.SelectorJSON) + if validateImageParameterPriceSelector(parameters, tierSelector) != nil { + continue + } + } + matched, score := matchSKUSelector(tierSelector, selector) if !matched { continue } @@ -274,6 +290,44 @@ func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent Mode return best } +// channelModelHasCompatiblePriceTier is only for catalog discovery: an option +// omitted from the intent is still a choice, while every supplied selector +// value must be compatible with at least one active, configured tier. +func channelModelHasCompatiblePriceTier(channelModel model.ChannelModel, intent ModelRequestIntent, specs ...CapabilitySpec) bool { + requested := skuSelectorForIntent(intent) + parameters := imagePriceParametersForChannel(channelModel, specs...) + if parameters != nil { + requested = imagePriceSelectorForParameters(parameters, intent) + } + for _, tier := range channelModel.PriceTiers { + if !tier.Enabled || !tier.PriceConfigured { + continue + } + compatible := true + tierSelector := skuSelectorForTier(tier) + if parameters != nil { + tierSelector = model.DecodeSKUSelector(tier.SelectorJSON) + if validateImageParameterPriceSelector(parameters, tierSelector) != nil { + continue + } + } + for key, expected := range tierSelector { + expected = strings.TrimSpace(expected) + if expected == "" || expected == "*" { + continue + } + if actual, provided := requested[key]; provided && actual != expected { + compatible = false + break + } + } + if compatible { + return true + } + } + return false +} + func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { selector := map[string]string{} if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { @@ -302,6 +356,11 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { selector["videoGenerateAudio"] = audio } case "image": + for name, value := range intent.Options { + if value != nil { + selector[name] = fmt.Sprint(value) + } + } if intent.Inputs["image"] > 0 { selector["operation"] = "image_to_image" } else { diff --git a/backend/internal/app/model_router_attempts.go b/backend/internal/app/model_router_attempts.go index 826ce9e5e..3091e8a7b 100644 --- a/backend/internal/app/model_router_attempts.go +++ b/backend/internal/app/model_router_attempts.go @@ -148,21 +148,22 @@ func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, e if err != nil { return nil, err } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel, s.protocolRegistry()) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, errors.New("当前模型服务暂不可用") + } + productSpec = capabilitySpecWithRoutePresets(productSpec, []CapabilitySpec{capabilitySpec}) defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) if err != nil { return nil, err } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, errors.New("当前模型服务暂不可用") - } if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { return nil, errors.New("任务使用的模型服务价格配置已失效") } if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, CapabilitySpec: capabilitySpec, Defaults: defaults} return routed, nil } @@ -187,15 +188,6 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route if err != nil { return nil, err } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } routes, err := s.repo.LogicalModelRoutes(revision.ID, false) if err != nil { return nil, err @@ -232,12 +224,21 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route if !channelOK { continue } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel, s.protocolRegistry()) if specErr != nil { continue } candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) } + productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(candidates)) + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent := resolveModelRequestIntent(productSpec, ModelRequestIntentFromTaskInput(input, task.Type, task.Operation), defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } tried := make(map[string]bool, len(attempts)) for _, attempt := range attempts { tried[attempt.RouteID] = true @@ -249,12 +250,12 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route selected := weightedRoute(eligible) var priceTier *model.ChannelModelPriceTier if logicalModel.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent, selected.CapabilitySpec) if priceTier == nil { return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") } } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, CapabilitySpec: selected.CapabilitySpec, PriceTier: priceTier, Defaults: defaults, Intent: intent} nextInput := applyRoutedProviderSelection(input, routed) if err := s.ValidateTaskCapability(nextInput); err != nil { return nil, err @@ -287,7 +288,7 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route } else if task.BillingOrderID != "" { config, _ := nextInput["config"].(map[string]any) capability := selected.ChannelModel.Capability - snapshotCreditCost(&costOrder, channelModelPriceTierForIntent(selected.ChannelModel, intent), requestedBillingQuantity(capability, config), estimateTaskBillingTokens(nextInput, capability)) + snapshotCreditCost(&costOrder, channelModelPriceTierForIntent(selected.ChannelModel, intent, selected.CapabilitySpec), requestedBillingQuantity(capability, config), estimateTaskBillingTokens(nextInput, capability)) } if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement, costOrder.BillingCostSnapshot); err != nil { if errors.Is(err, repository.ErrInsufficientCredits) { @@ -425,6 +426,11 @@ func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequest if err != nil { return nil, err } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel, s.protocolRegistry()) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + } + productSpec = capabilitySpecWithRoutePresets(productSpec, []CapabilitySpec{capabilitySpec}) defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) if err != nil { return nil, err @@ -433,14 +439,13 @@ func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequest if match := MatchCapability(productSpec, intent); !match.Matched { return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + if match := MatchCapability(capabilitySpec, intent); !match.Matched { + return nil, BadAuthRequest("历史任务原供应线路不再支持这些参数:" + strings.Join(match.Reasons, ";")) } if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") } - return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil + return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, CapabilitySpec: capabilitySpec, Defaults: defaults, Intent: intent}, nil } func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { diff --git a/backend/internal/app/model_router_capability.go b/backend/internal/app/model_router_capability.go index 3b488016b..cc4ae1d2a 100644 --- a/backend/internal/app/model_router_capability.go +++ b/backend/internal/app/model_router_capability.go @@ -15,15 +15,10 @@ import ( "time" "yingce/backend/internal/model" + "yingce/backend/internal/protocol" ) -type CapabilityImageSizePreset struct { - Size string `json:"size"` - Tier string `json:"tier"` - Ratio string `json:"ratio"` - Width int `json:"width"` - Height int `json:"height"` -} +type CapabilityImageSizePreset = ImageSizePreset type CapabilityImageSize struct { Parameter string `json:"parameter,omitempty"` @@ -32,12 +27,13 @@ type CapabilityImageSize struct { } type CapabilitySpec struct { - Version int `json:"version"` - Capability string `json:"capability"` - Operations []string `json:"operations,omitempty"` - Inputs map[string]InputConstraint `json:"inputs,omitempty"` - Options map[string]OptionConstraint `json:"options,omitempty"` - ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` + ImageParameters []protocol.ImageParameter `json:"imageParameters"` + Version int `json:"version"` + Capability string `json:"capability"` + Operations []string `json:"operations,omitempty"` + Inputs map[string]InputConstraint `json:"inputs,omitempty"` + Options map[string]OptionConstraint `json:"options,omitempty"` + ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` } type InputConstraint struct { @@ -78,18 +74,30 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper if options, ok := input["capabilityOptions"].(map[string]any); ok { explicitOptions = true for key, value := range options { - name := canonicalCapabilityOptionName(key) - // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 + name := canonicalModelOptionName(capability, key) + // auto/any 表示调用方不指定质量或图片分辨率;不能把它当作模型必须声明的 // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 - if name == "quality" { + if name == "quality" || name == "imageResolution" { if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { continue } } - intent.Options[name] = normalizeModelRequestOption(name, value) + if capability == "image" { + intent.Options[name] = value + } else { + intent.Options[name] = normalizeModelRequestOption(name, value) + } } } if config, ok := input["config"].(map[string]any); ok && !explicitOptions { + if capability == "image" { + if parameters, ok := config["imageParameters"].(map[string]any); ok { + for name, value := range parameters { + intent.Options[name] = value + } + return intent + } + } for key, value := range config { switch key { case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": @@ -107,7 +115,7 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper func normalizeModelRequestOption(name string, value any) any { canonicalName := canonicalCapabilityOptionName(name) - if canonicalName == "quality" || canonicalName == "size" { + if canonicalName == "quality" || canonicalName == "size" || canonicalName == "imageResolution" { if text, ok := value.(string); ok { return strings.ToLower(strings.TrimSpace(text)) } @@ -170,12 +178,15 @@ type routeCatalogSnapshot struct { } type RoutedModel struct { - LogicalModel model.LogicalModel - Revision model.LogicalModelRevision - Route model.LogicalModelRoute - ChannelModel model.ChannelModel - PriceTier *model.ChannelModelPriceTier - Defaults map[string]any + LogicalModel model.LogicalModel + Revision model.LogicalModelRevision + Route model.LogicalModelRoute + ChannelModel model.ChannelModel + CapabilitySpec CapabilitySpec + PriceTier *model.ChannelModelPriceTier + Defaults map[string]any + // Intent 是已归一的公开生成意图(比例口径),与线路执行编码分离。 + Intent ModelRequestIntent } func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { @@ -227,7 +238,7 @@ func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { spec.Inputs = normalizedInputs normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) for rawName, constraint := range spec.Options { - name := canonicalCapabilityOptionName(rawName) + name := canonicalModelOptionName(spec.Capability, rawName) if strings.TrimSpace(name) == "" { return spec, BadAuthRequest("能力参数名称不能为空") } @@ -254,6 +265,11 @@ func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { normalizedOptions[name] = constraint } spec.Options = normalizedOptions + if spec.Capability == "image" && spec.ImageParameters != nil { + if err := protocol.ValidateImageParameters(spec.ImageParameters); err != nil { + return spec, BadAuthRequest(err.Error()) + } + } return spec, nil } @@ -262,11 +278,44 @@ func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, err if err := json.Unmarshal([]byte(raw), &defaults); err != nil { return nil, err } + if spec.Capability == "image" && spec.ImageParameters != nil { + current := imageParameterDefaults(spec.ImageParameters) + for _, p := range spec.ImageParameters { + if value, ok := defaults[p.Name]; ok && p.IsEnabled() && p.Accepts(imageParameterValue(p, value)) { + current[p.Name] = imageParameterValue(p, value) + } + } + defaults = current + } return normalizeLogicalDefaults(spec, defaults) } func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { reasons := make([]string, 0) + if spec.Capability == "image" && spec.ImageParameters != nil { + values := make(map[string]any, len(intent.Options)) + for name, value := range intent.Options { + values[name] = value + } + // Size may already be encoded as a pixel preset by an older client. + parameters := append([]protocol.ImageParameter(nil), spec.ImageParameters...) + for index := range parameters { + parameters[index].Required = false + } + if value, ok := values["size"]; ok && capabilityImageSizePresetMatches(spec, value, values) { + delete(values, "size") + parameters = make([]protocol.ImageParameter, 0, len(spec.ImageParameters)) + for _, p := range spec.ImageParameters { + if p.Name != "size" { + p.Required = false + parameters = append(parameters, p) + } + } + } + if _, err := resolveImageParameterValues(parameters, values); err != nil { + reasons = append(reasons, err.Error()) + } + } if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { reasons = append(reasons, "能力类型不匹配") } @@ -295,7 +344,11 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM } } for name, value := range intent.Options { - constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] + canonicalName := canonicalModelOptionName(spec.Capability, name) + if inactiveCapabilityOptionValue(canonicalName, value) { + continue + } + constraint, declared := spec.Options[canonicalName] if !declared { reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) continue @@ -307,6 +360,105 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} } +// Image size intent and provider encoding share explicit preset mappings. +// 公开意图按比例口径表达,线路执行编码可能是像素。两者只依据当前规格的显式 presets +// 互为别名,不做宽高数值猜测。 + +// resolveModelRequestIntent 产出匹配、计价与线路选择共用的唯一公开生成意图。 +func resolveModelRequestIntent(spec CapabilitySpec, intent ModelRequestIntent, defaults map[string]any) ModelRequestIntent { + intent.Options = mergeIntentDefaults(intent.Options, defaults) + return normalizeImageSizeIntent(spec, intent) +} + +func normalizeImageSizeIntent(spec CapabilitySpec, intent ModelRequestIntent) ModelRequestIntent { + if normalizeCapability(intent.Capability) != "image" || spec.ImageSize == nil || intent.Options == nil { + return intent + } + value, ok := intent.Options["size"].(string) + if !ok || strings.TrimSpace(value) == "" { + return intent + } + tier := stringValue(intent.Options["imageResolution"]) + if _, hasIndependentResolution := spec.Options["imageResolution"]; hasIndependentResolution { + if strings.EqualFold(tier, "auto") || strings.EqualFold(tier, "any") { + tier = "" + } + if tier == "" { + return intent + } + } else if spec.ImageParameters == nil { + tier = stringValue(intent.Options["quality"]) + } + preset, ok := declaredImageSizePreset(spec.ImageSize.Presets, value, tier, "") + if !ok { + return intent + } + options := make(map[string]any, len(intent.Options)) + for key, option := range intent.Options { + options[key] = option + } + if spec.ImageSize.Parameter == "size" { + options["size"] = strings.TrimSpace(preset.Size) + } else { + options["size"] = normalizeDeclaredImageRatio(preset.Ratio) + } + intent.Options = options + return intent +} + +// capabilityImageSizePresetMatches lets routing compare the public image-size +// intent with either route encoding. Only an explicit tier + preset mapping is +// an alias; numeric similarity is never inferred. +func capabilityImageSizePresetMatches(spec CapabilitySpec, value any, options map[string]any) bool { + size := spec.ImageSize + if size == nil || len(size.Presets) == 0 { + return false + } + text, ok := value.(string) + if !ok { + return false + } + var tier any + if spec.ImageParameters == nil { + tier = options["quality"] + } + if _, independent := spec.Options["imageResolution"]; independent { + resolution := stringValue(options["imageResolution"]) + if resolution == "" || strings.EqualFold(resolution, "auto") || strings.EqualFold(resolution, "any") { + return false + } + tier = resolution + } + _, matched := declaredImageSizePreset(size.Presets, text, stringValue(tier), "") + return matched +} + +// inactiveCapabilityOptionValue 判断取值是否表示“未请求该参数”。 +// 空值和图片质量/分辨率的 auto/any 不构成请求;其他非空字符串、true、非零数字仍视为真实要求。 +func inactiveCapabilityOptionValue(name string, value any) bool { + switch typed := value.(type) { + case nil: + return true + case bool: + return !typed + case string: + normalized := strings.TrimSpace(typed) + if normalized == "" { + return true + } + return (name == "quality" || name == "imageResolution") && (strings.EqualFold(normalized, "auto") || strings.EqualFold(normalized, "any")) + case float64: + return typed == 0 + case float32: + return typed == 0 + case int: + return typed == 0 + case int64: + return typed == 0 + } + return false +} + func capabilityInputLabel(name string) string { switch normalizeCapabilityValue(name) { case "image": @@ -328,6 +480,8 @@ func capabilityOptionLabel(name string) string { return "画面尺寸" case "quality": return "生成质量" + case "imageResolution": + return "图片分辨率" case "transparentBackground": return "透明背景" case "count": @@ -461,10 +615,17 @@ func canonicalCapabilityOptionName(value string) string { } } +func canonicalModelOptionName(capability, value string) string { + if capability == "image" { + return strings.TrimSpace(value) + } + return canonicalCapabilityOptionName(value) +} + func isCapabilityOptionFor(capability string, name string) bool { switch normalizeCapability(capability) { case "image": - return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" + return name == "size" || name == "quality" || name == "imageResolution" || name == "transparentBackground" || name == "count" case "video": return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" case "audio": diff --git a/backend/internal/app/protocol_registry.go b/backend/internal/app/protocol_registry.go index 3730f8877..8eaa180c5 100644 --- a/backend/internal/app/protocol_registry.go +++ b/backend/internal/app/protocol_registry.go @@ -26,6 +26,7 @@ type PluginProviderCatalogItem struct { Enabled bool `json:"enabled"` UnavailableReason string `json:"unavailableReason,omitempty"` Parameters []protocol.Parameter `json:"parameters,omitempty"` + ImageParameters []protocol.ImageParameter `json:"imageParameters,omitempty"` Workflows []protocol.ManifestWorkflow `json:"workflows,omitempty"` } @@ -42,6 +43,7 @@ func (s *Service) PluginProviderCatalog(scope, capability string, includeUnavail } item := PluginProviderCatalogItem{ID: provider.ID, Version: plugin.Manifest.Version, Name: provider.Label, Vendor: plugin.Manifest.Author, Categories: provider.Capabilities, Scopes: provider.Scopes, BaseURL: provider.BaseURL, Enabled: plugin.Status == "enabled", UnavailableReason: plugin.Error, Parameters: provider.Parameters, Workflows: workflowsForProvider(plugin.Manifest.Contributes.Workflows, provider.ID)} item.Create, item.Poll, item.ContentType = operationSummary(provider.Create), operationSummaryPtr(provider.Poll), provider.Create.ContentType + item.ImageParameters = provider.ImageParameters // The registry metadata is the canonical provider projection. This keeps // host-backed dispatch paths out of every user-facing catalog consumer. if adapter, ok := canonicalProviderAdapter(s.protocolRegistry(), provider.ID); ok { @@ -180,6 +182,7 @@ func (s *Service) InstallPlugin(data []byte, fileName string) (PluginView, error } plugin, err := s.pluginRuntime.install(data, fileName) if err == nil { + s.invalidateRouteCatalog() s.refreshPaymentRegistry() } return plugin, err @@ -241,6 +244,7 @@ func (s *Service) SetPluginEnabled(id string, enabled bool) (PluginView, error) } plugin, err := s.pluginRuntime.setEnabled(id, enabled) if err == nil { + s.invalidateRouteCatalog() s.refreshPaymentRegistry() } return plugin, err @@ -255,6 +259,7 @@ func (s *Service) UninstallPlugin(id string) error { } err := s.pluginRuntime.uninstall(id) if err == nil { + s.invalidateRouteCatalog() s.refreshPaymentRegistry() } return err diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index c05542f89..91921b095 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -75,6 +75,8 @@ type providerConfig struct { Model string `json:"model"` Size string `json:"size"` Quality string `json:"quality"` + ImageResolution string `json:"imageResolution"` + ImageParameters map[string]any `json:"imageParameters,omitempty"` TransparentBackground string `json:"transparentBackground"` Count string `json:"count"` VideoSeconds string `json:"videoSeconds"` diff --git a/backend/internal/app/provider_image.go b/backend/internal/app/provider_image.go index 76fd1c1d5..c535cad80 100644 --- a/backend/internal/app/provider_image.go +++ b/backend/internal/app/provider_image.go @@ -306,7 +306,7 @@ func grokImageRequestBody(input canvasGenerationInput) (grokImageRequest, string ResponseFormat: grokImageResponseFormat(input.ImageCapability), // Grok 图片协议用 aspect_ratio 表达画布比例;同时发送 size 会被上游按 OpenAI 枚举校验并拒绝。 AspectRatio: normalizeGrokImageAspectRatio(input.Config.Size), - Resolution: normalizeGrokImageResolution(input.Config.Quality), + Resolution: normalizeGrokImageResolution(firstNonEmpty(input.Config.ImageResolution, input.Config.Quality)), } if len(input.ReferenceImages) == 0 { return body, "/images/generations", nil diff --git a/backend/internal/app/provider_protocol_request.go b/backend/internal/app/provider_protocol_request.go index ee09ecd12..ef4d07920 100644 --- a/backend/internal/app/provider_protocol_request.go +++ b/backend/internal/app/provider_protocol_request.go @@ -22,6 +22,9 @@ import ( func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRequest { resolution := strings.TrimSpace(input.Config.VQuality) + if input.Mode == "image" { + resolution = strings.TrimSpace(input.Config.ImageResolution) + } if input.Mode == "video" { if declared := videoResolutionNameRequest(input.VideoCapability, resolution); declared != "" { resolution = declared @@ -32,19 +35,20 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe aspectRatio = normalizePixelSize(aspectRatio) } request := protocol.GenerationRequest{ - Capability: protocol.Capability(input.Mode), - Model: input.Config.Model, - Prompt: input.Prompt, - Instructions: strings.TrimSpace(input.Config.SystemPrompt), - Images: protocolImageReferences(input), - Videos: protocolMediaReferences(input.ReferenceVideos, "video"), - Audios: protocolMediaReferences(input.ReferenceAudios, "audio"), - AspectRatio: aspectRatio, - Resolution: resolution, - Quality: input.Config.Quality, - GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false), - Watermark: parseBool(input.Config.VideoWatermark, false), - Operation: firstNonEmpty(metadataString(input.Metadata, "videoEditOperation"), metadataString(input.Metadata, "videoOperation")), + ImageParameters: protocolImageParameterValues(input.Config.ImageParameters), + Capability: protocol.Capability(input.Mode), + Model: input.Config.Model, + Prompt: input.Prompt, + Instructions: strings.TrimSpace(input.Config.SystemPrompt), + Images: protocolImageReferences(input), + Videos: protocolMediaReferences(input.ReferenceVideos, "video"), + Audios: protocolMediaReferences(input.ReferenceAudios, "audio"), + AspectRatio: aspectRatio, + Resolution: resolution, + Quality: input.Config.Quality, + GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false), + Watermark: parseBool(input.Config.VideoWatermark, false), + Operation: firstNonEmpty(metadataString(input.Metadata, "videoEditOperation"), metadataString(input.Metadata, "videoOperation")), Extra: map[string]any{ "videoSeconds": input.Config.VideoSeconds, "audioVoice": input.Config.AudioVoice, @@ -84,7 +88,7 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe Watermark: request.Watermark, Format: input.Config.AudioFormat, } request.ProviderOptions = make(map[string]map[string]any) - if configured, ok := input.Metadata["providerOptions"].(map[string]any); ok { + if configured, ok := input.Metadata["providerOptions"].(map[string]any); ok && input.Config.ImageParameters == nil { for namespace, raw := range configured { if options, ok := raw.(map[string]any); ok { request.ProviderOptions[strings.TrimSpace(namespace)] = options @@ -102,6 +106,22 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe return request } +func protocolImageParameterValues(values map[string]any) map[string]any { + if values == nil { + return nil + } + result := make(map[string]any, len(values)) + for name, value := range values { + // These fields are already encoded from the same validated intent. In + // particular size must retain the route's explicit pixel conversion. + if name == "size" { + continue + } + result[name] = value + } + return result +} + func protocolImageReferences(input canvasGenerationInput) []protocol.MediaReference { if input.Mode == "video" { return protocolVideoImageReferences(input) diff --git a/backend/internal/app/provider_style.go b/backend/internal/app/provider_style.go index fa6927cce..83cc59d9f 100644 --- a/backend/internal/app/provider_style.go +++ b/backend/internal/app/provider_style.go @@ -205,10 +205,17 @@ func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) fallback := DefaultImageCapabilityConfig(input.Config.InterfaceType, input.Config.Model) channelID := strings.TrimSpace(input.Config.ChannelID) if channelID == "" { - if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Image != nil { - input.ImageCapability = input.Config.CapabilityConfig.Image - } else { - input.ImageCapability = fallback + profile := input.Config.CapabilityConfig + if profile == nil || profile.Image == nil { + profile = &ModelCapabilityConfig{Version: 1, Image: fallback} + } + normalized, err := NormalizeModelCapabilityConfigForModel("image", input.Config.InterfaceType, input.Config.Model, profile, s.protocolRegistry()) + if err != nil { + return err + } + input.ImageCapability = normalized.Image + if err := applyImageParameterValues(&input.Config, input.ImageCapability); err != nil { + return err } return validateImageTask(input.ImageCapability, *input) } @@ -216,7 +223,7 @@ func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) if err != nil { return errors.New("当前系统渠道模型未配置或已停用") } - profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + profile, err := normalizedChannelModelCapability(item, s.protocolRegistry()) if err != nil { return errors.New("当前图片模型能力参数无效") } @@ -225,6 +232,16 @@ func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) } else { input.ImageCapability = fallback } + // 与任务输入边界同一套补齐/失败关闭规则:旧线路没有换算表时先按声明补齐,补不齐再报错。 + if input.ImageCapability.Parameters == nil { + fillMissingImageSizePresets(input.ImageCapability, input.Config.InterfaceType) + } + if presetErr := imageSizePresetReadyError(input.ImageCapability, input.Config.InterfaceType); presetErr != nil { + return presetErr + } + if err := applyImageParameterValues(&input.Config, input.ImageCapability); err != nil { + return err + } return validateImageTask(input.ImageCapability, *input) } diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 95edd2f67..e4e8fff2f 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "strings" "yingce/backend/internal/model" @@ -245,6 +246,9 @@ func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID if err != nil { return nil, input, err } + if normalizeCapability(routed.Intent.Capability) == "image" && routed.Intent.Options != nil { + input["capabilityOptions"] = maps.Clone(routed.Intent.Options) + } return routed, applyRoutedProviderSelection(input, routed), nil } @@ -275,7 +279,10 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map } } for key, value := range routed.Defaults { - canonical := canonicalCapabilityOptionName(key) + canonical := canonicalModelOptionName(routed.Intent.Capability, key) + if !isProviderCapabilityOption(canonical) { + continue + } if existing, exists := nextConfig[canonical]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" { nextConfig[canonical] = providerConfigOptionValue(value) } @@ -283,7 +290,7 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map // 路由匹配和真实请求必须使用同一组参数;逻辑能力参数覆盖空的页面配置,但不携带供应链字段。 if options, ok := input["capabilityOptions"].(map[string]any); ok { for key, value := range options { - canonical := canonicalCapabilityOptionName(key) + canonical := canonicalModelOptionName(routed.Intent.Capability, key) if isProviderCapabilityOption(canonical) { nextConfig[canonical] = providerConfigOptionValue(value) } @@ -292,12 +299,25 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map nextConfig["channelId"] = routed.ChannelModel.ChannelID nextConfig["model"] = routed.ChannelModel.ModelKey nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey + // 协议必须以服务端记录为唯一权威:受管逻辑模型流程的客户端只表达创作参数,不携带 + // interfaceType;缺失会让依赖协议的尺寸换算(比例 → 像素)静默跳过,把 16:9 这类比例 + // 原样发给只认 WIDTHxHEIGHT 的上游(OpenAI Images 直接拒绝)。 + nextConfig["interfaceType"] = string(routed.ChannelModel.Protocol) providerModelKey := routed.ChannelModel.ProviderModelKey if routed.PriceTier != nil { nextConfig["priceTierId"] = routed.PriceTier.ID providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey) } nextConfig["providerModelKey"] = firstNonEmpty(providerModelKey, routed.ChannelModel.ModelKey) + if routed.Intent.Capability == "image" && routed.CapabilitySpec.ImageParameters != nil { + nextConfig["imageParameters"] = maps.Clone(routed.Intent.Options) + for _, key := range []string{"size", "quality", "imageResolution", "transparentBackground", "count"} { + delete(nextConfig, key) + if value, exists := routed.Intent.Options[key]; exists { + nextConfig[key] = providerConfigOptionValue(value) + } + } + } input["config"] = nextConfig return input } @@ -326,6 +346,15 @@ func normalizeTaskInput(input map[string]any) (map[string]any, error) { if err := json.Unmarshal(encoded, &normalized); err != nil { return nil, BadAuthRequest("任务输入格式无效") } + if config, ok := normalized["config"].(map[string]any); ok { + if encoded, ok := config["imageParameters"].(string); ok { + var parameters map[string]any + if err := json.Unmarshal([]byte(encoded), ¶meters); err != nil || parameters == nil { + return nil, BadAuthRequest("图片扩展参数必须为有效对象") + } + config["imageParameters"] = parameters + } + } if snapshot, ok := normalized["canvasSnapshot"]; ok { normalized["canvasSnapshot"] = compactPersistedValue(snapshot) } @@ -460,17 +489,24 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT // 不能让两份客户端数据分别驱动计费与真实上游请求。 if options, ok := input["capabilityOptions"].(map[string]any); ok { for key, value := range options { - canonical := canonicalCapabilityOptionName(key) + canonical := canonicalModelOptionName(channelModel.Capability, key) if isCapabilityOptionFor(channelModel.Capability, canonical) { nextConfig[canonical] = value } } } - capabilityConfig, err := normalizedChannelModelCapability(channelModel) + capabilityConfig, err := normalizedChannelModelCapability(channelModel, s.protocolRegistry()) if err != nil { return input, InvalidModelSelection("指定的模型能力配置无效,请联系管理员") } + if legacyImageResolutionProtocol(string(channelModel.Protocol)) && capabilityConfig != nil && capabilityConfig.Image != nil && capabilityConfig.Image.Resolution.Supported { + legacyQuality := strings.ToLower(strings.TrimSpace(metadataString(nextConfig, "quality"))) + if legacyQuality != "" && legacyQuality != "" && legacyQuality != "auto" && legacyQuality != "any" && !containsCapabilityString(capabilityConfig.Image.Resolution.Values, legacyQuality) { + return input, ModelCapabilityNotSupported("所选模型不支持当前图片分辨率:" + legacyQuality) + } + } + legacyImageResolutionTier := normalizeLegacyImageResolutionRequest(string(channelModel.Protocol), capabilityConfig, nextConfig) if capabilityConfig != nil && capabilityConfig.Video != nil && capabilityConfig.Video.FixedScreenSpec != nil { videoInput := canvasGenerationInput{Config: providerConfig{ Size: metadataString(nextConfig, "size"), VQuality: metadataString(nextConfig, "vquality"), @@ -489,6 +525,34 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT capabilitySpec = &spec } applyChannelCapabilityDefaults(nextConfig, channelModel.Capability, capabilityConfig) + if capabilityConfig != nil && capabilityConfig.Image != nil && capabilityConfig.Image.Parameters != nil { + parameters := capabilityConfig.Image.Parameters + values := declaredImageParameterValues(nextConfig, parameters) + if explicit, ok := input["capabilityOptions"].(map[string]any); ok { + values = explicit + } + if value, ok := values["size"]; ok && capabilitySpec != nil && capabilityImageSizePresetMatches(*capabilitySpec, value, values) { + values = maps.Clone(values) + if ratio := imageSizeRatioForDeclaredPreset(capabilityConfig.Image, fmt.Sprint(value), fmt.Sprint(values["imageResolution"])); ratio != "" { + values["size"] = ratio + } + } + resolved, parameterErr := resolveImageParameterValues(parameters, values) + if parameterErr != nil { + return input, parameterErr + } + nextConfig["imageParameters"] = resolved + for _, p := range parameters { + if value, ok := resolved[p.Name]; ok && isCapabilityOptionFor("image", p.Name) { + nextConfig[p.Name] = providerConfigOptionValue(value) + } + } + for _, name := range []string{"size", "quality", "imageResolution", "transparentBackground"} { + if _, ok := resolved[name]; !ok { + delete(nextConfig, name) + } + } + } input["config"] = nextConfig var declaredOptions map[string]OptionConstraint if capabilitySpec != nil { @@ -497,6 +561,15 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT input["capabilityOptions"] = capabilityOptionsFromConfig(channelModel.Capability, nextConfig, declaredOptions) intent := ModelRequestIntentFromTaskInput(input, taskType, operation) + if capabilitySpec != nil && normalizeCapability(intent.Capability) == "image" { + intent = normalizeImageSizeIntent(*capabilitySpec, intent) + input["capabilityOptions"] = maps.Clone(intent.Options) + for key, value := range intent.Options { + if isProviderCapabilityOption(key) { + nextConfig[key] = providerConfigOptionValue(value) + } + } + } if normalizeCapability(intent.Capability) != normalizeCapability(channelModel.Capability) { return input, ModelCapabilityNotSupported("所选模型与任务能力不匹配") } @@ -510,16 +583,24 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT } pricingIntent := intent - if normalizeCapability(channelModel.Capability) == "image" { + if normalizeCapability(channelModel.Capability) == "image" && (capabilityConfig == nil || capabilityConfig.Image == nil || capabilityConfig.Image.Parameters == nil) { pricingOptions := make(map[string]any, len(intent.Options)+2) for k, v := range intent.Options { pricingOptions[k] = v } rawQuality := strings.ToLower(strings.TrimSpace(fmt.Sprint(nextConfig["quality"]))) - if rawQuality != "" && rawQuality != "" && rawQuality != "auto" && rawQuality != "any" { - pricingOptions["quality"] = rawQuality - } else if pricingOptions["quality"] == nil || pricingOptions["quality"] == "" || pricingOptions["quality"] == "auto" { - pricingOptions["quality"] = "1k" + if legacyImageResolutionTier != "" { + // Match pre-split price tiers without forwarding the legacy quality field upstream. + pricingOptions["quality"] = strings.ToLower(legacyImageResolutionTier) + } else if capabilityConfig == nil || capabilityConfig.Image == nil || !capabilityConfig.Image.Resolution.Supported { + if rawQuality != "" && rawQuality != "" && rawQuality != "auto" && rawQuality != "any" { + pricingOptions["quality"] = rawQuality + } else if pricingOptions["quality"] == nil || pricingOptions["quality"] == "" || pricingOptions["quality"] == "auto" { + pricingOptions["quality"] = "1k" + } + } + if rawResolution := strings.ToLower(strings.TrimSpace(fmt.Sprint(nextConfig["imageResolution"]))); rawResolution != "" && rawResolution != "" && rawResolution != "auto" { + pricingOptions["imageResolution"] = rawResolution } if rawSize := strings.ToLower(strings.TrimSpace(fmt.Sprint(nextConfig["size"]))); rawSize != "" && rawSize != "" && rawSize != "auto" { pricingOptions["size"] = rawSize @@ -527,9 +608,13 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT pricingIntent.Options = pricingOptions } - priceTier := channelModelPriceTierForIntent(*channelModel, pricingIntent) + var pricingSpecs []CapabilitySpec + if capabilitySpec != nil { + pricingSpecs = append(pricingSpecs, *capabilitySpec) + } + priceTier := channelModelPriceTierForIntent(*channelModel, pricingIntent, pricingSpecs...) if priceTier == nil { - priceTier = channelModelPriceTierForIntent(*channelModel, intent) + priceTier = channelModelPriceTierForIntent(*channelModel, intent, pricingSpecs...) } if priceTier == nil || !ValidatePriceTierPrice(priceTier, channelModel.Capability, channelModel.Protocol) { return input, ModelPriceNotConfigured("指定的模型未配置当前规格的有效价格") @@ -559,9 +644,20 @@ func applyChannelCapabilityDefaults(config map[string]any, capability string, pr if profile == nil || profile.Image == nil { return } + if profile.Image.Parameters != nil { + for _, parameter := range profile.Image.Parameters { + if parameter.IsEnabled() && parameter.Default != nil && isCapabilityOptionFor("image", parameter.Name) { + setDefault(parameter.Name, parameter.Default) + } + } + return + } if profile.Image.Size.Parameter != "none" { setDefault("size", profile.Image.Size.Default) } + if profile.Image.Resolution.Supported { + setDefault("imageResolution", profile.Image.Resolution.Default) + } if profile.Image.Quality.Supported { setDefault("quality", profile.Image.Quality.Default) } @@ -583,6 +679,11 @@ func applyChannelCapabilityDefaults(config map[string]any, capability string, pr func capabilityOptionsFromConfig(capability string, config map[string]any, declared map[string]OptionConstraint) map[string]any { options := map[string]any{} + if capability == "image" { + if parameters, ok := config["imageParameters"].(map[string]any); ok { + return maps.Clone(parameters) + } + } for key, value := range config { canonical := canonicalCapabilityOptionName(key) if !isCapabilityOptionFor(capability, canonical) || value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" { diff --git a/backend/internal/app/task_creation_test.go b/backend/internal/app/task_creation_test.go index 524999539..9fb6a847e 100644 --- a/backend/internal/app/task_creation_test.go +++ b/backend/internal/app/task_creation_test.go @@ -134,8 +134,8 @@ func TestResolveTaskModelSelectionStillRequiresLogicalModelWithoutExplicitSystem func TestResolveSystemChannelModelSelectionRebuildsAuthoritativeExecutionSpec(t *testing.T) { profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGrokImage), "grok-image") svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceGrokImage, profile, []model.ChannelModelPriceTier{ - newSelectionPriceTier("tier-1k", `{"operation":"text_to_image","quality":"1k"}`, "provider-image-1k", "fixed_request"), - newSelectionPriceTier("tier-2k", `{"operation":"text_to_image","quality":"2k"}`, "provider-image-2k", "fixed_request"), + newSelectionPriceTier("tier-1k", `{"operation":"text_to_image","imageResolution":"1k"}`, "provider-image-1k", "fixed_request"), + newSelectionPriceTier("tier-2k", `{"operation":"text_to_image","imageResolution":"2k"}`, "provider-image-2k", "fixed_request"), }) input := map[string]any{ @@ -155,7 +155,7 @@ func TestResolveSystemChannelModelSelectionRebuildsAuthoritativeExecutionSpec(t "capabilityConfig": map[string]any{"image": map[string]any{"maxOutputs": 99}}, }, // capabilityOptions 是创作端表达的最终规格,必须覆盖旧页面缓存到 config 的同名值。 - "capabilityOptions": map[string]any{"quality": "1k", "size": "1:1"}, + "capabilityOptions": map[string]any{"imageResolution": "1k", "size": "1:1"}, } resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_image", "") @@ -168,8 +168,8 @@ func TestResolveSystemChannelModelSelectionRebuildsAuthoritativeExecutionSpec(t t.Fatalf("config[%q] must be removed from a system-channel request: %#v", forbidden, config[forbidden]) } } - if got := config["quality"]; got != "1k" { - t.Fatalf("quality = %#v, want capabilityOptions value 1k", got) + if got := config["imageResolution"]; got != "1k" { + t.Fatalf("imageResolution = %#v, want capabilityOptions value 1k", got) } if got := config["priceTierId"]; got != "tier-1k" { t.Fatalf("priceTierId = %#v, want server-selected tier-1k", got) @@ -184,11 +184,60 @@ func TestResolveSystemChannelModelSelectionRebuildsAuthoritativeExecutionSpec(t t.Fatalf("apiFormat = %#v, want openai", got) } options := resolved["capabilityOptions"].(map[string]any) - if options["quality"] != config["quality"] || options["size"] != config["size"] || options["count"] != config["count"] { + if options["imageResolution"] != config["imageResolution"] || options["size"] != config["size"] || providerConfigOptionValue(options["count"]) != config["count"] { t.Fatalf("capabilityOptions and provider config diverged: options=%#v config=%#v", options, config) } } +func TestResolveSystemChannelModelSelectionPricesImageResolutionAndQualityIndependently(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceOpenAIImage), "gpt-image-2") + profile.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "1k"} + profile.Image.Quality = ImageQualityConfig{Supported: true, Values: []string{"high", "max"}, Default: "high"} + svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceOpenAIImage, profile, []model.ChannelModelPriceTier{ + newSelectionPriceTier("2k-max", `{"operation":"text_to_image","imageResolution":"2k","quality":"max"}`, "provider-2k-max", "fixed_request"), + }) + + resolved, err := svc.resolveSystemChannelModelSelection(map[string]any{ + "mode": "image", + "config": map[string]any{"channelId": channel.ID, "model": channelModel.ModelKey, "imageResolution": "2k", "quality": "max", "size": "1:1"}, + }, "canvas_image", "") + if err != nil { + t.Fatalf("resolveSystemChannelModelSelection() error = %v", err) + } + config := resolved["config"].(map[string]any) + if config["priceTierId"] != "2k-max" || config["imageResolution"] != "2k" || config["quality"] != "max" { + t.Fatalf("resolved request = %#v, want exact 2k-max tier and independent options", config) + } + options := resolved["capabilityOptions"].(map[string]any) + if options["imageResolution"] != "2k" || options["quality"] != "max" { + t.Fatalf("capabilityOptions = %#v, want independent imageResolution/quality", options) + } +} + +func TestResolveSystemChannelModelSelectionImportsLegacyGrokResolutionWithCanonicalPrice(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGrokImage), "grok-image") + profile.Image.Resolution = ImageResolutionConfig{} + profile.Image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceGrokImage, profile, []model.ChannelModelPriceTier{ + newSelectionPriceTier("legacy-2k", `{"imageResolution":"2k"}`, "provider-2k", "fixed_request"), + }) + + resolved, err := svc.resolveSystemChannelModelSelection(map[string]any{ + "mode": "image", + "config": map[string]any{"channelId": channel.ID, "model": channelModel.ModelKey, "quality": "2k", "size": "16:9"}, + }, "canvas_image", "") + if err != nil { + t.Fatalf("legacy request was rejected: %v", err) + } + config := resolved["config"].(map[string]any) + if config["imageResolution"] != "2k" || config["quality"] != nil || config["priceTierId"] != "legacy-2k" || config["providerModelKey"] != "provider-2k" { + t.Fatalf("legacy request did not preserve its execution and pricing semantics: %#v", config) + } + if channelModel.CapabilityConfigJSON == "" || !strings.Contains(channelModel.CapabilityConfigJSON, `"quality":{"supported":true`) { + t.Fatal("fixture did not retain the persisted legacy capability JSON") + } +} + func TestResolveSystemChannelModelSelectionMatchesMiniMax768PTier(t *testing.T) { profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceMiniMaxVideo), "MiniMax-H3") svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "video", model.ChannelInterfaceMiniMaxVideo, profile, []model.ChannelModelPriceTier{ @@ -221,7 +270,7 @@ func TestResolveSystemChannelModelSelectionMatchesMiniMax768PTier(t *testing.T) func TestResolveSystemChannelModelSelectionAppliesServerDefaults(t *testing.T) { profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGrokImage), "grok-image") svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceGrokImage, profile, []model.ChannelModelPriceTier{ - newSelectionPriceTier("tier-default", `{"operation":"text_to_image","quality":"2k"}`, "provider-image-default", "fixed_request"), + newSelectionPriceTier("tier-default", `{"operation":"text_to_image","imageResolution":"2k"}`, "provider-image-default", "fixed_request"), }) resolved, err := svc.resolveSystemChannelModelSelection(map[string]any{ @@ -232,7 +281,7 @@ func TestResolveSystemChannelModelSelectionAppliesServerDefaults(t *testing.T) { t.Fatalf("resolveSystemChannelModelSelection() error = %v", err) } config := resolved["config"].(map[string]any) - if config["size"] != "1:1" || config["quality"] != "2k" || config["count"] != "1" || config["transparentBackground"] != "false" { + if config["size"] != "1:1" || config["imageResolution"] != "2k" || config["quality"] != nil || config["count"] != "1" || config["transparentBackground"] != nil { t.Fatalf("server defaults were not applied: %#v", config) } encoded, err := json.Marshal(resolved) @@ -376,10 +425,11 @@ func newSelectionPriceTier(id string, selector string, providerModelKey string, func TestImageResolutionPricingOnSystemChannel(t *testing.T) { profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGeminiImage), "gemini-image-model") profile.Image.Quality = ImageQualityConfig{Supported: false, Default: "auto"} + profile.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"1k", "2k", "4k"}, Default: "1k"} profile.Image.Size = ImageSizeConfig{ Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, - Default: "1:1", + Default: "16:9", Presets: []ImageSizePreset{ {Tier: "1k", Ratio: "16:9", Size: "1824x1024", Width: 1824, Height: 1024}, {Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536}, @@ -387,9 +437,9 @@ func TestImageResolutionPricingOnSystemChannel(t *testing.T) { }, } svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceGeminiImage, profile, []model.ChannelModelPriceTier{ - newSelectionPriceTier("tier-1k", `{"quality":"1k"}`, "provider-1k", "fixed_request"), - newSelectionPriceTier("tier-2k", `{"quality":"2k"}`, "provider-2k", "fixed_request"), - newSelectionPriceTier("tier-4k", `{"quality":"4k"}`, "provider-4k", "fixed_request"), + newSelectionPriceTier("tier-1k", `{"imageResolution":"1k"}`, "provider-1k", "fixed_request"), + newSelectionPriceTier("tier-2k", `{"imageResolution":"2k"}`, "provider-2k", "fixed_request"), + newSelectionPriceTier("tier-4k", `{"imageResolution":"4k"}`, "provider-4k", "fixed_request"), }) for _, tc := range []struct { @@ -401,17 +451,16 @@ func TestImageResolutionPricingOnSystemChannel(t *testing.T) { {"1k specified", "1k", "16:9", "tier-1k"}, {"2k specified", "2k", "16:9", "tier-2k"}, {"4k specified", "4k", "16:9", "tier-4k"}, - {"auto specified", "auto", "16:9", "tier-1k"}, {"empty specified", "", "16:9", "tier-1k"}, } { t.Run(tc.name, func(t *testing.T) { input := map[string]any{ "mode": "image", "config": map[string]any{ - "channelId": channel.ID, - "model": channelModel.ModelKey, - "size": tc.size, - "quality": tc.quality, + "channelId": channel.ID, + "model": channelModel.ModelKey, + "size": tc.size, + "imageResolution": tc.quality, }, } resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_image", "") @@ -425,3 +474,90 @@ func TestImageResolutionPricingOnSystemChannel(t *testing.T) { }) } } + +func TestOpenAIAspectPresetSystemChannelPersistsPixelExecutionSize(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceOpenAIImage), "gpt-image-2.5-4k") + profile.Image.Size = ImageSizeConfig{ + Parameter: "aspect_ratio", + Values: []string{"1:1", "9:16"}, + Default: "1:1", + Presets: []ImageSizePreset{ + {Tier: "2k", Ratio: "1:1", Size: "2048x2048", Width: 2048, Height: 2048}, + {Tier: "2k", Ratio: "9:16", Size: "1536x2752", Width: 1536, Height: 2752}, + {Tier: "4k", Ratio: "1:1", Size: "2880x2880", Width: 2880, Height: 2880}, + {Tier: "4k", Ratio: "9:16", Size: "2160x3840", Width: 2160, Height: 3840}, + }, + } + profile.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"} + profile.Image.Quality = ImageQualityConfig{Supported: false} + svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceOpenAIImage, profile, []model.ChannelModelPriceTier{ + newSelectionPriceTier("tier-2k", `{"imageResolution":"2k"}`, "provider-2k", "fixed_request"), + newSelectionPriceTier("tier-4k", `{"imageResolution":"4k"}`, "provider-4k", "fixed_request"), + }) + input := map[string]any{ + "mode": "image", + "config": map[string]any{ + "channelId": channel.ID, + "model": channelModel.ModelKey, + "size": "9:16", + "imageResolution": "2k", + }, + } + resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_image", "") + if err != nil { + t.Fatalf("resolve error: %v", err) + } + if err := svc.ValidateTaskCapability(resolved); err != nil { + t.Fatalf("ValidateTaskCapability() error = %v", err) + } + cfg := resolved["config"].(map[string]any) + if cfg["priceTierId"] != "tier-2k" { + t.Fatalf("priceTierId = %#v, want tier-2k", cfg["priceTierId"]) + } + if cfg["size"] != "1536x2752" { + t.Fatalf("config.size = %#v, want 1536x2752", cfg["size"]) + } +} + +// 受管逻辑模型流程的客户端只表达创作参数:config 里没有 interfaceType。协议必须由 +// 服务端路由记录回填,否则比例型 size 不会换算成像素,只认 WIDTHxHEIGHT 的上游会 +// 直接拒绝(线上表现为创建成功但生成 400 失败)。 +func TestRoutedLogicalImageTaskWithoutClientInterfaceTypePersistsPixelSize(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceOpenAIImage), "gpt-image-2.5-4k") + profile.Image.Size = ImageSizeConfig{ + Parameter: "aspect_ratio", + Values: []string{"1:1", "9:16"}, + Default: "1:1", + Presets: []ImageSizePreset{ + {Tier: "2k", Ratio: "1:1", Size: "2048x2048", Width: 2048, Height: 2048}, + {Tier: "2k", Ratio: "9:16", Size: "1536x2752", Width: 1536, Height: 2752}, + }, + } + profile.Image.Quality = ImageQualityConfig{Supported: true, Values: []string{"2k"}, Default: "2k"} + svc, _, _, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceOpenAIImage, profile, nil) + tier := newSelectionPriceTier("tier-2k", `{"quality":"2k"}`, "provider-2k", "fixed_request") + tier.ChannelModelID = channelModel.ID + routed := &RoutedModel{ChannelModel: channelModel, PriceTier: &tier} + + input := map[string]any{ + "mode": "image", + "prompt": "A simple gray circle on a white background.", + "config": map[string]any{ + "channelId": channelModel.ChannelID, + "model": channelModel.ModelKey, + "size": "9:16", + "quality": "2k", + }, + } + resolved := applyRoutedProviderSelection(input, routed) + cfg := resolved["config"].(map[string]any) + if cfg["interfaceType"] != string(model.ChannelInterfaceOpenAIImage) { + t.Fatalf("config.interfaceType = %#v, want %s", cfg["interfaceType"], model.ChannelInterfaceOpenAIImage) + } + if err := svc.ValidateTaskCapability(resolved); err != nil { + t.Fatalf("ValidateTaskCapability() error = %v", err) + } + if cfg["size"] != "1536x2752" { + t.Fatalf("config.size = %#v, want 1536x2752", cfg["size"]) + } +} diff --git a/backend/internal/canvas/contract/generation.go b/backend/internal/canvas/contract/generation.go index b245f8e24..4a7326e27 100644 --- a/backend/internal/canvas/contract/generation.go +++ b/backend/internal/canvas/contract/generation.go @@ -33,6 +33,8 @@ type ModelSelection struct { // node/task tags are the only mapping between the persisted canvas and task wire names. type Options struct { Size *string `json:"size,omitempty" node:"size" task:"size" modes:"image,video"` + ImageResolution *string `json:"imageResolution,omitempty" node:"imageResolution" task:"imageResolution" modes:"image"` + ImageParameters *string `json:"imageParameters,omitempty" node:"imageParameters" task:"imageParameters" modes:"image"` Quality *string `json:"quality,omitempty" node:"quality" task:"quality" modes:"image"` Count *int `json:"count,omitempty" node:"count" task:"count" modes:"image"` TransparentBackground *bool `json:"transparentBackground,omitempty" node:"transparentBackground" task:"transparentBackground" modes:"image"` @@ -124,6 +126,19 @@ func (s GenerationSpec) Validate() error { if s.Options.Count != nil && *s.Options.Count < 1 { return invalid("options.count", "数量必须为正整数") } + if s.Options.ImageParameters != nil { + var parameters map[string]any + if err := json.Unmarshal([]byte(*s.Options.ImageParameters), ¶meters); err != nil || parameters == nil { + return invalid("options.imageParameters", "图片参数必须为有效对象") + } + for _, value := range parameters { + switch value.(type) { + case string, bool, float64: + default: + return invalid("options.imageParameters", "图片参数仅支持文本、数字或布尔值") + } + } + } ids := map[string]bool{} orders := map[int]bool{} for i, binding := range s.ReferenceBindings { @@ -189,6 +204,15 @@ func OptionsFromTaskConfig(mode string, config map[string]any) (Options, error) switch field.Type.Elem().Kind() { case reflect.String: text, ok := raw.(string) + if !ok && field.Tag.Get("task") == "imageParameters" { + if parameters, object := raw.(map[string]any); object { + encoded, err := json.Marshal(parameters) + if err != nil { + return options, invalid("options.imageParameters", "参数无法序列化") + } + text, ok = string(encoded), true + } + } if !ok { return options, invalid("options."+field.Tag.Get("task"), "必须是字符串") } @@ -219,6 +243,12 @@ func (o Options) TaskConfig() map[string]any { result[typ.Field(i).Tag.Get("task")] = fmt.Sprint(value.Field(i).Elem().Interface()) } } + if o.ImageParameters != nil { + var parameters map[string]any + if json.Unmarshal([]byte(*o.ImageParameters), ¶meters) == nil && parameters != nil { + result["imageParameters"] = parameters + } + } return result } diff --git a/backend/internal/protocol/image_parameters.go b/backend/internal/protocol/image_parameters.go new file mode 100644 index 000000000..f10545e1f --- /dev/null +++ b/backend/internal/protocol/image_parameters.go @@ -0,0 +1,292 @@ +package protocol + +import ( + "encoding/json" + "fmt" + "math" + "regexp" + "slices" + "strings" +) + +// ImageParameter is the image input contract shared by plugins, model +// configuration and generation. Binding is interpreted only at the provider +// boundary; names and values are never inferred from a model's display name. +type ImageParameter struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Values []any `json:"values,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Step *float64 `json:"step,omitempty"` + MaxLength int `json:"maxLength,omitempty"` + Default any `json:"default,omitempty"` + Required bool `json:"required,omitempty"` + Pricing bool `json:"pricing,omitempty"` + Binding string `json:"binding,omitempty"` + Encoding string `json:"encoding,omitempty"` + Models []string `json:"models,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (p ImageParameter) IsEnabled() bool { return p.Enabled == nil || *p.Enabled } +func (p ImageParameter) AppliesTo(model string) bool { + return len(p.Models) == 0 || slices.Contains(p.Models, strings.TrimPrefix(strings.TrimSpace(model), "models/")) +} + +var imageParameterName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`) + +func ValidateImageParameters(parameters []ImageParameter) error { + seen := map[string]bool{} + bindings := map[string]bool{} + for _, p := range parameters { + if !imageParameterName.MatchString(p.Name) || seen[p.Name] || strings.TrimSpace(p.Label) == "" { + return fmt.Errorf("invalid or duplicate image parameter %q", p.Name) + } + seen[p.Name] = true + if p.Encoding != "" && (p.Binding != "aspectRatio" || p.Encoding != "pixels" && p.Encoding != "ratio") { + return fmt.Errorf("image parameter %s has invalid encoding", p.Name) + } + switch p.Type { + case "string", "number", "integer", "boolean": + default: + return fmt.Errorf("image parameter %s has unsupported type %s", p.Name, p.Type) + } + if p.Min != nil && (!finite(*p.Min) || p.Max != nil && *p.Min > *p.Max) || p.Max != nil && !finite(*p.Max) || p.Step != nil && (!finite(*p.Step) || *p.Step <= 0) || p.MaxLength < 0 { + return fmt.Errorf("image parameter %s has invalid bounds", p.Name) + } + if p.Binding != "" { + if !validImageParameterBinding(p.Binding) || bindings[p.Binding] { + return fmt.Errorf("image parameter %s has invalid or duplicate binding", p.Name) + } + bindings[p.Binding] = true + } + for _, value := range p.Values { + constraint := p + constraint.Values = nil + if !constraint.Accepts(value) { + return fmt.Errorf("image parameter %s has invalid enum value", p.Name) + } + } + if p.Default != nil && !p.Accepts(p.Default) { + return fmt.Errorf("image parameter %s default is outside its constraints", p.Name) + } + } + return nil +} + +func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } + +func (p ImageParameter) Accepts(value any) bool { + if value == nil { + return !p.Required + } + switch p.Type { + case "string": + text, ok := value.(string) + if !ok || p.MaxLength > 0 && len([]rune(text)) > p.MaxLength { + return false + } + case "boolean": + if _, ok := value.(bool); !ok { + return false + } + case "number", "integer": + number, ok := imageParameterNumber(value) + if !ok || !finite(number) || p.Type == "integer" && math.Trunc(number) != number { + return false + } + if p.Min != nil && number < *p.Min || p.Max != nil && number > *p.Max { + return false + } + if p.Step != nil { + base := 0.0 + if p.Min != nil { + base = *p.Min + } + steps := (number - base) / *p.Step + if math.Abs(steps-math.Round(steps)) > 1e-8 { + return false + } + } + default: + return false + } + if len(p.Values) == 0 { + return true + } + for _, candidate := range p.Values { + if ImageParameterValuesEqual(candidate, value) { + return true + } + } + return false +} + +func imageParameterNumber(value any) (float64, bool) { + switch v := value.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + n, err := v.Float64() + return n, err == nil + default: + return 0, false + } +} + +func ImageParameterValuesEqual(left, right any) bool { + l, lok := imageParameterNumber(left) + r, rok := imageParameterNumber(right) + if lok || rok { + return lok && rok && l == r + } + lb, _ := json.Marshal(left) + rb, _ := json.Marshal(right) + return string(lb) == string(rb) +} + +func validImageParameterBinding(binding string) bool { + switch binding { + case "aspectRatio", "resolution", "quality", "imageCount", "transparentBackground": + return true + } + parts := strings.Split(binding, ".") + return len(parts) == 3 && parts[0] == "providerOptions" && validManifestIdentifier(parts[1]) && imageParameterName.MatchString(parts[2]) +} + +// ResolveImageParameters takes only channel-editable restrictions. Labels, +// types, pricing eligibility and bindings always come from the plugin. +func ResolveImageParameters(definitions, configured []ImageParameter, model string) ([]ImageParameter, error) { + if err := ValidateImageParameters(definitions); err != nil { + return nil, err + } + settings := map[string]ImageParameter{} + for _, p := range configured { + if _, duplicate := settings[p.Name]; duplicate { + return nil, fmt.Errorf("duplicate image parameter %s", p.Name) + } + settings[p.Name] = p + } + result := make([]ImageParameter, 0, len(definitions)) + for _, definition := range definitions { + if !definition.AppliesTo(model) { + continue + } + p := definition + if setting, ok := settings[p.Name]; ok { + p.Enabled = setting.Enabled + if !p.IsEnabled() && p.Required { + return nil, fmt.Errorf("%s is required", p.Label) + } + if setting.Values != nil { + for _, value := range setting.Values { + if !definition.Accepts(value) { + return nil, fmt.Errorf("%s value exceeds plugin constraints", p.Label) + } + } + if len(setting.Values) == 0 && len(definition.Values) > 0 && p.IsEnabled() { + return nil, fmt.Errorf("%s requires supported values", p.Label) + } + p.Values = setting.Values + } + if setting.Min != nil { + if definition.Min != nil && *setting.Min < *definition.Min { + return nil, fmt.Errorf("%s minimum exceeds plugin constraints", p.Label) + } + p.Min = setting.Min + } + if setting.Max != nil { + if definition.Max != nil && *setting.Max > *definition.Max { + return nil, fmt.Errorf("%s maximum exceeds plugin constraints", p.Label) + } + p.Max = setting.Max + } + if setting.Step != nil { + if definition.Step != nil && (math.Abs(*setting.Step / *definition.Step - math.Round(*setting.Step / *definition.Step)) > 1e-8 || *setting.Step < *definition.Step) { + return nil, fmt.Errorf("%s step exceeds plugin constraints", p.Label) + } + p.Step = setting.Step + } + if definition.Step != nil && p.Min != nil { + base := 0.0 + if definition.Min != nil { + base = *definition.Min + } + steps := (*p.Min - base) / *definition.Step + if math.Abs(steps-math.Round(steps)) > 1e-8 { + return nil, fmt.Errorf("%s minimum does not align with plugin step", p.Label) + } + } + p.Default = setting.Default + delete(settings, p.Name) + } + result = append(result, p) + } + for name, p := range settings { + if p.IsEnabled() { + return nil, fmt.Errorf("plugin does not declare image parameter %s for this model", name) + } + } + return result, ValidateImageParameters(result) +} + +// ApplyImageParameters runs before building a request. It preserves false and +// zero, rejects undeclared values, and maps only plugin-declared destinations. +func ApplyImageParameters(request GenerationRequest, definitions []ImageParameter) (GenerationRequest, error) { + if request.Capability != CapabilityImage || request.ImageParameters == nil { + return request, nil + } + allowed := map[string]ImageParameter{} + for _, p := range definitions { + if p.AppliesTo(request.Model) { + allowed[p.Name] = p + } + } + if request.ProviderOptions == nil { + request.ProviderOptions = map[string]map[string]any{} + } + if request.Extra == nil { + request.Extra = map[string]any{} + } + for name, value := range request.ImageParameters { + p, ok := allowed[name] + if !ok || !p.Accepts(value) { + return request, fmt.Errorf("unsupported image parameter %s", name) + } + if value == nil { + continue + } + switch p.Binding { + case "aspectRatio": + request.AspectRatio = fmt.Sprint(value) + case "resolution": + request.Resolution = fmt.Sprint(value) + case "quality": + request.Quality = fmt.Sprint(value) + case "imageCount": + n, _ := imageParameterNumber(value) + request.ImageCount = int(n) + case "transparentBackground": + request.Extra["transparentBackground"] = fmt.Sprint(value) + default: + parts := strings.Split(p.Binding, ".") + if len(parts) != 3 || parts[0] != "providerOptions" { + return request, fmt.Errorf("image parameter %s has no request binding", name) + } + if request.ProviderOptions[parts[1]] == nil { + request.ProviderOptions[parts[1]] = map[string]any{} + } + request.ProviderOptions[parts[1]][parts[2]] = value + } + } + return request, nil +} diff --git a/backend/internal/protocol/image_parameters_test.go b/backend/internal/protocol/image_parameters_test.go new file mode 100644 index 000000000..6d16c9178 --- /dev/null +++ b/backend/internal/protocol/image_parameters_test.go @@ -0,0 +1,67 @@ +package protocol + +import ( + "context" + "testing" +) + +func TestImageParametersPluginRequestPreservesValuesAndModelScope(t *testing.T) { + adapter := officialPackageAdapter(t, "kacang-midjourney.yingce-plugin", "kacang-midjourney") + if len(adapter.Metadata().ImageParameters) == 0 { + t.Fatal("packaged plugin is missing image parameter declarations") + } + request := GenerationRequest{Capability: CapabilityImage, Model: "mj-v8.2", Prompt: "portrait", ImageParameters: map[string]any{ + "size": "1:1", "count": 1, "seed": 0, "raw": false, "chaos": 0, "stylize": 450, "mjQuality": 0.5, + }} + spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request}) + if err != nil { + t.Fatal(err) + } + body := manifestTestBody(t, spec) + for name, expected := range map[string]any{"size": "1:1", "seed": float64(0), "raw": false, "chaos": float64(0), "stylize": float64(450), "quality": 0.5} { + if body[name] != expected { + t.Fatalf("%s = %#v, want %#v", name, body[name], expected) + } + } + request.Model = "Midjourney v8.2 高速" + if _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request}); err == nil { + t.Fatal("fast model accepted unsupported stable-only parameters") + } + request.Model = "mj-v8.2" + request.ImageParameters["stylize"] = 1001 + if _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request}); err == nil { + t.Fatal("out of range value accepted") + } +} + +func TestImageParametersCannotExpandPluginConstraintsOrBindings(t *testing.T) { + minimum, maximum := 0.0, 100.0 + definitions := []ImageParameter{{Name: "amount", Label: "Amount", Type: "number", Min: &minimum, Max: &maximum, Binding: "providerOptions.example.amount"}} + expanded := 101.0 + if _, err := ResolveImageParameters(definitions, []ImageParameter{{Name: "amount", Max: &expanded}}, "model"); err == nil { + t.Fatal("channel expanded plugin bounds") + } + resolved, err := ResolveImageParameters(definitions, []ImageParameter{{Name: "amount", Binding: "providerOptions.evil.amount", Pricing: true, Default: 0}}, "model") + if err != nil { + t.Fatal(err) + } + if resolved[0].Binding != definitions[0].Binding || resolved[0].Pricing { + t.Fatalf("channel replaced trusted metadata: %#v", resolved[0]) + } + if _, err := ResolveImageParameters(definitions, []ImageParameter{{Name: "invented"}}, "model"); err == nil { + t.Fatal("undeclared field accepted") + } +} + +func TestImageParametersGeminiResolutionIsIndependentFromStaleQuality(t *testing.T) { + adapter := officialPackageAdapter(t, "google-gemini-image.yingce-plugin", "gemini-image") + request := GenerationRequest{Capability: CapabilityImage, Model: "image-model", Prompt: "portrait", ImageParameters: map[string]any{"imageResolution": "2k", "seed": 0}} + spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request}) + if err != nil { + t.Fatal(err) + } + config := manifestTestBody(t, spec)["generationConfig"].(map[string]any) + if config["imageConfig"].(map[string]any)["imageSize"] != "2K" || config["seed"] != float64(0) { + t.Fatalf("mapped request: %#v", config) + } +} diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index 6f59fa23a..f68c4b3ea 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -292,6 +292,17 @@ func ValidateManifest(manifest Manifest) error { return fmt.Errorf("duplicate provider contribution %q", provider.ID) } providerIDs[provider.ID] = struct{}{} + if err := ValidateImageParameters(provider.ImageParameters); err != nil { + return fmt.Errorf("provider %q image parameters: %w", provider.ID, err) + } + for _, parameter := range provider.ImageParameters { + if parameter.Binding == "" { + return fmt.Errorf("provider %q image parameter %s requires a binding", provider.ID, parameter.Name) + } + if strings.HasPrefix(parameter.Binding, "providerOptions.") && !strings.HasPrefix(parameter.Binding, "providerOptions."+provider.ID+".") { + return fmt.Errorf("provider %q image parameter %s must use its own namespace", provider.ID, parameter.Name) + } + } if len(provider.Capabilities) == 0 || len(provider.Scopes) == 0 { return fmt.Errorf("provider contribution %q requires capabilities and scopes", provider.ID) } @@ -399,6 +410,7 @@ func normalizeManifestForProvider(manifest *Manifest, index int) error { manifest.Metadata.Categories = provider.Capabilities manifest.Metadata.Scopes = provider.Scopes manifest.Metadata.Parameters = provider.Parameters + manifest.Metadata.ImageParameters = provider.ImageParameters manifest.Metadata.Create = operationSummary(provider.Create) manifest.Metadata.Poll = operationSummaryPtr(provider.Poll) manifest.Metadata.Cancel = operationSummaryPtr(provider.Cancel) diff --git a/backend/internal/protocol/manifest_adapter.go b/backend/internal/protocol/manifest_adapter.go index 58402cb5c..a7dcea41b 100644 --- a/backend/internal/protocol/manifest_adapter.go +++ b/backend/internal/protocol/manifest_adapter.go @@ -32,10 +32,14 @@ func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (Reque if len(a.manifest.Contributes.Providers) == 0 { return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID) } - if err := validateManifestRequest(a.manifest.Validations, c.Request); err != nil { + request, err := ApplyImageParameters(c.Request, a.manifest.Metadata.ImageParameters) + if err != nil { return RequestSpec{}, err } - return buildManifestOperation(a.manifest.Create, a.manifest.Auth, c.Request, "") + if err := validateManifestRequest(a.manifest.Validations, request); err != nil { + return RequestSpec{}, err + } + return buildManifestOperation(a.manifest.Create, a.manifest.Auth, request, "") } func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) { diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index 42395f2b9..f841b72ce 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -95,6 +95,7 @@ type GenerationRequest struct { Operation string `json:"operation,omitempty"` Output OutputOptions `json:"output,omitempty"` ProviderOptions map[string]map[string]any `json:"providerOptions,omitempty"` + ImageParameters map[string]any `json:"imageParameters,omitempty"` Extra map[string]any `json:"extra,omitempty"` } @@ -163,25 +164,26 @@ type Parameter struct { } type Metadata struct { - ID string `json:"id"` - Version string `json:"version"` - Name string `json:"name"` - Vendor string `json:"vendor"` - Description string `json:"description,omitempty"` - Categories []Capability `json:"categories"` - Scopes []Surface `json:"scopes"` - Parameters []Parameter `json:"parameters,omitempty"` - Create string `json:"-"` - Poll string `json:"-"` - Cancel string `json:"-"` - ContentType string `json:"-"` - Documentation string `json:"documentation,omitempty"` - LegacyAliases []string `json:"-"` - Enabled bool `json:"enabled"` - Installable bool `json:"installable"` - Execution string `json:"-"` - RequiresPublicMediaURLs bool `json:"-"` - UnavailableReason string `json:"-"` + ID string `json:"id"` + Version string `json:"version"` + Name string `json:"name"` + Vendor string `json:"vendor"` + Description string `json:"description,omitempty"` + Categories []Capability `json:"categories"` + Scopes []Surface `json:"scopes"` + Parameters []Parameter `json:"parameters,omitempty"` + ImageParameters []ImageParameter `json:"imageParameters,omitempty"` + Create string `json:"-"` + Poll string `json:"-"` + Cancel string `json:"-"` + ContentType string `json:"-"` + Documentation string `json:"documentation,omitempty"` + LegacyAliases []string `json:"-"` + Enabled bool `json:"enabled"` + Installable bool `json:"installable"` + Execution string `json:"-"` + RequiresPublicMediaURLs bool `json:"-"` + UnavailableReason string `json:"-"` } // Manifest is the single plugin contract. A plugin may contribute providers, @@ -338,6 +340,7 @@ type ManifestProvider struct { RequiresPublicMediaURLs bool `json:"requiresPublicMediaUrls,omitempty"` Auth ManifestAuth `json:"auth,omitempty"` Parameters []Parameter `json:"parameters,omitempty"` + ImageParameters []ImageParameter `json:"imageParameters,omitempty"` Validations []ManifestValidation `json:"validations,omitempty"` Create ManifestOperation `json:"create"` Agent *ManifestOperation `json:"agent,omitempty"` diff --git a/docs/adr/0055-image-parameter-contract.md b/docs/adr/0055-image-parameter-contract.md new file mode 100644 index 000000000..519735257 --- /dev/null +++ b/docs/adr/0055-image-parameter-contract.md @@ -0,0 +1,13 @@ +# 图片参数声明与生成合同 + +图片协议通过 provider contribution 的 `imageParameters` 声明字段名称、标签、类型、支持值或范围、默认值、适用模型、计价资格和请求绑定。渠道只可启停字段或收窄插件范围,服务器重新读取插件定义验证,不信任客户端提交的绑定。 + +分辨率(imageResolution)、质量(quality)、比例或尺寸(size)是独立语义。管理表单、价格条件、前台模型、创作页、画布与 Agent 审批读取同一合同。pricing=false 的参数仍参与生成,价格表单仅显示 pricing=true 且启用的字段。枚举用平铺选项,数值、布尔和文本使用对应控件。 + +分辨率关闭时比例配置显示一组,开启时按管理员当前填写的档位显示。size.defaultTier 指定关闭分辨率后的有效像素组;存储保留其他组,重新开启时恢复。多个像素组缺少明确默认组时拒绝保存与执行。纯比例协议不需要像素换算。像素协议只使用已声明预设,不根据模型名猜测输出尺寸。 + +新配置或新增组默认选择 1:1、4:3、3:4、16:9、9:16、21:9,并受插件支持范围限制;已保存的选择和精确像素保持。capabilityOptions 保存公开意图,config.size 保存实际请求编码,扩展字段不能覆盖已经转换的像素值。 + +本次使用已有 JSON 列,没有数据库迁移。未声明 imageParameters 的插件保持既有处理路径。升级插件时需同时更新源码 manifest、嵌入说明和安装包。 + +验收覆盖仅分辨率、独立分辨率与质量、MJ 非计价扩展、字段关闭与重开、保存重读、精确像素、false/0、非法绑定、越界参数和定价一致性。真实供应商生成与费用需由维护者在自己的渠道单独验证。 diff --git a/plugin-packages/agnes-image.yingce-plugin b/plugin-packages/agnes-image.yingce-plugin index c4b28a97c..f854b4fa2 100644 Binary files a/plugin-packages/agnes-image.yingce-plugin and b/plugin-packages/agnes-image.yingce-plugin differ diff --git a/plugin-packages/agnes-image/docs/interface.md b/plugin-packages/agnes-image/docs/interface.md index 744c04547..e9068ab06 100644 --- a/plugin-packages/agnes-image/docs/interface.md +++ b/plugin-packages/agnes-image/docs/interface.md @@ -70,6 +70,10 @@ Agnes 官方图像端点,同步返回。文生图与图生图共用 /v1/images/generations:不传 image 为文生图,传 image 为图生图或多图合成。size 必填,取 1K/2K/3K/4K 档位或 WxH 精确尺寸;画面比例走独立的 ratio 字段。参考图放在 extra_body.image,支持公共 HTTPS URL 或 Data URI Base64。顶层 response_format 是官方明确列出的错误写法,输出格式只能声明在 extra_body.response_format。该端点不接受 n,单次请求固定返回一张图片,需要多张时由上层拆分为多个任务。 +## 动态图片参数 + +`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。 + ## Manifest 完整接口定义 @@ -80,7 +84,7 @@ Agnes 官方图像端点,同步返回。文生图与图生图共用 /v1/images "apiVersion": "yingce.plugin/v2", "id": "agnes-image", "name": "Agnes Image", - "version": "2.0.0", + "version": "2.1.0", "author": "Agnes AI / 影策", "description": "Agnes Image 独立请求协议插件。", "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>", @@ -119,6 +123,37 @@ Agnes 官方图像端点,同步返回。文生图与图生图共用 /v1/images "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅 / 尺寸", + "type": "string", + "binding": "aspectRatio" + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "values": [ + "1k", + "2k", + "4k" + ], + "default": "1k", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 100, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/agnes-image/manifest.json b/plugin-packages/agnes-image/manifest.json index d31273093..5f415d4ae 100644 --- a/plugin-packages/agnes-image/manifest.json +++ b/plugin-packages/agnes-image/manifest.json @@ -2,10 +2,10 @@ "apiVersion": "yingce.plugin/v2", "id": "agnes-image", "name": "Agnes Image", - "version": "2.0.0", + "version": "2.1.0", "author": "Agnes AI / 影策", "description": "Agnes Image 独立请求协议插件。", - "documentation": "# Agnes Image\n\n该目录是独立官方协议插件源码。后端从生成的 `agnes-image.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# Agnes Image 接口字段\n\n## 协议身份\n\n- 插件 ID:`agnes-image`。\n- Provider ID:`agnes-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.agnes-ai.cn`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.size` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$eq\":[{\"$len\":{\"$split\":[{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},\"x\"]}},2]},\"then\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},\"else\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"1k\",\"low\",\"standard\"]]},\"then\":\"1K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"2k\",\"medium\",\"hd\",\"high\"]]},\"then\":\"2K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"3k\"]]},\"then\":\"3K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"4k\"]]},\"then\":\"4K\"}],\"default\":\"1K\"}}}}}` |\n| `create.body.ratio` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$in\":[{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},[\"1:1\",\"3:4\",\"4:3\",\"16:9\",\"9:16\",\"2:3\",\"3:2\",\"21:9\"]]},\"then\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},\"else\":null}}}` |\n| `create.body.extra_body` | `{\"$omitEmpty\":{\"image\":{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"in\":{\"$coalesce\":[{\"$ref\":\"media.dataUrl\"},{\"$ref\":\"media.url\"}]}}}},\"response_format\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.agnes-image.response_format\"},\"url\"]}}}` |\n\n## Provider 扩展键\n\n- `providerOptions.agnes-image.response_format`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.errorPaths[1]` | `\"code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n| `response.messagePaths[1]` | `\"message\"` |\n| `response.messagePaths[2]` | `\"msg\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\nAgnes 官方图像端点,同步返回。文生图与图生图共用 /v1/images/generations:不传 image 为文生图,传 image 为图生图或多图合成。size 必填,取 1K/2K/3K/4K 档位或 WxH 精确尺寸;画面比例走独立的 ratio 字段。参考图放在 extra_body.image,支持公共 HTTPS URL 或 Data URI Base64。顶层 response_format 是官方明确列出的错误写法,输出格式只能声明在 extra_body.response_format。该端点不接受 n,单次请求固定返回一张图片,需要多张时由上层拆分为多个任务。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"agnes-image\",\n \"name\": \"Agnes Image\",\n \"version\": \"2.0.0\",\n \"author\": \"Agnes AI / 影策\",\n \"description\": \"Agnes Image 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"agnes-image\",\n \"label\": \"Agnes Image\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.agnes-ai.cn\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$or\": [\n {\n \"$in\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n [\n \"\",\n \"auto\",\n \"1:1\",\n \"3:4\",\n \"4:3\",\n \"16:9\",\n \"9:16\",\n \"2:3\",\n \"3:2\",\n \"21:9\"\n ]\n ]\n },\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n }\n ]\n },\n \"message\": \"Agnes 图像只支持 1:1、3:4、4:3、16:9、9:16、2:3、3:2、21:9 比例或 WxH 像素尺寸\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n \"then\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"else\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"1k\",\n \"low\",\n \"standard\"\n ]\n ]\n },\n \"then\": \"1K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"2k\",\n \"medium\",\n \"hd\",\n \"high\"\n ]\n ]\n },\n \"then\": \"2K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"3k\"\n ]\n ]\n },\n \"then\": \"3K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"4k\"\n ]\n ]\n },\n \"then\": \"4K\"\n }\n ],\n \"default\": \"1K\"\n }\n }\n }\n }\n },\n \"ratio\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n [\n \"1:1\",\n \"3:4\",\n \"4:3\",\n \"16:9\",\n \"9:16\",\n \"2:3\",\n \"3:2\",\n \"21:9\"\n ]\n ]\n },\n \"then\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"else\": null\n }\n }\n },\n \"extra_body\": {\n \"$omitEmpty\": {\n \"image\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$coalesce\": [\n {\n \"$ref\": \"media.dataUrl\"\n },\n {\n \"$ref\": \"media.url\"\n }\n ]\n }\n }\n }\n },\n \"response_format\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.agnes-image.response_format\"\n },\n \"url\"\n ]\n }\n }\n }\n },\n \"originPath\": true\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"errorPaths\": [\n \"error.code\",\n \"code\"\n ],\n \"messagePaths\": [\n \"error.message\",\n \"message\",\n \"msg\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", + "documentation": "# Agnes Image\n\n该目录是独立官方协议插件源码。后端从生成的 `agnes-image.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# Agnes Image 接口字段\n\n## 协议身份\n\n- 插件 ID:`agnes-image`。\n- Provider ID:`agnes-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.agnes-ai.cn`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.size` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$eq\":[{\"$len\":{\"$split\":[{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},\"x\"]}},2]},\"then\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},\"else\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"1k\",\"low\",\"standard\"]]},\"then\":\"1K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"2k\",\"medium\",\"hd\",\"high\"]]},\"then\":\"2K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"3k\"]]},\"then\":\"3K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"4k\"]]},\"then\":\"4K\"}],\"default\":\"1K\"}}}}}` |\n| `create.body.ratio` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$in\":[{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},[\"1:1\",\"3:4\",\"4:3\",\"16:9\",\"9:16\",\"2:3\",\"3:2\",\"21:9\"]]},\"then\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}},\"else\":null}}}` |\n| `create.body.extra_body` | `{\"$omitEmpty\":{\"image\":{\"$omitEmpty\":{\"$map\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"in\":{\"$coalesce\":[{\"$ref\":\"media.dataUrl\"},{\"$ref\":\"media.url\"}]}}}},\"response_format\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.agnes-image.response_format\"},\"url\"]}}}` |\n\n## Provider 扩展键\n\n- `providerOptions.agnes-image.response_format`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.errorPaths[1]` | `\"code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n| `response.messagePaths[1]` | `\"message\"` |\n| `response.messagePaths[2]` | `\"msg\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\nAgnes 官方图像端点,同步返回。文生图与图生图共用 /v1/images/generations:不传 image 为文生图,传 image 为图生图或多图合成。size 必填,取 1K/2K/3K/4K 档位或 WxH 精确尺寸;画面比例走独立的 ratio 字段。参考图放在 extra_body.image,支持公共 HTTPS URL 或 Data URI Base64。顶层 response_format 是官方明确列出的错误写法,输出格式只能声明在 extra_body.response_format。该端点不接受 n,单次请求固定返回一张图片,需要多张时由上层拆分为多个任务。\n\n## 动态图片参数\n\n`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"agnes-image\",\n \"name\": \"Agnes Image\",\n \"version\": \"2.1.0\",\n \"author\": \"Agnes AI / 影策\",\n \"description\": \"Agnes Image 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"agnes-image\",\n \"label\": \"Agnes Image\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.agnes-ai.cn\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅 / 尺寸\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\"\n },\n {\n \"name\": \"imageResolution\",\n \"label\": \"分辨率\",\n \"type\": \"string\",\n \"binding\": \"resolution\",\n \"values\": [\n \"1k\",\n \"2k\",\n \"4k\"\n ],\n \"default\": \"1k\",\n \"pricing\": true\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 100,\n \"step\": 1,\n \"default\": 1\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$or\": [\n {\n \"$in\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n [\n \"\",\n \"auto\",\n \"1:1\",\n \"3:4\",\n \"4:3\",\n \"16:9\",\n \"9:16\",\n \"2:3\",\n \"3:2\",\n \"21:9\"\n ]\n ]\n },\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n }\n ]\n },\n \"message\": \"Agnes 图像只支持 1:1、3:4、4:3、16:9、9:16、2:3、3:2、21:9 比例或 WxH 像素尺寸\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n \"then\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"else\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"1k\",\n \"low\",\n \"standard\"\n ]\n ]\n },\n \"then\": \"1K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"2k\",\n \"medium\",\n \"hd\",\n \"high\"\n ]\n ]\n },\n \"then\": \"2K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"3k\"\n ]\n ]\n },\n \"then\": \"3K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"4k\"\n ]\n ]\n },\n \"then\": \"4K\"\n }\n ],\n \"default\": \"1K\"\n }\n }\n }\n }\n },\n \"ratio\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n [\n \"1:1\",\n \"3:4\",\n \"4:3\",\n \"16:9\",\n \"9:16\",\n \"2:3\",\n \"3:2\",\n \"21:9\"\n ]\n ]\n },\n \"then\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"else\": null\n }\n }\n },\n \"extra_body\": {\n \"$omitEmpty\": {\n \"image\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$coalesce\": [\n {\n \"$ref\": \"media.dataUrl\"\n },\n {\n \"$ref\": \"media.url\"\n }\n ]\n }\n }\n }\n },\n \"response_format\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.agnes-image.response_format\"\n },\n \"url\"\n ]\n }\n }\n }\n },\n \"originPath\": true\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"errorPaths\": [\n \"error.code\",\n \"code\"\n ],\n \"messagePaths\": [\n \"error.message\",\n \"message\",\n \"msg\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", "permissions": [ "generation.run", "media.read" @@ -41,6 +41,37 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅 / 尺寸", + "type": "string", + "binding": "aspectRatio" + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "values": [ + "1k", + "2k", + "4k" + ], + "default": "1k", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 100, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/cangyuan-midjourney.yingce-plugin b/plugin-packages/cangyuan-midjourney.yingce-plugin index 73a32c0f1..24919369e 100644 Binary files a/plugin-packages/cangyuan-midjourney.yingce-plugin and b/plugin-packages/cangyuan-midjourney.yingce-plugin differ diff --git a/plugin-packages/cangyuan-midjourney/docs/interface.md b/plugin-packages/cangyuan-midjourney/docs/interface.md index 0b30efab5..92a62aec1 100644 --- a/plugin-packages/cangyuan-midjourney/docs/interface.md +++ b/plugin-packages/cangyuan-midjourney/docs/interface.md @@ -39,6 +39,10 @@ V7 不支持 --v、--niji、--fast、--hd,且不发送 speed、quality、resol - 所有 URL 通过现有宿主 SSRF、跨域凭证隔离、大小/MIME 校验和资源保存。跨源图片不携带供应商 API Key。 - 1K/2K 官方 CDN HTTP 403 与协议字段映射是两件事;新增插件不能保证消除 CDN 拦截。失败保留原任务恢复机制,不提交新的收费生成。 +## 动态图片参数 + +`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。 + ## Manifest 完整接口定义 @@ -49,7 +53,7 @@ V7 不支持 --v、--niji、--fast、--hd,且不发送 speed、quality、resol "apiVersion": "yingce.plugin/v2", "id": "cangyuan-midjourney", "name": "沧元 Midjourney", - "version": "1.0.0", + "version": "1.1.0", "author": "沧元算力 / 影绘", "description": "独立适配 V7 与默认 V8.2 的 1K/2K 线路,按供应商合同创建、查询并提取全部图片。", "enabled": true, @@ -89,6 +93,24 @@ V7 不支持 --v、--niji、--fast、--hd,且不发送 speed、quality、resol "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅 / 尺寸", + "type": "string", + "binding": "aspectRatio" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", @@ -483,6 +505,48 @@ V7 不支持 --v、--niji、--fast、--hd,且不发送 speed、quality、resol "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + }, + { + "name": "speed", + "label": "生成速度", + "type": "string", + "binding": "providerOptions.cangyuan-midjourney-v82.speed", + "values": [ + "relax", + "fast" + ], + "pricing": true + }, + { + "name": "reference", + "label": "参考方式", + "type": "string", + "binding": "providerOptions.cangyuan-midjourney-v82.reference", + "values": [ + "image", + "style", + "edit", + "moodboard", + "editor" + ] + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/cangyuan-midjourney/manifest.json b/plugin-packages/cangyuan-midjourney/manifest.json index 80d9e3077..ebbcd2f21 100644 --- a/plugin-packages/cangyuan-midjourney/manifest.json +++ b/plugin-packages/cangyuan-midjourney/manifest.json @@ -2,7 +2,7 @@ "apiVersion": "yingce.plugin/v2", "id": "cangyuan-midjourney", "name": "沧元 Midjourney", - "version": "1.0.0", + "version": "1.1.0", "author": "沧元算力 / 影绘", "description": "独立适配 V7 与默认 V8.2 的 1K/2K 线路,按供应商合同创建、查询并提取全部图片。", "enabled": true, @@ -42,6 +42,24 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅 / 尺寸", + "type": "string", + "binding": "aspectRatio" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", @@ -436,6 +454,48 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + }, + { + "name": "speed", + "label": "生成速度", + "type": "string", + "binding": "providerOptions.cangyuan-midjourney-v82.speed", + "values": [ + "relax", + "fast" + ], + "pricing": true + }, + { + "name": "reference", + "label": "参考方式", + "type": "string", + "binding": "providerOptions.cangyuan-midjourney-v82.reference", + "values": [ + "image", + "style", + "edit", + "moodboard", + "editor" + ] + } + ], "parameters": [ { "name": "model", @@ -1299,5 +1359,5 @@ } ] }, - "documentation": "# 沧元 Midjourney\n\n独立声明式协议插件(`yingce.plugin/v2`),同时提供 `cangyuan-midjourney-v7` 和 `cangyuan-midjourney-v82`。使用宿主的渠道鉴权、出站安全检查、下载和资源持久化,不执行额外服务代码。\n\nV7 使用 `midjourney-v7`,无分辨率和速度档。1K/2K 使用 `midjourney-1k` / `midjourney-2k`,供应商默认版本为 V8.2,提示词可显式写 `--v 8.2`;不存在独立的 `midjourney-v8.2` 模型名。\n\n两条线路不能互换。1K/2K 返回官网 CDN 原链,服务器下载可能遇到 HTTP 403;本插件不会伪报保存成功或自动换模型。V7 返回供应商可直接下载的结果地址。`n=1` 表示一次请求,返回的所有图片均交给宿主保存。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# 沧元 Midjourney 接口字段\n\n## 来源与协议身份\n\n- V7:https://ai.cangyuansuanli.cn/docs/models/midjourney-v7\n- V8.2 / 1K / 2K:https://ai.cangyuansuanli.cn/docs/models/midjourney-1k\n- 模型清单以渠道 `GET /v1/models` 为准:`midjourney-v7`、`midjourney-1k`、`midjourney-2k`。\n- 插件 ID `cangyuan-midjourney`;Provider ID `cangyuan-midjourney-v7`、`cangyuan-midjourney-v82`。\n- 配置 `apiKey`(secret、必填),由宿主渠道配置提供;鉴权 `Authorization: Bearer `。\n- 创建 `POST /v1/images/generations`;源图编辑 `POST /v1/images/edits`。\n- 查询 `GET /v1/images/generations/{task_id}`;编辑查询 `GET /v1/images/edits/{task_id}`。\n- 默认同步等待(`async=false`);保留任务 ID、查询路径以及 pending/completed/failed 的响应解析。\n- 不声明取消或单独结果下载端点,供应商未提供相应合同。\n\n## 统一参数与上游请求\n\n| 参数 | 映射与约束 |\n| --- | --- |\n| `model` | 两条线路分别校验公开模型名,不自动换模型。 |\n| `prompt` | 原样传递;V7 最多 4000 字符。1K/2K 可在提示词写 `--v 8.2`,省略版本时由供应商默认 V8.2。 |\n| `images` | 依据宿主 role 排序,映射为 HTTPS URL 字符串数组;不能发 data URI 或本地文件。V7 最多 5 张,拒绝蒙版。 |\n| `imageCount` | 只能为 1 或未设置,上游固定 `n=1`;返回多张不截断。 |\n| `aspectRatio` | 传 `size`;空值或 auto 不发送。V7 支持 1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2 / 2:3 / 5:4 / 4:5 / 21:9 及对应宽x高;默认 16:9。1K/2K 支持 1:2 / 6:11 / 9:16 / 2:3 / 3:4 / 4:5 / 5:6 / 1:1 及其反向比例。 |\n| `providerOptions` | 仅 1K/2K 使用 `cangyuan-midjourney-v82.speed`(relax / fast)和 `reference`(image / style / edit / moodboard / editor)。不把速度映射为 quality。 |\n\n`reference=editor` 或宿主显式 `edit_source` 默认走 edits,必须只有一张源图;`reference=edit` 走 generations,最多四张图。mask role 单独映射为 `mask` HTTPS URL,仅 editor 可用。style/moodboard 等含义由调用方显式指定,插件不猜测风格用途。speed 不指定时不发送,沿用供应商默认或 prompt 中的 --fast/--relax;两者同时指定时须一致。\n\nV7 不支持 --v、--niji、--fast、--hd,且不发送 speed、quality、resolution、background、output_format、mask。1K/2K 不发送 quality、resolution、aspect_ratio、background、output_format,不支持 b64_json;不要写 --turbo,1K 不写 --hd。\n\n两条线路均固定 `response_format=url`、`async=false`。请求完整字段、校验表达式与动态端点见下方 Manifest,未列出的字段不透传。\n\n## 响应、保存和错误\n\n- 创建或查询任务 ID:`id` / `task_id`;查询保留当前 task ID。\n- 状态读取 `status`,queued/pending 为等待,processing/running 为进行中,completed/succeeded 为完成,failed 为失败。\n- 同步 `data[].url` 无 status 时,宿主依据真实图片引用判定完成;遍历全部 data 元素。\n- 错误读取 `error.code` 和 `error.message` / `message`;空结果仍失败,不伪造媒体。\n- 用量读取 `usage`,不在插件自行计算积分。\n- 所有 URL 通过现有宿主 SSRF、跨域凭证隔离、大小/MIME 校验和资源保存。跨源图片不携带供应商 API Key。\n- 1K/2K 官方 CDN HTTP 403 与协议字段映射是两件事;新增插件不能保证消除 CDN 拦截。失败保留原任务恢复机制,不提交新的收费生成。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"cangyuan-midjourney\",\n \"name\": \"沧元 Midjourney\",\n \"version\": \"1.0.0\",\n \"author\": \"沧元算力 / 影绘\",\n \"description\": \"独立适配 V7 与默认 V8.2 的 1K/2K 线路,按供应商合同创建、查询并提取全部图片。\",\n \"enabled\": true,\n \"installable\": true,\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"cangyuan-midjourney-v7\",\n \"label\": \"沧元 Midjourney V7\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://ai.cangyuansuanli.cn/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"供应商公开模型名;两条线路分别校验。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原提示词;1K/2K 默认 V8.2,可写官网 --v / --niji 参数;V7 不支持这些版本参数。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images[] / mask\",\n \"description\": \"按宿主显式 role 转换为 HTTPS URL 字符串数组;mask 单独映射。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次提交只能为 1,结果 data 可能含多张,全部保存。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"mapping\": \"size\",\n \"description\": \"比例或 V7 宽x高;空值和 auto 不发送,沿用供应商默认值。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"mapping\": \"cangyuan-midjourney-v82.speed/reference\",\n \"description\": \"1K/2K 插件命名空间可指定 speed=relax/fast、reference=image/style/edit/moodboard/editor。V7 没有速度或版本扩展字段。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"midjourney-v7\"\n ]\n ]\n },\n \"message\": \"模型名与 Midjourney 线路不匹配。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 1\n ]\n },\n \"message\": \"Midjourney 一次提交 n 只能为 1;返回多张图片会全部保存。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$first\": {\n \"$split\": [\n {\n \"$ref\": \"media.value\"\n },\n \":\"\n ]\n }\n },\n \"https\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 参考图和蒙版必须是 HTTPS URL,不能发送本地文件或 data URI。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 5\n ]\n },\n \"message\": \"Midjourney V7 参考图最多 5 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney V7 线路不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.prompt\"\n }\n },\n 4000\n ]\n },\n \"message\": \"Midjourney V7 提示词最多 4000 字符。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"n\": 1,\n \"size\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n },\n \"response_format\": \"url\",\n \"async\": false\n },\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/images/generations/{{taskId}}\",\n \"pathTemplate\": {\n \"$concat\": [\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"/\",\n {\n \"$ref\": \"taskId\"\n }\n ]\n }\n },\n \"response\": {\n \"taskIdPaths\": [\n \"id\",\n \"task_id\"\n ],\n \"statusPaths\": [\n \"status\"\n ],\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\",\n \"message\"\n ],\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n }\n }\n },\n {\n \"id\": \"cangyuan-midjourney-v82\",\n \"label\": \"沧元 Midjourney 1K/2K · V8.2\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://ai.cangyuansuanli.cn/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"供应商公开模型名;两条线路分别校验。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原提示词;1K/2K 默认 V8.2,可写官网 --v / --niji 参数;V7 不支持这些版本参数。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images[] / mask\",\n \"description\": \"按宿主显式 role 转换为 HTTPS URL 字符串数组;mask 单独映射。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次提交只能为 1,结果 data 可能含多张,全部保存。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"mapping\": \"size\",\n \"description\": \"比例或 V7 宽x高;空值和 auto 不发送,沿用供应商默认值。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"mapping\": \"cangyuan-midjourney-v82.speed/reference\",\n \"description\": \"1K/2K 插件命名空间可指定 speed=relax/fast、reference=image/style/edit/moodboard/editor。V7 没有速度或版本扩展字段。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"midjourney-1k\",\n \"midjourney-2k\"\n ]\n ]\n },\n \"message\": \"模型名与 Midjourney 线路不匹配。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 1\n ]\n },\n \"message\": \"Midjourney 一次提交 n 只能为 1;返回多张图片会全部保存。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$first\": {\n \"$split\": [\n {\n \"$ref\": \"media.value\"\n },\n \":\"\n ]\n }\n },\n \"https\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 参考图和蒙版必须是 HTTPS URL,不能发送本地文件或 data URI。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.speed\"\n },\n \"relax\"\n ]\n },\n [\n \"relax\",\n \"fast\"\n ]\n ]\n },\n \"message\": \"Midjourney speed 只支持 relax 或 fast。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n [\n \"image\",\n \"style\",\n \"edit\",\n \"moodboard\",\n \"editor\"\n ]\n ]\n },\n \"message\": \"Midjourney reference 类型无效。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$not\": {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n }\n },\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 1\n ]\n }\n ]\n },\n \"message\": \"Midjourney editor 只能有一张源图。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$ne\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"edit\"\n ]\n },\n {\n \"$lte\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 4\n ]\n }\n ]\n },\n \"message\": \"Midjourney edit 参考图最多 4 张。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 1\n ]\n },\n \"message\": \"Midjourney 蒙版只能有一张。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n },\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 1\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"蒙版只能用于 editor,且必须有一张源图。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"n\": 1,\n \"size\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n },\n \"response_format\": \"url\",\n \"async\": false,\n \"speed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.speed\"\n }\n },\n \"reference\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"else\": null\n }\n },\n \"mask\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$and\": [\n {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n }\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/images/generations/{{taskId}}\",\n \"pathTemplate\": {\n \"$concat\": [\n {\n \"$if\": {\n \"condition\": {\n \"$and\": [\n {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n }\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"/\",\n {\n \"$ref\": \"taskId\"\n }\n ]\n }\n },\n \"response\": {\n \"taskIdPaths\": [\n \"id\",\n \"task_id\"\n ],\n \"statusPaths\": [\n \"status\"\n ],\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\",\n \"message\"\n ],\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n }\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" + "documentation": "# 沧元 Midjourney\n\n独立声明式协议插件(`yingce.plugin/v2`),同时提供 `cangyuan-midjourney-v7` 和 `cangyuan-midjourney-v82`。使用宿主的渠道鉴权、出站安全检查、下载和资源持久化,不执行额外服务代码。\n\nV7 使用 `midjourney-v7`,无分辨率和速度档。1K/2K 使用 `midjourney-1k` / `midjourney-2k`,供应商默认版本为 V8.2,提示词可显式写 `--v 8.2`;不存在独立的 `midjourney-v8.2` 模型名。\n\n两条线路不能互换。1K/2K 返回官网 CDN 原链,服务器下载可能遇到 HTTP 403;本插件不会伪报保存成功或自动换模型。V7 返回供应商可直接下载的结果地址。`n=1` 表示一次请求,返回的所有图片均交给宿主保存。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# 沧元 Midjourney 接口字段\n\n## 来源与协议身份\n\n- V7:https://ai.cangyuansuanli.cn/docs/models/midjourney-v7\n- V8.2 / 1K / 2K:https://ai.cangyuansuanli.cn/docs/models/midjourney-1k\n- 模型清单以渠道 `GET /v1/models` 为准:`midjourney-v7`、`midjourney-1k`、`midjourney-2k`。\n- 插件 ID `cangyuan-midjourney`;Provider ID `cangyuan-midjourney-v7`、`cangyuan-midjourney-v82`。\n- 配置 `apiKey`(secret、必填),由宿主渠道配置提供;鉴权 `Authorization: Bearer `。\n- 创建 `POST /v1/images/generations`;源图编辑 `POST /v1/images/edits`。\n- 查询 `GET /v1/images/generations/{task_id}`;编辑查询 `GET /v1/images/edits/{task_id}`。\n- 默认同步等待(`async=false`);保留任务 ID、查询路径以及 pending/completed/failed 的响应解析。\n- 不声明取消或单独结果下载端点,供应商未提供相应合同。\n\n## 统一参数与上游请求\n\n| 参数 | 映射与约束 |\n| --- | --- |\n| `model` | 两条线路分别校验公开模型名,不自动换模型。 |\n| `prompt` | 原样传递;V7 最多 4000 字符。1K/2K 可在提示词写 `--v 8.2`,省略版本时由供应商默认 V8.2。 |\n| `images` | 依据宿主 role 排序,映射为 HTTPS URL 字符串数组;不能发 data URI 或本地文件。V7 最多 5 张,拒绝蒙版。 |\n| `imageCount` | 只能为 1 或未设置,上游固定 `n=1`;返回多张不截断。 |\n| `aspectRatio` | 传 `size`;空值或 auto 不发送。V7 支持 1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2 / 2:3 / 5:4 / 4:5 / 21:9 及对应宽x高;默认 16:9。1K/2K 支持 1:2 / 6:11 / 9:16 / 2:3 / 3:4 / 4:5 / 5:6 / 1:1 及其反向比例。 |\n| `providerOptions` | 仅 1K/2K 使用 `cangyuan-midjourney-v82.speed`(relax / fast)和 `reference`(image / style / edit / moodboard / editor)。不把速度映射为 quality。 |\n\n`reference=editor` 或宿主显式 `edit_source` 默认走 edits,必须只有一张源图;`reference=edit` 走 generations,最多四张图。mask role 单独映射为 `mask` HTTPS URL,仅 editor 可用。style/moodboard 等含义由调用方显式指定,插件不猜测风格用途。speed 不指定时不发送,沿用供应商默认或 prompt 中的 --fast/--relax;两者同时指定时须一致。\n\nV7 不支持 --v、--niji、--fast、--hd,且不发送 speed、quality、resolution、background、output_format、mask。1K/2K 不发送 quality、resolution、aspect_ratio、background、output_format,不支持 b64_json;不要写 --turbo,1K 不写 --hd。\n\n两条线路均固定 `response_format=url`、`async=false`。请求完整字段、校验表达式与动态端点见下方 Manifest,未列出的字段不透传。\n\n## 响应、保存和错误\n\n- 创建或查询任务 ID:`id` / `task_id`;查询保留当前 task ID。\n- 状态读取 `status`,queued/pending 为等待,processing/running 为进行中,completed/succeeded 为完成,failed 为失败。\n- 同步 `data[].url` 无 status 时,宿主依据真实图片引用判定完成;遍历全部 data 元素。\n- 错误读取 `error.code` 和 `error.message` / `message`;空结果仍失败,不伪造媒体。\n- 用量读取 `usage`,不在插件自行计算积分。\n- 所有 URL 通过现有宿主 SSRF、跨域凭证隔离、大小/MIME 校验和资源保存。跨源图片不携带供应商 API Key。\n- 1K/2K 官方 CDN HTTP 403 与协议字段映射是两件事;新增插件不能保证消除 CDN 拦截。失败保留原任务恢复机制,不提交新的收费生成。\n\n## 动态图片参数\n\n`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"cangyuan-midjourney\",\n \"name\": \"沧元 Midjourney\",\n \"version\": \"1.1.0\",\n \"author\": \"沧元算力 / 影绘\",\n \"description\": \"独立适配 V7 与默认 V8.2 的 1K/2K 线路,按供应商合同创建、查询并提取全部图片。\",\n \"enabled\": true,\n \"installable\": true,\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"cangyuan-midjourney-v7\",\n \"label\": \"沧元 Midjourney V7\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://ai.cangyuansuanli.cn/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅 / 尺寸\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\"\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 1,\n \"step\": 1,\n \"default\": 1\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"供应商公开模型名;两条线路分别校验。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原提示词;1K/2K 默认 V8.2,可写官网 --v / --niji 参数;V7 不支持这些版本参数。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images[] / mask\",\n \"description\": \"按宿主显式 role 转换为 HTTPS URL 字符串数组;mask 单独映射。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次提交只能为 1,结果 data 可能含多张,全部保存。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"mapping\": \"size\",\n \"description\": \"比例或 V7 宽x高;空值和 auto 不发送,沿用供应商默认值。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"mapping\": \"cangyuan-midjourney-v82.speed/reference\",\n \"description\": \"1K/2K 插件命名空间可指定 speed=relax/fast、reference=image/style/edit/moodboard/editor。V7 没有速度或版本扩展字段。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"midjourney-v7\"\n ]\n ]\n },\n \"message\": \"模型名与 Midjourney 线路不匹配。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 1\n ]\n },\n \"message\": \"Midjourney 一次提交 n 只能为 1;返回多张图片会全部保存。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$first\": {\n \"$split\": [\n {\n \"$ref\": \"media.value\"\n },\n \":\"\n ]\n }\n },\n \"https\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 参考图和蒙版必须是 HTTPS URL,不能发送本地文件或 data URI。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 5\n ]\n },\n \"message\": \"Midjourney V7 参考图最多 5 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney V7 线路不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.prompt\"\n }\n },\n 4000\n ]\n },\n \"message\": \"Midjourney V7 提示词最多 4000 字符。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"n\": 1,\n \"size\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n },\n \"response_format\": \"url\",\n \"async\": false\n },\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/images/generations/{{taskId}}\",\n \"pathTemplate\": {\n \"$concat\": [\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"/\",\n {\n \"$ref\": \"taskId\"\n }\n ]\n }\n },\n \"response\": {\n \"taskIdPaths\": [\n \"id\",\n \"task_id\"\n ],\n \"statusPaths\": [\n \"status\"\n ],\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\",\n \"message\"\n ],\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n }\n }\n },\n {\n \"id\": \"cangyuan-midjourney-v82\",\n \"label\": \"沧元 Midjourney 1K/2K · V8.2\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://ai.cangyuansuanli.cn/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅比例\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\"\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 1,\n \"step\": 1,\n \"default\": 1\n },\n {\n \"name\": \"speed\",\n \"label\": \"生成速度\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.cangyuan-midjourney-v82.speed\",\n \"values\": [\n \"relax\",\n \"fast\"\n ],\n \"pricing\": true\n },\n {\n \"name\": \"reference\",\n \"label\": \"参考方式\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.cangyuan-midjourney-v82.reference\",\n \"values\": [\n \"image\",\n \"style\",\n \"edit\",\n \"moodboard\",\n \"editor\"\n ]\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"供应商公开模型名;两条线路分别校验。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原提示词;1K/2K 默认 V8.2,可写官网 --v / --niji 参数;V7 不支持这些版本参数。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images[] / mask\",\n \"description\": \"按宿主显式 role 转换为 HTTPS URL 字符串数组;mask 单独映射。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次提交只能为 1,结果 data 可能含多张,全部保存。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"mapping\": \"size\",\n \"description\": \"比例或 V7 宽x高;空值和 auto 不发送,沿用供应商默认值。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"mapping\": \"cangyuan-midjourney-v82.speed/reference\",\n \"description\": \"1K/2K 插件命名空间可指定 speed=relax/fast、reference=image/style/edit/moodboard/editor。V7 没有速度或版本扩展字段。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"midjourney-1k\",\n \"midjourney-2k\"\n ]\n ]\n },\n \"message\": \"模型名与 Midjourney 线路不匹配。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 1\n ]\n },\n \"message\": \"Midjourney 一次提交 n 只能为 1;返回多张图片会全部保存。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$first\": {\n \"$split\": [\n {\n \"$ref\": \"media.value\"\n },\n \":\"\n ]\n }\n },\n \"https\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 参考图和蒙版必须是 HTTPS URL,不能发送本地文件或 data URI。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.speed\"\n },\n \"relax\"\n ]\n },\n [\n \"relax\",\n \"fast\"\n ]\n ]\n },\n \"message\": \"Midjourney speed 只支持 relax 或 fast。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n [\n \"image\",\n \"style\",\n \"edit\",\n \"moodboard\",\n \"editor\"\n ]\n ]\n },\n \"message\": \"Midjourney reference 类型无效。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$not\": {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n }\n },\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 1\n ]\n }\n ]\n },\n \"message\": \"Midjourney editor 只能有一张源图。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$ne\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"edit\"\n ]\n },\n {\n \"$lte\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 4\n ]\n }\n ]\n },\n \"message\": \"Midjourney edit 参考图最多 4 张。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 1\n ]\n },\n \"message\": \"Midjourney 蒙版只能有一张。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n },\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 1\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"蒙版只能用于 editor,且必须有一张源图。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"n\": 1,\n \"size\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n },\n \"response_format\": \"url\",\n \"async\": false,\n \"speed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.speed\"\n }\n },\n \"reference\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"else\": null\n }\n },\n \"mask\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$and\": [\n {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n }\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/images/generations/{{taskId}}\",\n \"pathTemplate\": {\n \"$concat\": [\n {\n \"$if\": {\n \"condition\": {\n \"$and\": [\n {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n {\n \"$eq\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.cangyuan-midjourney-v82.reference\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"edit_source\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": \"editor\",\n \"else\": \"image\"\n }\n }\n ]\n },\n \"editor\"\n ]\n }\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"/\",\n {\n \"$ref\": \"taskId\"\n }\n ]\n }\n },\n \"response\": {\n \"taskIdPaths\": [\n \"id\",\n \"task_id\"\n ],\n \"statusPaths\": [\n \"status\"\n ],\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\",\n \"message\"\n ],\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n }\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" } diff --git a/plugin-packages/google-gemini-image.yingce-plugin b/plugin-packages/google-gemini-image.yingce-plugin index cac30b1b3..ad5f513f8 100644 Binary files a/plugin-packages/google-gemini-image.yingce-plugin and b/plugin-packages/google-gemini-image.yingce-plugin differ diff --git a/plugin-packages/google-gemini-image/docs/interface.md b/plugin-packages/google-gemini-image/docs/interface.md index c2c51be3b..8f4533fd0 100644 --- a/plugin-packages/google-gemini-image/docs/interface.md +++ b/plugin-packages/google-gemini-image/docs/interface.md @@ -81,6 +81,10 @@ imageSize 只映射 1K/2K/4K;未知质量值(如视频清晰度 720)必须省略。多图输出由宿主按次创建,不映射 candidateCount。 +## 动态图片参数 + +`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。 + ## Manifest 完整接口定义 @@ -91,7 +95,7 @@ imageSize 只映射 1K/2K/4K;未知质量值(如视频清晰度 720)必须 "apiVersion": "yingce.plugin/v2", "id": "google-gemini-image", "name": "Google Gemini Image", - "version": "2.0.0", + "version": "2.1.0", "author": "Google / 影策", "description": "Google Gemini Image 独立请求协议插件。", "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>", @@ -130,6 +134,67 @@ imageSize 只映射 1K/2K/4K;未知质量值(如视频清晰度 720)必须 "type": "google-api-key", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "encoding": "ratio", + "values": [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ], + "default": "1:1" + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "values": [ + "1k", + "2k", + "4k" + ], + "default": "1k", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + }, + { + "name": "seed", + "label": "随机种子", + "type": "integer", + "binding": "providerOptions.gemini-image.seed", + "enabled": false + }, + { + "name": "temperature", + "label": "随机程度", + "type": "number", + "binding": "providerOptions.gemini-image.temperature", + "min": 0, + "max": 2, + "enabled": false + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/google-gemini-image/manifest.json b/plugin-packages/google-gemini-image/manifest.json index a48c6cf16..6b841a5cc 100644 --- a/plugin-packages/google-gemini-image/manifest.json +++ b/plugin-packages/google-gemini-image/manifest.json @@ -2,10 +2,10 @@ "apiVersion": "yingce.plugin/v2", "id": "google-gemini-image", "name": "Google Gemini Image", - "version": "2.0.0", + "version": "2.1.0", "author": "Google / 影策", "description": "Google Gemini Image 独立请求协议插件。", - "documentation": "# Google Gemini Image\n\n该目录是独立官方协议插件源码。后端从生成的 `google-gemini-image.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# Google Gemini Image 接口字段\n\n## 协议身份\n\n- 插件 ID:`google-gemini-image`。\n- Provider ID:`gemini-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://generativelanguage.googleapis.com`。\n- 鉴权驱动:`google-api-key`。\n- 创建:`POST /v1beta/models/{{model}}:generateContent`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1beta/models/{{model}}:generateContent\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.contents[0].role` | `\"user\"` |\n| `create.body.contents[0].parts` | `{\"$concatArrays\":[[{\"text\":{\"$ref\":\"request.prompt\"}}],{\"$map\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"in\":{\"$if\":{\"condition\":{\"$ref\":\"media.dataUrl\"},\"then\":{\"inlineData\":{\"mimeType\":{\"$dataMime\":{\"$ref\":\"media.dataUrl\"}},\"data\":{\"$dataPayload\":{\"$ref\":\"media.dataUrl\"}}}},\"else\":{\"fileData\":{\"mimeType\":{\"$omitEmpty\":{\"$ref\":\"media.mimeType\"}},\"fileUri\":{\"$ref\":\"media.url\"}}}}}}}]}` |\n| `create.body.generationConfig.responseModalities` | `{\"$coalesce\":[{\"$ref\":\"request.providerOptions.gemini-image.responseModalities\"},[\"TEXT\",\"IMAGE\"]]}` |\n| `create.body.generationConfig.imageConfig.aspectRatio` | `{\"$omitEmpty\":{\"$ref\":\"request.aspectRatio\"}}` |\n| `create.body.generationConfig.imageConfig.imageSize` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.quality\"}},[\"1k\",\"low\"]]},\"then\":\"1K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.quality\"}},[\"2k\",\"medium\"]]},\"then\":\"2K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.quality\"}},[\"4k\",\"high\"]]},\"then\":\"4K\"}],\"default\":null}},{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.resolution\"}},[\"1k\",\"low\"]]},\"then\":\"1K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.resolution\"}},[\"2k\",\"medium\"]]},\"then\":\"2K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.resolution\"}},[\"4k\",\"high\"]]},\"then\":\"4K\"}],\"default\":null}}]}}` |\n| `create.body.generationConfig.temperature` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.temperature\"}}` |\n| `create.body.generationConfig.topP` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.topP\"}}` |\n| `create.body.generationConfig.topK` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.topK\"}}` |\n| `create.body.generationConfig.seed` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.seed\"}}` |\n| `create.body.safetySettings` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.safetySettings\"}}` |\n| `create.body.systemInstruction` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.gemini-image.systemInstruction\"},{\"$if\":{\"condition\":{\"$ref\":\"request.instructions\"},\"then\":{\"parts\":[{\"text\":{\"$ref\":\"request.instructions\"}}]},\"else\":null}}]}}` |\n\n## Provider 扩展键\n\n- `providerOptions.gemini-image.responseModalities`\n- `providerOptions.gemini-image.safetySettings`\n- `providerOptions.gemini-image.seed`\n- `providerOptions.gemini-image.systemInstruction`\n- `providerOptions.gemini-image.temperature`\n- `providerOptions.gemini-image.topK`\n- `providerOptions.gemini-image.topP`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$ref\":\"response.candidates.0.content.parts\"},\"as\":\"part\",\"where\":{\"$or\":[{\"$ref\":\"part.inlineData\"},{\"$ref\":\"part.inline_data\"}]}}},\"as\":\"part\",\"in\":{\"dataUrl\":{\"$concat\":[\"data:\",{\"$coalesce\":[{\"$ref\":\"part.inlineData.mimeType\"},{\"$ref\":\"part.inline_data.mime_type\"},\"image/png\"]},\";base64,\",{\"$coalesce\":[{\"$ref\":\"part.inlineData.data\"},{\"$ref\":\"part.inline_data.data\"}]}]}}}}` |\n| `response.text` | `{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$ref\":\"response.candidates.0.content.parts\"},\"as\":\"part\",\"where\":{\"$ref\":\"part.text\"}}},\"as\":\"part\",\"in\":{\"$ref\":\"part.text\"}}}` |\n| `response.usage` | `{\"$ref\":\"response.usageMetadata\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\nimageSize 只映射 1K/2K/4K;未知质量值(如视频清晰度 720)必须省略。多图输出由宿主按次创建,不映射 candidateCount。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"google-gemini-image\",\n \"name\": \"Google Gemini Image\",\n \"version\": \"2.0.0\",\n \"author\": \"Google / 影策\",\n \"description\": \"Google Gemini Image 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"gemini-image\",\n \"label\": \"Google Gemini Image\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://generativelanguage.googleapis.com\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"google-api-key\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1beta/models/{{model}}:generateContent\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": {\n \"$concatArrays\": [\n [\n {\n \"text\": {\n \"$ref\": \"request.prompt\"\n }\n }\n ],\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"in\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"media.dataUrl\"\n },\n \"then\": {\n \"inlineData\": {\n \"mimeType\": {\n \"$dataMime\": {\n \"$ref\": \"media.dataUrl\"\n }\n },\n \"data\": {\n \"$dataPayload\": {\n \"$ref\": \"media.dataUrl\"\n }\n }\n }\n },\n \"else\": {\n \"fileData\": {\n \"mimeType\": {\n \"$omitEmpty\": {\n \"$ref\": \"media.mimeType\"\n }\n },\n \"fileUri\": {\n \"$ref\": \"media.url\"\n }\n }\n }\n }\n }\n }\n }\n ]\n }\n }\n ],\n \"generationConfig\": {\n \"responseModalities\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.gemini-image.responseModalities\"\n },\n [\n \"TEXT\",\n \"IMAGE\"\n ]\n ]\n },\n \"imageConfig\": {\n \"aspectRatio\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"imageSize\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.quality\"\n }\n },\n [\n \"1k\",\n \"low\"\n ]\n ]\n },\n \"then\": \"1K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.quality\"\n }\n },\n [\n \"2k\",\n \"medium\"\n ]\n ]\n },\n \"then\": \"2K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.quality\"\n }\n },\n [\n \"4k\",\n \"high\"\n ]\n ]\n },\n \"then\": \"4K\"\n }\n ],\n \"default\": null\n }\n },\n {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.resolution\"\n }\n },\n [\n \"1k\",\n \"low\"\n ]\n ]\n },\n \"then\": \"1K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.resolution\"\n }\n },\n [\n \"2k\",\n \"medium\"\n ]\n ]\n },\n \"then\": \"2K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.resolution\"\n }\n },\n [\n \"4k\",\n \"high\"\n ]\n ]\n },\n \"then\": \"4K\"\n }\n ],\n \"default\": null\n }\n }\n ]\n }\n }\n },\n \"temperature\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.temperature\"\n }\n },\n \"topP\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.topP\"\n }\n },\n \"topK\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.topK\"\n }\n },\n \"seed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.seed\"\n }\n }\n },\n \"safetySettings\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.safetySettings\"\n }\n },\n \"systemInstruction\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.gemini-image.systemInstruction\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"request.instructions\"\n },\n \"then\": {\n \"parts\": [\n {\n \"text\": {\n \"$ref\": \"request.instructions\"\n }\n }\n ]\n },\n \"else\": null\n }\n }\n ]\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"response.candidates.0.content.parts\"\n },\n \"as\": \"part\",\n \"where\": {\n \"$or\": [\n {\n \"$ref\": \"part.inlineData\"\n },\n {\n \"$ref\": \"part.inline_data\"\n }\n ]\n }\n }\n },\n \"as\": \"part\",\n \"in\": {\n \"dataUrl\": {\n \"$concat\": [\n \"data:\",\n {\n \"$coalesce\": [\n {\n \"$ref\": \"part.inlineData.mimeType\"\n },\n {\n \"$ref\": \"part.inline_data.mime_type\"\n },\n \"image/png\"\n ]\n },\n \";base64,\",\n {\n \"$coalesce\": [\n {\n \"$ref\": \"part.inlineData.data\"\n },\n {\n \"$ref\": \"part.inline_data.data\"\n }\n ]\n }\n ]\n }\n }\n }\n },\n \"text\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"response.candidates.0.content.parts\"\n },\n \"as\": \"part\",\n \"where\": {\n \"$ref\": \"part.text\"\n }\n }\n },\n \"as\": \"part\",\n \"in\": {\n \"$ref\": \"part.text\"\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usageMetadata\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", + "documentation": "# Google Gemini Image\n\n该目录是独立官方协议插件源码。后端从生成的 `google-gemini-image.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# Google Gemini Image 接口字段\n\n## 协议身份\n\n- 插件 ID:`google-gemini-image`。\n- Provider ID:`gemini-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://generativelanguage.googleapis.com`。\n- 鉴权驱动:`google-api-key`。\n- 创建:`POST /v1beta/models/{{model}}:generateContent`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1beta/models/{{model}}:generateContent\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.contents[0].role` | `\"user\"` |\n| `create.body.contents[0].parts` | `{\"$concatArrays\":[[{\"text\":{\"$ref\":\"request.prompt\"}}],{\"$map\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"in\":{\"$if\":{\"condition\":{\"$ref\":\"media.dataUrl\"},\"then\":{\"inlineData\":{\"mimeType\":{\"$dataMime\":{\"$ref\":\"media.dataUrl\"}},\"data\":{\"$dataPayload\":{\"$ref\":\"media.dataUrl\"}}}},\"else\":{\"fileData\":{\"mimeType\":{\"$omitEmpty\":{\"$ref\":\"media.mimeType\"}},\"fileUri\":{\"$ref\":\"media.url\"}}}}}}}]}` |\n| `create.body.generationConfig.responseModalities` | `{\"$coalesce\":[{\"$ref\":\"request.providerOptions.gemini-image.responseModalities\"},[\"TEXT\",\"IMAGE\"]]}` |\n| `create.body.generationConfig.imageConfig.aspectRatio` | `{\"$omitEmpty\":{\"$ref\":\"request.aspectRatio\"}}` |\n| `create.body.generationConfig.imageConfig.imageSize` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.quality\"}},[\"1k\",\"low\"]]},\"then\":\"1K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.quality\"}},[\"2k\",\"medium\"]]},\"then\":\"2K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.quality\"}},[\"4k\",\"high\"]]},\"then\":\"4K\"}],\"default\":null}},{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.resolution\"}},[\"1k\",\"low\"]]},\"then\":\"1K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.resolution\"}},[\"2k\",\"medium\"]]},\"then\":\"2K\"},{\"when\":{\"$in\":[{\"$lower\":{\"$ref\":\"request.resolution\"}},[\"4k\",\"high\"]]},\"then\":\"4K\"}],\"default\":null}}]}}` |\n| `create.body.generationConfig.temperature` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.temperature\"}}` |\n| `create.body.generationConfig.topP` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.topP\"}}` |\n| `create.body.generationConfig.topK` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.topK\"}}` |\n| `create.body.generationConfig.seed` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.seed\"}}` |\n| `create.body.safetySettings` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.gemini-image.safetySettings\"}}` |\n| `create.body.systemInstruction` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.gemini-image.systemInstruction\"},{\"$if\":{\"condition\":{\"$ref\":\"request.instructions\"},\"then\":{\"parts\":[{\"text\":{\"$ref\":\"request.instructions\"}}]},\"else\":null}}]}}` |\n\n## Provider 扩展键\n\n- `providerOptions.gemini-image.responseModalities`\n- `providerOptions.gemini-image.safetySettings`\n- `providerOptions.gemini-image.seed`\n- `providerOptions.gemini-image.systemInstruction`\n- `providerOptions.gemini-image.temperature`\n- `providerOptions.gemini-image.topK`\n- `providerOptions.gemini-image.topP`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$ref\":\"response.candidates.0.content.parts\"},\"as\":\"part\",\"where\":{\"$or\":[{\"$ref\":\"part.inlineData\"},{\"$ref\":\"part.inline_data\"}]}}},\"as\":\"part\",\"in\":{\"dataUrl\":{\"$concat\":[\"data:\",{\"$coalesce\":[{\"$ref\":\"part.inlineData.mimeType\"},{\"$ref\":\"part.inline_data.mime_type\"},\"image/png\"]},\";base64,\",{\"$coalesce\":[{\"$ref\":\"part.inlineData.data\"},{\"$ref\":\"part.inline_data.data\"}]}]}}}}` |\n| `response.text` | `{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$ref\":\"response.candidates.0.content.parts\"},\"as\":\"part\",\"where\":{\"$ref\":\"part.text\"}}},\"as\":\"part\",\"in\":{\"$ref\":\"part.text\"}}}` |\n| `response.usage` | `{\"$ref\":\"response.usageMetadata\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\nimageSize 只映射 1K/2K/4K;未知质量值(如视频清晰度 720)必须省略。多图输出由宿主按次创建,不映射 candidateCount。\n\n## 动态图片参数\n\n`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"google-gemini-image\",\n \"name\": \"Google Gemini Image\",\n \"version\": \"2.1.0\",\n \"author\": \"Google / 影策\",\n \"description\": \"Google Gemini Image 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"gemini-image\",\n \"label\": \"Google Gemini Image\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://generativelanguage.googleapis.com\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"google-api-key\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅比例\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\",\n \"encoding\": \"ratio\",\n \"values\": [\n \"1:1\",\n \"2:3\",\n \"3:2\",\n \"3:4\",\n \"4:3\",\n \"4:5\",\n \"5:4\",\n \"9:16\",\n \"16:9\",\n \"21:9\"\n ],\n \"default\": \"1:1\"\n },\n {\n \"name\": \"imageResolution\",\n \"label\": \"分辨率\",\n \"type\": \"string\",\n \"binding\": \"resolution\",\n \"values\": [\n \"1k\",\n \"2k\",\n \"4k\"\n ],\n \"default\": \"1k\",\n \"pricing\": true\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 1,\n \"step\": 1,\n \"default\": 1\n },\n {\n \"name\": \"seed\",\n \"label\": \"随机种子\",\n \"type\": \"integer\",\n \"binding\": \"providerOptions.gemini-image.seed\",\n \"enabled\": false\n },\n {\n \"name\": \"temperature\",\n \"label\": \"随机程度\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.gemini-image.temperature\",\n \"min\": 0,\n \"max\": 2,\n \"enabled\": false\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1beta/models/{{model}}:generateContent\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": {\n \"$concatArrays\": [\n [\n {\n \"text\": {\n \"$ref\": \"request.prompt\"\n }\n }\n ],\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"in\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"media.dataUrl\"\n },\n \"then\": {\n \"inlineData\": {\n \"mimeType\": {\n \"$dataMime\": {\n \"$ref\": \"media.dataUrl\"\n }\n },\n \"data\": {\n \"$dataPayload\": {\n \"$ref\": \"media.dataUrl\"\n }\n }\n }\n },\n \"else\": {\n \"fileData\": {\n \"mimeType\": {\n \"$omitEmpty\": {\n \"$ref\": \"media.mimeType\"\n }\n },\n \"fileUri\": {\n \"$ref\": \"media.url\"\n }\n }\n }\n }\n }\n }\n }\n ]\n }\n }\n ],\n \"generationConfig\": {\n \"responseModalities\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.gemini-image.responseModalities\"\n },\n [\n \"TEXT\",\n \"IMAGE\"\n ]\n ]\n },\n \"imageConfig\": {\n \"aspectRatio\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"imageSize\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.quality\"\n }\n },\n [\n \"1k\",\n \"low\"\n ]\n ]\n },\n \"then\": \"1K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.quality\"\n }\n },\n [\n \"2k\",\n \"medium\"\n ]\n ]\n },\n \"then\": \"2K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.quality\"\n }\n },\n [\n \"4k\",\n \"high\"\n ]\n ]\n },\n \"then\": \"4K\"\n }\n ],\n \"default\": null\n }\n },\n {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.resolution\"\n }\n },\n [\n \"1k\",\n \"low\"\n ]\n ]\n },\n \"then\": \"1K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.resolution\"\n }\n },\n [\n \"2k\",\n \"medium\"\n ]\n ]\n },\n \"then\": \"2K\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$ref\": \"request.resolution\"\n }\n },\n [\n \"4k\",\n \"high\"\n ]\n ]\n },\n \"then\": \"4K\"\n }\n ],\n \"default\": null\n }\n }\n ]\n }\n }\n },\n \"temperature\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.temperature\"\n }\n },\n \"topP\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.topP\"\n }\n },\n \"topK\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.topK\"\n }\n },\n \"seed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.seed\"\n }\n }\n },\n \"safetySettings\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.gemini-image.safetySettings\"\n }\n },\n \"systemInstruction\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.gemini-image.systemInstruction\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"request.instructions\"\n },\n \"then\": {\n \"parts\": [\n {\n \"text\": {\n \"$ref\": \"request.instructions\"\n }\n }\n ]\n },\n \"else\": null\n }\n }\n ]\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"response.candidates.0.content.parts\"\n },\n \"as\": \"part\",\n \"where\": {\n \"$or\": [\n {\n \"$ref\": \"part.inlineData\"\n },\n {\n \"$ref\": \"part.inline_data\"\n }\n ]\n }\n }\n },\n \"as\": \"part\",\n \"in\": {\n \"dataUrl\": {\n \"$concat\": [\n \"data:\",\n {\n \"$coalesce\": [\n {\n \"$ref\": \"part.inlineData.mimeType\"\n },\n {\n \"$ref\": \"part.inline_data.mime_type\"\n },\n \"image/png\"\n ]\n },\n \";base64,\",\n {\n \"$coalesce\": [\n {\n \"$ref\": \"part.inlineData.data\"\n },\n {\n \"$ref\": \"part.inline_data.data\"\n }\n ]\n }\n ]\n }\n }\n }\n },\n \"text\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"response.candidates.0.content.parts\"\n },\n \"as\": \"part\",\n \"where\": {\n \"$ref\": \"part.text\"\n }\n }\n },\n \"as\": \"part\",\n \"in\": {\n \"$ref\": \"part.text\"\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usageMetadata\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", "permissions": [ "generation.run", "media.read" @@ -41,6 +41,67 @@ "type": "google-api-key", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "encoding": "ratio", + "values": [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ], + "default": "1:1" + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "values": [ + "1k", + "2k", + "4k" + ], + "default": "1k", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + }, + { + "name": "seed", + "label": "随机种子", + "type": "integer", + "binding": "providerOptions.gemini-image.seed", + "enabled": false + }, + { + "name": "temperature", + "label": "随机程度", + "type": "number", + "binding": "providerOptions.gemini-image.temperature", + "min": 0, + "max": 2, + "enabled": false + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/kacang-midjourney.yingce-plugin b/plugin-packages/kacang-midjourney.yingce-plugin index 0d234a290..dc9cfa54e 100644 Binary files a/plugin-packages/kacang-midjourney.yingce-plugin and b/plugin-packages/kacang-midjourney.yingce-plugin differ diff --git a/plugin-packages/kacang-midjourney/docs/interface.md b/plugin-packages/kacang-midjourney/docs/interface.md index 6d7ad2a86..4c35af686 100644 --- a/plugin-packages/kacang-midjourney/docs/interface.md +++ b/plugin-packages/kacang-midjourney/docs/interface.md @@ -47,6 +47,10 @@ 请求示例:`{"model":"mj-v8.2","prompt":"生成一张氛围感美女照片","size":"9:16","raw":false,"quality":1,"stylize":100,"chaos":0,"weird":0,"hd":false,"n":1}`。 +## 动态图片参数 + +`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。 + ## Manifest 完整接口定义 @@ -57,7 +61,7 @@ "apiVersion": "yingce.plugin/v2", "id": "kacang-midjourney", "name": "卡藏 Midjourney", - "version": "1.0.0", + "version": "1.1.0", "author": "影绘", "description": "适配卡藏特惠、V7特惠与稳定/高速生图,按公开模型合同映射请求并查询、保存全部结果。", "enabled": true, @@ -97,6 +101,37 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "values": [ + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "21:9" + ], + "default": "16:9" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", @@ -386,6 +421,39 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "values": [ + "auto", + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "21:9", + "9:21" + ], + "default": "16:9" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", @@ -673,6 +741,265 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "values": [ + "auto", + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "21:9", + "9:21" + ], + "default": "9:16" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + }, + { + "name": "raw", + "label": "原始风格", + "type": "boolean", + "binding": "providerOptions.kacang-midjourney.raw", + "default": false + }, + { + "name": "mjQuality", + "label": "细节质量", + "type": "number", + "binding": "providerOptions.kacang-midjourney.quality", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "values": [ + 0.25, + 0.5, + 1, + 2 + ], + "default": 1, + "pricing": true + }, + { + "name": "stylize", + "label": "风格化程度", + "type": "number", + "binding": "providerOptions.kacang-midjourney.stylize", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 1000, + "default": 100 + }, + { + "name": "chaos", + "label": "变化程度", + "type": "number", + "binding": "providerOptions.kacang-midjourney.chaos", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 100, + "default": 0 + }, + { + "name": "weird", + "label": "奇异程度", + "type": "number", + "binding": "providerOptions.kacang-midjourney.weird", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 3000, + "default": 0 + }, + { + "name": "seed", + "label": "随机种子", + "type": "integer", + "binding": "providerOptions.kacang-midjourney.seed", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "negative_prompt", + "label": "排除内容", + "type": "string", + "binding": "providerOptions.kacang-midjourney.negative_prompt", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "tile", + "label": "无缝平铺", + "type": "boolean", + "binding": "providerOptions.kacang-midjourney.tile", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "iw", + "label": "图片权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.iw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 3 + }, + { + "name": "cref", + "label": "角色参考", + "type": "string", + "binding": "providerOptions.kacang-midjourney.cref", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "sref", + "label": "风格参考", + "type": "string", + "binding": "providerOptions.kacang-midjourney.sref", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "dref", + "label": "细节参考", + "type": "string", + "binding": "providerOptions.kacang-midjourney.dref", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "cw", + "label": "角色权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.cw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 100 + }, + { + "name": "sw", + "label": "风格权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.sw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 1000 + }, + { + "name": "dw", + "label": "细节权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.dw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 100 + }, + { + "name": "hd", + "label": "高清输出", + "type": "boolean", + "binding": "providerOptions.kacang-midjourney.hd", + "models": [ + "mj-v8.2", + "mj-v8.1" + ], + "default": false, + "pricing": true + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/kacang-midjourney/manifest.json b/plugin-packages/kacang-midjourney/manifest.json index 16aa5f678..572803680 100644 --- a/plugin-packages/kacang-midjourney/manifest.json +++ b/plugin-packages/kacang-midjourney/manifest.json @@ -2,7 +2,7 @@ "apiVersion": "yingce.plugin/v2", "id": "kacang-midjourney", "name": "卡藏 Midjourney", - "version": "1.0.0", + "version": "1.1.0", "author": "影绘", "description": "适配卡藏特惠、V7特惠与稳定/高速生图,按公开模型合同映射请求并查询、保存全部结果。", "enabled": true, @@ -42,6 +42,37 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "values": [ + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "21:9" + ], + "default": "16:9" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", @@ -331,6 +362,39 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "values": [ + "auto", + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "21:9", + "9:21" + ], + "default": "16:9" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", @@ -618,6 +682,265 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "values": [ + "auto", + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "21:9", + "9:21" + ], + "default": "9:16" + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 1, + "step": 1, + "default": 1 + }, + { + "name": "raw", + "label": "原始风格", + "type": "boolean", + "binding": "providerOptions.kacang-midjourney.raw", + "default": false + }, + { + "name": "mjQuality", + "label": "细节质量", + "type": "number", + "binding": "providerOptions.kacang-midjourney.quality", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "values": [ + 0.25, + 0.5, + 1, + 2 + ], + "default": 1, + "pricing": true + }, + { + "name": "stylize", + "label": "风格化程度", + "type": "number", + "binding": "providerOptions.kacang-midjourney.stylize", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 1000, + "default": 100 + }, + { + "name": "chaos", + "label": "变化程度", + "type": "number", + "binding": "providerOptions.kacang-midjourney.chaos", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 100, + "default": 0 + }, + { + "name": "weird", + "label": "奇异程度", + "type": "number", + "binding": "providerOptions.kacang-midjourney.weird", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 3000, + "default": 0 + }, + { + "name": "seed", + "label": "随机种子", + "type": "integer", + "binding": "providerOptions.kacang-midjourney.seed", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "negative_prompt", + "label": "排除内容", + "type": "string", + "binding": "providerOptions.kacang-midjourney.negative_prompt", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "tile", + "label": "无缝平铺", + "type": "boolean", + "binding": "providerOptions.kacang-midjourney.tile", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "iw", + "label": "图片权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.iw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 3 + }, + { + "name": "cref", + "label": "角色参考", + "type": "string", + "binding": "providerOptions.kacang-midjourney.cref", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "sref", + "label": "风格参考", + "type": "string", + "binding": "providerOptions.kacang-midjourney.sref", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "dref", + "label": "细节参考", + "type": "string", + "binding": "providerOptions.kacang-midjourney.dref", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ] + }, + { + "name": "cw", + "label": "角色权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.cw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 100 + }, + { + "name": "sw", + "label": "风格权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.sw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 1000 + }, + { + "name": "dw", + "label": "细节权重", + "type": "number", + "binding": "providerOptions.kacang-midjourney.dw", + "models": [ + "mj-v8.2", + "mj-v8.1", + "mj-v6.1", + "mj-v7", + "mj-niji7" + ], + "min": 0, + "max": 100 + }, + { + "name": "hd", + "label": "高清输出", + "type": "boolean", + "binding": "providerOptions.kacang-midjourney.hd", + "models": [ + "mj-v8.2", + "mj-v8.1" + ], + "default": false, + "pricing": true + } + ], "parameters": [ { "name": "model", @@ -1669,5 +1992,5 @@ } ] }, - "documentation": "# 卡藏 Midjourney\n\n统一声明式 `yingce.plugin/v2` 插件,提供三个生图协议入口:`kacang-midjourney-special`(V6/V8 特惠)、`kacang-midjourney-v7`(V7 特惠)、`kacang-midjourney`(稳定/高速)。使用宿主鉴权、出站校验、轮询、资源保存和计费合同,不执行额外插件代码。\n\n选择协议后,“能力与参数”自动填入对应比例、默认比例、参考图上限和一次提交。特惠默认 16:9;稳定/高速默认 9:16。V7 特惠与稳定/高速支持 auto 和 9:21。单次 n=1 表示一次提交;所有实际返回的图片均保存,包含上游提供的可选四宫格封面。\n\n稳定/高速使用任务接口,首响应只是受理确认。插件保留公开任务 ID 并查询 `/v1/tasks/{task_id}`,不会将受理当作成功,也不会重新提交收费任务。特惠走标准图片接口。模型 ID 必须填写卡藏公开 API 模型 ID。\n\n完整请求、模型清单、默认参数和结果合同见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# 卡藏 Midjourney 生图接口\n\n## 来源、模型和边界\n\n供应商文档:[文档首页](https://console.prompt-hubs.com/docs)、[V8.2 特惠](https://console.prompt-hubs.com/docs/model/Midjourney%20v8.2-%E7%89%B9%E6%83%A0)、[V7 特惠](https://console.prompt-hubs.com/docs/model/Midjourney%20v7-%E7%89%B9%E6%83%A0)、[V8.2 稳定](https://console.prompt-hubs.com/docs/model/mj-v8.2)、[V8.2 高速](https://console.prompt-hubs.com/docs/model/Midjourney%20v8.2%20%E9%AB%98%E9%80%9F)。以具体参数表为准;页面通用提示可能与某型号的输出说明不同,插件按实际返回保存全部图片,不按文档样例的图片数量截断。\n\n| Provider | 公开模型 ID | 创建 | 查询 |\n| --- | --- | --- | --- |\n| `kacang-midjourney-special` | `Midjourney v8.2-特惠`、`Midjourney v8.1-特惠`、`Midjourney v6.1-特惠`、`Midjourney v6-特惠` | `POST /v1/images/generations` | 如果返回任务 ID,使用 `GET /v1/tasks/{task_id}` |\n| `kacang-midjourney-v7` | `Midjourney v7-特惠` | 同上 | 同上 |\n| `kacang-midjourney` | `mj-v8.2`、`mj-v8.1`、`mj-v6.1`、`mj-v7`、`mj-niji7`、`Midjourney v8.2 高速` | `POST /v1/midjourney/generations` | `GET /v1/tasks/{task_id}` |\n\n默认 Base URL:`https://newapi.prompt-hubs.com/v1`;鉴权:`Authorization: Bearer `。API Key 由宿主渠道配置提供,不写入插件包、任务正文或 URL。实际可用模型以该渠道令牌的 `/v1/models` 返回为准,不自动更换模型。\n\n本插件只提供图片生成及参考图生成。供应商后续 `action`、父任务操作和 describe 文本结果需要独立的交互合同,本生图入口不透传这些字段。\n\n## 统一请求与默认能力\n\n| 宿主参数 | 上游映射与约束 |\n| --- | --- |\n| `model` | `model`,严格校验当前协议公开模型列表。 |\n| `prompt` | 原样传递,不能为空;不自动翻译或追加 Midjourney 提示词参数。 |\n| `aspectRatio` | `size`;V6/V8 特惠默认 16:9,允许 1:1、3:2、2:3、4:3、3:4、5:4、4:5、16:9、9:16、21:9。V7 特惠增加 auto、9:21;稳定/高速同样增加这两个值,默认 9:16。 |\n| `images` | 以显式顺序生成 `images` URL 数组;特惠最多 1 张,稳定/高速最多 5 张。宿主将本地资源转换为短期公网 URL。拒绝 mask。 |\n| `imageCount` | 未设置或 1;上游固定 `n=1`,不是返回图片的截断数量。 |\n| `providerOptions.kacang-midjourney.raw` | 稳定/高速 `raw`,默认 false。 |\n| `providerOptions.kacang-midjourney.quality` | 仅稳定 `mj-*`,数值 0.25、0.5、1、2,默认 1。不使用宿主 `quality` 的 1K/2K/4K 值。 |\n| `providerOptions.kacang-midjourney.stylize/chaos/weird` | 仅稳定 `mj-*`,默认 100/0/0,范围分别 0–1000、0–100、0–3000。 |\n| `providerOptions.kacang-midjourney.iw/cw/sw/dw` | 仅稳定 `mj-*`;可选,范围 0–3、0–100、0–1000、0–100。 |\n| `providerOptions.kacang-midjourney.seed/negative_prompt/tile/cref/sref/dref` | 仅稳定 `mj-*`;调用方明确指定时才发送,不设未声明的默认值。 |\n| `providerOptions.kacang-midjourney.hd` | 仅 mj-v8.2 / mj-v8.1,默认 false。 |\n\n特惠不发送 Raw 或稳定版扩展字段;高速只发送 Raw。所有协议均不发送 resolution、透明背景、response_format、output_format、async 或 Prefer 请求头。\n\n“能力与参数”自动配置:参考图上限为 1 或 5,无蒙版,比例选择不可输入自定义像素,单次请求数量 1;不支持的通用输出控件关闭。宿主提示词默认上限 32000 字符、参考图单张 30 MB,属于宿主限制,上游未声明这两个上限。1K 预设只用于宿主比例选择与界面尺寸换算,不承诺实际输出像素。稳定版数值画质与风格参数由插件命名空间映射,不伪装成分辨率选项。\n\n## 响应、失败与保存\n\n- 任务 ID:优先 `data.task_id`,其次 `task_id` / `id`,查询时保留原 task ID。\n- 状态:读取 `data.status` / `status`;submitted / queued 为等待,processing 为进行中,completed 为成功,failed 为失败。\n- 特惠同步结果:遍历 `data[].url`;没有 status 的真实图片结果仍按宿主合同完成。\n- 稳定/高速结果:同时支持 `data.result.data.image_urls` 和 `result.data.image_urls`。可选 `grid_image_url` 一并保存,若它已经在 image_urls 中则不重复。\n- 错误:读取 `data.error_code` / `error_code` / `error.code`,以及对应 error_message / error.message。失败和空结果不会生成伪资源。\n- 返回 URL 标记 `resultEphemeral=true`,宿主立即下载并存入已配置对象存储。跨源下载不携带供应商 API Key。\n- 供应商托管链接有有效期;生成成功不等于转存成功,最终必须以宿主资源 ready 与任务成功为准。已有任务只查询,不重新生成。\n- 取消和独立结果下载端点未声明;供应商未提供适用于本生图入口的相应合同。\n\n请求示例:`{\"model\":\"mj-v8.2\",\"prompt\":\"生成一张氛围感美女照片\",\"size\":\"9:16\",\"raw\":false,\"quality\":1,\"stylize\":100,\"chaos\":0,\"weird\":0,\"hd\":false,\"n\":1}`。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"kacang-midjourney\",\n \"name\": \"卡藏 Midjourney\",\n \"version\": \"1.0.0\",\n \"author\": \"影绘\",\n \"description\": \"适配卡藏特惠、V7特惠与稳定/高速生图,按公开模型合同映射请求并查询、保存全部结果。\",\n \"enabled\": true,\n \"installable\": true,\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"kacang-midjourney-special\",\n \"label\": \"卡藏 Midjourney 特惠 V6/V8\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://newapi.prompt-hubs.com/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"values\": [\n \"Midjourney v8.2-特惠\",\n \"Midjourney v8.1-特惠\",\n \"Midjourney v6.1-特惠\",\n \"Midjourney v6-特惠\"\n ],\n \"mapping\": \"model\",\n \"description\": \"卡藏公开 API 模型 ID,必须与所选协议一致。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原样保留提示词;上游仅声明不能为空。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"values\": [\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\"\n ],\n \"mapping\": \"size\",\n \"description\": \"默认 16:9;仅发送已确认的比例,不发送像素或 resolution。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images\",\n \"description\": \"按显式顺序发送参考图 URL 数组;最多 1 张,不支持蒙版。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次请求固定 n=1,宿主保存所有返回单图及可选四宫格封面。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"Midjourney v8.2-特惠\",\n \"Midjourney v8.1-特惠\",\n \"Midjourney v6.1-特惠\",\n \"Midjourney v6-特惠\"\n ]\n ]\n },\n \"message\": \"模型名与卡藏 Midjourney 协议不匹配。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n [\n 0,\n 1\n ]\n ]\n },\n \"message\": \"Midjourney 一次提交只能 n=1,所有返回图片仍会保存。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 1\n ]\n },\n \"message\": \"当前卡藏 Midjourney 参考图最多 1 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"卡藏 Midjourney 生图协议不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n [\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\"\n ]\n ]\n },\n \"message\": \"当前卡藏 Midjourney 不支持此比例。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n \"n\": 1,\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/tasks/{{taskId}}\"\n },\n \"response\": {\n \"taskIdPaths\": [\n \"data.task_id\",\n \"task_id\",\n \"id\"\n ],\n \"statusPaths\": [\n \"data.status\",\n \"status\"\n ],\n \"errorPaths\": [\n \"data.error_code\",\n \"error_code\",\n \"error.code\"\n ],\n \"messagePaths\": [\n \"data.error_message\",\n \"error_message\",\n \"error.message\",\n \"message\"\n ],\n \"resultEphemeral\": true,\n \"usage\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.usage\"\n },\n {\n \"$ref\": \"response.usage\"\n }\n ]\n },\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n }\n }\n },\n {\n \"id\": \"kacang-midjourney-v7\",\n \"label\": \"卡藏 Midjourney V7 特惠\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://newapi.prompt-hubs.com/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"values\": [\n \"Midjourney v7-特惠\"\n ],\n \"mapping\": \"model\",\n \"description\": \"卡藏公开 API 模型 ID,必须与所选协议一致。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原样保留提示词;上游仅声明不能为空。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"values\": [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ],\n \"mapping\": \"size\",\n \"description\": \"默认 16:9;仅发送已确认的比例,不发送像素或 resolution。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images\",\n \"description\": \"按显式顺序发送参考图 URL 数组;最多 1 张,不支持蒙版。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次请求固定 n=1,宿主保存所有返回单图及可选四宫格封面。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"Midjourney v7-特惠\"\n ]\n ]\n },\n \"message\": \"模型名与卡藏 Midjourney 协议不匹配。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n [\n 0,\n 1\n ]\n ]\n },\n \"message\": \"Midjourney 一次提交只能 n=1,所有返回图片仍会保存。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 1\n ]\n },\n \"message\": \"当前卡藏 Midjourney 参考图最多 1 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"卡藏 Midjourney 生图协议不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ]\n ]\n },\n \"message\": \"当前卡藏 Midjourney 不支持此比例。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n \"n\": 1,\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/tasks/{{taskId}}\"\n },\n \"response\": {\n \"taskIdPaths\": [\n \"data.task_id\",\n \"task_id\",\n \"id\"\n ],\n \"statusPaths\": [\n \"data.status\",\n \"status\"\n ],\n \"errorPaths\": [\n \"data.error_code\",\n \"error_code\",\n \"error.code\"\n ],\n \"messagePaths\": [\n \"data.error_message\",\n \"error_message\",\n \"error.message\",\n \"message\"\n ],\n \"resultEphemeral\": true,\n \"usage\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.usage\"\n },\n {\n \"$ref\": \"response.usage\"\n }\n ]\n },\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n }\n }\n },\n {\n \"id\": \"kacang-midjourney\",\n \"label\": \"卡藏 Midjourney 稳定/高速\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://newapi.prompt-hubs.com/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"values\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\",\n \"Midjourney v8.2 高速\"\n ],\n \"mapping\": \"model\",\n \"description\": \"卡藏公开 API 模型 ID,必须与所选协议一致。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原样保留提示词;上游仅声明不能为空。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"values\": [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ],\n \"mapping\": \"size\",\n \"description\": \"默认 9:16;仅发送已确认的比例,不发送像素或 resolution。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images\",\n \"description\": \"按显式顺序发送参考图 URL 数组;最多 5 张,不支持蒙版。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次请求固定 n=1,宿主保存所有返回单图及可选四宫格封面。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"mapping\": \"kacang-midjourney.raw/quality/stylize/chaos/weird/seed/negative_prompt/tile/iw/cref/sref/dref/cw/sw/dw/hd\",\n \"description\": \"默认 raw=false;稳定 mj-* 的数值 quality 默认1,不能将1K/2K分辨率作为quality。高速版仅映射raw。后续action任务不属于本生图插件。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\",\n \"Midjourney v8.2 高速\"\n ]\n ]\n },\n \"message\": \"模型名与卡藏 Midjourney 协议不匹配。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n [\n 0,\n 1\n ]\n ]\n },\n \"message\": \"Midjourney 一次提交只能 n=1,所有返回图片仍会保存。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 5\n ]\n },\n \"message\": \"当前卡藏 Midjourney 参考图最多 5 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"卡藏 Midjourney 生图协议不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"9:16\"\n ]\n },\n [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ]\n ]\n },\n \"message\": \"当前卡藏 Midjourney 不支持此比例。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$in\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.quality\"\n },\n 1\n ]\n }\n },\n [\n 0.25,\n 0.5,\n 1,\n 2\n ]\n ]\n }\n ]\n },\n \"message\": \"稳定版 quality 仅支持 0.25、0.5、1、2。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.stylize\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.stylize\"\n },\n 0\n ]\n }\n },\n 1000\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"stylize 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.chaos\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.chaos\"\n },\n 0\n ]\n }\n },\n 100\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"chaos 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.weird\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.weird\"\n },\n 0\n ]\n }\n },\n 3000\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"weird 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.iw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.iw\"\n },\n 0\n ]\n }\n },\n 3\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"iw 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cw\"\n },\n 0\n ]\n }\n },\n 100\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"cw 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sw\"\n },\n 0\n ]\n }\n },\n 1000\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"sw 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dw\"\n },\n 0\n ]\n }\n },\n 100\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"dw 超出文档规定范围。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/midjourney/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"9:16\"\n ]\n },\n \"n\": 1,\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n },\n \"raw\": {\n \"$toBool\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.raw\"\n },\n false\n ]\n }\n },\n \"seed\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.seed\"\n }\n }\n }\n },\n \"negative_prompt\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.negative_prompt\"\n }\n }\n }\n },\n \"tile\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.tile\"\n }\n }\n }\n },\n \"iw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.iw\"\n }\n }\n }\n },\n \"cref\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cref\"\n }\n }\n }\n },\n \"sref\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sref\"\n }\n }\n }\n },\n \"dref\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dref\"\n }\n }\n }\n },\n \"cw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cw\"\n }\n }\n }\n },\n \"sw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sw\"\n }\n }\n }\n },\n \"dw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dw\"\n }\n }\n }\n },\n \"quality\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.quality\"\n },\n 1\n ]\n }\n }\n }\n },\n \"stylize\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.stylize\"\n },\n 100\n ]\n }\n }\n }\n },\n \"chaos\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.chaos\"\n },\n 0\n ]\n }\n }\n }\n },\n \"weird\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.weird\"\n },\n 0\n ]\n }\n }\n }\n },\n \"hd\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"mj-v8.2\",\n \"mj-v8.1\"\n ]\n ]\n },\n \"then\": {\n \"$toBool\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.hd\"\n },\n false\n ]\n }\n },\n \"else\": null\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/tasks/{{taskId}}\"\n },\n \"response\": {\n \"taskIdPaths\": [\n \"data.task_id\",\n \"task_id\",\n \"id\"\n ],\n \"statusPaths\": [\n \"data.status\",\n \"status\"\n ],\n \"errorPaths\": [\n \"data.error_code\",\n \"error_code\",\n \"error.code\"\n ],\n \"messagePaths\": [\n \"data.error_message\",\n \"error_message\",\n \"error.message\",\n \"message\"\n ],\n \"resultEphemeral\": true,\n \"usage\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.usage\"\n },\n {\n \"$ref\": \"response.usage\"\n }\n ]\n },\n \"images\": {\n \"$concatArrays\": [\n {\n \"$map\": {\n \"from\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.image_urls\"\n },\n {\n \"$ref\": \"response.result.data.image_urls\"\n }\n ]\n },\n \"as\": \"url\",\n \"in\": {\n \"url\": {\n \"$ref\": \"url\"\n }\n }\n }\n },\n {\n \"$if\": {\n \"condition\": {\n \"$and\": [\n {\n \"$gt\": [\n {\n \"$len\": {\n \"$toString\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.grid_image_url\"\n },\n {\n \"$ref\": \"response.result.data.grid_image_url\"\n }\n ]\n }\n }\n },\n 0\n ]\n },\n {\n \"$not\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.grid_image_url\"\n },\n {\n \"$ref\": \"response.result.data.grid_image_url\"\n }\n ]\n },\n {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.image_urls\"\n },\n {\n \"$ref\": \"response.result.data.image_urls\"\n }\n ]\n }\n ]\n }\n }\n ]\n },\n \"then\": [\n {\n \"url\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.grid_image_url\"\n },\n {\n \"$ref\": \"response.result.data.grid_image_url\"\n }\n ]\n }\n }\n ],\n \"else\": []\n }\n }\n ]\n }\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" + "documentation": "# 卡藏 Midjourney\n\n统一声明式 `yingce.plugin/v2` 插件,提供三个生图协议入口:`kacang-midjourney-special`(V6/V8 特惠)、`kacang-midjourney-v7`(V7 特惠)、`kacang-midjourney`(稳定/高速)。使用宿主鉴权、出站校验、轮询、资源保存和计费合同,不执行额外插件代码。\n\n选择协议后,“能力与参数”自动填入对应比例、默认比例、参考图上限和一次提交。特惠默认 16:9;稳定/高速默认 9:16。V7 特惠与稳定/高速支持 auto 和 9:21。单次 n=1 表示一次提交;所有实际返回的图片均保存,包含上游提供的可选四宫格封面。\n\n稳定/高速使用任务接口,首响应只是受理确认。插件保留公开任务 ID 并查询 `/v1/tasks/{task_id}`,不会将受理当作成功,也不会重新提交收费任务。特惠走标准图片接口。模型 ID 必须填写卡藏公开 API 模型 ID。\n\n完整请求、模型清单、默认参数和结果合同见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# 卡藏 Midjourney 生图接口\n\n## 来源、模型和边界\n\n供应商文档:[文档首页](https://console.prompt-hubs.com/docs)、[V8.2 特惠](https://console.prompt-hubs.com/docs/model/Midjourney%20v8.2-%E7%89%B9%E6%83%A0)、[V7 特惠](https://console.prompt-hubs.com/docs/model/Midjourney%20v7-%E7%89%B9%E6%83%A0)、[V8.2 稳定](https://console.prompt-hubs.com/docs/model/mj-v8.2)、[V8.2 高速](https://console.prompt-hubs.com/docs/model/Midjourney%20v8.2%20%E9%AB%98%E9%80%9F)。以具体参数表为准;页面通用提示可能与某型号的输出说明不同,插件按实际返回保存全部图片,不按文档样例的图片数量截断。\n\n| Provider | 公开模型 ID | 创建 | 查询 |\n| --- | --- | --- | --- |\n| `kacang-midjourney-special` | `Midjourney v8.2-特惠`、`Midjourney v8.1-特惠`、`Midjourney v6.1-特惠`、`Midjourney v6-特惠` | `POST /v1/images/generations` | 如果返回任务 ID,使用 `GET /v1/tasks/{task_id}` |\n| `kacang-midjourney-v7` | `Midjourney v7-特惠` | 同上 | 同上 |\n| `kacang-midjourney` | `mj-v8.2`、`mj-v8.1`、`mj-v6.1`、`mj-v7`、`mj-niji7`、`Midjourney v8.2 高速` | `POST /v1/midjourney/generations` | `GET /v1/tasks/{task_id}` |\n\n默认 Base URL:`https://newapi.prompt-hubs.com/v1`;鉴权:`Authorization: Bearer `。API Key 由宿主渠道配置提供,不写入插件包、任务正文或 URL。实际可用模型以该渠道令牌的 `/v1/models` 返回为准,不自动更换模型。\n\n本插件只提供图片生成及参考图生成。供应商后续 `action`、父任务操作和 describe 文本结果需要独立的交互合同,本生图入口不透传这些字段。\n\n## 统一请求与默认能力\n\n| 宿主参数 | 上游映射与约束 |\n| --- | --- |\n| `model` | `model`,严格校验当前协议公开模型列表。 |\n| `prompt` | 原样传递,不能为空;不自动翻译或追加 Midjourney 提示词参数。 |\n| `aspectRatio` | `size`;V6/V8 特惠默认 16:9,允许 1:1、3:2、2:3、4:3、3:4、5:4、4:5、16:9、9:16、21:9。V7 特惠增加 auto、9:21;稳定/高速同样增加这两个值,默认 9:16。 |\n| `images` | 以显式顺序生成 `images` URL 数组;特惠最多 1 张,稳定/高速最多 5 张。宿主将本地资源转换为短期公网 URL。拒绝 mask。 |\n| `imageCount` | 未设置或 1;上游固定 `n=1`,不是返回图片的截断数量。 |\n| `providerOptions.kacang-midjourney.raw` | 稳定/高速 `raw`,默认 false。 |\n| `providerOptions.kacang-midjourney.quality` | 仅稳定 `mj-*`,数值 0.25、0.5、1、2,默认 1。不使用宿主 `quality` 的 1K/2K/4K 值。 |\n| `providerOptions.kacang-midjourney.stylize/chaos/weird` | 仅稳定 `mj-*`,默认 100/0/0,范围分别 0–1000、0–100、0–3000。 |\n| `providerOptions.kacang-midjourney.iw/cw/sw/dw` | 仅稳定 `mj-*`;可选,范围 0–3、0–100、0–1000、0–100。 |\n| `providerOptions.kacang-midjourney.seed/negative_prompt/tile/cref/sref/dref` | 仅稳定 `mj-*`;调用方明确指定时才发送,不设未声明的默认值。 |\n| `providerOptions.kacang-midjourney.hd` | 仅 mj-v8.2 / mj-v8.1,默认 false。 |\n\n特惠不发送 Raw 或稳定版扩展字段;高速只发送 Raw。所有协议均不发送 resolution、透明背景、response_format、output_format、async 或 Prefer 请求头。\n\n“能力与参数”自动配置:参考图上限为 1 或 5,无蒙版,比例选择不可输入自定义像素,单次请求数量 1;不支持的通用输出控件关闭。宿主提示词默认上限 32000 字符、参考图单张 30 MB,属于宿主限制,上游未声明这两个上限。1K 预设只用于宿主比例选择与界面尺寸换算,不承诺实际输出像素。稳定版数值画质与风格参数由插件命名空间映射,不伪装成分辨率选项。\n\n## 响应、失败与保存\n\n- 任务 ID:优先 `data.task_id`,其次 `task_id` / `id`,查询时保留原 task ID。\n- 状态:读取 `data.status` / `status`;submitted / queued 为等待,processing 为进行中,completed 为成功,failed 为失败。\n- 特惠同步结果:遍历 `data[].url`;没有 status 的真实图片结果仍按宿主合同完成。\n- 稳定/高速结果:同时支持 `data.result.data.image_urls` 和 `result.data.image_urls`。可选 `grid_image_url` 一并保存,若它已经在 image_urls 中则不重复。\n- 错误:读取 `data.error_code` / `error_code` / `error.code`,以及对应 error_message / error.message。失败和空结果不会生成伪资源。\n- 返回 URL 标记 `resultEphemeral=true`,宿主立即下载并存入已配置对象存储。跨源下载不携带供应商 API Key。\n- 供应商托管链接有有效期;生成成功不等于转存成功,最终必须以宿主资源 ready 与任务成功为准。已有任务只查询,不重新生成。\n- 取消和独立结果下载端点未声明;供应商未提供适用于本生图入口的相应合同。\n\n请求示例:`{\"model\":\"mj-v8.2\",\"prompt\":\"生成一张氛围感美女照片\",\"size\":\"9:16\",\"raw\":false,\"quality\":1,\"stylize\":100,\"chaos\":0,\"weird\":0,\"hd\":false,\"n\":1}`。\n\n## 动态图片参数\n\n`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"kacang-midjourney\",\n \"name\": \"卡藏 Midjourney\",\n \"version\": \"1.1.0\",\n \"author\": \"影绘\",\n \"description\": \"适配卡藏特惠、V7特惠与稳定/高速生图,按公开模型合同映射请求并查询、保存全部结果。\",\n \"enabled\": true,\n \"installable\": true,\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"kacang-midjourney-special\",\n \"label\": \"卡藏 Midjourney 特惠 V6/V8\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://newapi.prompt-hubs.com/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅比例\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\",\n \"values\": [\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\"\n ],\n \"default\": \"16:9\"\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 1,\n \"step\": 1,\n \"default\": 1\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"values\": [\n \"Midjourney v8.2-特惠\",\n \"Midjourney v8.1-特惠\",\n \"Midjourney v6.1-特惠\",\n \"Midjourney v6-特惠\"\n ],\n \"mapping\": \"model\",\n \"description\": \"卡藏公开 API 模型 ID,必须与所选协议一致。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原样保留提示词;上游仅声明不能为空。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"values\": [\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\"\n ],\n \"mapping\": \"size\",\n \"description\": \"默认 16:9;仅发送已确认的比例,不发送像素或 resolution。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images\",\n \"description\": \"按显式顺序发送参考图 URL 数组;最多 1 张,不支持蒙版。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次请求固定 n=1,宿主保存所有返回单图及可选四宫格封面。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"Midjourney v8.2-特惠\",\n \"Midjourney v8.1-特惠\",\n \"Midjourney v6.1-特惠\",\n \"Midjourney v6-特惠\"\n ]\n ]\n },\n \"message\": \"模型名与卡藏 Midjourney 协议不匹配。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n [\n 0,\n 1\n ]\n ]\n },\n \"message\": \"Midjourney 一次提交只能 n=1,所有返回图片仍会保存。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 1\n ]\n },\n \"message\": \"当前卡藏 Midjourney 参考图最多 1 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"卡藏 Midjourney 生图协议不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n [\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\"\n ]\n ]\n },\n \"message\": \"当前卡藏 Midjourney 不支持此比例。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n \"n\": 1,\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/tasks/{{taskId}}\"\n },\n \"response\": {\n \"taskIdPaths\": [\n \"data.task_id\",\n \"task_id\",\n \"id\"\n ],\n \"statusPaths\": [\n \"data.status\",\n \"status\"\n ],\n \"errorPaths\": [\n \"data.error_code\",\n \"error_code\",\n \"error.code\"\n ],\n \"messagePaths\": [\n \"data.error_message\",\n \"error_message\",\n \"error.message\",\n \"message\"\n ],\n \"resultEphemeral\": true,\n \"usage\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.usage\"\n },\n {\n \"$ref\": \"response.usage\"\n }\n ]\n },\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n }\n }\n },\n {\n \"id\": \"kacang-midjourney-v7\",\n \"label\": \"卡藏 Midjourney V7 特惠\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://newapi.prompt-hubs.com/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅比例\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\",\n \"values\": [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ],\n \"default\": \"16:9\"\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 1,\n \"step\": 1,\n \"default\": 1\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"values\": [\n \"Midjourney v7-特惠\"\n ],\n \"mapping\": \"model\",\n \"description\": \"卡藏公开 API 模型 ID,必须与所选协议一致。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原样保留提示词;上游仅声明不能为空。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"values\": [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ],\n \"mapping\": \"size\",\n \"description\": \"默认 16:9;仅发送已确认的比例,不发送像素或 resolution。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images\",\n \"description\": \"按显式顺序发送参考图 URL 数组;最多 1 张,不支持蒙版。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次请求固定 n=1,宿主保存所有返回单图及可选四宫格封面。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"Midjourney v7-特惠\"\n ]\n ]\n },\n \"message\": \"模型名与卡藏 Midjourney 协议不匹配。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n [\n 0,\n 1\n ]\n ]\n },\n \"message\": \"Midjourney 一次提交只能 n=1,所有返回图片仍会保存。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 1\n ]\n },\n \"message\": \"当前卡藏 Midjourney 参考图最多 1 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"卡藏 Midjourney 生图协议不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ]\n ]\n },\n \"message\": \"当前卡藏 Midjourney 不支持此比例。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"16:9\"\n ]\n },\n \"n\": 1,\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/tasks/{{taskId}}\"\n },\n \"response\": {\n \"taskIdPaths\": [\n \"data.task_id\",\n \"task_id\",\n \"id\"\n ],\n \"statusPaths\": [\n \"data.status\",\n \"status\"\n ],\n \"errorPaths\": [\n \"data.error_code\",\n \"error_code\",\n \"error.code\"\n ],\n \"messagePaths\": [\n \"data.error_message\",\n \"error_message\",\n \"error.message\",\n \"message\"\n ],\n \"resultEphemeral\": true,\n \"usage\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.usage\"\n },\n {\n \"$ref\": \"response.usage\"\n }\n ]\n },\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$ref\": \"item.url\"\n }\n }\n }\n }\n }\n },\n {\n \"id\": \"kacang-midjourney\",\n \"label\": \"卡藏 Midjourney 稳定/高速\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://newapi.prompt-hubs.com/v1\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅比例\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\",\n \"values\": [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ],\n \"default\": \"9:16\"\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 1,\n \"step\": 1,\n \"default\": 1\n },\n {\n \"name\": \"raw\",\n \"label\": \"原始风格\",\n \"type\": \"boolean\",\n \"binding\": \"providerOptions.kacang-midjourney.raw\",\n \"default\": false\n },\n {\n \"name\": \"mjQuality\",\n \"label\": \"细节质量\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.quality\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"values\": [\n 0.25,\n 0.5,\n 1,\n 2\n ],\n \"default\": 1,\n \"pricing\": true\n },\n {\n \"name\": \"stylize\",\n \"label\": \"风格化程度\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.stylize\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 1000,\n \"default\": 100\n },\n {\n \"name\": \"chaos\",\n \"label\": \"变化程度\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.chaos\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 100,\n \"default\": 0\n },\n {\n \"name\": \"weird\",\n \"label\": \"奇异程度\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.weird\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 3000,\n \"default\": 0\n },\n {\n \"name\": \"seed\",\n \"label\": \"随机种子\",\n \"type\": \"integer\",\n \"binding\": \"providerOptions.kacang-midjourney.seed\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ]\n },\n {\n \"name\": \"negative_prompt\",\n \"label\": \"排除内容\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.kacang-midjourney.negative_prompt\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ]\n },\n {\n \"name\": \"tile\",\n \"label\": \"无缝平铺\",\n \"type\": \"boolean\",\n \"binding\": \"providerOptions.kacang-midjourney.tile\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ]\n },\n {\n \"name\": \"iw\",\n \"label\": \"图片权重\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.iw\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 3\n },\n {\n \"name\": \"cref\",\n \"label\": \"角色参考\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.kacang-midjourney.cref\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ]\n },\n {\n \"name\": \"sref\",\n \"label\": \"风格参考\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.kacang-midjourney.sref\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ]\n },\n {\n \"name\": \"dref\",\n \"label\": \"细节参考\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.kacang-midjourney.dref\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ]\n },\n {\n \"name\": \"cw\",\n \"label\": \"角色权重\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.cw\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 100\n },\n {\n \"name\": \"sw\",\n \"label\": \"风格权重\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.sw\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 1000\n },\n {\n \"name\": \"dw\",\n \"label\": \"细节权重\",\n \"type\": \"number\",\n \"binding\": \"providerOptions.kacang-midjourney.dw\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\"\n ],\n \"min\": 0,\n \"max\": 100\n },\n {\n \"name\": \"hd\",\n \"label\": \"高清输出\",\n \"type\": \"boolean\",\n \"binding\": \"providerOptions.kacang-midjourney.hd\",\n \"models\": [\n \"mj-v8.2\",\n \"mj-v8.1\"\n ],\n \"default\": false,\n \"pricing\": true\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"values\": [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\",\n \"Midjourney v8.2 高速\"\n ],\n \"mapping\": \"model\",\n \"description\": \"卡藏公开 API 模型 ID,必须与所选协议一致。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"原样保留提示词;上游仅声明不能为空。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"values\": [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ],\n \"mapping\": \"size\",\n \"description\": \"默认 9:16;仅发送已确认的比例,不发送像素或 resolution。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"mapping\": \"images\",\n \"description\": \"按显式顺序发送参考图 URL 数组;最多 5 张,不支持蒙版。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"mapping\": \"n=1\",\n \"description\": \"一次请求固定 n=1,宿主保存所有返回单图及可选四宫格封面。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"mapping\": \"kacang-midjourney.raw/quality/stylize/chaos/weird/seed/negative_prompt/tile/iw/cref/sref/dref/cw/sw/dw/hd\",\n \"description\": \"默认 raw=false;稳定 mj-* 的数值 quality 默认1,不能将1K/2K分辨率作为quality。高速版仅映射raw。后续action任务不属于本生图插件。\"\n }\n ],\n \"validations\": [\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"mj-v8.2\",\n \"mj-v8.1\",\n \"mj-v6.1\",\n \"mj-v7\",\n \"mj-niji7\",\n \"Midjourney v8.2 高速\"\n ]\n ]\n },\n \"message\": \"模型名与卡藏 Midjourney 协议不匹配。\"\n },\n {\n \"assert\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$trim\": {\n \"$ref\": \"request.prompt\"\n }\n }\n },\n 0\n ]\n },\n \"message\": \"Midjourney 提示词不能为空。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n [\n 0,\n 1\n ]\n ]\n },\n \"message\": \"Midjourney 一次提交只能 n=1,所有返回图片仍会保存。\"\n },\n {\n \"assert\": {\n \"$lte\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 5\n ]\n },\n \"message\": \"当前卡藏 Midjourney 参考图最多 5 张。\"\n },\n {\n \"assert\": {\n \"$eq\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"message\": \"卡藏 Midjourney 生图协议不支持蒙版。\"\n },\n {\n \"assert\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"9:16\"\n ]\n },\n [\n \"auto\",\n \"1:1\",\n \"3:2\",\n \"2:3\",\n \"4:3\",\n \"3:4\",\n \"5:4\",\n \"4:5\",\n \"16:9\",\n \"9:16\",\n \"21:9\",\n \"9:21\"\n ]\n ]\n },\n \"message\": \"当前卡藏 Midjourney 不支持此比例。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$in\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.quality\"\n },\n 1\n ]\n }\n },\n [\n 0.25,\n 0.5,\n 1,\n 2\n ]\n ]\n }\n ]\n },\n \"message\": \"稳定版 quality 仅支持 0.25、0.5、1、2。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.stylize\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.stylize\"\n },\n 0\n ]\n }\n },\n 1000\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"stylize 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.chaos\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.chaos\"\n },\n 0\n ]\n }\n },\n 100\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"chaos 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.weird\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.weird\"\n },\n 0\n ]\n }\n },\n 3000\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"weird 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.iw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.iw\"\n },\n 0\n ]\n }\n },\n 3\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"iw 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cw\"\n },\n 0\n ]\n }\n },\n 100\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"cw 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sw\"\n },\n 0\n ]\n }\n },\n 1000\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"sw 超出文档规定范围。\"\n },\n {\n \"assert\": {\n \"$or\": [\n {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n {\n \"$and\": [\n {\n \"$gte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dw\"\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$lte\": [\n {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dw\"\n },\n 0\n ]\n }\n },\n 100\n ]\n }\n ]\n }\n ]\n },\n \"message\": \"dw 超出文档规定范围。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/midjourney/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"size\": {\n \"$coalesce\": [\n {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"9:16\"\n ]\n },\n \"n\": 1,\n \"images\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n },\n \"else\": null\n }\n },\n \"raw\": {\n \"$toBool\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.raw\"\n },\n false\n ]\n }\n },\n \"seed\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.seed\"\n }\n }\n }\n },\n \"negative_prompt\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.negative_prompt\"\n }\n }\n }\n },\n \"tile\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.tile\"\n }\n }\n }\n },\n \"iw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.iw\"\n }\n }\n }\n },\n \"cref\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cref\"\n }\n }\n }\n },\n \"sref\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sref\"\n }\n }\n }\n },\n \"dref\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dref\"\n }\n }\n }\n },\n \"cw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.cw\"\n }\n }\n }\n },\n \"sw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.sw\"\n }\n }\n }\n },\n \"dw\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.kacang-midjourney.dw\"\n }\n }\n }\n },\n \"quality\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.quality\"\n },\n 1\n ]\n }\n }\n }\n },\n \"stylize\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.stylize\"\n },\n 100\n ]\n }\n }\n }\n },\n \"chaos\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.chaos\"\n },\n 0\n ]\n }\n }\n }\n },\n \"weird\": {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.model\"\n },\n \"Midjourney v8.2 高速\"\n ]\n },\n \"then\": null,\n \"else\": {\n \"$toFloat\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.weird\"\n },\n 0\n ]\n }\n }\n }\n },\n \"hd\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$ref\": \"request.model\"\n },\n [\n \"mj-v8.2\",\n \"mj-v8.1\"\n ]\n ]\n },\n \"then\": {\n \"$toBool\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.kacang-midjourney.hd\"\n },\n false\n ]\n }\n },\n \"else\": null\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/tasks/{{taskId}}\"\n },\n \"response\": {\n \"taskIdPaths\": [\n \"data.task_id\",\n \"task_id\",\n \"id\"\n ],\n \"statusPaths\": [\n \"data.status\",\n \"status\"\n ],\n \"errorPaths\": [\n \"data.error_code\",\n \"error_code\",\n \"error.code\"\n ],\n \"messagePaths\": [\n \"data.error_message\",\n \"error_message\",\n \"error.message\",\n \"message\"\n ],\n \"resultEphemeral\": true,\n \"usage\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.usage\"\n },\n {\n \"$ref\": \"response.usage\"\n }\n ]\n },\n \"images\": {\n \"$concatArrays\": [\n {\n \"$map\": {\n \"from\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.image_urls\"\n },\n {\n \"$ref\": \"response.result.data.image_urls\"\n }\n ]\n },\n \"as\": \"url\",\n \"in\": {\n \"url\": {\n \"$ref\": \"url\"\n }\n }\n }\n },\n {\n \"$if\": {\n \"condition\": {\n \"$and\": [\n {\n \"$gt\": [\n {\n \"$len\": {\n \"$toString\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.grid_image_url\"\n },\n {\n \"$ref\": \"response.result.data.grid_image_url\"\n }\n ]\n }\n }\n },\n 0\n ]\n },\n {\n \"$not\": {\n \"$in\": [\n {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.grid_image_url\"\n },\n {\n \"$ref\": \"response.result.data.grid_image_url\"\n }\n ]\n },\n {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.image_urls\"\n },\n {\n \"$ref\": \"response.result.data.image_urls\"\n }\n ]\n }\n ]\n }\n }\n ]\n },\n \"then\": [\n {\n \"url\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.data.result.data.grid_image_url\"\n },\n {\n \"$ref\": \"response.result.data.grid_image_url\"\n }\n ]\n }\n }\n ],\n \"else\": []\n }\n }\n ]\n }\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" } diff --git a/plugin-packages/openai-images.yingce-plugin b/plugin-packages/openai-images.yingce-plugin index d3be7ec8f..76138f045 100644 Binary files a/plugin-packages/openai-images.yingce-plugin and b/plugin-packages/openai-images.yingce-plugin differ diff --git a/plugin-packages/openai-images/docs/interface.md b/plugin-packages/openai-images/docs/interface.md index 2cce8744a..ca9633c8c 100644 --- a/plugin-packages/openai-images/docs/interface.md +++ b/plugin-packages/openai-images/docs/interface.md @@ -83,6 +83,10 @@ 无参考图走 JSON generations;有参考图或蒙版走 JSON edits,并按官方 images 数组传入多张 image_url。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。 +## 动态图片参数 + +`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。 + ## Manifest 完整接口定义 @@ -93,7 +97,7 @@ "apiVersion": "yingce.plugin/v2", "id": "openai-images", "name": "OpenAI Images", - "version": "2.0.0", + "version": "2.1.0", "author": "OpenAI / 影策", "description": "OpenAI Images 独立请求协议插件。", "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>", @@ -127,11 +131,77 @@ "agent" ], "baseUrl": "https://api.openai.com", - "requiresPublicMediaUrls": true, + "requiresPublicMediaUrls": false, "auth": { "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅 / 尺寸", + "type": "string", + "binding": "aspectRatio", + "encoding": "pixels", + "pricing": true + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "enabled": false, + "pricing": true + }, + { + "name": "quality", + "label": "图片质量", + "type": "string", + "binding": "quality", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 100, + "step": 1, + "default": 1 + }, + { + "name": "transparentBackground", + "label": "透明背景", + "type": "boolean", + "binding": "transparentBackground", + "default": false, + "enabled": false + }, + { + "name": "outputFormat", + "label": "输出格式", + "type": "string", + "binding": "providerOptions.openai-image.output_format", + "values": [ + "png", + "jpeg", + "webp" + ], + "default": "png", + "enabled": false + }, + { + "name": "outputCompression", + "label": "压缩质量", + "type": "integer", + "binding": "providerOptions.openai-image.output_compression", + "min": 0, + "max": 100, + "step": 1, + "enabled": false + } + ], "parameters": [ { "name": "model", @@ -365,81 +435,31 @@ } } }, + "resolution": { + "$omitEmpty": { + "$ref": "request.resolution" + } + }, "quality": { "$omitEmpty": { - "$switch": { - "cases": [ - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "1k" - ] - ] + "$if": { + "condition": { + "$in": [ + { + "$lower": { + "$trim": { + "$ref": "request.quality" + } + } }, - "then": "low" - }, - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "2k" - ] - ] - }, - "then": "medium" - }, - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "4k" - ] - ] - }, - "then": "high" - }, - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "", - "auto" - ] - ] - }, - "then": null - } - ], - "default": { + [ + "", + "auto" + ] + ] + }, + "then": null, + "else": { "$ref": "request.quality" } } diff --git a/plugin-packages/openai-images/manifest.json b/plugin-packages/openai-images/manifest.json index 16d42fe95..90d95123f 100644 --- a/plugin-packages/openai-images/manifest.json +++ b/plugin-packages/openai-images/manifest.json @@ -2,10 +2,10 @@ "apiVersion": "yingce.plugin/v2", "id": "openai-images", "name": "OpenAI Images", - "version": "2.0.0", + "version": "2.1.0", "author": "OpenAI / 影策", "description": "OpenAI Images 独立请求协议插件。", - "documentation": "# OpenAI Images\n\n该目录是独立官方协议插件源码。后端从生成的 `openai-images.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# OpenAI Images 接口字段\n\n## 协议身份\n\n- 插件 ID:`openai-images`。\n- Provider ID:`openai-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.openai.com`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.images` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$ref\":\"request.images\"}},0]},\"then\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$ne\":[{\"$ref\":\"media.role\"},\"mask\"]}}},\"as\":\"media\",\"in\":{\"image_url\":{\"$ref\":\"media.value\"}}}},\"else\":null}}}` |\n| `create.body.mask` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$filter\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}}},0]},\"then\":{\"image_url\":{\"$first\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}},\"else\":null}}}` |\n| `create.body.n` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$ref\":\"request.imageCount\"},0]},\"then\":{\"$ref\":\"request.imageCount\"},\"else\":1}}}` |\n| `create.body.size` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"\",\"auto\"]]},\"then\":null,\"else\":{\"$ref\":\"request.aspectRatio\"}}}}` |\n| `create.body.quality` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"1k\"]]},\"then\":\"low\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"2k\"]]},\"then\":\"medium\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"4k\"]]},\"then\":\"high\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"\",\"auto\"]]},\"then\":null}],\"default\":{\"$ref\":\"request.quality\"}}}}` |\n| `create.body.background` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.background\"},{\"$if\":{\"condition\":{\"$eq\":[{\"$ref\":\"request.extra.transparentBackground\"},\"true\"]},\"then\":\"transparent\",\"else\":null}}]}}` |\n| `create.body.output_format` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.output_format\"},\"png\"]}}` |\n| `create.body.output_compression` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.output_compression\"}}` |\n| `create.body.moderation` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.moderation\"}}` |\n| `create.body.response_format` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.response_format\"}}` |\n| `create.body.style` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.style\"}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.user\"}}` |\n\n## Provider 扩展键\n\n- `providerOptions.openai-image.background`\n- `providerOptions.openai-image.moderation`\n- `providerOptions.openai-image.output_compression`\n- `providerOptions.openai-image.output_format`\n- `providerOptions.openai-image.response_format`\n- `providerOptions.openai-image.style`\n- `providerOptions.openai-image.user`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.usage` | `{\"$ref\":\"response.usage\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n无参考图走 JSON generations;有参考图或蒙版走 JSON edits,并按官方 images 数组传入多张 image_url。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"openai-images\",\n \"name\": \"OpenAI Images\",\n \"version\": \"2.0.0\",\n \"author\": \"OpenAI / 影策\",\n \"description\": \"OpenAI Images 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"openai-image\",\n \"label\": \"OpenAI Images\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.openai.com\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"images\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"image_url\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"mask\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"image_url\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"n\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n }\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n }\n },\n \"quality\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"1k\"\n ]\n ]\n },\n \"then\": \"low\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"2k\"\n ]\n ]\n },\n \"then\": \"medium\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"4k\"\n ]\n ]\n },\n \"then\": \"high\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null\n }\n ],\n \"default\": {\n \"$ref\": \"request.quality\"\n }\n }\n }\n },\n \"background\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.background\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.extra.transparentBackground\"\n },\n \"true\"\n ]\n },\n \"then\": \"transparent\",\n \"else\": null\n }\n }\n ]\n }\n },\n \"output_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.output_format\"\n },\n \"png\"\n ]\n }\n },\n \"output_compression\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.output_compression\"\n }\n },\n \"moderation\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.moderation\"\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.response_format\"\n }\n },\n \"style\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.style\"\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", + "documentation": "# OpenAI Images\n\n该目录是独立官方协议插件源码。后端从生成的 `openai-images.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# OpenAI Images 接口字段\n\n## 协议身份\n\n- 插件 ID:`openai-images`。\n- Provider ID:`openai-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.openai.com`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.images` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$ref\":\"request.images\"}},0]},\"then\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$ne\":[{\"$ref\":\"media.role\"},\"mask\"]}}},\"as\":\"media\",\"in\":{\"image_url\":{\"$ref\":\"media.value\"}}}},\"else\":null}}}` |\n| `create.body.mask` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$filter\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}}},0]},\"then\":{\"image_url\":{\"$first\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}},\"else\":null}}}` |\n| `create.body.n` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$ref\":\"request.imageCount\"},0]},\"then\":{\"$ref\":\"request.imageCount\"},\"else\":1}}}` |\n| `create.body.size` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"\",\"auto\"]]},\"then\":null,\"else\":{\"$ref\":\"request.aspectRatio\"}}}}` |\n| `create.body.quality` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"1k\"]]},\"then\":\"low\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"2k\"]]},\"then\":\"medium\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"4k\"]]},\"then\":\"high\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"\",\"auto\"]]},\"then\":null}],\"default\":{\"$ref\":\"request.quality\"}}}}` |\n| `create.body.background` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.background\"},{\"$if\":{\"condition\":{\"$eq\":[{\"$ref\":\"request.extra.transparentBackground\"},\"true\"]},\"then\":\"transparent\",\"else\":null}}]}}` |\n| `create.body.output_format` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.output_format\"},\"png\"]}}` |\n| `create.body.output_compression` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.output_compression\"}}` |\n| `create.body.moderation` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.moderation\"}}` |\n| `create.body.response_format` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.response_format\"}}` |\n| `create.body.style` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.style\"}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.user\"}}` |\n\n## Provider 扩展键\n\n- `providerOptions.openai-image.background`\n- `providerOptions.openai-image.moderation`\n- `providerOptions.openai-image.output_compression`\n- `providerOptions.openai-image.output_format`\n- `providerOptions.openai-image.response_format`\n- `providerOptions.openai-image.style`\n- `providerOptions.openai-image.user`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.usage` | `{\"$ref\":\"response.usage\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n无参考图走 JSON generations;有参考图或蒙版走 JSON edits,并按官方 images 数组传入多张 image_url。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。\n\n## 动态图片参数\n\n`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"openai-images\",\n \"name\": \"OpenAI Images\",\n \"version\": \"2.1.0\",\n \"author\": \"OpenAI / 影策\",\n \"description\": \"OpenAI Images 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"openai-image\",\n \"label\": \"OpenAI Images\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.openai.com\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅 / 尺寸\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\",\n \"encoding\": \"pixels\",\n \"pricing\": true\n },\n {\n \"name\": \"imageResolution\",\n \"label\": \"分辨率\",\n \"type\": \"string\",\n \"binding\": \"resolution\",\n \"enabled\": false,\n \"pricing\": true\n },\n {\n \"name\": \"quality\",\n \"label\": \"图片质量\",\n \"type\": \"string\",\n \"binding\": \"quality\",\n \"pricing\": true\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 100,\n \"step\": 1,\n \"default\": 1\n },\n {\n \"name\": \"transparentBackground\",\n \"label\": \"透明背景\",\n \"type\": \"boolean\",\n \"binding\": \"transparentBackground\",\n \"default\": false,\n \"enabled\": false\n },\n {\n \"name\": \"outputFormat\",\n \"label\": \"输出格式\",\n \"type\": \"string\",\n \"binding\": \"providerOptions.openai-image.output_format\",\n \"values\": [\n \"png\",\n \"jpeg\",\n \"webp\"\n ],\n \"default\": \"png\",\n \"enabled\": false\n },\n {\n \"name\": \"outputCompression\",\n \"label\": \"压缩质量\",\n \"type\": \"integer\",\n \"binding\": \"providerOptions.openai-image.output_compression\",\n \"min\": 0,\n \"max\": 100,\n \"step\": 1,\n \"enabled\": false\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"images\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"image_url\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"mask\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"image_url\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"n\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n }\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n }\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.resolution\"\n }\n },\n \"quality\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.quality\"\n }\n }\n }\n },\n \"background\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.background\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.extra.transparentBackground\"\n },\n \"true\"\n ]\n },\n \"then\": \"transparent\",\n \"else\": null\n }\n }\n ]\n }\n },\n \"output_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.output_format\"\n },\n \"png\"\n ]\n }\n },\n \"output_compression\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.output_compression\"\n }\n },\n \"moderation\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.moderation\"\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.response_format\"\n }\n },\n \"style\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.style\"\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", "permissions": [ "generation.run", "media.read" @@ -36,11 +36,77 @@ "agent" ], "baseUrl": "https://api.openai.com", - "requiresPublicMediaUrls": true, + "requiresPublicMediaUrls": false, "auth": { "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅 / 尺寸", + "type": "string", + "binding": "aspectRatio", + "encoding": "pixels", + "pricing": true + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "enabled": false, + "pricing": true + }, + { + "name": "quality", + "label": "图片质量", + "type": "string", + "binding": "quality", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 100, + "step": 1, + "default": 1 + }, + { + "name": "transparentBackground", + "label": "透明背景", + "type": "boolean", + "binding": "transparentBackground", + "default": false, + "enabled": false + }, + { + "name": "outputFormat", + "label": "输出格式", + "type": "string", + "binding": "providerOptions.openai-image.output_format", + "values": [ + "png", + "jpeg", + "webp" + ], + "default": "png", + "enabled": false + }, + { + "name": "outputCompression", + "label": "压缩质量", + "type": "integer", + "binding": "providerOptions.openai-image.output_compression", + "min": 0, + "max": 100, + "step": 1, + "enabled": false + } + ], "parameters": [ { "name": "model", @@ -274,81 +340,31 @@ } } }, + "resolution": { + "$omitEmpty": { + "$ref": "request.resolution" + } + }, "quality": { "$omitEmpty": { - "$switch": { - "cases": [ - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "1k" - ] - ] + "$if": { + "condition": { + "$in": [ + { + "$lower": { + "$trim": { + "$ref": "request.quality" + } + } }, - "then": "low" - }, - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "2k" - ] - ] - }, - "then": "medium" - }, - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "4k" - ] - ] - }, - "then": "high" - }, - { - "when": { - "$in": [ - { - "$lower": { - "$trim": { - "$ref": "request.quality" - } - } - }, - [ - "", - "auto" - ] - ] - }, - "then": null - } - ], - "default": { + [ + "", + "auto" + ] + ] + }, + "then": null, + "else": { "$ref": "request.quality" } } diff --git a/plugin-packages/xai-grok-images.yingce-plugin b/plugin-packages/xai-grok-images.yingce-plugin index 44c3268f1..787d879ad 100644 Binary files a/plugin-packages/xai-grok-images.yingce-plugin and b/plugin-packages/xai-grok-images.yingce-plugin differ diff --git a/plugin-packages/xai-grok-images/docs/interface.md b/plugin-packages/xai-grok-images/docs/interface.md index 0f2f641d7..dd8cdf8bd 100644 --- a/plugin-packages/xai-grok-images/docs/interface.md +++ b/plugin-packages/xai-grok-images/docs/interface.md @@ -71,6 +71,10 @@ 有参考图时改走 /v1/images/edits;resolution 只接受 1k/2k;像素尺寸会换算为 aspect_ratio。 +## 动态图片参数 + +`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。 + ## Manifest 完整接口定义 @@ -81,7 +85,7 @@ "apiVersion": "yingce.plugin/v2", "id": "xai-grok-images", "name": "xAI Grok Images", - "version": "2.0.0", + "version": "2.1.0", "author": "xAI / 影策", "description": "xAI Grok Images 独立请求协议插件。", "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>", @@ -120,6 +124,49 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "encoding": "ratio", + "values": [ + "1:1", + "16:9", + "9:16", + "4:3", + "3:4", + "3:2", + "2:3", + "2:1", + "1:2" + ], + "default": "1:1" + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "values": [ + "1k", + "2k" + ], + "default": "1k", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 10, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", diff --git a/plugin-packages/xai-grok-images/manifest.json b/plugin-packages/xai-grok-images/manifest.json index 1755ff667..82c2f29d3 100644 --- a/plugin-packages/xai-grok-images/manifest.json +++ b/plugin-packages/xai-grok-images/manifest.json @@ -2,10 +2,10 @@ "apiVersion": "yingce.plugin/v2", "id": "xai-grok-images", "name": "xAI Grok Images", - "version": "2.0.0", + "version": "2.1.0", "author": "xAI / 影策", "description": "xAI Grok Images 独立请求协议插件。", - "documentation": "# xAI Grok Images\n\n该目录是独立官方协议插件源码。后端从生成的 `xai-grok-images.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# xAI Grok Images 接口字段\n\n## 协议身份\n\n- 插件 ID:`xai-grok-images`。\n- Provider ID:`grok-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.x.ai`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.image` | `{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$ref\":\"request.images\"}},0]},\"then\":{\"url\":{\"$first\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$in\":[{\"$ref\":\"media.role\"},[\"edit_source\",\"reference_image\",\"\"]]}}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}},\"else\":null}}` |\n| `create.body.n` | `{\"$if\":{\"condition\":{\"$gt\":[{\"$ref\":\"request.imageCount\"},0]},\"then\":{\"$ref\":\"request.imageCount\"},\"else\":1}}` |\n| `create.body.response_format` | `{\"$coalesce\":[{\"$ref\":\"request.providerOptions.grok-image.response_format\"},\"url\"]}` |\n| `create.body.aspect_ratio` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"\",\"auto\"]]},\"then\":null},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"1:1\",\"3:4\",\"4:3\",\"9:16\",\"16:9\",\"2:3\",\"3:2\",\"9:19.5\",\"19.5:9\",\"1:2\",\"2:1\"]]},\"then\":{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}}},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$eq\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]}]},\"then\":\"1:1\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.7]},{\"$lte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.8]}]},\"then\":\"16:9\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.5555555555555556]},{\"$lte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.5882352941176471]}]},\"then\":\"9:16\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.2]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.4]}]},\"then\":\"4:3\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.7]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.85]}]},\"then\":\"3:4\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.6]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.72]}]},\"then\":\"2:3\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.35]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.6]}]},\"then\":\"3:2\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.45]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.55]}]},\"then\":\"1:2\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.85]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},2.2]}]},\"then\":\"2:1\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]}]},\"then\":\"16:9\"},{\"when\":{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},\"then\":\"9:16\"}],\"default\":null}}}` |\n| `create.body.resolution` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$coalesce\":[{\"$ref\":\"request.resolution\"},{\"$ref\":\"request.quality\"}]}}},[\"1k\",\"low\",\"standard\"]]},\"then\":\"1k\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$coalesce\":[{\"$ref\":\"request.resolution\"},{\"$ref\":\"request.quality\"}]}}},[\"2k\",\"medium\",\"hd\",\"high\",\"4k\"]]},\"then\":\"2k\"}],\"default\":null}}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.grok-image.user\"}}` |\n\n## Provider 扩展键\n\n- `providerOptions.grok-image.response_format`\n- `providerOptions.grok-image.user`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n有参考图时改走 /v1/images/edits;resolution 只接受 1k/2k;像素尺寸会换算为 aspect_ratio。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"xai-grok-images\",\n \"name\": \"xAI Grok Images\",\n \"version\": \"2.0.0\",\n \"author\": \"xAI / 影策\",\n \"description\": \"xAI Grok Images 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"grok-image\",\n \"label\": \"xAI Grok Images\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.x.ai\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"image\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"url\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$in\": [\n {\n \"$ref\": \"media.role\"\n },\n [\n \"edit_source\",\n \"reference_image\",\n \"\"\n ]\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n },\n \"else\": null\n }\n },\n \"n\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n },\n \"response_format\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.grok-image.response_format\"\n },\n \"url\"\n ]\n },\n \"aspect_ratio\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"1:1\",\n \"3:4\",\n \"4:3\",\n \"9:16\",\n \"16:9\",\n \"2:3\",\n \"3:2\",\n \"9:19.5\",\n \"19.5:9\",\n \"1:2\",\n \"2:1\"\n ]\n ]\n },\n \"then\": {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n }\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$eq\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n }\n ]\n },\n \"then\": \"1:1\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.7\n ]\n },\n {\n \"$lte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.8\n ]\n }\n ]\n },\n \"then\": \"16:9\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.5555555555555556\n ]\n },\n {\n \"$lte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.5882352941176471\n ]\n }\n ]\n },\n \"then\": \"9:16\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.2\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.4\n ]\n }\n ]\n },\n \"then\": \"4:3\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.7\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.85\n ]\n }\n ]\n },\n \"then\": \"3:4\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.6\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.72\n ]\n }\n ]\n },\n \"then\": \"2:3\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.35\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.6\n ]\n }\n ]\n },\n \"then\": \"3:2\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.45\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.55\n ]\n }\n ]\n },\n \"then\": \"1:2\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.85\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 2.2\n ]\n }\n ]\n },\n \"then\": \"2:1\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n }\n ]\n },\n \"then\": \"16:9\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n \"then\": \"9:16\"\n }\n ],\n \"default\": null\n }\n }\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.resolution\"\n },\n {\n \"$ref\": \"request.quality\"\n }\n ]\n }\n }\n },\n [\n \"1k\",\n \"low\",\n \"standard\"\n ]\n ]\n },\n \"then\": \"1k\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.resolution\"\n },\n {\n \"$ref\": \"request.quality\"\n }\n ]\n }\n }\n },\n [\n \"2k\",\n \"medium\",\n \"hd\",\n \"high\",\n \"4k\"\n ]\n ]\n },\n \"then\": \"2k\"\n }\n ],\n \"default\": null\n }\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.grok-image.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", + "documentation": "# xAI Grok Images\n\n该目录是独立官方协议插件源码。后端从生成的 `xai-grok-images.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# xAI Grok Images 接口字段\n\n## 协议身份\n\n- 插件 ID:`xai-grok-images`。\n- Provider ID:`grok-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.x.ai`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.image` | `{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$ref\":\"request.images\"}},0]},\"then\":{\"url\":{\"$first\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$in\":[{\"$ref\":\"media.role\"},[\"edit_source\",\"reference_image\",\"\"]]}}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}},\"else\":null}}` |\n| `create.body.n` | `{\"$if\":{\"condition\":{\"$gt\":[{\"$ref\":\"request.imageCount\"},0]},\"then\":{\"$ref\":\"request.imageCount\"},\"else\":1}}` |\n| `create.body.response_format` | `{\"$coalesce\":[{\"$ref\":\"request.providerOptions.grok-image.response_format\"},\"url\"]}` |\n| `create.body.aspect_ratio` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"\",\"auto\"]]},\"then\":null},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"1:1\",\"3:4\",\"4:3\",\"9:16\",\"16:9\",\"2:3\",\"3:2\",\"9:19.5\",\"19.5:9\",\"1:2\",\"2:1\"]]},\"then\":{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}}},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$eq\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]}]},\"then\":\"1:1\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.7]},{\"$lte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.8]}]},\"then\":\"16:9\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.5555555555555556]},{\"$lte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.5882352941176471]}]},\"then\":\"9:16\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.2]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.4]}]},\"then\":\"4:3\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.7]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.85]}]},\"then\":\"3:4\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gte\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.6]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.72]}]},\"then\":\"2:3\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.35]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.6]}]},\"then\":\"3:2\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.45]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},0.55]}]},\"then\":\"1:2\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},1.85]},{\"$lt\":[{\"$divide\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]},2.2]}]},\"then\":\"2:1\"},{\"when\":{\"$and\":[{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}}]}]},\"then\":\"16:9\"},{\"when\":{\"$and\":[{\"$eq\":[{\"$len\":{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]}},2]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},0]}},0]},{\"$gt\":[{\"$toFloat\":{\"$at\":[{\"$split\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},\"x\"]},1]}},0]}]},\"then\":\"9:16\"}],\"default\":null}}}` |\n| `create.body.resolution` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$coalesce\":[{\"$ref\":\"request.resolution\"},{\"$ref\":\"request.quality\"}]}}},[\"1k\",\"low\",\"standard\"]]},\"then\":\"1k\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$coalesce\":[{\"$ref\":\"request.resolution\"},{\"$ref\":\"request.quality\"}]}}},[\"2k\",\"medium\",\"hd\",\"high\",\"4k\"]]},\"then\":\"2k\"}],\"default\":null}}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.grok-image.user\"}}` |\n\n## Provider 扩展键\n\n- `providerOptions.grok-image.response_format`\n- `providerOptions.grok-image.user`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n有参考图时改走 /v1/images/edits;resolution 只接受 1k/2k;像素尺寸会换算为 aspect_ratio。\n\n## 动态图片参数\n\n`contributes.providers[].imageParameters` 是渠道模型、前台模型与生成界面共用的参数合同。`name` 为稳定身份,`type`、`values`、`min/max/step`、`default` 描述输入;`models` 限定适用上游模型,`enabled` 为初始启用状态。只有 `pricing: true` 的启用字段允许作为价格条件;其他参数仍参与生成。渠道可收窄范围,绑定与计价资格由插件拥有。`binding` 映射到统一请求或插件自己的 `providerOptions` 命名空间。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"xai-grok-images\",\n \"name\": \"xAI Grok Images\",\n \"version\": \"2.1.0\",\n \"author\": \"xAI / 影策\",\n \"description\": \"xAI Grok Images 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"grok-image\",\n \"label\": \"xAI Grok Images\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.x.ai\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"imageParameters\": [\n {\n \"name\": \"size\",\n \"label\": \"画幅比例\",\n \"type\": \"string\",\n \"binding\": \"aspectRatio\",\n \"encoding\": \"ratio\",\n \"values\": [\n \"1:1\",\n \"16:9\",\n \"9:16\",\n \"4:3\",\n \"3:4\",\n \"3:2\",\n \"2:3\",\n \"2:1\",\n \"1:2\"\n ],\n \"default\": \"1:1\"\n },\n {\n \"name\": \"imageResolution\",\n \"label\": \"分辨率\",\n \"type\": \"string\",\n \"binding\": \"resolution\",\n \"values\": [\n \"1k\",\n \"2k\"\n ],\n \"default\": \"1k\",\n \"pricing\": true\n },\n {\n \"name\": \"count\",\n \"label\": \"生成数量\",\n \"type\": \"integer\",\n \"binding\": \"imageCount\",\n \"min\": 1,\n \"max\": 10,\n \"step\": 1,\n \"default\": 1\n }\n ],\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"image\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"url\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$in\": [\n {\n \"$ref\": \"media.role\"\n },\n [\n \"edit_source\",\n \"reference_image\",\n \"\"\n ]\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n },\n \"else\": null\n }\n },\n \"n\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n },\n \"response_format\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.grok-image.response_format\"\n },\n \"url\"\n ]\n },\n \"aspect_ratio\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"1:1\",\n \"3:4\",\n \"4:3\",\n \"9:16\",\n \"16:9\",\n \"2:3\",\n \"3:2\",\n \"9:19.5\",\n \"19.5:9\",\n \"1:2\",\n \"2:1\"\n ]\n ]\n },\n \"then\": {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n }\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$eq\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n }\n ]\n },\n \"then\": \"1:1\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.7\n ]\n },\n {\n \"$lte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.8\n ]\n }\n ]\n },\n \"then\": \"16:9\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.5555555555555556\n ]\n },\n {\n \"$lte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.5882352941176471\n ]\n }\n ]\n },\n \"then\": \"9:16\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.2\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.4\n ]\n }\n ]\n },\n \"then\": \"4:3\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.7\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.85\n ]\n }\n ]\n },\n \"then\": \"3:4\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gte\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.6\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.72\n ]\n }\n ]\n },\n \"then\": \"2:3\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.35\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.6\n ]\n }\n ]\n },\n \"then\": \"3:2\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.45\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 0.55\n ]\n }\n ]\n },\n \"then\": \"1:2\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 1.85\n ]\n },\n {\n \"$lt\": [\n {\n \"$divide\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n },\n 2.2\n ]\n }\n ]\n },\n \"then\": \"2:1\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n }\n ]\n }\n ]\n },\n \"then\": \"16:9\"\n },\n {\n \"when\": {\n \"$and\": [\n {\n \"$eq\": [\n {\n \"$len\": {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n }\n },\n 2\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 0\n ]\n }\n },\n 0\n ]\n },\n {\n \"$gt\": [\n {\n \"$toFloat\": {\n \"$at\": [\n {\n \"$split\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n \"x\"\n ]\n },\n 1\n ]\n }\n },\n 0\n ]\n }\n ]\n },\n \"then\": \"9:16\"\n }\n ],\n \"default\": null\n }\n }\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.resolution\"\n },\n {\n \"$ref\": \"request.quality\"\n }\n ]\n }\n }\n },\n [\n \"1k\",\n \"low\",\n \"standard\"\n ]\n ]\n },\n \"then\": \"1k\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.resolution\"\n },\n {\n \"$ref\": \"request.quality\"\n }\n ]\n }\n }\n },\n [\n \"2k\",\n \"medium\",\n \"hd\",\n \"high\",\n \"4k\"\n ]\n ]\n },\n \"then\": \"2k\"\n }\n ],\n \"default\": null\n }\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.grok-image.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", "permissions": [ "generation.run", "media.read" @@ -41,6 +41,49 @@ "type": "bearer", "field": "apiKey" }, + "imageParameters": [ + { + "name": "size", + "label": "画幅比例", + "type": "string", + "binding": "aspectRatio", + "encoding": "ratio", + "values": [ + "1:1", + "16:9", + "9:16", + "4:3", + "3:4", + "3:2", + "2:3", + "2:1", + "1:2" + ], + "default": "1:1" + }, + { + "name": "imageResolution", + "label": "分辨率", + "type": "string", + "binding": "resolution", + "values": [ + "1k", + "2k" + ], + "default": "1k", + "pricing": true + }, + { + "name": "count", + "label": "生成数量", + "type": "integer", + "binding": "imageCount", + "min": 1, + "max": 10, + "step": 1, + "default": 1 + } + ], "parameters": [ { "name": "model", diff --git a/web/src/components/canvas/batch-generation-settings-dialog.tsx b/web/src/components/canvas/batch-generation-settings-dialog.tsx index 57d50c467..800e834fd 100644 --- a/web/src/components/canvas/batch-generation-settings-dialog.tsx +++ b/web/src/components/canvas/batch-generation-settings-dialog.tsx @@ -12,7 +12,7 @@ import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; -export type BatchGenerationSettings = Pick & { cameraControl?: CameraControlOptions }; +export type BatchGenerationSettings = Pick & { cameraControl?: CameraControlOptions }; type BatchGenerationSettingsDialogProps = { open: boolean; @@ -35,7 +35,7 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre } }, [config, open]); - const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => { + const handleConfigChange = (key: "quality" | "imageResolution" | "imageParameters" | "size" | "transparentBackground" | "count", value: string) => { setGenerationConfig((current) => ({ ...current, [key]: value })); }; @@ -102,6 +102,8 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre onClick={() => onConfirm({ model: generationConfig.model, imageModel: generationConfig.imageModel, + imageResolution: generationConfig.imageResolution, + imageParameters: generationConfig.imageParameters, quality: generationConfig.quality, size: generationConfig.size, transparentBackground: generationConfig.transparentBackground, diff --git a/web/src/components/canvas/canvas-agent-image-approval-settings.tsx b/web/src/components/canvas/canvas-agent-image-approval-settings.tsx index 66cd1268a..a5fff0b70 100644 --- a/web/src/components/canvas/canvas-agent-image-approval-settings.tsx +++ b/web/src/components/canvas/canvas-agent-image-approval-settings.tsx @@ -6,7 +6,7 @@ import { agentApprovalModel, agentApprovalModelSelection, type AgentImageApprova import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import type { AgentMediaSettings } from "@/services/api/agent"; -import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store"; +import { PUBLIC_MODEL_CATALOG_ID, resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store"; export function CanvasAgentImageApprovalSettings({ initial, value, onChange, theme, disabled }: { initial: AgentImageApproval; @@ -29,12 +29,13 @@ export function CanvasAgentImageApprovalSettings({ initial, value, onChange, the if (disabled) return; const profile = modelCapabilityConfigFor(config, next).image; if (!profile) return; - const normalized = normalizeImageValue(profile, settings); - onChange({ ...agentApprovalModelSelection(config, next), size: normalized.size, quality: profile.quality.supported ? normalized.quality : "" }); + const normalized = normalizeImageValue(profile, settings, resolveModelRequestConfig(config, next).interfaceType); + onChange({ ...agentApprovalModelSelection(config, next), size: normalized.size, imageResolution: normalized.imageResolution, imageParameters: normalized.imageParameters, quality: profile.quality.supported ? normalized.quality : "" }); }; - const changeOption = (key: "quality" | "size" | "transparentBackground" | "count", next: string) => { - if (disabled || (key !== "size" && key !== "quality")) return; - const options = { size: settings.size, quality: settings.quality, [key]: next }; + const changeOption = (key: "quality" | "imageResolution" | "imageParameters" | "size" | "transparentBackground" | "count", next: string) => { + if (disabled || (key !== "size" && key !== "quality" && key !== "imageResolution" && key !== "imageParameters")) return; + if (key === "imageParameters") { onChange({ ...settings, imageParameters: next }); return; } + const options = { size: settings.size, imageResolution: settings.imageResolution, quality: settings.quality, [key]: next }; const selected = resolveCompatibleModel(config, model, { ...requirements, imageSize: options.size, options: { ...options, count: 1 } }); if (!selected) return; onChange({ ...agentApprovalModelSelection(config, selected), ...options }); @@ -44,8 +45,8 @@ export function CanvasAgentImageApprovalSettings({ initial, value, onChange, the
生成模型
- {model ? :

当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。

} -

本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}

+ {model ? :

当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。

} +

本次规格:{settings.size}{settings.imageResolution && settings.imageResolution !== "auto" ? ` · ${settings.imageResolution}` : ""}{settings.quality ? ` · ${settings.quality}` : ""}

修改仅用于本次生成,费用按最终模型和规格计算。

; } diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index aaa5d8522..0e71eb1c3 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -8,7 +8,7 @@ import { Switch } from "@/components/ui/base/switch"; import { configuredModelMatchesCapability, defaultConfig, modelOptionName, normalizeRunningHubCapability, resolveModelChannel, useEffectiveConfig, type AiConfig, type RunningHubCapability, type RunningHubWorkflow, type RunningHubWorkflowKind } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities"; +import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldIsRatioChoices, workflowFieldKey, workflowFieldNumberBounds, workflowFieldOptionValue, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities"; import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection"; import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import { canonicalGenerationMetadata, nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; @@ -363,11 +363,20 @@ export function WorkflowParameterControls({ fields, node, theme, onConfigChange, const fieldType = ["FLOAT", "INT", "INTEGER"].includes(rawFieldType) ? "NUMBER" : rawFieldType; const options = workflowFieldChoiceValues(field); const selectOptions = options.length ? options : fieldType === "SELECT" && value !== "" ? [value] : []; + const ratioOptions = workflowFieldIsRatioChoices(field, selectOptions); const bounds = workflowFieldNumberBounds(field); const numeric = fieldType === "NUMBER" || fieldType === "SLIDER" || typeof value === "number" || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined; const numericValue = value === "" || !Number.isFinite(Number(value)) ? undefined : Number(value); const valueError = randomEnabled ? "" : workflowFieldValueError(field, value); - const control = fieldType === "SELECT" || selectOptions.length ? ( + const control = ratioOptions ? ( +
+ {selectOptions.map((option) => { + const optionValue = workflowFieldOptionValue(option); + const selected = optionValue === workflowFieldOptionValue(value); + return ; + })} +
+ ) : fieldType === "SELECT" || selectOptions.length ? ( ({ value: String(v), label: typeof v === "boolean" ? (v ? "开启" : "关闭") : String(v) }))} + onChange={(next) => onChange(field.values!.find((v) => String(v) === next))} + /> + ); + } + if (field.type === "boolean") return ; + if (field.type === "integer" || field.type === "number") + return ( + { + editing.current = true; + }} + onBlur={() => { + editing.current = false; + }} + min={field.min} + max={field.max} + step={field.step ?? (field.type === "integer" ? 1 : undefined)} + precision={field.type === "integer" ? 0 : undefined} + placeholder="跟随模型默认" + onChange={(next) => changeDraft(next ?? undefined)} + /> + ); + return ( + { + editing.current = true; + }} + onBlur={() => { + editing.current = false; + }} + onChange={(event) => changeDraft(event.target.value || undefined)} + /> + ); +} + +export function ImageParameterFields({ + profile, + config, + onChange, + showCount = true, + showSize = true, + showQuality = true, + showTransparent = true, +}: { + profile: ImageCapabilityConfig; + config: AiConfig; + onChange: (key: "size" | "quality" | "imageResolution" | "transparentBackground" | "count" | "imageParameters", value: string) => void; + showCount?: boolean; + showSize?: boolean; + showQuality?: boolean; + showTransparent?: boolean; +}) { + const values = imageParameterRequest(profile, config) || {}; + const sizeField = profile.parameters?.find((p) => p.name === "size"); + const usesSizePresets = profile.resolution.supported || profile.size.parameter === "size" || sizeField?.encoding === "pixels" || profile.parameters?.some((p) => p.name === "imageResolution"); + const groupedSize = showSize && profile.size.parameter !== "none" && usesSizePresets && imageSizePresets(profile).length > 0; + const fields = (profile.parameters || []).filter( + (p) => + p.enabled !== false && + (!groupedSize || (p.name !== "size" && p.name !== "imageResolution")) && + (p.name !== "count" || (showCount && (p.max ?? 1) > 1)) && + (p.name !== "size" || showSize) && + (p.name !== "quality" || showQuality) && + (p.name !== "transparentBackground" || showTransparent), + ); + return ( +
event.stopPropagation()}> + {groupedSize ? ( + { + onChange("size", size); + if (resolution !== undefined) onChange("imageResolution", resolution); + }} + /> + ) : null} + {fields.map((field) => ( +
+
{field.label}
+ { + if (imageHostControls.has(field.name)) { + onChange(field.name as "size" | "quality" | "imageResolution" | "transparentBackground" | "count", next === undefined ? "" : String(next)); + return; + } + const parameters = parseImageParameters(config.imageParameters); + if (next === undefined) delete parameters[field.name]; + else parameters[field.name] = next; + onChange("imageParameters", JSON.stringify(parameters)); + }} + /> + {field.description ?

{field.description}

: null} +
+ ))} +
+ ); +} + +export function ImageParameterSchemaEditor({ definitions, parameters, disabled, onChange }: { definitions: ImageParameter[]; parameters: ImageParameter[]; disabled?: boolean; onChange: (parameters: ImageParameter[]) => void }) { + const update = (name: string, patch: Partial) => onChange(parameters.map((p) => (p.name === name ? { ...p, ...patch } : p))); + if (!parameters.length) return null; + return ( +
+

选择此模型支持的参数和范围。生成界面同步显示启用项,标记“可计价”的参数可用于规格价格。

+ {parameters.map((field) => { + const definition = definitions.find((p) => p.name === field.name) || field; + return ( +
+
+
+ {field.label} + {definition.pricing ? 可计价 : null} +
+ update(field.name, { enabled })} /> +
+ {field.description ?

{field.description}

: null} + {field.enabled !== false ? ( +
+ {definition.values?.length || field.type === "string" ? ( +
+ ) : null} + {error ?

{error}

: null} +
+ 默认输出 +
+ {profile.size.values.map((value) => ( + + ))} +
+
+ + ) + ) : null} +
+ ); +} + +export function ImagePixelMappingEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) { + const sizeField = profile.parameters?.find((p) => p.name === "size" && p.enabled !== false && p.encoding === "pixels"); + if (!sizeField || !profile.size.values.some((v) => v.includes(":"))) return null; + const tiers = profile.resolution.supported ? profile.resolution.values : [""]; + const presets = profile.size.presets || []; + const rows = tiers.flatMap((tier) => profile.size.values.filter((value) => value.includes(":")).map((ratio) => presets.find((p) => p.tier === tier && p.ratio === ratio) || { tier, ratio, width: 0, height: 0, size: "" })); + const update = (index: number, key: "width" | "height", value: number | null) => { + onChange({ + ...profile.size, + presets: rows.map((p, i) => { + if (i !== index) return p; + const next = { ...p, [key]: value ?? 0 }; + return { ...next, size: `${next.width}x${next.height}` }; + }), + }); + }; + return ( +
+
+

比例对应的像素尺寸

+

此接口接收像素。按已启用的分辨率和比例填写尺寸,保存后用于请求换算。

+
+
+ {rows.map((preset, index) => ( +
+ + {preset.tier ? `${preset.tier.toUpperCase()} · ` : ""} + {preset.ratio} + + update(index, "width", v)} /> + update(index, "height", v)} /> +
+ ))} +
+

尺寸以供应商支持范围为准,也可将画幅支持值直接配置为像素尺寸。

+
+ ); +} diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index d68739288..828e3c9d8 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -1,5 +1,6 @@ import { ImageSizePicker } from "./image-size-picker"; -import { imageResolutionUsesQuality } from "@/lib/image-size-presets"; +import { ImageParameterFields } from "./image-parameter-fields"; +import { imageResolutionUsesQuality, imageSizePresets } from "@/lib/image-size-presets"; import { type ReactNode } from "react"; import { ConfigProvider } from "antd"; import { Switch } from "@/components/ui/base/switch"; @@ -36,7 +37,7 @@ const aspectOptions: AspectOption[] = [ type ImageSettingsPanelProps = { config: AiConfig; - onConfigChange: (key: "quality" | "size" | "transparentBackground" | "count", value: string) => void; + onConfigChange: (key: "quality" | "imageResolution" | "size" | "transparentBackground" | "count" | "imageParameters", value: string) => void; theme: CanvasTheme; showTitle?: boolean; showQuality?: boolean; @@ -60,6 +61,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = const activeSize = normalized.size; const activeQualityOptions = profile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) })); const priceTiers = imageModelPriceTiers(config); + if (profile.parameters) return
{showTitle ?
图像设置
: null}
; return ( diff --git a/web/src/components/image-size-picker.css b/web/src/components/image-size-picker.css index d2c66edf3..0e0b5d380 100644 --- a/web/src/components/image-size-picker.css +++ b/web/src/components/image-size-picker.css @@ -109,6 +109,9 @@ .image-size-presets-editor { container: image-presets / inline-size; } +.image-size-preset-group.is-ungrouped { + grid-template-columns: minmax(0, 1fr); +} .image-size-editor-intro { margin: 0 0 2px; color: var(--muted-foreground); @@ -159,8 +162,8 @@ } .image-size-preset-options { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 84px), 1fr)); - gap: 6px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 168px), 1fr)); + gap: 12px; } .image-size-preset-option-wrap { display: grid; @@ -171,9 +174,9 @@ display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - gap: 3px; + gap: 6px; color: var(--muted-foreground); - font-size: var(--fs-tiny); + font-size: var(--fs-label); font-variant-numeric: tabular-nums; } .image-size-preset-dimensions label { @@ -187,10 +190,15 @@ .image-size-preset-dimensions .ant-input-number { width: 100%; min-width: 0; + padding-inline: 0; } .image-size-preset-dimensions .ant-input-number-input { - padding-inline: 5px; - font-size: var(--fs-tiny); + padding-inline: 6px; + color: var(--foreground); + font-size: var(--fs-body); + font-weight: 500; + font-variant-numeric: tabular-nums; + text-align: center; } .image-size-preset-option, .image-size-default-options button { @@ -220,7 +228,7 @@ flex: 0 0 auto; } .image-size-preset-option small { - color: var(--muted-foreground); + color: var(--foreground); font-size: var(--fs-label); font-variant-numeric: tabular-nums; white-space: nowrap; diff --git a/web/src/components/image-size-picker.tsx b/web/src/components/image-size-picker.tsx index 94b871c8f..ce89b905c 100644 --- a/web/src/components/image-size-picker.tsx +++ b/web/src/components/image-size-picker.tsx @@ -7,8 +7,23 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation"; -export function ImageSizePicker({ profile, size, quality, onChange }: { profile: ImageCapabilityConfig; size: string; quality?: string; onChange: (size: string, quality?: string) => void }) { - const IMAGE_RESOLUTIONS = imageResolutionTiers(profile); +export function ImageSizePicker({ + profile, + size, + quality, + imageResolution, + canSelectResolution, + onChange, +}: { + profile: ImageCapabilityConfig; + size: string; + quality?: string; + imageResolution?: string; + canSelectResolution?: (resolution: string) => boolean; + onChange: (size: string, quality?: string, resolution?: string) => void; +}) { + const ungrouped = Boolean(profile.parameters && !profile.resolution.supported); + const IMAGE_RESOLUTIONS = ungrouped ? [...new Set(imageSizePresets(profile).map((p) => p.tier))] : imageResolutionTiers(profile); const presets = imageSizePresets(profile).filter((preset) => imageTierAvailable(profile, preset.tier)); const visibleTiers = IMAGE_RESOLUTIONS.filter((value) => presets.some((preset) => preset.tier === value)); const [chosenTier, setChosenTier] = useState("1k"); @@ -16,9 +31,12 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile: const [custom, setCustom] = useState(""); const [error, setError] = useState(""); const qualityTier = IMAGE_RESOLUTIONS.find((value) => value === (quality || "").toLowerCase()); + const resolutionTier = IMAGE_RESOLUTIONS.find((value) => value === (imageResolution || "").toLowerCase()); + const selectedTier = profile.resolution.supported ? resolutionTier : qualityTier; const matches = (preset: ImageResolutionOption) => { if (imagePresetValue(profile, preset) !== size) return false; if (profile.size.parameter !== "aspect_ratio") return true; + if (profile.resolution.supported) return !resolutionTier || resolutionTier === preset.tier; if (imageResolutionUsesQuality(profile)) return imageQualityForTier(profile, preset.tier) === (quality || profile.quality.default); return !qualityTier || qualityTier === preset.tier; }; @@ -50,19 +68,26 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile: presets.find((preset) => preset.size === resolvedLegacySize) || (restoredCustom && imageTierAvailable(profile, restoredCustom.tier) ? restoredCustom : undefined); const available = (value: ImageResolutionTier) => imageTierAvailable(profile, value) && (profile.size.allowCustom || presets.some((preset) => preset.tier === value)); - const tier = - (active && visibleTiers.includes(active.tier) ? active.tier : undefined) || - (profile.size.parameter === "aspect_ratio" && imageResolutionUsesQuality(profile) ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) || - (qualityTier && visibleTiers.includes(qualityTier) ? qualityTier : undefined) || - (visibleTiers.includes(chosenTier) ? chosenTier : visibleTiers[0]) || - "1k"; + const tier = ungrouped + ? (IMAGE_RESOLUTIONS[0] ?? "") + : (active && visibleTiers.includes(active.tier) ? active.tier : undefined) || + (profile.size.parameter === "aspect_ratio" && imageResolutionUsesQuality(profile) ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) || + (selectedTier && visibleTiers.includes(selectedTier) ? selectedTier : undefined) || + (visibleTiers.includes(chosenTier) ? chosenTier : visibleTiers[0]) || + "1k"; const choices = presets.filter((preset) => preset.tier === tier); const select = (preset: ImageResolutionOption) => { if (!available(preset.tier)) return; + if (profile.resolution.supported && canSelectResolution && !canSelectResolution(preset.tier)) return; + const resolution = profile.resolution.values.find((value) => value.toLowerCase() === preset.tier); setChosenTier(preset.tier); setCustomPreset(preset); setError(""); - onChange(imagePresetValue(profile, preset), profile.size.parameter === "aspect_ratio" ? imageQualityForSelection(profile, preset.tier) : undefined); + onChange( + imagePresetValue(profile, preset), + profile.resolution.supported ? undefined : profile.size.parameter === "aspect_ratio" ? imageQualityForSelection(profile, preset.tier) : undefined, + profile.resolution.supported ? resolution || preset.tier : undefined, + ); }; const makeCustom = (next: ImageResolutionTier, ratio: string) => { const preset = imagePresetForRatio(next, ratio); @@ -116,10 +141,43 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile: setError(reason instanceof Error ? reason.message : "尺寸格式无效"); } }; - if (profile.size.parameter === "none") return null; + if (profile.size.parameter === "none") + return profile.resolution.supported ? ( +
+
分辨率
+
+ {profile.resolution.values.map((value) => ( + + ))} +
+
+ ) : null; return (
- {visibleTiers.length ? ( + {profile.resolution.supported ? ( + <> +
分辨率
+
+ {profile.resolution.values.map((value) => ( + + ))} +
+ + ) : !ungrouped && visibleTiers.length && (profile.size.parameter === "size" || !profile.quality.supported || imageResolutionUsesQuality(profile)) ? ( <>
分辨率
diff --git a/web/src/components/image-size-presets-editor.tsx b/web/src/components/image-size-presets-editor.tsx index 732efe139..a2877ae0c 100644 --- a/web/src/components/image-size-presets-editor.tsx +++ b/web/src/components/image-size-presets-editor.tsx @@ -4,16 +4,21 @@ import { Check, Plus } from "lucide-react"; import { Switch } from "@/components/ui/base/switch"; import "./image-size-picker.css"; import type { ImageCapabilityConfig } from "@/lib/model-capabilities"; -import { IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets"; +import { DEFAULT_IMAGE_RATIOS, IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets"; import { validateImageSize } from "@/services/api/image-validation"; import type { ImageResolutionTier } from "@/lib/image-resolution-tiers"; -export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) { - const IMAGE_RESOLUTIONS = imageResolutionTiers(profile); +export function ImageSizePresetsEditor({ profile, disabled, onChange, tiers, ratios: allowedRatios, allowCustomRatio = true, grouped = true }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void; tiers?: string[]; ratios?: string[]; allowCustomRatio?: boolean; grouped?: boolean }) { + const IMAGE_RESOLUTIONS = tiers ?? imageResolutionTiers(profile); const presets = imageSizePresets(profile); const id = useId(); const [drafts, setDrafts] = useState>>({}); const [errors, setErrors] = useState>>({}); + const candidate = (tier: string, ratio: string) => { + const existing = presets.find((preset) => preset.tier === tier && preset.ratio === ratio); + if (existing) return existing; + try { return imagePresetForRatio(tier, ratio); } catch { return { tier, ratio, width: 0, height: 0, size: "" }; } + }; const toggleTier = (tier: ImageResolutionTier, ratios: string[], enabled: boolean) => { if (disabled) return; update(tier, enabled ? ratios : []); @@ -24,8 +29,8 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil const next = [ ...new Map( ratios.map((ratio) => { - const normalized = imagePresetForRatio(tier, ratio); - const preset = presets.find((preset) => preset.tier === tier && preset.ratio === normalized.ratio) || normalized; + if (!/^\d+[::]\d+$/.test(ratio) || ratio.split(/[::]/).some((part) => Number(part) <= 0)) throw new Error("请输入正数比例,例如 16:9"); + const preset = candidate(tier, ratio); return [preset.ratio, preset] as const; }), ).values(), @@ -49,7 +54,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil const width = field === "width" ? value : selected.width; const height = field === "height" ? value : selected.height; try { - validateImageSize(width, height); + if (width > 0 && height > 0) validateImageSize(width, height, !profile.parameters); const next = presets.map((preset) => preset === selected ? { ...preset, width, height, size: `${width}x${height}` } : preset); onChange(imageSizeConfigWithPresets(profile, next)); setErrors((current) => ({ ...current, [tier]: "" })); @@ -69,34 +74,35 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil }; return (
-

点选支持的比例,再次点击可移除。尺寸自动换算,已有精确尺寸保持不变。

+

点选支持的比例,再次点击可移除。已有精确尺寸保持不变,有换算规则时自动填入。

{IMAGE_RESOLUTIONS.map((tier) => { const items = presets.filter((preset) => preset.tier === tier); - const ratios = [...new Set([...IMAGE_RATIOS, ...items.map((item) => item.ratio)])]; + const ratios = [...new Set([...(allowedRatios ?? IMAGE_RATIOS), ...items.map((item) => item.ratio)])]; return ( -
-
+
+ {grouped ?
{tier.toUpperCase()} {items.length ? `${items.length} 个比例` : "未启用"} - 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} /> + {!profile.parameters ? 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} /> : null}
-
+ {!items.length && DEFAULT_IMAGE_RATIOS.some((ratio) => ratios.includes(ratio)) ? : null} +
: null}
{imageResolutionUsesQuality(profile) && !imageTierAvailable(profile, tier) ?

需先配置此档位对应的质量值,用户端才会开放。

: null} -
+
{ratios.map((ratio) => { const selected = items.find((item) => item.ratio === ratio); - const dimensions = selected || imagePresetForRatio(tier, ratio); + const dimensions = selected || candidate(tier, ratio); return (
{selected ? (
) : null} @@ -124,7 +130,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil ); })}
-
+ {allowCustomRatio ?
} onClick={() => addRatio(tier)} aria-label={`添加 ${tier.toUpperCase()} 比例`}> 添加 -
+
: null} {errors[tier] ? (
{profile.size.values.map((value) => { const preset = presets.find((item) => (profile.size.parameter === "aspect_ratio" ? item.ratio === value : item.size === value)); - const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${preset.tier.toUpperCase()} · ${preset.ratio}` : value; + const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${grouped ? preset.tier.toUpperCase() + " · " : ""}${preset.ratio}` : value; return ( )}
: null} + const panel = props.mode === "image" && props.imageProfile.parameters ?
{ + const setters = { size: props.setRatio, quality: props.setQuality, imageResolution: props.setImageResolution, imageParameters: props.setImageParameters, count: props.setCount, transparentBackground: props.setTransparentBackground }; + setters[key](value); + }} />
:
+ {props.mode === "image" ? { props.setRatio(size); if (quality) props.setQuality(quality); if (resolution) props.setImageResolution(resolution); }} /> : videoRatioSupported ?
{ratios.map((value) => )}
: null} {props.mode === "image" && referenceImageSizeValue ? : null} {props.mode === "video" ? (videoResolutionSupported ?
{resolutions.map((option) => )}
: null) : <> diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index d0d0c8513..ed15e072d 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -7,6 +7,7 @@ import { useNavigate, useLocation } from "react-router"; import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; +import { imageParameterRequest, normalizeImageParameters } from "@/lib/image-parameters"; import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { getActiveUserScope } from "@/lib/user-scope"; import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation"; @@ -17,7 +18,7 @@ import type { BackendGenerationResult } from "@/services/api/generation-task"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; import { loadCreationConversations, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; -import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useAppearanceStore } from "@/stores/use-appearance-store"; @@ -116,6 +117,8 @@ export default function CreatePage() { const [ratio, setRatio] = useState("16:9"); const [seconds, setSeconds] = useState("6"); const [quality, setQuality] = useState("auto"); + const [imageResolution, setImageResolution] = useState("auto"); + const [imageParameters, setImageParameters] = useState("{}"); const [videoQuality, setVideoQuality] = useState(config.vquality || "720"); const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [textStreaming, setTextStreaming] = useState(() => readComposerPref(TEXT_STREAMING_PREF_KEY, true)); @@ -162,11 +165,11 @@ export default function CreatePage() { videoSeconds: mode === "video" ? seconds : undefined, imageSize: mode === "image" ? ratio : undefined, options: mode === "image" - ? { size: ratio, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" } + ? imageParameterRequest(modelCapabilityConfigFor(config, preferredModel).image!, { ...config, size: ratio, imageResolution, quality, count, imageParameters }) ?? { size: ratio, imageResolution, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" } : mode === "video" ? { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } : {}, - }), [attachments, config.transparentBackground, count, hasPrompt, mode, quality, ratio, seconds, videoQuality]); + }), [attachments, config, preferredModel, count, hasPrompt, imageResolution, imageParameters, mode, quality, ratio, seconds, videoQuality]); const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const generationConfig = useMemo(() => mode === "video" ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) @@ -234,10 +237,14 @@ export default function CreatePage() { const normalized = normalizeImageValue(imageProfile, { size: saved?.ratio || imageProfile.size.default, quality: saved?.quality || imageProfile.quality.default, + imageResolution: saved?.imageResolution || imageProfile.resolution.default, + imageParameters: saved?.model === selectedModel ? saved.imageParameters : undefined, count: saved?.count || count, }); setRatio(normalized.size); setQuality(normalized.quality); + setImageResolution(normalized.imageResolution || imageProfile.resolution.default); + setImageParameters(JSON.stringify(normalizeImageParameters(imageProfile.parameters, saved?.model === selectedModel ? saved.imageParameters : undefined))); setCount(normalized.count); }, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, imageProfile]); @@ -423,6 +430,10 @@ export default function CreatePage() { setQuality(value); if (mode === "image") rememberImageSettings({ quality: value }); }; + const setComposerImageResolution = (value: string) => { + setImageResolution(value); + if (mode === "image") rememberImageSettings({ imageResolution: value }); + }; const setComposerVideoQuality = (value: string) => { setVideoQuality(value); if (mode === "video") rememberVideoSettings({ videoQuality: value }); @@ -701,11 +712,17 @@ export default function CreatePage() { const controller = new AbortController(); const requestLifecycle = runtime.beginGenerationConsumer(controller.signal); abortRef.current = controller; - const normalizedImage = mode === "image" ? normalizeImageValue(imageProfile, { size: ratio, quality, count }) : undefined; + const normalizedImage = mode === "image" + ? imageProfile.parameters + ? { size: ratio, quality, imageResolution, imageParameters, count } + : normalizeImageValue(imageProfile, { size: ratio, quality, imageResolution, imageParameters, count }, resolveModelRequestConfig(config, selectedModel).interfaceType) + : undefined; const requestConfig = { ...generationConfig, model: selectedModel, imageModel: selectedModel, + imageResolution: normalizedImage?.imageResolution || imageResolution, + imageParameters: normalizedImage?.imageParameters || imageParameters, videoModel: selectedModel, textModel: selectedModel, ...(mode === "image" @@ -1037,6 +1054,11 @@ export default function CreatePage() { setSeconds: setComposerSeconds, quality, setQuality: setComposerQuality, + imageResolution, + setImageResolution: setComposerImageResolution, + imageParameters, + setImageParameters: (value: string) => { setImageParameters(value); rememberImageSettings({ model: selectedModel, imageParameters: value }); }, + setTransparentBackground: (value: string) => useConfigStore.getState().updateConfig("transparentBackground", value), videoQuality, setVideoQuality: setComposerVideoQuality, count, diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 513f71596..c5bcd1141 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -182,6 +182,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)} protocol={activeProtocol} workflows={modelProtocolDefinition(activeProtocol, availableProtocols)?.workflows} + imageParameters={modelProtocolDefinition(activeProtocol, availableProtocols)?.imageParameters} onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })} /> ) :

当前模型类型无需额外配置引用与参数。

} diff --git a/web/src/services/api/agent.ts b/web/src/services/api/agent.ts index f6f292ba1..55daa25b1 100644 --- a/web/src/services/api/agent.ts +++ b/web/src/services/api/agent.ts @@ -8,6 +8,8 @@ export type AgentMediaSettings = { channelId?: string; channelModelKey?: string; size: string; + imageResolution?: string; + imageParameters?: string; quality: string; durationSeconds?: number; videoGenerateAudio?: boolean; diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index 1a5678774..71ad7f3e3 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -1,4 +1,5 @@ import { normalizeAudioFormatForConfig, normalizeAudioVoiceForConfig } from "@/lib/audio-generation"; +import { imageParameterRequest } from "@/lib/image-parameters"; import { getMediaBlob } from "@/services/file-storage"; import { getImageBlob } from "@/services/image-storage"; import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; @@ -321,6 +322,10 @@ function generationMetadata(config: AiConfig, metadata?: Record const channel = resolveModelChannel(config, config.model); const model = modelOptionName(config.model); const modelCost = channel.modelCosts?.find((item) => item.model === model); + if (modelCost?.capability === "image" && modelCost.capabilityConfig?.image?.parameters) { + const { providerOptions: _providerOptions, ...rest } = metadata || {}; + return rest; + } const protocol = modelCost?.protocol || channel.interfaceType; if (!protocol) return metadata; const existing = metadata?.providerOptions && typeof metadata.providerOptions === "object" && !Array.isArray(metadata.providerOptions) @@ -404,7 +409,9 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM const workflow = resolveGenerationWorkflowExecution(config, mode); if (workflow) return workflowProviderConfig(config, requestConfig, workflow); const generationOptions = { + ...(mode === "image" ? { imageParameters: imageParameterRequest(modelCapabilityConfigFor(config, config.model).image!, config, true) } : {}), size: config.size, + imageResolution: omittedImageQuality(config.imageResolution), quality: omittedImageQuality(config.quality), transparentBackground: config.transparentBackground, count: config.count, @@ -487,6 +494,10 @@ function workflowPublicExecution(workflow: GenerationWorkflowExecution) { } function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode) { + if (mode === "image") { + const values = imageParameterRequest(modelCapabilityConfigFor(config, config.model).image!, config, true); + if (values) return values; + } const channel = resolveModelChannel(config, config.model); const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec; const candidates: Record = mode === "image" diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 7ab3978ea..03e3e6051 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -60,7 +60,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option const requestConfig = resolveModelRequestConfig(config, selectedModel); const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!; validateImageCapability(imageProfile, []); - const normalizedImage = normalizeImageValue(imageProfile, config); + const normalizedImage = normalizeImageValue(imageProfile, config, requestConfig.interfaceType); const n = Number(normalizedImage.count); if (requestConfig.interfaceType === "gemini-image") { try { @@ -73,7 +73,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option try { const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined; const aspectRatio = size?.includes(":") ? size : undefined; - const resolution = normalizeGrokImageResolution(normalizedImage.quality); + const resolution = normalizeGrokImageResolution(normalizedImage.imageResolution !== "auto" ? normalizedImage.imageResolution : normalizedImage.quality); const responseData = await postChannelJSON( requestConfig, aiApiUrl(requestConfig, "/images/generations"), @@ -94,7 +94,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option } } const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined; - const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size); + const requestSize = resolveImageRequestSize(imageProfile, imageProfile.resolution.supported ? normalizedImage.imageResolution : quality, normalizedImage.size); const isVolcengineArk = isVolcengineArkImageProtocol(requestConfig.interfaceType); const normalizedRequestSize = requestSize?.parameter === "size" && isVolcengineArk ? { ...requestSize, value: normalizeVolcengineArkImageSize(requestSize.value)! } : requestSize; try { @@ -136,7 +136,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: const requestConfig = resolveModelRequestConfig(config, selectedModel); const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!; validateImageCapability(imageProfile, references, mask); - const normalizedImage = normalizeImageValue(imageProfile, config); + const normalizedImage = normalizeImageValue(imageProfile, config, requestConfig.interfaceType); const n = Number(normalizedImage.count); const requestPrompt = buildImageReferencePromptText(prompt, references); if (requestConfig.interfaceType === "gemini-image") { @@ -154,7 +154,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: const imageUrl = await grokImageInputURL(references[0]); const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined; const aspectRatio = size?.includes(":") ? size : undefined; - const resolution = normalizeGrokImageResolution(normalizedImage.quality); + const resolution = normalizeGrokImageResolution(normalizedImage.imageResolution !== "auto" ? normalizedImage.imageResolution : normalizedImage.quality); const response = await postChannelJSON( requestConfig, aiApiUrl(requestConfig, "/images/edits"), @@ -178,7 +178,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: if (isVolcengineArkImageProtocol(requestConfig.interfaceType)) { if (mask) throw new Error("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试"); const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined; - const sizeRequest = resolveImageRequestSize(imageProfile, quality, normalizedImage.size); + const sizeRequest = resolveImageRequestSize(imageProfile, imageProfile.resolution.supported ? normalizedImage.imageResolution : quality, normalizedImage.size); const requestSize = sizeRequest?.parameter === "size" ? { ...sizeRequest, value: normalizeVolcengineArkImageSize(sizeRequest.value)! } : sizeRequest; try { const images = await Promise.all(references.map((image) => imageToDataUrl(image))); @@ -201,7 +201,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: } } const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined; - const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size); + const requestSize = resolveImageRequestSize(imageProfile, imageProfile.resolution.supported ? normalizedImage.imageResolution : quality, normalizedImage.size); const formData = new FormData(); formData.set("model", requestConfig.model); formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt)); diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index f49be1438..41daf0437 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -11,6 +11,7 @@ export type CapabilityImageSize = { presets?: CapabilityImageSizePreset[]; }; export type CapabilitySpec = { + imageParameters?: import("@/lib/image-parameters").ImageParameter[]; version: 1; capability: "text" | "image" | "video" | "audio"; operations?: string[]; diff --git a/web/src/services/api/plugin-catalog.ts b/web/src/services/api/plugin-catalog.ts index 010a1c0e3..070f0f2b8 100644 --- a/web/src/services/api/plugin-catalog.ts +++ b/web/src/services/api/plugin-catalog.ts @@ -2,6 +2,7 @@ import { http } from "@/services/api/request"; import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols"; type PluginProviderCatalogItem = { + imageParameters?: import("@/lib/image-parameters").ImageParameter[]; id: string; version: string; name: string; @@ -50,6 +51,7 @@ function toProviderDefinition(item: PluginProviderCatalogItem): ModelProtocolDef enabled: item.enabled && !item.unavailableReason, baseUrl: item.baseUrl, parameters: item.parameters || [], + imageParameters: item.imageParameters, workflows: item.workflows || [], }; } diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 3835f7821..1827924ac 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -157,6 +157,8 @@ export type AiConfig = { textModels: string[]; audioModels: string[]; quality: string; + imageResolution: string; + imageParameters?: string; size: string; transparentBackground: string; count: string; @@ -211,6 +213,7 @@ export const defaultConfig: AiConfig = { textModels: [], audioModels: [], quality: "auto", + imageResolution: "auto", size: "1:1", transparentBackground: "false", count: "1", diff --git a/web/src/stores/use-creation-preferences-store.ts b/web/src/stores/use-creation-preferences-store.ts index b92bfb890..9c38e9ff8 100644 --- a/web/src/stores/use-creation-preferences-store.ts +++ b/web/src/stores/use-creation-preferences-store.ts @@ -6,8 +6,11 @@ import { scopedLocalStorage } from "@/lib/user-scope"; export type CreationModePreference = "text" | "image" | "video"; export type CreationImagePreferences = { + model?: string; + imageParameters?: string; ratio?: string; quality?: string; + imageResolution?: string; count?: string; }; @@ -41,8 +44,11 @@ function normalizeImagePreferences(value: unknown): CreationImagePreferences | u if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const raw = value as Record; const preferences = { + ...(nonEmptyString(raw.model) ? { model: raw.model } : {}), + ...(nonEmptyString(raw.imageParameters) ? { imageParameters: raw.imageParameters } : {}), ...(nonEmptyString(raw.ratio) ? { ratio: raw.ratio } : {}), ...(nonEmptyString(raw.quality) ? { quality: raw.quality } : {}), + ...(nonEmptyString(raw.imageResolution) ? { imageResolution: raw.imageResolution } : {}), ...(nonEmptyString(raw.count) ? { count: raw.count } : {}), }; return Object.keys(preferences).length ? preferences : undefined; diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 018a554c9..0c28244f1 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -288,6 +288,8 @@ export type CanvasNodeMetadata = { workflowParameters?: Record; size?: string; quality?: string; + imageResolution?: string; + imageParameters?: string; transparentBackground?: string; count?: number; textCount?: number; diff --git a/web/test/image-parameters.test.tsx b/web/test/image-parameters.test.tsx new file mode 100644 index 000000000..7380e8876 --- /dev/null +++ b/web/test/image-parameters.test.tsx @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ImageParameterFields, ImageParameterSchemaEditor, ImagePixelMappingEditor } from "../src/components/image-parameter-fields"; +import { ModelCapabilityEditor } from "../src/components/model-capability-editor"; +import { imagePresetForRatio } from "../src/lib/image-size-presets"; +import { imageParameterConstraints, imageParameterRequest, initializeImageRatioDefaults, normalizeImageParameters, projectImageParameters, updateImageParameterSchema, updateImageParameterSize, type ImageParameter } from "../src/lib/image-parameters"; +import { defaultImageCapabilityConfig } from "../src/lib/model-capabilities"; +import { projectLogicalCapability } from "../src/lib/user-session"; +import { capabilitySpecFromChannelModel, mergeCapabilitySpecs, normalizeCapabilitySpecForSources } from "../src/pages/admin/logical-models/model-routing-capabilities"; +import { defaultPriceTier, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form"; +import { prepareBackendGenerationTask } from "../src/services/api/generation-task"; +import { defaultConfig, createModelChannel, encodeChannelModel } from "../src/stores/use-config-store"; +import type { ChannelModel } from "../src/services/api/wallet"; +import { specFromConfig, generationSpecMetadata, synchronizeGenerationSpec } from "../src/lib/canvas/generation-contract"; +import type { CanvasNodeData } from "../src/types/canvas"; + +const parameters: ImageParameter[] = [ + { name: "imageResolution", label: "分辨率", type: "string", values: ["small", "large"], default: "small", pricing: true }, + { name: "creativeAmount", label: "创意程度", type: "integer", min: 0, max: 200, step: 1, default: 0 }, + { name: "rawMode", label: "原始模式", type: "boolean", default: false }, +]; +function fixture(fields = parameters) { + const image = projectImageParameters(defaultImageCapabilityConfig(), fields); + const spec = capabilitySpecFromChannelModel({ capability: "image", capabilityConfig: { version: 1, image } } as ChannelModel)!; + const channel = createModelChannel({ + id: "test", + models: ["opaque-model"], + modelCosts: [{ model: "opaque-model", capability: "image", protocol: "opaque-plugin", logicalModelId: "logical", logicalCapabilitySpec: spec, capabilityConfig: { version: 1, image }, billingMode: "fixed_request", unitPriceMicrocredits: 1 }], + }); + const model = encodeChannelModel(channel.id, "opaque-model"); + return { image, spec, config: { ...defaultConfig, channels: [channel], model, imageModel: model, imageResolution: "large", imageParameters: JSON.stringify({ creativeAmount: 0, rawMode: false, oldParameter: 999 }) } }; +} + +describe("plugin image parameters across model configuration and generation", () => { + test("new resolution selects the six preferred ratios without changing existing choices", () => { + const fields: ImageParameter[] = [ + { name: "size", label: "画幅", type: "string", encoding: "pixels" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["1k"], default: "1k" }, + ]; + const { image } = fixture(fields); + image.size.presets = []; + const initialized = initializeImageRatioDefaults(image, fields); + expect(initialized.size.presets?.map((p) => p.ratio)).toEqual(["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]); + const cleared = updateImageParameterSize(initialized, { ...initialized.size, values: ["auto"], default: "auto", presets: [] }); + const added = updateImageParameterSchema( + cleared, + cleared.parameters!.map((p) => (p.name === "imageResolution" ? { ...p, values: ["1k", "2k"] } : p)), + fields, + ); + expect(added.size.presets?.filter((p) => p.tier === "1k")).toHaveLength(0); + expect(added.size.presets?.filter((p) => p.tier === "2k")).toHaveLength(6); + const reopened = projectImageParameters(JSON.parse(JSON.stringify(added)), added.parameters!); + expect(reopened.size.presets).toEqual(added.size.presets); + }); + test("preferred ratios respect plugin limits and retain exact configured sizes", () => { + const fields: ImageParameter[] = [ + { name: "size", label: "画幅", type: "string", values: ["1:1", "9:16", "2:3"], default: "1:1", encoding: "ratio" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["1k", "2k"], default: "1k" }, + ]; + const { image } = fixture(fields); + const exact = { tier: "1k", ratio: "1:1", width: 1080, height: 1080, size: "1080x1080" }; + image.size.presets = [exact]; + const initialized = initializeImageRatioDefaults(image, fields); + expect(initialized.size.presets?.[0]).toEqual(exact); + expect(initialized.size.presets?.filter((p) => p.tier === "2k").map((p) => p.ratio)).toEqual(["1:1", "9:16"]); + }); + test("declared resolution keeps the existing grouped ratio editor and exact presets", () => { + const fields: ImageParameter[] = [ + { name: "size", label: "画幅", type: "string", values: ["16:9", "1:1"], default: "16:9", encoding: "pixels" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["2k"], default: "2k" }, + ]; + const { image } = fixture(fields); + image.size.presets = [{ ...imagePresetForRatio("2k", "16:9"), width: 2560, height: 1440, size: "2560x1440" }]; + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="2K 支持的宽高比"'); + expect(html).toContain("2560"); + expect(html).toContain("1440"); + expect(html).toContain("默认输出"); + expect(html).not.toContain('aria-label="1K 支持的宽高比"'); + expect(html).not.toContain("比例对应的像素尺寸"); + }); + test("ratio-only contract has ratio buttons without invented resolution or pixel inputs", () => { + const fields: ImageParameter[] = [{ name: "size", label: "画幅", type: "string", values: ["7:5", "5:7"], default: "7:5", encoding: "ratio" }]; + const { image } = fixture(fields); + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="支持的宽高比"'); + expect(html).toContain("7:5"); + expect(html).toContain("默认输出"); + expect(html).not.toContain("1K"); + expect(html).not.toContain("宽度"); + expect(html).not.toContain("像素"); + expect(html).not.toContain("16:9"); + image.size.presets = [imagePresetForRatio("1k", "7:5")]; + const panel = renderToStaticMarkup( {}} />); + expect(panel).not.toContain("image-size-output"); + expect(panel).not.toContain("未配置独立分辨率"); + }); + test("saving tier ratios preserves the contract and generation only shows the active tier", () => { + const { image, config } = fixture([ + { name: "size", label: "画幅", type: "string", values: ["1:1", "16:9"], default: "1:1", encoding: "pixels" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["1k", "2k"], default: "1k" }, + ]); + const saved = updateImageParameterSize(image, { ...image.size, presets: [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("2k", "16:9")] }); + const reopened = projectImageParameters(saved, saved.parameters!); + expect(reopened.size.presets).toHaveLength(2); + const html = renderToStaticMarkup( {}} />); + expect(html).toContain("2752 × 1536"); + expect(html).not.toContain("1024 × 1024"); + expect(html).not.toContain("1:1"); + const singleTier = updateImageParameterSize(saved, { ...saved.size, values: ["16:9"], default: "16:9", presets: [imagePresetForRatio("2k", "16:9")] }); + expect(singleTier.resolution.values).toEqual(["1k", "2k"]); + expect(singleTier.resolution.default).toBe("1k"); + }); + test("size groups follow channel resolutions, not every plugin candidate", () => { + const { image } = fixture([ + { name: "size", label: "画幅", type: "string", values: ["1:1"], default: "1:1", encoding: "pixels" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["2k"], default: "2k" }, + ]); + const definitions = image.parameters!.map((p) => (p.name === "imageResolution" ? { ...p, values: ["1k", "2k", "4k"] } : p)); + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="2K 支持的宽高比"'); + expect(html).not.toContain('aria-label="1K 支持的宽高比"'); + expect(html).not.toContain('aria-label="启用 2K 规格"'); + }); + test("disabled resolution uses one ratio group and restores existing tier presets on enabling", () => { + const { image, config } = fixture([ + { name: "size", label: "画幅", type: "string", values: ["1:1"], default: "1:1", encoding: "pixels" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["1k", "2k"], default: "2k", enabled: false }, + ]); + image.size.presets = [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("2k", "1:1")]; + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="支持的宽高比"'); + expect(html).not.toContain('aria-label="1K 支持的宽高比"'); + expect(html).not.toContain('aria-label="2K 支持的宽高比"'); + const panel = renderToStaticMarkup( {}} />); + expect(panel).not.toContain("1K"); + expect(panel).not.toContain("2K"); + expect(panel).toContain("2048"); + const reopened = projectImageParameters( + JSON.parse(JSON.stringify(image)), + image.parameters!.map((p) => ({ ...p, enabled: true })), + ); + expect(reopened.size.presets).toEqual(image.size.presets); + expect(reopened.resolution.values).toEqual(["1k", "2k"]); + }); + test("generation enums display declared choices directly with ratio previews", () => { + const { image, config } = fixture([ + { name: "size", label: "画幅", type: "string", values: ["7:5", "5:7"], default: "7:5", encoding: "ratio" }, + { name: "finish", label: "表面", type: "string", values: ["matte", "gloss"], default: "matte" }, + ]); + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('role="group" aria-label="画幅"'); + expect(html).toContain('aria-pressed="true"'); + expect(html).toContain("image-size-ratio-icon"); + expect(html).toContain("7:5"); + expect(html).toContain("5:7"); + expect(html).toContain("gloss"); + expect(html).not.toContain('role="combobox"'); + expect(html).not.toContain("16:9"); + }); + test("resolution-only contract renders no invented quality or pixel-tier controls", () => { + const { image, config } = fixture([parameters[0]]); + const html = renderToStaticMarkup( {}} />); + expect(html).toContain("分辨率"); + expect(html).not.toContain("图片质量"); + expect(html).not.toContain("像素档位"); + expect(html).not.toContain("1k"); + const editor = renderToStaticMarkup( {}} />); + expect(editor).toContain("small"); + expect(editor).toContain("large"); + expect(editor).toContain("可计价"); + }); + test("an arbitrary plugin field crosses channel, logical model and frontend projection", () => { + const { spec } = fixture(); + const merged = mergeCapabilitySpecs("image", [spec]); + const image = projectLogicalCapability(merged, {}).image!; + expect(image.parameters?.map((p) => p.name)).toEqual(parameters.map((p) => p.name)); + expect(image.quality.supported).toBe(false); + expect(image.resolution.values).toEqual(["small", "large"]); + expect(imageParameterConstraints(image.parameters!).creativeAmount).toEqual({ min: 0, max: 200, step: 1 }); + }); + test("channel changes remove stale quality; explicit frontend restrictions remain disabled", () => { + const { spec } = fixture(); + const old = { version: 1 as const, capability: "image" as const, options: { quality: { values: ["2k"] } } }; + const next = normalizeCapabilitySpecForSources(old, [spec])!; + expect(next.options?.quality).toBeUndefined(); + expect(next.options?.creativeAmount).toBeDefined(); + const disabled = { ...next, options: { imageResolution: next.options!.imageResolution } }; + expect(normalizeCapabilitySpecForSources(disabled, [spec])?.options?.creativeAmount).toBeUndefined(); + const withNew = { ...spec, imageParameters: [...spec.imageParameters!, { name: "newField", label: "New", type: "boolean" as const }], options: { ...spec.options, newField: { values: [false, true] } } }; + expect(normalizeCapabilitySpecForSources(disabled, [withNew])?.options?.newField).toBeDefined(); + }); + test("only pricing declarations can enter price selectors", () => { + const { image } = fixture(); + const tier = { ...defaultPriceTier("advanced"), imageSelector: { imageResolution: "large", creativeAmount: "100", rawMode: "true", quality: "4k" } }; + expect(skuSelectorFromForm("image", tier, { version: 1, image })).toEqual({ imageResolution: "large" }); + }); + test("zero and false persist while unsupported fields do not cross model switches", () => { + const { image, config } = fixture(); + expect(imageParameterRequest(image, config)).toEqual({ imageResolution: "large", creativeAmount: 0, rawMode: false }); + expect(normalizeImageParameters([parameters[0]], config.imageParameters)).toEqual({ imageResolution: "small" }); + const spec = specFromConfig("image", "portrait", config); + const metadata = generationSpecMetadata(spec); + expect(metadata.imageParameters).toBe(config.imageParameters); + const node = { id: "node", type: "image", metadata } as CanvasNodeData; + const changed = synchronizeGenerationSpec(node, { model: encodeChannelModel("other", "model") }); + expect(changed.metadata?.imageParameters).toBeUndefined(); + }); + test("actual task payload includes typed extensions and no stale quality requirement", async () => { + const { config } = fixture(); + const task = await prepareBackendGenerationTask({ mode: "image", prompt: "portrait", config }); + const input = task.input as Record; + expect(input.capabilityOptions).toEqual({ imageResolution: "large", creativeAmount: 0, rawMode: false }); + expect(input.config.imageParameters).toEqual(input.capabilityOptions); + expect(input.capabilityOptions.quality).toBeUndefined(); + }); + test("submission rejects explicit invalid fields while display can normalize stored preferences", async () => { + const { image, config } = fixture(); + const invalid = { ...config, imageParameters: JSON.stringify({ creativeAmount: 201 }) }; + expect(imageParameterRequest(image, invalid)?.creativeAmount).toBe(0); + expect(() => imageParameterRequest(image, invalid, true)).toThrow("创意程度"); + await expect(prepareBackendGenerationTask({ mode: "image", prompt: "portrait", config: invalid })).rejects.toThrow("创意程度"); + expect(() => imageParameterRequest(image, { ...config, imageParameters: "invalid" }, true)).toThrow("格式无效"); + }); + test("pixel mapping accepts plugin resolution names without inventing dimensions", () => { + const { image } = fixture([ + { name: "size", label: "画幅", type: "string", values: ["2:1"], default: "2:1", encoding: "pixels" }, + { name: "imageResolution", label: "分辨率", type: "string", values: ["panorama"], default: "panorama" }, + ]); + const html = renderToStaticMarkup( {}} />); + expect(html).toContain("PANORAMA"); + expect(html).toContain('placeholder="宽度"'); + expect(html).not.toContain("1024"); + expect(html).not.toContain("补齐"); + }); +}); diff --git a/web/test/image-resolution-tiers.test.ts b/web/test/image-resolution-tiers.test.ts index 5f1e1a3da..15ccc5e36 100644 --- a/web/test/image-resolution-tiers.test.ts +++ b/web/test/image-resolution-tiers.test.ts @@ -72,8 +72,9 @@ describe("image resolution tiers", () => { expect(profile.size.parameter).toBe("aspect_ratio"); expect(profile.size.values).toEqual(["1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"]); - expect(profile.quality.values).toEqual(["1k", "2k", "4k"]); - expect(normalizeImageValue(profile, { size: "3:4", quality: "4k", count: "4" })).toMatchObject({ size: "3:4", quality: "4k", count: "1" }); + expect(profile.resolution.values).toEqual(["1k", "2k", "4k"]); + expect(profile.quality.supported).toBe(false); + expect(normalizeImageValue(profile, { size: "3:4", imageResolution: "4k", count: "4" })).toMatchObject({ size: "3:4", imageResolution: "4k", count: "1" }); // 顶层 response_format 是官方明确列出的错误写法,output_format 会被上游直接拒绝。 expect(profile.responseFormat.supported).toBe(false); diff --git a/web/test/image-size-presets.test.tsx b/web/test/image-size-presets.test.tsx index d56d1edc4..6accf8ba8 100644 --- a/web/test/image-size-presets.test.tsx +++ b/web/test/image-size-presets.test.tsx @@ -208,6 +208,8 @@ describe("统一图片分辨率与宽高比", () => { expect(picker).not.toMatch(/]*>4K/); expect(picker).toContain("换算参考"); profile.size.parameter = "none"; + expect(renderToStaticMarkup( {}} />)).toContain("分辨率"); + profile.resolution.supported = false; expect(renderToStaticMarkup( {}} />)).toBe(""); }); diff --git a/web/test/model-capabilities.test.ts b/web/test/model-capabilities.test.ts index 59e36dc36..bc91dcc3a 100644 --- a/web/test/model-capabilities.test.ts +++ b/web/test/model-capabilities.test.ts @@ -4,6 +4,13 @@ import test from "node:test"; // Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。 import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeImageValue, normalizeVideoValue } from "../src/lib/model-capabilities.ts"; import { imageTierAvailable, imageResolutionTiers } from "../src/lib/image-size-presets.ts"; +import { workflowFieldIsRatioChoices } from "../src/lib/model-capabilities-workflow.ts"; + +test("workflow ratio choices use the direct selection control only for ratio values", () => { + const field = { fieldName: "aspectRatio", fieldType: "SELECT", options: ["auto", "9:16", "16:9", "21:9"] }; + assert.equal(workflowFieldIsRatioChoices(field), true); + assert.equal(workflowFieldIsRatioChoices({ ...field, options: ["fast", "quality"] }), false); +}); test("Kemei Seedream defaults retain official 1K/1.5K/2K sizes without a 4K preset", () => { for (const model of ["doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915"]) {