增加2.5模型调用
This commit is contained in:
1 parent
3f29ba716a
commit
be655b6569
32 files changed
+1075
-56
No files matched your search
@@ -194,6 +194,18 @@ func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
|
||||
}
|
||||
}
|
||||
|
||||
func providerImageModels(getenv postgres.Getenv) map[string]jobs.ProviderTarget {
|
||||
models := []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2")}
|
||||
targets := make(map[string]jobs.ProviderTarget, len(models))
|
||||
for _, model := range models {
|
||||
targets[model] = jobs.ProviderTarget{
|
||||
Provider: "evolink", Model: model,
|
||||
Settings: map[string]any{"quality": firstNonEmpty(getenv("EVOLINK_IMAGE_QUALITY"), "medium")},
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget {
|
||||
seedanceModel := firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128")
|
||||
return map[string]jobs.ProviderTarget{
|
||||
@@ -293,8 +305,13 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error
|
||||
primaryVideo["label"] = "百炼图生视频"
|
||||
primaryVideo["limits"] = bailianVideoCapabilityLimits()
|
||||
}
|
||||
imageCapability := map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)}
|
||||
imageCapability["evolink"] = evolinkImageCapability(getenv)
|
||||
if engine == "evolink" {
|
||||
imageCapability["models"] = evolinkImageModelOptions(getenv)
|
||||
}
|
||||
return []any{
|
||||
map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)},
|
||||
imageCapability,
|
||||
primaryVideo,
|
||||
map[string]any{"id": "video.generate.bailian", "label": "百炼图生视频", "kind": "video", "engine": "bailian", "provider": "bailian", "reqKey": firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), "limits": bailianVideoCapabilityLimits()},
|
||||
map[string]any{"id": "video.generate.minimax", "label": "MiniMax H3 视频生成", "kind": "video", "engine": "minimax", "provider": "minimax", "reqKey": providers.MinimaxH3Model, "limits": minimaxVideoCapabilityLimits()},
|
||||
@@ -302,6 +319,26 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error
|
||||
}
|
||||
}
|
||||
|
||||
func evolinkImageModelOptions(getenv postgres.Getenv) []any {
|
||||
options := []any{
|
||||
map[string]any{"id": "gpt-image-2", "label": "GPT Image 2"},
|
||||
map[string]any{"id": "gpt-image-2.5-flare", "label": "GPT Image 2.5 Flare"},
|
||||
map[string]any{"id": "gpt-image-2.5-sunburst", "label": "GPT Image 2.5 Sunburst"},
|
||||
}
|
||||
custom := firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2")
|
||||
if custom != "gpt-image-2" && custom != "gpt-image-2.5-flare" && custom != "gpt-image-2.5-sunburst" {
|
||||
options = append(options, map[string]any{"id": custom, "label": custom})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func evolinkImageCapability(getenv postgres.Getenv) map[string]any {
|
||||
return map[string]any{
|
||||
"reqKey": firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"),
|
||||
"models": evolinkImageModelOptions(getenv),
|
||||
}
|
||||
}
|
||||
|
||||
func seedanceCapabilityLimits(model string) map[string]any {
|
||||
maximum := 15
|
||||
if model == "doubao-seedance-2-5-260628" {
|
||||
@@ -346,6 +383,11 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
|
||||
}
|
||||
}
|
||||
videoCapability := map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv)}
|
||||
imageCapability := map[string]any{"id": "image.generate", "label": imageCapabilityLabel(image), "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)}
|
||||
imageCapability["evolink"] = evolinkImageCapability(getenv)
|
||||
if image == "evolink" {
|
||||
imageCapability["models"] = evolinkImageModelOptions(getenv)
|
||||
}
|
||||
if video == "seedance" {
|
||||
videoCapability["models"] = seedanceModelOptions()
|
||||
}
|
||||
@@ -357,7 +399,7 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
|
||||
MinimaxMode: providerMode(strings.TrimSpace(getenv("MINIMAX_API_KEY")) != "", "minimax"),
|
||||
AuthMode: authMode,
|
||||
Capabilities: []any{
|
||||
map[string]any{"id": "image.generate", "label": imageCapabilityLabel(image), "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)},
|
||||
imageCapability,
|
||||
videoCapability,
|
||||
},
|
||||
}
|
||||
@@ -477,7 +519,7 @@ func configuredProviderJobBuilder(getenv postgres.Getenv, enforceAvailability bo
|
||||
return jobs.ProviderJobBuilder{
|
||||
ImageProvider: imageProvider(getenv), VideoProvider: videoProvider(getenv),
|
||||
ImageModel: imageModel(getenv), VideoModel: videoModel(getenv), ImageEngine: imageEngine(getenv), VideoEngine: videoEngine(getenv),
|
||||
ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), VideoModels: providerVideoModels(getenv), UnavailableProviders: unavailable, NewID: applicationJobID,
|
||||
ImageEngines: providerImageTargets(getenv), ImageModels: providerImageModels(getenv), VideoEngines: providerVideoTargets(getenv), VideoModels: providerVideoModels(getenv), UnavailableProviders: unavailable, NewID: applicationJobID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,72 @@ func TestRuntimeProviderJobBuilderRefreshesDatabaseSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvoLinkImageModelOptionsAndConfiguredDefault(t *testing.T) {
|
||||
getenv := func(name string) string {
|
||||
switch name {
|
||||
case "IMAGE_GENERATE_ENGINE":
|
||||
return "evolink"
|
||||
case "EVOLINK_IMAGE_MODEL":
|
||||
return "custom-image-model"
|
||||
case "EVOLINK_IMAGE_QUALITY":
|
||||
return "high"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
options := evolinkImageModelOptions(getenv)
|
||||
if len(options) != 4 || options[3].(map[string]any)["id"] != "custom-image-model" {
|
||||
t.Fatalf("options=%#v", options)
|
||||
}
|
||||
capabilities, err := capabilitySummary(getenv)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
image := capabilities.([]any)[0].(map[string]any)
|
||||
if image["reqKey"] != "custom-image-model" || len(image["models"].([]any)) != 4 {
|
||||
t.Fatalf("image capability=%#v", image)
|
||||
}
|
||||
builder := configuredProviderJobBuilder(getenv, false)
|
||||
for _, model := range []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "custom-image-model"} {
|
||||
command, err := builder.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
||||
"engine": "evolink", "model": model, "prompt": "draw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("model %s: %v", model, err)
|
||||
}
|
||||
var request providers.Request
|
||||
if err := json.Unmarshal(command.Job.RequestPayload, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if command.Job.ReqKey != model || request.Model != model || request.Settings["quality"] != "high" {
|
||||
t.Fatalf("model %s: job=%#v request=%#v", model, command.Job, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCapabilityExposesEvoLinkDefaultWhenPrimaryIsJimeng(t *testing.T) {
|
||||
getenv := func(name string) string {
|
||||
if name == "EVOLINK_IMAGE_MODEL" {
|
||||
return "gpt-image-2.5-sunburst"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
capabilities, err := capabilitySummary(getenv)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
image := capabilities.([]any)[0].(map[string]any)
|
||||
evolink := image["evolink"].(map[string]any)
|
||||
if image["engine"] != "jimeng" || evolink["reqKey"] != "gpt-image-2.5-sunburst" || len(evolink["models"].([]any)) != 3 {
|
||||
t.Fatalf("image capability=%#v", image)
|
||||
}
|
||||
health := runtimeHealthDetails(getenv)
|
||||
healthImage := health.Capabilities[0].(map[string]any)
|
||||
if healthImage["evolink"].(map[string]any)["reqKey"] != "gpt-image-2.5-sunburst" {
|
||||
t.Fatalf("health capability=%#v", healthImage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProviderJobBuilderRoutesSeedreamWithoutReplacingJimeng(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"SEEDANCE_API_KEY": "ark-secret",
|
||||
|
||||
@@ -23,6 +23,8 @@ func DefaultBillingPriceRules() []PriceRule {
|
||||
rules := []PriceRule{
|
||||
{ID: "base-volcengine-jimeng-seedream46", Provider: "volcengine-visual", Capability: "image.generate", ReqKey: "jimeng_seedream46_cvtob", Unit: UnitImage, StandardUnitPriceFen: 20, Note: "即梦4.6公开资源包基准。", Source: catalogSource("https://www.volcengine.com/activity/jimeng")},
|
||||
{ID: "base-evolink-gpt-image-2", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, StandardUnitPriceFen: 34, Note: "medium / 1K / 1:1 / 无参考图基准。", Source: catalogSource("https://evolink.ai/zh/gpt-image-2"), Dimensions: evolinkDimensions()},
|
||||
evolink25PriceRule("gpt-image-2.5-flare", "https://evolink.ai/gpt-image-2-5"),
|
||||
evolink25PriceRule("gpt-image-2.5-sunburst", "https://evolink.ai/zh/gpt-image-2-5-sunburst"),
|
||||
{ID: "base-bailian-wan27-image-pro", Provider: "bailian", Capability: "image.generate", ReqKey: "wan2.7-image-pro", Unit: UnitImage, StandardUnitPriceFen: 50, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-image-pro")},
|
||||
{ID: "base-seedream-5-0-pro", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, Note: "基础生图:1K/1.5K ¥0.30,2K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()},
|
||||
{ID: "base-seedream-5-0-pro-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Note: "图层拆分:≤261万像素 ¥0.15/张,>261万像素 ¥0.30/张;最多预留底图加16个图层,完成后按每张实际像素结算。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()},
|
||||
@@ -86,6 +88,30 @@ func evolinkDimensions() []ParameterDimension {
|
||||
}
|
||||
}
|
||||
|
||||
func evolink25PriceRule(model, sourceURL string) PriceRule {
|
||||
dimensions := evolinkDimensions()
|
||||
dimensions[0] = ParameterDimension{
|
||||
Key: "quality", Label: "生成质量", BaselineValue: "medium", DefaultValue: "medium",
|
||||
Tiers: []ParameterTier{
|
||||
{Value: "low", Label: "快速", StandardFactor: 4.0 / 9, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
|
||||
{Value: "medium", Label: "标准", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
|
||||
{Value: "high", Label: "精细", StandardFactor: 35.0 / 9, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
|
||||
},
|
||||
}
|
||||
// This integration exposes only 1K. Do not inherit Image 2's 2K/4K estimates.
|
||||
dimensions[1].Tiers = dimensions[1].Tiers[:1]
|
||||
dimensions[2].Tiers = append(dimensions[2].Tiers, ParameterTier{Value: "other", Match: map[string]any{}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true})
|
||||
return PriceRule{
|
||||
ID: "base-evolink-" + model, Provider: "evolink", Capability: "image.generate", ReqKey: model,
|
||||
Unit: UnitImage, StandardUnitPriceFen: 9, Dimensions: dimensions,
|
||||
Note: "1K 基础输出估算:low/medium/high 成本为 ¥0.04/¥0.09/¥0.35;输入费用未单独加收,非上游实际 token 账单。",
|
||||
Source: map[string]any{
|
||||
"url": sourceURL, "observedAt": "2026-09-23", "currency": "USD", "unitPrice": 0.012, "fxRate": 7.2,
|
||||
"basis": "1024×1024、无参考图输出费用估算:low 约 $0.0053,medium 约 $0.012,high 约 $0.0474;人民币标准成本向上取整到分。参考图和提示词会影响上游实扣。",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeBillingParameters derives quote inputs from the persisted generation
|
||||
// payload. Explicit parameters are retained only as a fallback for values the
|
||||
// payload does not carry.
|
||||
|
||||
@@ -5,22 +5,24 @@ import "testing"
|
||||
func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) {
|
||||
rules := DefaultBillingPriceRules()
|
||||
want := map[string]bool{
|
||||
"base-volcengine-jimeng-seedream46": false,
|
||||
"base-evolink-gpt-image-2": false,
|
||||
"base-bailian-wan27-image-pro": false,
|
||||
"base-seedream-5-0-pro": false,
|
||||
"base-seedream-5-0-pro-layers": false,
|
||||
"base-bailian-wan27-i2v-720p": false,
|
||||
"base-bailian-wan27-i2v-1080p": false,
|
||||
"base-seedance-2-0-480p": false,
|
||||
"base-seedance-2-0-720p": false,
|
||||
"base-seedance-2-0-1080p": false,
|
||||
"base-seedance-2-0-4k": false,
|
||||
"base-seedance-2-5-480p": false,
|
||||
"base-seedance-2-5-720p": false,
|
||||
"base-seedance-2-5-1080p": false,
|
||||
"base-minimax-h3-768p": false,
|
||||
"base-minimax-h3-2k": false,
|
||||
"base-volcengine-jimeng-seedream46": false,
|
||||
"base-evolink-gpt-image-2": false,
|
||||
"base-evolink-gpt-image-2.5-flare": false,
|
||||
"base-evolink-gpt-image-2.5-sunburst": false,
|
||||
"base-bailian-wan27-image-pro": false,
|
||||
"base-seedream-5-0-pro": false,
|
||||
"base-seedream-5-0-pro-layers": false,
|
||||
"base-bailian-wan27-i2v-720p": false,
|
||||
"base-bailian-wan27-i2v-1080p": false,
|
||||
"base-seedance-2-0-480p": false,
|
||||
"base-seedance-2-0-720p": false,
|
||||
"base-seedance-2-0-1080p": false,
|
||||
"base-seedance-2-0-4k": false,
|
||||
"base-seedance-2-5-480p": false,
|
||||
"base-seedance-2-5-720p": false,
|
||||
"base-seedance-2-5-1080p": false,
|
||||
"base-minimax-h3-768p": false,
|
||||
"base-minimax-h3-2k": false,
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if _, ok := want[rule.ID]; !ok {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvoLink25QuotesUseIndependentModelAndQualityPrices(t *testing.T) {
|
||||
catalog := Catalog{Rules: DefaultBillingPriceRules()}
|
||||
for _, model := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} {
|
||||
for _, test := range []struct {
|
||||
quality string
|
||||
cost, amount int64
|
||||
}{{"low", 4, 5}, {"medium", 9, 11}, {"high", 35, 42}} {
|
||||
t.Run(model+"/"+test.quality, func(t *testing.T) {
|
||||
quote, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: model, Parameters: Parameters{"quality": test.quality}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quote.PriceRuleID != "base-evolink-"+model || quote.StandardUnitPriceFen != test.cost || quote.AmountFen != test.amount {
|
||||
t.Fatalf("quote=%+v", quote)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, params := range []Parameters{{"quality": "xhigh"}, {"resolution": "2K"}, {"resolution": "4K"}} {
|
||||
if _, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: model, Parameters: params}); !errors.Is(err, ErrParameterTier) {
|
||||
t.Fatalf("unsupported params %v: %v", params, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
legacy, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2"})
|
||||
if err != nil || legacy.AmountFen != 41 {
|
||||
t.Fatalf("legacy quote=%+v, err=%v", legacy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvoLink25A4QuoteKeepsModelAndQuality(t *testing.T) {
|
||||
store := "eStoreStub{rules: DefaultBillingPriceRules()}
|
||||
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
||||
Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2.5-sunburst",
|
||||
Payload: map[string]any{"settings": map[string]any{"width": 848.0, "height": 1200.0, "quality": "high", "imageCount": 1.0}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quote.AmountFen != 42 || quote.Parameters["aspectRatio"] != "70:99" {
|
||||
t.Fatalf("quote=%+v", quote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvoLink25QuotesAllSupportedAspectRatios(t *testing.T) {
|
||||
store := "eStoreStub{rules: DefaultBillingPriceRules()}
|
||||
for _, size := range []string{"1:2", "2:3", "5:4", "21:9", "848x1200"} {
|
||||
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
||||
Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2.5-flare",
|
||||
Payload: map[string]any{"settings": map[string]any{"size": size, "quality": "medium", "imageCount": 1.0}},
|
||||
})
|
||||
if err != nil || quote.AmountFen != 11 {
|
||||
t.Fatalf("size=%s quote=%+v err=%v", size, quote, err)
|
||||
}
|
||||
}
|
||||
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
||||
Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2.5-flare",
|
||||
Payload: map[string]any{"settings": map[string]any{"width": 512.0, "height": 1024.0, "quality": "medium"}},
|
||||
})
|
||||
if err != nil || quote.AmountFen != 11 || quote.Parameters["aspectRatio"] != "512:1024" {
|
||||
t.Fatalf("unlisted ratio quote=%+v err=%v", quote, err)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func openAPISchemas() map[string]any {
|
||||
"properties": map[string]any{
|
||||
"capability": openAPIRef("GenerationCapability"),
|
||||
"engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance", "minimax"}},
|
||||
"model": map[string]any{"type": "string", "enum": []string{"doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"}, "description": "Optional allow-listed Seedance model for video.generate."},
|
||||
"model": map[string]any{"type": "string", "description": "Optional allow-listed model. For image.generate with engine=evolink: gpt-image-2, gpt-image-2.5-flare, gpt-image-2.5-sunburst, or the configured EVOLINK_IMAGE_MODEL. For video.generate with engine=seedance: doubao-seedance-2-0-260128 or doubao-seedance-2-5-260628."},
|
||||
"prompt": map[string]any{"type": "string", "description": "Prompt text. Required for generation except Seedream layer decomposition, where an empty prompt triggers automatic decomposition."},
|
||||
"inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."},
|
||||
"imageUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Alias for image input URLs."},
|
||||
|
||||
@@ -314,6 +314,7 @@ type ProviderJobBuilder struct {
|
||||
ImageModel, VideoModel string
|
||||
ImageEngine, VideoEngine string
|
||||
ImageEngines map[string]ProviderTarget
|
||||
ImageModels map[string]ProviderTarget
|
||||
VideoEngines map[string]ProviderTarget
|
||||
VideoModels map[string]ProviderTarget
|
||||
UnavailableProviders map[string]string
|
||||
@@ -372,6 +373,21 @@ func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) (
|
||||
if rawEngine != nil && engine == "" {
|
||||
return ProviderTarget{}, "", invalidPreparation("unsupported image engine")
|
||||
}
|
||||
configured := firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image")
|
||||
if rawModel != nil {
|
||||
if engine == "" {
|
||||
engine = configured
|
||||
}
|
||||
model := stringValue(rawModel)
|
||||
if model == "" || engine != "evolink" {
|
||||
return ProviderTarget{}, "", invalidPreparation("unsupported image model")
|
||||
}
|
||||
target, ok := b.ImageModels[model]
|
||||
if !ok || target.Provider != "evolink" || target.Model == "" {
|
||||
return ProviderTarget{}, "", invalidPreparation("unsupported image model")
|
||||
}
|
||||
return target, engine, nil
|
||||
}
|
||||
if engine != "" {
|
||||
if engine != "jimeng" && engine != "evolink" && engine != "bailian" && engine != "seedream" {
|
||||
return ProviderTarget{}, "", invalidPreparation("unsupported image engine")
|
||||
@@ -382,7 +398,6 @@ func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) (
|
||||
}
|
||||
return target, engine, nil
|
||||
}
|
||||
configured := firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image")
|
||||
if target, ok := b.ImageEngines[configured]; ok && target.Provider == b.ImageProvider && target.Model == b.ImageModel {
|
||||
return target, configured, nil
|
||||
}
|
||||
@@ -483,6 +498,12 @@ func prepareProviderRequest(capability, engine, model string, defaults map[strin
|
||||
if err == nil {
|
||||
applyImageSettingDefaults(settings, defaults)
|
||||
}
|
||||
if err == nil && engine == "evolink" && isEvoLinkImage25(model) {
|
||||
err = validateEvoLinkImageQuality(body, settings)
|
||||
}
|
||||
if err == nil && engine == "evolink" && isEvoLinkImage25(model) {
|
||||
err = validateEvoLinkImage25(body, urls, settings)
|
||||
}
|
||||
if err == nil && engine == "bailian" {
|
||||
err = validateBailianImage(urls, settings)
|
||||
}
|
||||
@@ -517,6 +538,127 @@ func prepareProviderRequest(capability, engine, model string, defaults map[strin
|
||||
return preparedProviderRequest{request: providers.Request{Capability: capability, Prompt: text, InputURLs: urls, Materials: providerMaterials, Settings: settings}, assetIDs: assetIDs}, nil
|
||||
}
|
||||
|
||||
func validateEvoLinkImageQuality(body, settings map[string]any) error {
|
||||
quality := body["quality"]
|
||||
if quality == nil {
|
||||
if nested, ok := body["settings"].(map[string]any); ok {
|
||||
quality = nested["quality"]
|
||||
}
|
||||
}
|
||||
if quality != nil {
|
||||
value, ok := quality.(string)
|
||||
if !ok || !validImageQuality(strings.ToLower(strings.TrimSpace(value))) {
|
||||
return invalidPreparation("invalid image parameter: quality")
|
||||
}
|
||||
}
|
||||
if settings["quality"] == nil {
|
||||
settings["quality"] = "medium"
|
||||
}
|
||||
value, ok := settings["quality"].(string)
|
||||
if !ok || !validImageQuality(value) {
|
||||
return invalidPreparation("invalid image parameter: quality")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validImageQuality(value string) bool {
|
||||
return value == "low" || value == "medium" || value == "high"
|
||||
}
|
||||
|
||||
func isEvoLinkImage25(model string) bool {
|
||||
return model == "gpt-image-2.5-flare" || model == "gpt-image-2.5-sunburst"
|
||||
}
|
||||
|
||||
func validateEvoLinkImage25(body map[string]any, urls []string, settings map[string]any) error {
|
||||
if len(urls) > 16 {
|
||||
return invalidPreparation("EvoLink Image 2.5 supports at most 16 reference images")
|
||||
}
|
||||
resolutions := []any{body["resolution"]}
|
||||
counts := []any{body["n"]}
|
||||
if nested, ok := body["settings"].(map[string]any); ok {
|
||||
resolutions = append(resolutions, nested["resolution"])
|
||||
counts = append(counts, nested["n"])
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if resolution != nil && !strings.EqualFold(strings.TrimSpace(stringValue(resolution)), "1K") {
|
||||
return invalidPreparation("EvoLink Image 2.5 resolution must be 1K")
|
||||
}
|
||||
}
|
||||
for _, count := range counts {
|
||||
if count != nil {
|
||||
value, ok := finiteNumber(count)
|
||||
if !ok || value != 1 {
|
||||
return invalidPreparation("EvoLink Image 2.5 n must be 1")
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw := settings["imageCount"]; raw != nil {
|
||||
count, ok := finiteNumber(raw)
|
||||
if !ok || count != 1 {
|
||||
return invalidPreparation("EvoLink Image 2.5 imageCount must be 1")
|
||||
}
|
||||
}
|
||||
if settings["force_single"] == false {
|
||||
return invalidPreparation("EvoLink Image 2.5 requires force_single")
|
||||
}
|
||||
if raw := settings["size"]; raw != nil {
|
||||
size, _ := raw.(string)
|
||||
if !evoLink1KSize(size) {
|
||||
return invalidPreparation("EvoLink Image 2.5 size must be a supported 1K aspect ratio or A4 size")
|
||||
}
|
||||
}
|
||||
widthRaw, heightRaw := settings["width"], settings["height"]
|
||||
if (widthRaw == nil) != (heightRaw == nil) {
|
||||
return invalidPreparation("EvoLink Image 2.5 requires both width and height")
|
||||
}
|
||||
if widthRaw != nil {
|
||||
width, widthOK := finiteNumber(widthRaw)
|
||||
height, heightOK := finiteNumber(heightRaw)
|
||||
if !widthOK || !heightOK || width < 1 || height < 1 || width > 8192 || height > 8192 || width != math.Trunc(width) || height != math.Trunc(height) {
|
||||
return invalidPreparation("invalid EvoLink Image 2.5 dimensions")
|
||||
}
|
||||
if !evoLink1KDimensions(int(width), int(height)) {
|
||||
return invalidPreparation("EvoLink Image 2.5 custom dimensions exceed the supported 1K size")
|
||||
}
|
||||
} else if settings["size"] == nil {
|
||||
// EvoLink's auto size may choose a larger output even with resolution=1K.
|
||||
settings["size"] = "1:1"
|
||||
}
|
||||
settings["imageCount"] = float64(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func evoLink1KSize(size string) bool {
|
||||
switch strings.TrimSpace(size) {
|
||||
case "1:1", "1:2", "2:1", "1:3", "3:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "9:21", "21:9", "848x1200", "1200x848":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func evoLink1KDimensions(width, height int) bool {
|
||||
if (width == 848 && height == 1200) || (width == 1200 && height == 848) ||
|
||||
(width == 1680 && height == 2376) || (width == 2376 && height == 1680) {
|
||||
return true
|
||||
}
|
||||
divisor := gcdImageDimensions(width, height)
|
||||
if evoLink1KSize(fmt.Sprintf("%d:%d", width/divisor, height/divisor)) {
|
||||
return true
|
||||
}
|
||||
shorter, longer := min(width, height), max(width, height)
|
||||
pixels := width * height
|
||||
return width%16 == 0 && height%16 == 0 &&
|
||||
pixels >= 655360 && pixels <= 1024*1024 && longer <= 3*shorter
|
||||
}
|
||||
|
||||
func gcdImageDimensions(left, right int) int {
|
||||
for right != 0 {
|
||||
left, right = right, left%right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func applyImageSettingDefaults(settings, defaults map[string]any) {
|
||||
for key, value := range defaults {
|
||||
if _, configured := settings[key]; !configured {
|
||||
|
||||
@@ -62,6 +62,107 @@ func TestProviderJobBuilderRejectsUnconfiguredProviderBeforeCreation(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderSelectsAllowListedEvoLinkImageModel(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
for _, model := range []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} {
|
||||
cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
||||
"engine": "evolink", "model": model, "prompt": "draw", "imageUrls": []any{"https://in.test/reference.png"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("model %s: %v", model, err)
|
||||
}
|
||||
var request providers.Request
|
||||
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.Job.Provider != "evolink" || cmd.Job.ReqKey != model || request.Model != model || (model != "gpt-image-2" && request.Settings["quality"] != "medium") {
|
||||
t.Fatalf("model %s: job=%#v request=%#v", model, cmd.Job, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderRejectsUnknownOrCrossEngineImageModel(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
for _, body := range []map[string]any{
|
||||
{"engine": "evolink", "model": "unknown", "prompt": "draw"},
|
||||
{"engine": "jimeng", "model": "gpt-image-2.5-flare", "prompt": "draw"},
|
||||
{"model": "gpt-image-2.5-flare", "prompt": "draw"},
|
||||
{"engine": "evolink", "model": 1, "prompt": "draw"},
|
||||
} {
|
||||
_, err := b.Build(context.Background(), "owner", "", "image.generate", "", body)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported image model") {
|
||||
t.Fatalf("body=%#v error=%v", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderRejectsInvalidEvoLinkQuality(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
for _, body := range []map[string]any{
|
||||
{"engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", "quality": "auto"},
|
||||
{"engine": "evolink", "model": "gpt-image-2.5-sunburst", "prompt": "draw", "settings": map[string]any{"quality": 1}},
|
||||
} {
|
||||
_, err := b.Build(context.Background(), "owner", "", "image.generate", "", body)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid image parameter: quality") {
|
||||
t.Fatalf("body=%#v error=%v", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderEnforcesImage25OneKSingleOutput(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
for _, body := range []map[string]any{
|
||||
{"resolution": "2K"},
|
||||
{"settings": map[string]any{"resolution": "2K"}},
|
||||
{"n": 2},
|
||||
{"settings": map[string]any{"n": 2}},
|
||||
{"size": "2K"},
|
||||
{"size": "2048x2048"},
|
||||
{"width": 2048, "height": 1537},
|
||||
{"width": 1200, "height": 1199},
|
||||
{"width": 1, "height": 7},
|
||||
{"width": 100, "height": 101},
|
||||
{"width": 800, "height": 816},
|
||||
{"width": 2048},
|
||||
{"imageCount": 2},
|
||||
{"force_single": false},
|
||||
} {
|
||||
body["engine"], body["model"], body["prompt"] = "evolink", "gpt-image-2.5-flare", "draw"
|
||||
if _, err := b.Build(context.Background(), "owner", "", "image.generate", "", body); err == nil {
|
||||
t.Fatalf("accepted invalid Image 2.5 request: %#v", body)
|
||||
}
|
||||
}
|
||||
urls := make([]any, 17)
|
||||
for index := range urls {
|
||||
urls[index] = fmt.Sprintf("https://in.test/%d.png", index)
|
||||
}
|
||||
if _, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
||||
"engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", "imageUrls": urls,
|
||||
}); err == nil {
|
||||
t.Fatal("accepted more than 16 reference images")
|
||||
}
|
||||
for _, dimensions := range [][2]int{{2048, 2048}, {848, 1200}, {1200, 848}, {800, 1024}, {1280, 736}} {
|
||||
if _, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
||||
"engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", "width": dimensions[0], "height": dimensions[1], "force_single": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("dimensions %v: %v", dimensions, err)
|
||||
}
|
||||
}
|
||||
command, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
||||
"engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request providers.Request
|
||||
if err := json.Unmarshal(command.Job.RequestPayload, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Settings["size"] != "1:1" || request.Settings["imageCount"] != float64(1) {
|
||||
t.Fatalf("default settings=%#v", request.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderUsesPublicInputURLsForImageMaterialCoverage(t *testing.T) {
|
||||
b := testProviderBuilder()
|
||||
cmd, err := b.Build(context.Background(), "owner", "client", "image.generate", "", map[string]any{
|
||||
@@ -733,6 +834,11 @@ func testProviderBuilder() ProviderJobBuilder {
|
||||
"bailian": {Provider: "bailian", Model: "bailian-image-test"},
|
||||
"seedream": {Provider: "seedream", Model: providers.Seedream50ProModel, Settings: map[string]any{"size": "1.5K", "outputFormat": "png", "optimizeMode": "standard", "imageCount": float64(1)}},
|
||||
},
|
||||
ImageModels: map[string]ProviderTarget{
|
||||
"gpt-image-2": {Provider: "evolink", Model: "gpt-image-2"},
|
||||
"gpt-image-2.5-flare": {Provider: "evolink", Model: "gpt-image-2.5-flare"},
|
||||
"gpt-image-2.5-sunburst": {Provider: "evolink", Model: "gpt-image-2.5-sunburst"},
|
||||
},
|
||||
VideoEngines: map[string]ProviderTarget{
|
||||
"seedance": {Provider: "seedance", Model: "seedance-test", Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}},
|
||||
"bailian": {Provider: "bailian", Model: "bailian-video-test"},
|
||||
|
||||
@@ -136,6 +136,15 @@ func (c *CreationCoordinator) RetryPlatform(ctx context.Context, session identit
|
||||
if engine := retryEngine(original.Provider, original.Capability); engine != "" {
|
||||
body["engine"] = engine
|
||||
}
|
||||
if original.Provider == "evolink" && original.Capability == "image.generate" {
|
||||
model := strings.TrimSpace(original.ReqKey)
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(request.Model)
|
||||
}
|
||||
if model != "" {
|
||||
body["model"] = model
|
||||
}
|
||||
}
|
||||
created, _, err := c.CreatePlatform(ctx, session, CreationInput{Capability: original.Capability, Body: body, RetryOf: original.ID})
|
||||
if err != nil {
|
||||
return jobs.Job{}, err
|
||||
|
||||
@@ -285,6 +285,48 @@ func TestCreationCoordinatorRetryRebuildsAnyOwnedImageJobWithFreshQuote(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreationCoordinatorRetryPreservesEvoLinkImageModelForQuoteAndJob(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, reqKey, payloadModel, expected string
|
||||
bodyModel bool
|
||||
}{
|
||||
{name: "reqKey wins", reqKey: "gpt-image-2.5-flare", payloadModel: "gpt-image-2.5-sunburst", expected: "gpt-image-2.5-flare", bodyModel: true},
|
||||
{name: "payload fallback", payloadModel: "gpt-image-2.5-sunburst", expected: "gpt-image-2.5-sunburst", bodyModel: true},
|
||||
{name: "legacy default", expected: "gpt-image-2"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
builder := jobs.ProviderJobBuilder{
|
||||
ImageProvider: "evolink", ImageModel: "gpt-image-2", ImageEngine: "evolink",
|
||||
ImageEngines: map[string]jobs.ProviderTarget{"evolink": {Provider: "evolink", Model: "gpt-image-2", Settings: map[string]any{"quality": "medium"}}},
|
||||
ImageModels: map[string]jobs.ProviderTarget{
|
||||
"gpt-image-2.5-flare": {Provider: "evolink", Model: "gpt-image-2.5-flare", Settings: map[string]any{"quality": "medium"}},
|
||||
"gpt-image-2.5-sunburst": {Provider: "evolink", Model: "gpt-image-2.5-sunburst", Settings: map[string]any{"quality": "medium"}},
|
||||
},
|
||||
NewID: func() string { return "retry-job" },
|
||||
}
|
||||
creator := &creationStoreStub{}
|
||||
quoter := "erStub{}
|
||||
coordinator := NewCreationCoordinator(builder, creator, quoter, &chargeLedgerStub{}, &creationStateStub{})
|
||||
payload, _ := json.Marshal(providers.Request{Capability: "image.generate", Model: test.payloadModel, Prompt: "draw again", Settings: map[string]any{"quality": "high"}})
|
||||
original := jobs.Job{ID: "original", OwnerID: "account", Capability: "image.generate", Provider: "evolink", ReqKey: test.reqKey, RequestPayload: payload}
|
||||
created, err := coordinator.RetryPlatform(context.Background(), identity.Session{User: identity.User{ID: "account", OrganizationID: "org"}}, original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var prepared providers.Request
|
||||
if err := json.Unmarshal(created.RequestPayload, &prepared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ReqKey != test.expected || prepared.Model != test.expected || quoter.command.ReqKey != test.expected || quoter.command.Provider != "evolink" || created.RetryOf != "original" || prepared.Settings["quality"] != "high" {
|
||||
t.Fatalf("created=%#v prepared=%#v quote=%#v", created, prepared, quoter.command)
|
||||
}
|
||||
if got, ok := quoter.command.Payload["model"]; ok != test.bodyModel || (ok && got != test.expected) {
|
||||
t.Fatalf("quote payload model=%#v present=%v", got, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeBillingErrorMapsPostgresWalletFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -822,6 +822,27 @@ func TestEvoLinkPayloadIncludesQualityAndSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvoLinkImage25PayloadUsesSelectedModelAndOneK(t *testing.T) {
|
||||
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
images, ok := body["image_urls"].([]any)
|
||||
if request.URL.Path != "/v1/images/generations" || body["model"] != "gpt-image-2.5-flare" || body["quality"] != "medium" || body["n"] != float64(1) || body["resolution"] != "1K" || body["size"] != "848x1200" || !ok || len(images) != 1 {
|
||||
t.Fatalf("payload=%#v", body)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-25","status":"queued"}`)), Header: http.Header{}}, nil
|
||||
})
|
||||
_, err := NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, client).Submit(context.Background(), Request{
|
||||
Capability: "image.generate", Model: "gpt-image-2.5-flare", Prompt: "draw", InputURLs: []string{"https://in.test/ref.png"},
|
||||
Settings: map[string]any{"quality": "medium", "width": 848, "height": 1200, "imageCount": float64(1)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedreamPayloadAndSynchronousResponse(t *testing.T) {
|
||||
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path != "/api/v3/images/generations" || request.Header.Get("Authorization") != "Bearer secret" {
|
||||
|
||||
@@ -602,7 +602,7 @@ func definitions() []Group {
|
||||
{ID: "auth", Title: "平台账号安全", Description: "平台自建手机号账号登录。", Fields: []Field{{Key: "ZHINIAN_AUTH_REQUIRED", Label: "登录保护", Type: "select", DefaultValue: "auto", Options: []Option{{Label: "自动", Value: "auto"}, {Label: "启用", Value: "1"}, {Label: "停用", Value: "0"}}}, {Key: "ZHINIAN_AUTH_SESSION_SECRET", Label: "会话签名密钥", Secret: true, Type: "password"}}},
|
||||
{ID: "billing", Title: "企业计费", Description: "企业计费和对公账户信息。", Fields: []Field{{Key: "ZHINIAN_BILLING_REQUIRED", Label: "真实任务计费", Type: "select", DefaultValue: "1", Options: []Option{{Label: "启用", Value: "1"}, {Label: "停用(免计费)", Value: "0"}}}, {Key: "ZHINIAN_BILLING_ACCOUNT_NAME", Label: "对公账户名称"}, {Key: "ZHINIAN_BILLING_ACCOUNT_BANK", Label: "开户行"}, {Key: "ZHINIAN_BILLING_ACCOUNT_NUMBER", Label: "银行账号"}, {Key: "ZHINIAN_BILLING_CONTACT", Label: "充值对接信息"}}},
|
||||
{ID: "visual", Title: "即梦图片 API", Description: "火山 AK/SK。", Fields: []Field{{Key: "VOLCENGINE_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "VOLCENGINE_SECRET_ACCESS_KEY", Label: "Secret Access Key", Secret: true, Type: "password"}}},
|
||||
{ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "质量", DefaultValue: "medium"}}},
|
||||
{ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 / 2.5 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "默认图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "默认质量", DefaultValue: "medium"}}},
|
||||
{ID: "seedance", Title: "火山方舟 API", Description: "Seedance 视频与 Seedream 5.0 Pro 图片共用此 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}},
|
||||
{ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}},
|
||||
{ID: "minimax", Title: "MiniMax API", Description: "MiniMax H3 视频生成。", Fields: []Field{{Key: "MINIMAX_API_KEY", Label: "MiniMax API Key", Secret: true, Type: "password"}, {Key: "MINIMAX_BASE_URL", Label: "Base URL", DefaultValue: "https://api.minimax.cn"}}},
|
||||
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
|
||||
type Settings struct {
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
ForceSingle *bool `json:"forceSingle,omitempty"`
|
||||
@@ -231,6 +232,10 @@ func normalizeSettings(value Settings) Settings {
|
||||
if value.Engine != "jimeng" && value.Engine != "evolink" && value.Engine != "bailian" && value.Engine != "seedream" {
|
||||
value.Engine = ""
|
||||
}
|
||||
value.Model = strings.TrimSpace(value.Model)
|
||||
if value.Engine != "evolink" || len(value.Model) > 120 {
|
||||
value.Model = ""
|
||||
}
|
||||
if value.Width < 1 || value.Height < 1 {
|
||||
value.Width, value.Height = 0, 0
|
||||
} else {
|
||||
|
||||
@@ -86,6 +86,25 @@ func TestServiceNormalizesAndScopesTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateSettingsPreserveEvoLinkModelAndLegacyEmptyModel(t *testing.T) {
|
||||
service := NewService(&memoryCatalog{}, nil, func() string { return "tmpl_model" })
|
||||
created, err := service.Create(context.Background(), "owner", CreateCommand{
|
||||
Name: "Flare", Prompt: "draw", Settings: Settings{Engine: "evolink", Model: " gpt-image-2.5-flare ", Quality: "medium"},
|
||||
})
|
||||
if err != nil || created.Settings.Model != "gpt-image-2.5-flare" {
|
||||
t.Fatalf("created=%#v err=%v", created, err)
|
||||
}
|
||||
legacy, err := service.Create(context.Background(), "owner", CreateCommand{
|
||||
Name: "Legacy", Prompt: "draw", Settings: Settings{Engine: "evolink"},
|
||||
})
|
||||
if err != nil || legacy.Settings.Model != "" {
|
||||
t.Fatalf("legacy=%#v err=%v", legacy, err)
|
||||
}
|
||||
if got := normalizeSettings(Settings{Engine: "jimeng", Model: "gpt-image-2.5-flare"}); got.Model != "" {
|
||||
t.Fatalf("cross-engine model=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateDeleteAndNotFound(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
catalog := &memoryCatalog{items: []Template{{ID: "t1", OwnerID: "owner", Name: "old", Prompt: "p", Settings: Settings{}, CreatedAt: now, UpdatedAt: now}}}
|
||||
|
||||
Reference in new issue
Block a user