672 lines
25 KiB
Go
672 lines
25 KiB
Go
package clients
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func TestSimulationClientReturnsImageDemoAssets(t *testing.T) {
|
|
response, err := (SimulationClient{}).Run(context.Background(), Request{
|
|
Kind: "images.generations",
|
|
Model: "gpt-image-1",
|
|
Body: map[string]any{
|
|
"prompt": "demo image",
|
|
"n": 2,
|
|
"simulationDurationMs": 5,
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run simulation image client: %v", err)
|
|
}
|
|
data, _ := response.Result["data"].([]any)
|
|
if len(data) != 2 || response.ResponseDurationMS <= 0 {
|
|
t.Fatalf("unexpected simulated image response: %+v duration=%d", response.Result, response.ResponseDurationMS)
|
|
}
|
|
item, _ := data[0].(map[string]any)
|
|
if item["url"] != "/static/simulation/image.svg" || item["assetSource"] != "simulation" {
|
|
t.Fatalf("unexpected simulated image item: %+v", item)
|
|
}
|
|
}
|
|
|
|
func TestSimulationClientReturnsVideoDemoAssets(t *testing.T) {
|
|
response, err := (SimulationClient{}).Run(context.Background(), Request{
|
|
Kind: "videos.generations",
|
|
ModelType: "video_generate",
|
|
Model: "demo-video-model",
|
|
Body: map[string]any{
|
|
"prompt": "demo video",
|
|
"count": 2,
|
|
"duration": 6,
|
|
"simulationDurationMs": 5,
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run simulation video client: %v", err)
|
|
}
|
|
data, _ := response.Result["data"].([]any)
|
|
if len(data) != 2 || response.ResponseDurationMS <= 0 {
|
|
t.Fatalf("unexpected simulated video response: %+v duration=%d", response.Result, response.ResponseDurationMS)
|
|
}
|
|
item, _ := data[0].(map[string]any)
|
|
if item["video_url"] != "/static/simulation/video.mp4" || item["url"] != "/static/simulation/video.mp4" || item["poster"] != "/static/simulation/video-poster.svg" {
|
|
t.Fatalf("unexpected simulated video item: %+v", item)
|
|
}
|
|
if item["duration"] != 6 || item["assetSource"] != "simulation" {
|
|
t.Fatalf("unexpected simulated video metadata: %+v", item)
|
|
}
|
|
}
|
|
|
|
func TestSimulationDurationDefaultsByMediaType(t *testing.T) {
|
|
imageDuration := simulationDuration(Request{Kind: "images.generations"})
|
|
if imageDuration < 10*time.Second || imageDuration > 30*time.Second {
|
|
t.Fatalf("image simulation duration should default to 10-30s, got %s", imageDuration)
|
|
}
|
|
videoDuration := simulationDuration(Request{Kind: "videos.generations"})
|
|
if videoDuration < 2*time.Minute || videoDuration > 3*time.Minute {
|
|
t.Fatalf("video simulation duration should default to 2-3m, got %s", videoDuration)
|
|
}
|
|
textDuration := simulationDuration(Request{Kind: "chat.completions"})
|
|
if textDuration < 800*time.Millisecond || textDuration > 2400*time.Millisecond {
|
|
t.Fatalf("text simulation duration should keep short defaults, got %s", textDuration)
|
|
}
|
|
}
|
|
|
|
func TestSimulationDurationCanBeControlledByParams(t *testing.T) {
|
|
fixedDuration := simulationDuration(Request{Body: map[string]any{"simulationDurationSeconds": 7}})
|
|
if fixedDuration != 7*time.Second {
|
|
t.Fatalf("simulationDurationSeconds should set fixed duration, got %s", fixedDuration)
|
|
}
|
|
rangeDuration := simulationDuration(Request{
|
|
Kind: "videos.generations",
|
|
Body: map[string]any{
|
|
"simulationMinDurationSeconds": 1,
|
|
"simulationMaxDurationSeconds": 1,
|
|
},
|
|
})
|
|
if rangeDuration != time.Second {
|
|
t.Fatalf("simulation duration range params should override video defaults, got %s", rangeDuration)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClientChatContract(t *testing.T) {
|
|
var gotPath string
|
|
var gotAuth string
|
|
var gotModel string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.Header().Set("X-Request-Id", "req-chat-test")
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
gotModel, _ = body["model"].(string)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion",
|
|
"model": gotModel,
|
|
"choices": []any{map[string]any{
|
|
"message": map[string]any{"role": "assistant", "content": "ok"},
|
|
}},
|
|
"usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "chat.completions",
|
|
Model: "openai:gpt-4o-mini",
|
|
Body: map[string]any{"model": "openai:gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ModelName: "gpt-4o-mini",
|
|
ProviderModelName: "openai-compatible-gpt-4o-mini",
|
|
Credentials: map[string]any{"apiKey": "test-key"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run openai client: %v", err)
|
|
}
|
|
if gotPath != "/chat/completions" || gotAuth != "Bearer test-key" || gotModel != "openai-compatible-gpt-4o-mini" {
|
|
t.Fatalf("unexpected request path=%s auth=%s model=%s", gotPath, gotAuth, gotModel)
|
|
}
|
|
if response.Usage.TotalTokens != 5 || response.Result["id"] != "chatcmpl-test" {
|
|
t.Fatalf("unexpected response: %+v", response)
|
|
}
|
|
if response.RequestID != "req-chat-test" || response.ResponseStartedAt.IsZero() || response.ResponseFinishedAt.IsZero() {
|
|
t.Fatalf("response metadata was not captured: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClientChatStreamContract(t *testing.T) {
|
|
var gotStream bool
|
|
var gotIncludeUsage bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
gotStream, _ = body["stream"].(bool)
|
|
streamOptions, _ := body["stream_options"].(map[string]any)
|
|
gotIncludeUsage, _ = streamOptions["include_usage"].(bool)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":null}\n\n"))
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n"))
|
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n"))
|
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "chat.completions",
|
|
Model: "DeepSeek-V4-Flash",
|
|
Body: map[string]any{
|
|
"model": "DeepSeek-V4-Flash",
|
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
|
"stream": true,
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ModelName: "deepseek-v4-flash",
|
|
Credentials: map[string]any{"apiKey": "test-key"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run openai stream client: %v", err)
|
|
}
|
|
if !gotStream {
|
|
t.Fatalf("expected upstream stream request")
|
|
}
|
|
if !gotIncludeUsage {
|
|
t.Fatalf("expected upstream stream_options.include_usage=true")
|
|
}
|
|
if response.Usage.TotalTokens != 3 {
|
|
t.Fatalf("unexpected usage: %+v", response.Usage)
|
|
}
|
|
choices, _ := response.Result["choices"].([]any)
|
|
choice, _ := choices[0].(map[string]any)
|
|
message, _ := choice["message"].(map[string]any)
|
|
if message["content"] != "hello world" {
|
|
t.Fatalf("unexpected stream response: %+v", response.Result)
|
|
}
|
|
}
|
|
|
|
func TestGeminiClientChatContract(t *testing.T) {
|
|
var gotPath string
|
|
var gotKey string
|
|
var gotText string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotKey = r.URL.Query().Get("key")
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
contents, _ := body["contents"].([]any)
|
|
first, _ := contents[0].(map[string]any)
|
|
parts, _ := first["parts"].([]any)
|
|
part, _ := parts[0].(map[string]any)
|
|
gotText, _ = part["text"].(string)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{map[string]any{"text": "gemini ok"}},
|
|
},
|
|
}},
|
|
"usageMetadata": map[string]any{
|
|
"promptTokenCount": 4,
|
|
"candidatesTokenCount": 6,
|
|
"totalTokenCount": 10,
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "chat.completions",
|
|
Model: "gemini:gemini-2.5-flash",
|
|
Body: map[string]any{
|
|
"model": "gemini:gemini-2.5-flash",
|
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ModelName: "gemini-2.5-flash",
|
|
ProviderModelName: "gemini-compatible-2.5-flash",
|
|
ModelType: "chat",
|
|
Credentials: map[string]any{"apiKey": "gemini-key"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run gemini client: %v", err)
|
|
}
|
|
if gotPath != "/v1beta/models/gemini-compatible-2.5-flash:generateContent" || gotKey != "gemini-key" || gotText != "ping" {
|
|
t.Fatalf("unexpected request path=%s key=%s text=%s", gotPath, gotKey, gotText)
|
|
}
|
|
if response.Usage.TotalTokens != 10 || extractText(response.Result) != "gemini ok" {
|
|
t.Fatalf("unexpected response: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
|
|
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
|
|
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
|
|
if got != want {
|
|
t.Fatalf("unexpected gemini url: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) {
|
|
var gotPath string
|
|
var gotAuth string
|
|
var gotModel string
|
|
var gotImage string
|
|
var gotSequential string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotAuth = r.Header.Get("Authorization")
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
gotModel, _ = body["model"].(string)
|
|
gotImage, _ = body["image"].(string)
|
|
gotSequential, _ = body["sequential_image_generation"].(string)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "img-volces-edit",
|
|
"created": 123,
|
|
"data": []any{map[string]any{"url": "https://example.com/out.png"}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "images.edits",
|
|
ModelType: "image_edit",
|
|
Model: "doubao-4.0图像编辑",
|
|
Body: map[string]any{
|
|
"model": "doubao-4.0图像编辑",
|
|
"prompt": "make it brighter",
|
|
"image": "https://example.com/source.png",
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ModelName: "豆包Seedream-4.0",
|
|
ProviderModelName: "doubao-seedream-4-0-250828",
|
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
|
Capabilities: map[string]any{
|
|
"image_edit": map[string]any{"output_multiple_images": true},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run volces image edit: %v", err)
|
|
}
|
|
if gotPath != "/images/generations" || gotAuth != "Bearer volces-key" {
|
|
t.Fatalf("unexpected request path=%s auth=%s", gotPath, gotAuth)
|
|
}
|
|
if gotModel != "doubao-seedream-4-0-250828" || gotImage != "https://example.com/source.png" || gotSequential != "auto" {
|
|
t.Fatalf("unexpected body model=%s image=%s sequential=%s", gotModel, gotImage, gotSequential)
|
|
}
|
|
if response.Result["id"] != "img-volces-edit" {
|
|
t.Fatalf("unexpected response: %+v", response.Result)
|
|
}
|
|
}
|
|
|
|
func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|
var submitPath string
|
|
var pollPath string
|
|
var gotAuth string
|
|
var gotModel string
|
|
var gotText string
|
|
var gotFirstFrameRole string
|
|
var gotDuration float64
|
|
var gotRatio string
|
|
var gotResolution string
|
|
var gotSeed float64
|
|
var gotCameraFixed bool
|
|
var gotWatermark bool
|
|
var submittedRemoteTaskID string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
switch r.Method + " " + r.URL.Path {
|
|
case "POST /contents/generations/tasks":
|
|
submitPath = r.URL.Path
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
gotModel, _ = body["model"].(string)
|
|
if body["prompt"] != nil || body["first_frame"] != nil {
|
|
t.Fatalf("video convenience fields leaked upstream: %+v", body)
|
|
}
|
|
for _, key := range []string{"duration_seconds", "aspect_ratio", "audio", "cameraFixed"} {
|
|
if _, ok := body[key]; ok {
|
|
t.Fatalf("volces video task body should not include top-level %s: %+v", key, body)
|
|
}
|
|
}
|
|
gotDuration, _ = body["duration"].(float64)
|
|
gotRatio, _ = body["ratio"].(string)
|
|
gotResolution, _ = body["resolution"].(string)
|
|
gotSeed, _ = body["seed"].(float64)
|
|
gotCameraFixed, _ = body["camera_fixed"].(bool)
|
|
gotWatermark, _ = body["watermark"].(bool)
|
|
content, _ := body["content"].([]any)
|
|
textItem, _ := content[0].(map[string]any)
|
|
gotText, _ = textItem["text"].(string)
|
|
frameItem, _ := content[1].(map[string]any)
|
|
gotFirstFrameRole, _ = frameItem["role"].(string)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-test"})
|
|
case "GET /contents/generations/tasks/cgt-test":
|
|
pollPath = r.URL.Path
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "cgt-test",
|
|
"model": "doubao-seedance-2-0-260128",
|
|
"status": "succeeded",
|
|
"created_at": 456,
|
|
"content": map[string]any{"video_url": "https://example.com/out.mp4"},
|
|
"usage": map[string]any{"completion_tokens": 7, "total_tokens": 9},
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "videos.generations",
|
|
ModelType: "video_generate",
|
|
Model: "豆包Seedance-2.0",
|
|
Body: map[string]any{
|
|
"model": "豆包Seedance-2.0",
|
|
"prompt": "A clean product reveal",
|
|
"first_frame": "https://example.com/first.png",
|
|
"duration": 6,
|
|
"aspect_ratio": "16:9",
|
|
"resolution": "720p",
|
|
"seed": 11,
|
|
"cameraFixed": false,
|
|
"watermark": true,
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ModelName: "豆包Seedance-2.0",
|
|
ProviderModelName: "doubao-seedance-2-0-260128",
|
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
|
PlatformConfig: map[string]any{
|
|
"volcesPollIntervalMs": 100,
|
|
"volcesPollTimeoutSeconds": 1,
|
|
},
|
|
},
|
|
OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
|
|
submittedRemoteTaskID = remoteTaskID
|
|
if payload["id"] != "cgt-test" {
|
|
t.Fatalf("unexpected submitted payload: %+v", payload)
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run volces video: %v", err)
|
|
}
|
|
if submitPath != "/contents/generations/tasks" || pollPath != "/contents/generations/tasks/cgt-test" || gotAuth != "Bearer volces-key" {
|
|
t.Fatalf("unexpected paths/auth submit=%s poll=%s auth=%s", submitPath, pollPath, gotAuth)
|
|
}
|
|
if submittedRemoteTaskID != "cgt-test" {
|
|
t.Fatalf("remote task submit callback did not receive task id, got %q", submittedRemoteTaskID)
|
|
}
|
|
if gotModel != "doubao-seedance-2-0-260128" || gotFirstFrameRole != "first_frame" {
|
|
t.Fatalf("unexpected submitted model=%s role=%s", gotModel, gotFirstFrameRole)
|
|
}
|
|
if gotText != "A clean product reveal" {
|
|
t.Fatalf("video params should not be appended to prompt text, got %q", gotText)
|
|
}
|
|
if gotDuration != 6 || gotRatio != "16:9" || gotResolution != "720p" || gotSeed != 11 || gotCameraFixed != false || gotWatermark != true {
|
|
t.Fatalf("unexpected submitted video params duration=%v ratio=%s resolution=%s seed=%v camera_fixed=%v watermark=%v", gotDuration, gotRatio, gotResolution, gotSeed, gotCameraFixed, gotWatermark)
|
|
}
|
|
data, _ := response.Result["data"].([]any)
|
|
item, _ := data[0].(map[string]any)
|
|
if item["url"] != "https://example.com/out.mp4" || response.Usage.TotalTokens != 9 {
|
|
t.Fatalf("unexpected response: %+v usage=%+v", response.Result, response.Usage)
|
|
}
|
|
}
|
|
|
|
func TestVolcesClientVideoRejectsDuplicateFirstFrameBeforeSubmit(t *testing.T) {
|
|
var submitted bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
submitted = true
|
|
t.Fatalf("duplicate first_frame request should not be submitted upstream")
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "videos.generations",
|
|
ModelType: "image_to_video",
|
|
Model: "豆包Seedance",
|
|
Body: map[string]any{
|
|
"model": "豆包Seedance",
|
|
"content": []any{
|
|
map[string]any{"type": "text", "text": "animate it"},
|
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/second.png"}},
|
|
},
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ProviderModelName: "doubao-seedance-1-5-pro-251215",
|
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
|
},
|
|
})
|
|
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
|
t.Fatalf("expected local invalid_parameter error, got %v", err)
|
|
}
|
|
if submitted {
|
|
t.Fatal("request was submitted upstream")
|
|
}
|
|
}
|
|
|
|
func TestVolcesVideoBodyAllowsOnlyTaskPayloadFields(t *testing.T) {
|
|
body := volcesVideoBody(Request{
|
|
Kind: "videos.generations",
|
|
ModelType: "omni_video",
|
|
Model: "豆包Seedance",
|
|
Body: map[string]any{
|
|
"model": "豆包Seedance",
|
|
"duration": 8,
|
|
"duration_seconds": 8,
|
|
"aspect_ratio": "9:16",
|
|
"resolution": "720p",
|
|
"audio": true,
|
|
"callback_url": "https://example.com/callback",
|
|
"returnLastFrame": true,
|
|
"executionExpiresAfter": 3600,
|
|
"draft": false,
|
|
"cameraFixed": false,
|
|
"watermark": true,
|
|
"seed": -1,
|
|
"task_id": "local-task-id",
|
|
"runMode": "simulation",
|
|
"fps": 24,
|
|
"content": []any{
|
|
map[string]any{"type": "text", "text": "Use <<<element_1>>> in a product reveal"},
|
|
map[string]any{
|
|
"type": "element",
|
|
"element": map[string]any{
|
|
"inline_element": map[string]any{
|
|
"name": "subject",
|
|
"frontal_image_url": "https://example.com/subject.png",
|
|
"refer_images": []any{map[string]any{"url": "https://example.com/side.png", "slot_key": "side"}},
|
|
},
|
|
},
|
|
},
|
|
map[string]any{
|
|
"type": "image_url",
|
|
"role": "unexpected_role",
|
|
"name": "drop-me",
|
|
"image_url": map[string]any{"url": "https://example.com/ref.png", "extra": "drop-me"},
|
|
},
|
|
map[string]any{
|
|
"type": "video_url",
|
|
"duration": 3,
|
|
"video_url": map[string]any{
|
|
"url": "https://example.com/ref.mp4",
|
|
"refer_type": "feature",
|
|
"keep_original_sound": "yes",
|
|
"extra": "drop-me",
|
|
},
|
|
},
|
|
map[string]any{
|
|
"type": "audio_url",
|
|
"audio_url": map[string]any{"url": "https://example.com/ref.mp3", "extra": "drop-me"},
|
|
},
|
|
},
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
ModelName: "豆包Seedance",
|
|
ProviderModelName: "doubao-seedance-2-0-260128",
|
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
|
},
|
|
})
|
|
|
|
allowedTopLevel := map[string]bool{
|
|
"model": true, "content": true, "callback_url": true, "return_last_frame": true, "execution_expires_after": true,
|
|
"generate_audio": true, "draft": true, "resolution": true, "ratio": true, "duration": true,
|
|
"seed": true, "camera_fixed": true, "watermark": true,
|
|
}
|
|
for key := range body {
|
|
if !allowedTopLevel[key] {
|
|
t.Fatalf("unexpected top-level volces field %q in %+v", key, body)
|
|
}
|
|
}
|
|
if body["model"] != "doubao-seedance-2-0-260128" ||
|
|
body["generate_audio"] != true ||
|
|
body["callback_url"] != "https://example.com/callback" ||
|
|
body["return_last_frame"] != true ||
|
|
body["execution_expires_after"] != 3600 ||
|
|
body["draft"] != false ||
|
|
body["resolution"] != "720p" ||
|
|
body["ratio"] != "9:16" ||
|
|
body["duration"] != 8 ||
|
|
body["seed"] != -1 ||
|
|
body["camera_fixed"] != false ||
|
|
body["watermark"] != true {
|
|
t.Fatalf("unexpected direct video fields: %+v", body)
|
|
}
|
|
|
|
content, ok := body["content"].([]map[string]any)
|
|
if !ok || len(content) != 5 {
|
|
t.Fatalf("unexpected sanitized content: %#v", body["content"])
|
|
}
|
|
text := content[0]
|
|
if text["type"] != "text" || strings.Contains(text["text"].(string), "--dur") || strings.Contains(text["text"].(string), "--ratio") {
|
|
t.Fatalf("video params should not be appended to the text item: %+v", text)
|
|
}
|
|
elementImage := content[1]
|
|
if elementImage["type"] != "image_url" || elementImage["role"] != "reference_image" {
|
|
t.Fatalf("referenced element should be converted to reference image: %+v", elementImage)
|
|
}
|
|
imageURL, _ := elementImage["image_url"].(map[string]any)
|
|
if imageURL["url"] != "https://example.com/subject.png" || len(imageURL) != 1 {
|
|
t.Fatalf("element image payload should only include url: %+v", imageURL)
|
|
}
|
|
referenceImage := content[2]
|
|
if referenceImage["role"] != "reference_image" || referenceImage["name"] != nil {
|
|
t.Fatalf("image references should be role-normalized and scrubbed: %+v", referenceImage)
|
|
}
|
|
videoItem := content[3]
|
|
videoURL, _ := videoItem["video_url"].(map[string]any)
|
|
if videoItem["role"] != "reference_video" || videoURL["url"] != "https://example.com/ref.mp4" || videoURL["refer_type"] != "feature" || videoURL["extra"] != nil {
|
|
t.Fatalf("video references should keep only allowed nested fields: %+v", videoItem)
|
|
}
|
|
audioItem := content[4]
|
|
audioURL, _ := audioItem["audio_url"].(map[string]any)
|
|
if audioItem["role"] != "reference_audio" || audioURL["url"] != "https://example.com/ref.mp3" || len(audioURL) != 1 {
|
|
t.Fatalf("audio references should keep only url: %+v", audioItem)
|
|
}
|
|
}
|
|
|
|
func TestVolcesVideoBodyPrefersFramesOverDuration(t *testing.T) {
|
|
body := volcesVideoBody(Request{
|
|
Kind: "videos.generations",
|
|
ModelType: "video_generate",
|
|
Body: map[string]any{
|
|
"prompt": "A quick camera move",
|
|
"duration": 8,
|
|
"frames": 57,
|
|
},
|
|
Candidate: store.RuntimeModelCandidate{
|
|
ProviderModelName: "doubao-seedance-1-0-pro-250528",
|
|
},
|
|
})
|
|
if body["frames"] != 57 {
|
|
t.Fatalf("frames should be passed through as the official duration control: %+v", body)
|
|
}
|
|
if _, ok := body["duration"]; ok {
|
|
t.Fatalf("duration should not be sent when frames is present: %+v", body)
|
|
}
|
|
}
|
|
|
|
func TestVolcesClientVideoResumePollsExistingTaskID(t *testing.T) {
|
|
var submitCalled bool
|
|
var pollPath string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method + " " + r.URL.Path {
|
|
case "POST /contents/generations/tasks":
|
|
submitCalled = true
|
|
t.Fatalf("resume should skip upstream submit when remote task id exists")
|
|
case "GET /contents/generations/tasks/cgt-existing":
|
|
pollPath = r.URL.Path
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "cgt-existing",
|
|
"status": "succeeded",
|
|
"created_at": 789,
|
|
"content": map[string]any{"video_url": "https://example.com/resumed.mp4"},
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
|
Kind: "videos.generations",
|
|
ModelType: "video_generate",
|
|
Model: "豆包Seedance-2.0",
|
|
Body: map[string]any{"prompt": "resume polling", "pollIntervalMs": 100, "pollTimeoutSeconds": 1},
|
|
RemoteTaskID: "cgt-existing",
|
|
Candidate: store.RuntimeModelCandidate{
|
|
BaseURL: server.URL,
|
|
ModelName: "豆包Seedance-2.0",
|
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("resume volces video: %v", err)
|
|
}
|
|
if submitCalled || pollPath != "/contents/generations/tasks/cgt-existing" {
|
|
t.Fatalf("resume should poll existing task only, submit=%v poll=%s", submitCalled, pollPath)
|
|
}
|
|
data, _ := response.Result["data"].([]any)
|
|
item, _ := data[0].(map[string]any)
|
|
if response.Result["upstream_task_id"] != "cgt-existing" || item["url"] != "https://example.com/resumed.mp4" {
|
|
t.Fatalf("unexpected resumed response: %+v", response.Result)
|
|
}
|
|
}
|
|
|
|
func extractText(result map[string]any) string {
|
|
choices, _ := result["choices"].([]any)
|
|
choice, _ := choices[0].(map[string]any)
|
|
message, _ := choice["message"].(map[string]any)
|
|
text, _ := message["content"].(string)
|
|
return text
|
|
}
|