889 lines
42 KiB
Go
889 lines
42 KiB
Go
package jobs
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers"
|
|
)
|
|
|
|
func TestProviderJobBuilderPreparesImageRequestAndEngineOverride(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
body := map[string]any{
|
|
"engine": "evolink",
|
|
"promptAssembly": map[string]any{
|
|
"mode": "image", "manualPrompt": " assembled image ",
|
|
"materials": []any{map[string]any{"id": "asset-1", "url": "https://in.test/reference.png", "type": "image"}},
|
|
},
|
|
"scale": 0.7, "width": 1536.0, "height": 1024.0,
|
|
"min_ratio": 0.5, "max_ratio": 2.0, "force_single": true,
|
|
"quality": " HIGH ", "settings": map[string]any{"imageCount": 2.0},
|
|
}
|
|
|
|
cmd, err := b.Build(context.Background(), "owner", "client", "image.generate", "idem", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantRequest := providers.Request{
|
|
Capability: "image.generate", Model: "gpt-image-test", Prompt: "assembled image", InputURLs: []string{"https://in.test/reference.png"},
|
|
Materials: []providers.Material{{URL: "https://in.test/reference.png", Type: providers.MaterialImage, Label: "@图片1"}},
|
|
Settings: map[string]any{"scale": 0.7, "width": float64(1536), "height": float64(1024), "min_ratio": 0.5, "max_ratio": 2.0, "force_single": true, "quality": "high", "imageCount": float64(2)},
|
|
}
|
|
assertPreparedJob(t, cmd.Job, "evolink", "gpt-image-test", wantRequest)
|
|
if !reflect.DeepEqual(cmd.Job.InputAssetIDs, []string{"asset-1"}) {
|
|
t.Fatalf("InputAssetIDs = %#v", cmd.Job.InputAssetIDs)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderRejectsUnconfiguredProviderBeforeCreation(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
b.UnavailableProviders = map[string]string{
|
|
"evolink": "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。",
|
|
}
|
|
_, err := b.Build(context.Background(), "owner", "client", "image.generate", "idem", map[string]any{
|
|
"engine": "evolink", "prompt": "hello",
|
|
})
|
|
if err == nil || err.Error() != "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。" {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
var unavailable *Error
|
|
if !errors.As(err, &unavailable) || unavailable.Kind != ErrorUnavailable || unavailable.Status != 503 {
|
|
t.Fatalf("error = %#v, want unavailable 503", err)
|
|
}
|
|
}
|
|
|
|
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{
|
|
"prompt": "compose @图片2", "inputUrls": []any{"https://in.test/one.png", "https://in.test/two.png"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request providers.Request
|
|
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(request.InputURLs, []string{"https://in.test/one.png", "https://in.test/two.png"}) {
|
|
t.Fatalf("InputURLs = %#v", request.InputURLs)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderPreparesSeedreamImageRequest(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
|
"engine": "seedream", "prompt": "use @图片2",
|
|
"materials": []any{
|
|
map[string]any{"id": "image-1", "url": "https://in.test/one.png", "type": "image"},
|
|
map[string]any{"id": "image-2", "url": "https://in.test/two.png", "type": "image"},
|
|
},
|
|
"settings": map[string]any{"size": "2k", "outputFormat": "JPEG", "optimizeMode": "fast"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request providers.Request
|
|
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cmd.Job.Provider != "seedream" || cmd.Job.ReqKey != providers.Seedream50ProModel || request.Settings["size"] != "2K" || request.Settings["outputFormat"] != "jpeg" || request.Settings["optimizeMode"] != "fast" || request.Settings["imageCount"] != float64(1) || len(request.InputURLs) != 2 {
|
|
t.Fatalf("job=%#v request=%#v", cmd.Job, request)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderPreparesSeedreamLayerDecompositionWithoutPrompt(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
|
"engine": "seedream",
|
|
"materials": []any{map[string]any{"id": "image-1", "url": "https://in.test/source.png", "type": "image"}},
|
|
"settings": map[string]any{"size": "auto", "outputFormat": "png", "optimizeMode": "standard", "creationMode": "layers", "layerDecomposition": true},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request providers.Request
|
|
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cmd.Job.Prompt != "" || len(request.InputURLs) != 1 || request.Settings["size"] != "auto" || request.Settings["creationMode"] != "layers" || request.Settings["layerDecomposition"] != true {
|
|
t.Fatalf("job=%#v request=%#v", cmd.Job, request)
|
|
}
|
|
_, err = b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
|
"engine": "seedream", "settings": map[string]any{"size": "auto", "outputFormat": "png", "optimizeMode": "standard", "creationMode": "layers", "layerDecomposition": true},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "exactly one input image") {
|
|
t.Fatalf("missing input error=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderPreparesSeedreamInteractiveMultiImageRequest(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{
|
|
"engine": "seedream", "prompt": "move the subject between images",
|
|
"materials": []any{
|
|
map[string]any{"id": "image-1", "url": "https://in.test/one.png", "type": "image"},
|
|
map[string]any{"id": "image-2", "url": "https://in.test/two.png", "type": "image"},
|
|
},
|
|
"settings": map[string]any{"size": "2K", "outputFormat": "png", "optimizeMode": "standard", "creationMode": "interactive"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request providers.Request
|
|
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(request.InputURLs) != 2 || request.Settings["creationMode"] != "interactive" || request.Settings["layerDecomposition"] != nil {
|
|
t.Fatalf("job=%#v request=%#v", cmd.Job, request)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderPreparesSeedanceVideoRequest(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
body := map[string]any{
|
|
"engine": "seedance", "prompt": " launch video ",
|
|
"materials": []any{
|
|
map[string]any{"id": "image-1", "url": "https://in.test/first.png", "type": "image"},
|
|
map[string]any{"id": "video-1", "url": "https://in.test/reference.mp4", "type": "video"},
|
|
map[string]any{"id": "audio-1", "url": "https://in.test/music.mp3", "type": "audio"},
|
|
},
|
|
"settings": map[string]any{"ratio": "16:9", "duration": 8.0, "resolution": "1080p", "ignored": "value"},
|
|
}
|
|
cmd, err := b.Build(context.Background(), "owner", "", "video.generate", "", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantRequest := providers.Request{
|
|
Capability: "video.generate", Model: "seedance-test", Prompt: "launch video",
|
|
InputURLs: []string{"https://in.test/first.png", "https://in.test/reference.mp4", "https://in.test/music.mp3"},
|
|
Materials: []providers.Material{
|
|
{URL: "https://in.test/first.png", Type: providers.MaterialImage, Label: "@图片1"},
|
|
{URL: "https://in.test/reference.mp4", Type: providers.MaterialVideo, Label: "@视频1"},
|
|
{URL: "https://in.test/music.mp3", Type: providers.MaterialAudio, Label: "@音频1"},
|
|
},
|
|
Settings: map[string]any{"ratio": "16:9", "duration": float64(8), "resolution": "1080p"},
|
|
}
|
|
assertPreparedJob(t, cmd.Job, "seedance", "seedance-test", wantRequest)
|
|
}
|
|
|
|
func TestProviderJobBuilderAppliesInjectedVideoDefaults(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
cmd, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{
|
|
"engine": "seedance", "prompt": "video",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request providers.Request
|
|
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]any{"ratio": "9:16", "duration": float64(5), "resolution": "720p"}
|
|
if !reflect.DeepEqual(request.Settings, want) {
|
|
t.Fatalf("Settings = %#v, want %#v", request.Settings, want)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderSelectsAllowListedSeedance25Model(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
materials := []any{}
|
|
for index := 0; index < 5; index++ {
|
|
materials = append(materials, map[string]any{"url": fmt.Sprintf("https://in.test/%d.png", index), "type": "image"})
|
|
}
|
|
cmd, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{
|
|
"engine": "seedance", "model": seedance25Model, "prompt": "video", "materials": materials,
|
|
"settings": map[string]any{"duration": 30.0, "resolution": "720p", "ratio": "16:9"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request providers.Request
|
|
if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cmd.Job.ReqKey != seedance25Model || request.Model != seedance25Model || request.Settings["duration"] != float64(30) || len(request.Materials) != 5 {
|
|
t.Fatalf("job/request = %#v / %#v", cmd.Job, request)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderPreparesBailianFirstAndLastFrameVideo(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
body := map[string]any{
|
|
"engine": "bailian",
|
|
"promptAssembly": map[string]any{
|
|
"mode": "video", "manualPrompt": "animate frames",
|
|
"materials": []any{
|
|
map[string]any{"id": "first", "url": "https://in.test/first.png", "type": "image"},
|
|
map[string]any{"id": "last", "url": "https://in.test/last.png", "type": "image"},
|
|
},
|
|
},
|
|
"settings": map[string]any{"duration": 12.0, "resolution": "1080p"},
|
|
}
|
|
cmd, err := b.Build(context.Background(), "owner", "", "video.generate", "", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantRequest := providers.Request{
|
|
Capability: "video.generate", Model: "bailian-video-test", Prompt: "animate frames",
|
|
InputURLs: []string{"https://in.test/first.png", "https://in.test/last.png"},
|
|
Materials: []providers.Material{
|
|
{URL: "https://in.test/first.png", Type: providers.MaterialImage, Label: "@图片1"},
|
|
{URL: "https://in.test/last.png", Type: providers.MaterialImage, Label: "@图片2"},
|
|
},
|
|
Settings: map[string]any{"duration": float64(12), "resolution": "1080P"},
|
|
}
|
|
assertPreparedJob(t, cmd.Job, "bailian", "bailian-video-test", wantRequest)
|
|
}
|
|
|
|
func TestProviderJobBuilderPreparesMinimaxH3TextAndFirstFrameVideo(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
text, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{
|
|
"engine": "minimax", "prompt": "cinematic sunrise",
|
|
"settings": map[string]any{"duration": 6.0, "resolution": "2k", "ratio": "3:4"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var textRequest providers.Request
|
|
if err := json.Unmarshal(text.Job.RequestPayload, &textRequest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if text.Job.Provider != "minimax" || text.Job.ReqKey != providers.MinimaxH3Model || !reflect.DeepEqual(textRequest.Settings, map[string]any{"duration": float64(6), "resolution": "2K", "ratio": "3:4"}) {
|
|
t.Fatalf("job=%#v request=%#v", text.Job, textRequest)
|
|
}
|
|
|
|
image, err := b.Build(context.Background(), "owner", "", "video.generate", "", map[string]any{
|
|
"engine": "minimax", "prompt": "animate @图片1",
|
|
"materials": []any{map[string]any{"id": "first", "url": "https://in.test/first.png", "type": "image"}},
|
|
"settings": map[string]any{"duration": 15.0, "resolution": "768p", "ratio": "16:9"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var imageRequest providers.Request
|
|
if err := json.Unmarshal(image.Job.RequestPayload, &imageRequest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if imageRequest.Settings["ratio"] != "adaptive" || imageRequest.Settings["resolution"] != "768P" || len(imageRequest.Materials) != 1 || imageRequest.Materials[0].Type != providers.MaterialImage {
|
|
t.Fatalf("request=%#v", imageRequest)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) {
|
|
tests := []struct {
|
|
name, capability, message string
|
|
body map[string]any
|
|
}{
|
|
{name: "missing image prompt", capability: "image.generate", body: map[string]any{}, message: "prompt is required"},
|
|
{name: "arbitrary provider", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "attacker-provider"}, message: "unsupported image engine"},
|
|
{name: "bailian too many references", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "bailian", "imageUrls": tenURLs()}, message: "at most 9 reference images"},
|
|
{name: "bailian invalid image pixels", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "bailian", "width": 100.0, "height": 100.0}, message: "image dimensions"},
|
|
{name: "seedream too many references", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "imageUrls": append(tenURLs(), "https://in.test/11.png")}, message: "at most 10 reference images"},
|
|
{name: "seedream rejects dimensions", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "width": 1024.0, "height": 1024.0}, message: "size preset"},
|
|
{name: "seedream rejects invalid format", capability: "image.generate", body: map[string]any{"prompt": "p", "engine": "seedream", "settings": map[string]any{"outputFormat": "webp"}}, message: "png or jpeg"},
|
|
{name: "seedance missing materials referenced by prompt", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "use @图片2"}, message: "requires @图片2"},
|
|
{name: "bailian requires frame", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{}}, message: "1 or 2 image materials"},
|
|
{name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "1 or 2 image materials"},
|
|
{name: "bad seedance settings", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"duration": 99.0}}, message: "video duration"},
|
|
{name: "unknown seedance model", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": "attacker-model", "prompt": "p"}, message: "unsupported video model"},
|
|
{name: "seedance 2.0 rejects 30 seconds", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": seedance20Model, "prompt": "p", "settings": map[string]any{"duration": 30.0}}, message: "between 4 and 15"},
|
|
{name: "seedance 2.5 rejects over 30 seconds", capability: "video.generate", body: map[string]any{"engine": "seedance", "model": seedance25Model, "prompt": "p", "settings": map[string]any{"duration": 31.0}}, message: "between 4 and 30"},
|
|
{name: "seedance unsupported 4k output", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"resolution": "4k"}}, message: "unsupported video resolution"},
|
|
{name: "minimax rejects adaptive text ratio", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"ratio": "adaptive"}}, message: "requires a fixed"},
|
|
{name: "minimax rejects two images", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.png", "type": "image"}, map[string]any{"url": "https://in.test/2.png", "type": "image"}}}, message: "at most one"},
|
|
{name: "minimax rejects video", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/1.mp4", "type": "video"}}}, message: "image material only"},
|
|
{name: "minimax rejects short duration", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"duration": 3.0}}, message: "between 4 and 15"},
|
|
{name: "minimax rejects resolution", capability: "video.generate", body: map[string]any{"engine": "minimax", "prompt": "p", "settings": map[string]any{"resolution": "1080p"}}, message: "768P or 2K"},
|
|
{name: "seedance too many materials", capability: "video.generate", body: map[string]any{
|
|
"engine": "seedance", "prompt": "p", "materials": []any{
|
|
map[string]any{"url": "https://in.test/1.png", "type": "image"},
|
|
map[string]any{"url": "https://in.test/2.png", "type": "image"},
|
|
map[string]any{"url": "https://in.test/3.png", "type": "image"},
|
|
map[string]any{"url": "https://in.test/4.png", "type": "image"},
|
|
map[string]any{"url": "https://in.test/5.png", "type": "image"},
|
|
},
|
|
}, message: "at most 4 materials"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := testProviderBuilder().Build(context.Background(), "owner", "", test.capability, "", test.body)
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.message)) {
|
|
t.Fatalf("error = %v, want containing %q", err, test.message)
|
|
}
|
|
var jobErr *Error
|
|
if !strings.Contains(err.Error(), "configured") && (!errorsAs(err, &jobErr) || jobErr.Status != 400 || jobErr.Kind != ErrorInvalid) {
|
|
t.Fatalf("error = %#v, want generic invalid job error", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderBuilderAndProcessor(t *testing.T) {
|
|
b := testProviderBuilder()
|
|
cmd, err := b.Build(context.Background(), "owner", "client", "image.generate", "idem", map[string]any{"prompt": "hello", "inputUrls": []any{"https://in.test/a.png"}})
|
|
if err != nil || cmd.Job.OwnerID != "owner" || cmd.Job.ExternalClientID != "client" {
|
|
t.Fatalf("Build=%#v,%v", cmd, err)
|
|
}
|
|
store := newMemoryJobStore()
|
|
store.jobs[cmd.Job.ID] = cmd.Job
|
|
p := ProviderProcessor{Providers: ProviderRegistry{"image-default": providers.NewMock("fixture")}, Store: store}
|
|
submitted, err := p.Advance(context.Background(), cmd.Job)
|
|
if err != nil || submitted.ProviderTaskID == "" || submitted.Status != StatusQueued {
|
|
t.Fatalf("submit=%#v,%v", submitted, err)
|
|
}
|
|
done, err := p.Advance(context.Background(), submitted)
|
|
if err != nil || done.Status != StatusSucceeded {
|
|
t.Fatalf("query=%#v,%v", done, err)
|
|
}
|
|
if store.jobs[cmd.Job.ID].Status != StatusSucceeded {
|
|
t.Fatal("provider result was not persisted")
|
|
}
|
|
var persisted providers.HTTPResult
|
|
if err := json.Unmarshal(done.ResponsePayload, &persisted); err != nil || len(persisted.OutputURLs) != 1 || persisted.OutputURLs[0] == "" {
|
|
t.Fatalf("persisted result = %#v, %v raw=%s", persisted, err, done.ResponsePayload)
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorRefreshesPrivateAssetURLImmediatelyBeforeSubmit(t *testing.T) {
|
|
store := newMemoryJobStore()
|
|
job := Job{
|
|
ID: "job-private", OwnerID: "owner-a", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued,
|
|
InputAssetIDs: []string{"asset-private"}, InputURLs: []string{"https://private.test/uploads/a.png"},
|
|
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"draw","inputUrls":["https://private.test/uploads/a.png"],"materials":[{"url":"https://private.test/uploads/a.png","type":"image"}]}`),
|
|
}
|
|
store.jobs[job.ID] = job
|
|
adapter := &countingProvider{result: providers.Result{TaskID: "provider-task", Status: providers.StatusQueued}}
|
|
resolver := &recordingProviderAssetURLResolver{
|
|
sourceURL: "https://private.test/uploads/a.png",
|
|
accessURL: "https://private.test/uploads/a.png?OSSAccessKeyId=test&Signature=temporary",
|
|
}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"fixture": adapter}, Store: store, AssetURLs: resolver}
|
|
|
|
if _, err := processor.Advance(context.Background(), job); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resolver.owner != "owner-a" || resolver.assetID != "asset-private" || resolver.ttl != time.Hour {
|
|
t.Fatalf("resolver call = owner %q asset %q ttl %s", resolver.owner, resolver.assetID, resolver.ttl)
|
|
}
|
|
if !reflect.DeepEqual(adapter.request.InputURLs, []string{resolver.accessURL}) || len(adapter.request.Materials) != 1 || adapter.request.Materials[0].URL != resolver.accessURL {
|
|
t.Fatalf("submitted request = %#v", adapter.request)
|
|
}
|
|
if strings.Contains(string(job.RequestPayload), "Signature=temporary") || strings.Contains(string(store.jobs[job.ID].RequestPayload), "Signature=temporary") {
|
|
t.Fatal("temporary signature must not be persisted in the job request")
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorNeverResubmitsAfterPersistedDispatchIntent(t *testing.T) {
|
|
store := newMemoryJobStore()
|
|
started := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
|
job := Job{ID: "job-unknown", OwnerID: "owner", Provider: "mock", Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderDispatchStartedAt: &started, RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"fixture","prompt":"hello"}`)}
|
|
store.jobs[job.ID] = job
|
|
adapter := &countingProvider{result: providers.Result{TaskID: "duplicate", Status: providers.StatusQueued}}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"mock": adapter}, Store: store}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
if err != nil || adapter.submits != 0 || got.Status != StatusFailed || got.Error == nil || got.Error.Retryable {
|
|
t.Fatalf("got=%#v err=%v submits=%d", got, err, adapter.submits)
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorLogsSafeUnknownSubmissionOutcome(t *testing.T) {
|
|
var output bytes.Buffer
|
|
previousOutput, previousFlags := log.Writer(), log.Flags()
|
|
log.SetOutput(&output)
|
|
log.SetFlags(0)
|
|
t.Cleanup(func() {
|
|
log.SetOutput(previousOutput)
|
|
log.SetFlags(previousFlags)
|
|
})
|
|
|
|
started := time.Now().UTC()
|
|
job := Job{
|
|
ID: "job-unknown-outcome", OwnerID: "private-owner", Provider: "volcengine-visual", ReqKey: "private-model",
|
|
Capability: "image.generate", Status: StatusRunning, ProviderDispatchStartedAt: &started,
|
|
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"private-model","prompt":"private prompt"}`),
|
|
}
|
|
adapter := &countingProvider{}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
if err != nil || got.Status != StatusFailed || adapter.submits != 0 {
|
|
t.Fatalf("got=%#v err=%v submits=%d", got, err, adapter.submits)
|
|
}
|
|
logged := output.String()
|
|
for _, expected := range []string{"jobId=job-unknown-outcome", "provider=volcengine-visual", "phase=submit", "status=0", "errorClass=unknown_outcome"} {
|
|
if !strings.Contains(logged, expected) {
|
|
t.Fatalf("log %q does not contain %q", logged, expected)
|
|
}
|
|
}
|
|
for _, secret := range []string{"private-owner", "private-model", "private prompt"} {
|
|
if strings.Contains(logged, secret) {
|
|
t.Fatalf("log leaks %q: %s", secret, logged)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorLogsSafeJobCorrelationWhenSubmitFails(t *testing.T) {
|
|
var output bytes.Buffer
|
|
previousOutput, previousFlags := log.Writer(), log.Flags()
|
|
log.SetOutput(&output)
|
|
log.SetFlags(0)
|
|
t.Cleanup(func() {
|
|
log.SetOutput(previousOutput)
|
|
log.SetFlags(previousFlags)
|
|
})
|
|
|
|
store := newMemoryJobStore()
|
|
job := Job{
|
|
ID: "job-safe-log", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob",
|
|
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker",
|
|
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"private prompt"}`),
|
|
}
|
|
store.jobs[job.ID] = job
|
|
adapter := &countingProvider{err: &providers.ProviderError{Operation: "volcengine request", Status: http.StatusForbidden}}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Message != unknownProviderSubmissionMessage {
|
|
t.Fatalf("got=%#v err=%v", got, err)
|
|
}
|
|
logged := output.String()
|
|
for _, expected := range []string{"jobId=job-safe-log", "provider=volcengine-visual", "phase=submit", "status=403", "errorClass=service"} {
|
|
if !strings.Contains(logged, expected) {
|
|
t.Fatalf("log %q does not contain %q", logged, expected)
|
|
}
|
|
}
|
|
for _, secret := range []string{"private prompt", "jimeng_seedream46_cvtob", "owner", "volcengine request"} {
|
|
if strings.Contains(logged, secret) {
|
|
t.Fatalf("log leaks %q: %s", secret, logged)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorPersistsFriendlySeedreamOutputSafetyMessage(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/api/v3/images/generations" {
|
|
t.Fatalf("request path = %q", request.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{
|
|
"error": {
|
|
"code": "OutputImageSensitiveContentDetected.PolicyViolation",
|
|
"type": "BadRequest",
|
|
"message": "private upstream moderation detail"
|
|
}
|
|
}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
store := newMemoryJobStore()
|
|
job := Job{
|
|
ID: "job-seedream-output-safety", OwnerID: "owner", Provider: "seedream", ReqKey: providers.Seedream50ProModel,
|
|
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker",
|
|
RequestPayload: json.RawMessage(`{
|
|
"capability":"image.generate",
|
|
"model":"doubao-seedream-5-0-pro-260628",
|
|
"prompt":"拆分元素",
|
|
"inputUrls":["https://assets.test/source.jpg"],
|
|
"settings":{"layerDecomposition":true,"size":"2K","outputFormat":"png"}
|
|
}`),
|
|
}
|
|
store.jobs[job.ID] = job
|
|
adapter := providers.NewSeedream(providers.Config{
|
|
BaseURL: server.URL + "/api/v3", APIKey: "private-api-key", Model: providers.Seedream50ProModel,
|
|
}, server.Client())
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"seedream": adapter}, Store: store}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
const want = "生成结果触发内容安全审核,请更换素材或调整内容后重试。"
|
|
if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Message != want || got.Error.Retryable {
|
|
t.Fatalf("got=%#v err=%v", got, err)
|
|
}
|
|
persisted := store.jobs[job.ID]
|
|
if persisted.Error == nil || persisted.Error.Message != want {
|
|
t.Fatalf("persisted error = %#v", persisted.Error)
|
|
}
|
|
}
|
|
|
|
func TestMinimaxSubmissionFailuresUseActionableSafeMessages(t *testing.T) {
|
|
tests := []struct {
|
|
status int
|
|
want string
|
|
}{
|
|
{http.StatusUnauthorized, "鉴权失败"},
|
|
{http.StatusForbidden, "调用权限"},
|
|
{http.StatusPaymentRequired, "余额不足"},
|
|
{http.StatusNotFound, "接口或模型不可用"},
|
|
{http.StatusUnprocessableEntity, "内容安全审核"},
|
|
{http.StatusTooManyRequests, "频率过高"},
|
|
}
|
|
for _, test := range tests {
|
|
failure := providerSubmissionFailure(&providers.ProviderError{Operation: "minimax submit", Status: test.status, Code: "safe-code"})
|
|
if failure.Retryable || !strings.Contains(failure.Message, test.want) || strings.Contains(failure.Message, "safe-code") {
|
|
t.Fatalf("status=%d failure=%#v", test.status, failure)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorPersistsMinimaxDiagnosticWithoutResubmission(t *testing.T) {
|
|
store := newMemoryJobStore()
|
|
job := Job{
|
|
ID: "job-minimax-rejected", OwnerID: "owner", Provider: "minimax", ReqKey: providers.MinimaxH3Model,
|
|
Capability: "video.generate", Status: StatusQueued,
|
|
RequestPayload: json.RawMessage(`{"capability":"video.generate","model":"MiniMax-H3","prompt":"private prompt","settings":{"duration":4,"resolution":"768P","ratio":"1:1"}}`),
|
|
}
|
|
store.jobs[job.ID] = job
|
|
adapter := &countingProvider{err: &providers.ProviderError{
|
|
Operation: "minimax submit", Status: 400, Code: "2013", RequestID: "request-minimax-rejected",
|
|
Detail: "invalid params, model not supported (2013)",
|
|
}}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"minimax": adapter}, Store: store}
|
|
failed, err := processor.Advance(context.Background(), job)
|
|
if err != nil || failed.Status != StatusFailed || failed.Error == nil || failed.Error.Code != "2013" || failed.Error.Retryable {
|
|
t.Fatalf("failed=%#v err=%v", failed, err)
|
|
}
|
|
var response struct {
|
|
ProviderError struct {
|
|
Status int `json:"status"`
|
|
Code string `json:"code"`
|
|
RequestID string `json:"requestId"`
|
|
Detail string `json:"detail"`
|
|
} `json:"providerError"`
|
|
}
|
|
if err := json.Unmarshal(store.jobs[job.ID].ResponsePayload, &response); err != nil || response.ProviderError.RequestID != "request-minimax-rejected" || response.ProviderError.Code != "2013" || response.ProviderError.Status != 400 || response.ProviderError.Detail != adapter.err.(*providers.ProviderError).Detail {
|
|
t.Fatalf("persisted diagnostic=%#v err=%v", response, err)
|
|
}
|
|
if _, err := processor.Advance(context.Background(), store.jobs[job.ID]); err != nil || adapter.submits != 1 {
|
|
t.Fatalf("rejected task resubmitted: count=%d err=%v", adapter.submits, err)
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorClearsTransientErrorAfterSuccessfulPoll(t *testing.T) {
|
|
store := newMemoryJobStore()
|
|
job := Job{ID: "job-recovered", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", Error: &JobError{Message: "temporary timeout", Retryable: true}, RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)}
|
|
store.jobs[job.ID] = job
|
|
adapter := &countingProvider{result: providers.Result{TaskID: "provider-task", Status: providers.StatusSucceeded, OutputURLs: []string{"https://cdn.test/result.png"}}}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"fixture": adapter}, Store: store}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
if err != nil || got.Status != StatusSucceeded || got.Error != nil {
|
|
t.Fatalf("got=%#v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorRejectsSuccessWithoutOutput(t *testing.T) {
|
|
store := newMemoryJobStore()
|
|
job := Job{ID: "job-no-output", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderTaskID: "provider-task", RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)}
|
|
store.jobs[job.ID] = job
|
|
adapter := &countingProvider{result: providers.Result{TaskID: "provider-task", Status: providers.StatusSucceeded}}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"fixture": adapter}, Store: store}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
if err != nil || got.Status != StatusFailed || got.Error == nil || got.Error.Retryable || !strings.Contains(got.Error.Message, "without an output URL") {
|
|
t.Fatalf("got=%#v err=%v", got, err)
|
|
}
|
|
if store.jobs[job.ID].Status != StatusFailed {
|
|
t.Fatalf("persisted status = %s, want failed", store.jobs[job.ID].Status)
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorQueriesWithPersistedRequestModel(t *testing.T) {
|
|
store := newMemoryJobStore()
|
|
job := Job{ID: "job-model", OwnerID: "owner", Provider: "fixture", ReqKey: "persisted-model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"persisted-model-a","prompt":"hello"}`)}
|
|
store.jobs[job.ID] = job
|
|
adapter := &modelQueryProvider{result: providers.Result{TaskID: "provider-task", Status: providers.StatusRunning}}
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"fixture": adapter}, Store: store}
|
|
|
|
if _, err := processor.Advance(context.Background(), job); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if adapter.model != "persisted-model-a" {
|
|
t.Fatalf("query model = %q", adapter.model)
|
|
}
|
|
}
|
|
|
|
func TestProviderProcessorCompletesOfficialVolcengineQueryResponse(t *testing.T) {
|
|
var action string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
action = request.URL.Query().Get("Action")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"code":10000,
|
|
"data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"},
|
|
"message":"Success",
|
|
"request_id":"request-1",
|
|
"status":10000
|
|
}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
store := newMemoryJobStore()
|
|
job := Job{
|
|
ID: "job-jimeng", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob",
|
|
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderTaskID: "task-1",
|
|
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"hello"}`),
|
|
}
|
|
store.jobs[job.ID] = job
|
|
adapter := providers.NewVolcengine(providers.Config{
|
|
BaseURL: server.URL, AccessKeyID: "ak", SecretAccessKey: "sk", Region: "cn-north-1", Service: "cv", Model: "jimeng_seedream46_cvtob",
|
|
}, server.Client(), func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
|
|
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store}
|
|
|
|
got, err := processor.Advance(context.Background(), job)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var persisted providers.HTTPResult
|
|
if err := json.Unmarshal(got.ResponsePayload, &persisted); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action != "JimengSeedream46CVToBGetResult" || got.Status != StatusSucceeded || got.ProviderTaskID != "task-1" || len(persisted.OutputURLs) != 1 || persisted.OutputURLs[0] != "https://cdn.test/generated.png" {
|
|
t.Fatalf("job=%#v response=%#v", got, persisted)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderRejectsInvalidPublicWebhookURL(t *testing.T) {
|
|
for _, value := range []string{"/internal/callback", "javascript:alert(1)", "https://user:pass@example.test/hook"} {
|
|
_, err := testProviderBuilder().Build(context.Background(), "api:client", "client", "image.generate", "", map[string]any{"prompt": "hello", "webhookUrl": value})
|
|
var invalid *Error
|
|
if !errorsAs(err, &invalid) || invalid.Status != 400 {
|
|
t.Fatalf("webhook %q error = %#v", value, err)
|
|
}
|
|
}
|
|
cmd, err := testProviderBuilder().Build(context.Background(), "api:client", "client", "image.generate", "", map[string]any{"prompt": "hello", "webhookUrl": "https://hooks.example.test/done"})
|
|
if err != nil || cmd.Job.WebhookURL != "https://hooks.example.test/done" {
|
|
t.Fatalf("valid webhook = %#v, %v", cmd.Job, err)
|
|
}
|
|
}
|
|
|
|
func TestProviderJobBuilderClampsPriorityToPublicContract(t *testing.T) {
|
|
for _, test := range []struct {
|
|
raw any
|
|
want int
|
|
}{{raw: 999.0, want: 100}, {raw: -999.0, want: -100}, {raw: 42.0, want: 42}} {
|
|
cmd, err := testProviderBuilder().Build(context.Background(), "owner", "client", "image.generate", "", map[string]any{"prompt": "hello", "priority": test.raw})
|
|
if err != nil || cmd.Job.Priority != test.want {
|
|
t.Fatalf("priority(%v)=%d err=%v, want %d", test.raw, cmd.Job.Priority, err, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type countingProvider struct {
|
|
submits int
|
|
result providers.Result
|
|
request providers.Request
|
|
err error
|
|
}
|
|
|
|
type recordingProviderAssetURLResolver struct {
|
|
sourceURL, accessURL string
|
|
owner, assetID string
|
|
ttl time.Duration
|
|
}
|
|
|
|
func (resolver *recordingProviderAssetURLResolver) ResolveProviderAssetURL(_ context.Context, owner, assetID string, ttl time.Duration) (string, string, error) {
|
|
resolver.owner, resolver.assetID, resolver.ttl = owner, assetID, ttl
|
|
return resolver.sourceURL, resolver.accessURL, nil
|
|
}
|
|
|
|
type modelQueryProvider struct {
|
|
result providers.Result
|
|
model string
|
|
}
|
|
|
|
func (provider *modelQueryProvider) Submit(context.Context, providers.Request) (providers.Result, error) {
|
|
return provider.result, nil
|
|
}
|
|
func (provider *modelQueryProvider) Query(context.Context, string) (providers.Result, error) {
|
|
return providers.Result{}, errors.New("fallback query must not be used")
|
|
}
|
|
func (provider *modelQueryProvider) QueryModel(_ context.Context, _ string, model string) (providers.Result, error) {
|
|
provider.model = model
|
|
return provider.result, nil
|
|
}
|
|
|
|
func (p *countingProvider) Submit(_ context.Context, request providers.Request) (providers.Result, error) {
|
|
p.submits++
|
|
p.request = request
|
|
return p.result, p.err
|
|
}
|
|
func (p *countingProvider) Query(context.Context, string) (providers.Result, error) {
|
|
return p.result, p.err
|
|
}
|
|
|
|
func testProviderBuilder() ProviderJobBuilder {
|
|
return ProviderJobBuilder{
|
|
ImageProvider: "image-default", ImageModel: "image-default-model",
|
|
VideoProvider: "video-default", VideoModel: "video-default-model",
|
|
ImageEngine: "jimeng", VideoEngine: "seedance",
|
|
ImageEngines: map[string]ProviderTarget{
|
|
"jimeng": {Provider: "volcengine-visual", Model: "jimeng-test"},
|
|
"evolink": {Provider: "evolink", Model: "gpt-image-test"},
|
|
"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"},
|
|
"minimax": {Provider: "minimax", Model: providers.MinimaxH3Model, Settings: map[string]any{"ratio": "16:9", "duration": 5, "resolution": "768P"}},
|
|
},
|
|
VideoModels: map[string]ProviderTarget{
|
|
seedance20Model: {Provider: "seedance", Model: seedance20Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}},
|
|
seedance25Model: {Provider: "seedance", Model: seedance25Model, Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}},
|
|
},
|
|
NewID: func() string { return "job-1" },
|
|
}
|
|
}
|
|
|
|
func assertPreparedJob(t *testing.T, job Job, provider, model string, want providers.Request) {
|
|
t.Helper()
|
|
if job.Provider != provider || job.ReqKey != model {
|
|
t.Fatalf("provider/model = %q/%q, want %q/%q", job.Provider, job.ReqKey, provider, model)
|
|
}
|
|
var got providers.Request
|
|
if err := json.Unmarshal(job.RequestPayload, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("request = %#v, want %#v; raw=%s", got, want, job.RequestPayload)
|
|
}
|
|
if job.Prompt != want.Prompt || !reflect.DeepEqual(job.InputURLs, want.InputURLs) {
|
|
t.Fatalf("job preparation = prompt %q URLs %#v", job.Prompt, job.InputURLs)
|
|
}
|
|
}
|
|
|
|
func tenURLs() []any {
|
|
urls := make([]any, 10)
|
|
for i := range urls {
|
|
urls[i] = "https://in.test/reference.png"
|
|
}
|
|
return urls
|
|
}
|
|
|
|
// Kept local so the test remains compatible with the package's supported Go version.
|
|
func errorsAs(err error, target any) bool {
|
|
jobErr, ok := err.(*Error)
|
|
pointer, targetOK := target.(**Error)
|
|
if ok && targetOK {
|
|
*pointer = jobErr
|
|
}
|
|
return ok && targetOK
|
|
}
|