Files
ttstd-infinite-canvas/backend/internal/app/provider_test.go

3025 lines
121 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/prompts"
"infinite-canvas/backend/internal/protocol"
)
const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8="
const testGeminiReferenceImageDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
func officialVideoCreateBody(t *testing.T, input canvasGenerationInput) map[string]any {
t.Helper()
adapter, ok := loadOfficialFallbackRegistry().Resolve(strings.TrimSpace(input.Config.InterfaceType))
if !ok {
t.Fatalf("official adapter %s missing", input.Config.InterfaceType)
}
spec, err := adapter.BuildCreate(context.Background(), protocol.RequestContext{Request: protocolRequestFromInput(input)})
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(spec.Body)
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal(data, &body); err != nil {
t.Fatal(err)
}
return body
}
func TestProviderMediaHydrationPolicyPrefersObjectURLs(t *testing.T) {
prefer := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceGrokImage)}})
if prefer.requireURL || !prefer.preferURL {
t.Fatalf("grok image policy = %#v", prefer)
}
required := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceMiniMaxVideo)}})
if !required.requireURL || !required.preferURL {
t.Fatalf("minimax policy = %#v", required)
}
bytesOnly := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceGeminiImage)}})
if bytesOnly.requireURL || bytesOnly.preferURL {
t.Fatalf("gemini image policy = %#v", bytesOnly)
}
masked := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceOpenAIImage)}, Mask: &providerMedia{ID: "mask"}})
if masked.requireURL || masked.preferURL {
t.Fatalf("openai mask policy = %#v", masked)
}
if resourceUsesObjectStorage(&model.Resource{Provider: "aliyun"}) != true {
t.Fatal("aliyun should use object storage URLs")
}
if resourceUsesObjectStorage(&model.Resource{Provider: "tencent"}) != true || resourceUsesObjectStorage(&model.Resource{Provider: "qiniu"}) != true || resourceUsesObjectStorage(&model.Resource{Provider: "s3"}) != true {
t.Fatal("cos/kodo/s3 should use object storage URLs")
}
if resourceUsesObjectStorage(&model.Resource{Provider: "local"}) {
t.Fatal("local resources should fall back to protocol-compatible bytes")
}
}
func TestProviderRequestErrorDetails(t *testing.T) {
tests := []struct {
name string
err error
code string
text string
}{
{name: "cancelled", err: context.Canceled, code: "request_cancelled", text: "任务取消,中断上游请求"},
{name: "timeout", err: context.DeadlineExceeded, code: "upstream_timeout", text: "等待上游响应超时"},
{name: "network error", err: errors.New("dial tcp: connection refused"), text: "dial tcp: connection refused"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, text := providerRequestErrorDetails(tt.err)
if code != tt.code || text != tt.text {
t.Fatalf("providerRequestErrorDetails() = (%q, %q), want (%q, %q)", code, text, tt.code, tt.text)
}
})
}
}
func TestPostJSONRejectsUnserializableBodyBeforeRequest(t *testing.T) {
err := postJSON(context.Background(), providerConfig{BaseURL: "http://127.0.0.1:1"}, "/should-not-run", map[string]any{
"invalid": make(chan int),
}, &map[string]any{})
if err == nil || !strings.Contains(err.Error(), "序列化上游请求失败") {
t.Fatalf("postJSON() error = %v, want serialization error", err)
}
}
func TestChannelAPIURLNormalizesConfiguredVersionPrefix(t *testing.T) {
tests := []struct {
name string
base string
path string
want string
}{
{name: "host", base: "http://provider.test:8000", path: "/chat/completions", want: "http://provider.test:8000/v1/chat/completions"},
{name: "host slash", base: "http://provider.test:8000/", path: "/chat/completions", want: "http://provider.test:8000/v1/chat/completions"},
{name: "v1", base: "http://provider.test:8000/v1", path: "/chat/completions", want: "http://provider.test:8000/v1/chat/completions"},
{name: "v1 slash", base: "http://provider.test:8000/v1/", path: "/chat/completions", want: "http://provider.test:8000/v1/chat/completions"},
{name: "path carries v1beta", base: "http://provider.test:8000", path: "/v1beta/models/model", want: "http://provider.test:8000/v1beta/models/model"},
{name: "same v1beta is not duplicated", base: "http://provider.test:8000/v1beta", path: "/v1beta/models/model", want: "http://provider.test:8000/v1beta/models/model"},
{name: "path carries v2", base: "http://provider.test:8000/v1", path: "/v2/tasks", want: "http://provider.test:8000/v2/tasks"},
{name: "ark v3", base: "https://ark.example.com/api/v3", path: "/images/generations", want: "https://ark.example.com/api/v3/images/generations"},
{name: "ark v3 chat", base: "https://ark.example.com/api/v3", path: "/chat/completions", want: "https://ark.example.com/api/v3/chat/completions"},
{name: "ark v3 responses", base: "https://ark.example.com/api/v3", path: "/responses", want: "https://ark.example.com/api/v3/responses"},
{name: "path carries ark v3", base: "https://ark.example.com", path: "/api/v3/images/generations", want: "https://ark.example.com/api/v3/images/generations"},
{name: "path carries autodl api v1", base: "https://autodl.art", path: "/api/v1/comfyui/comfyui_workflow/workflow-1", want: "https://autodl.art/api/v1/comfyui/comfyui_workflow/workflow-1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ChannelAPIURL(tt.base, tt.path); got != tt.want {
t.Fatalf("ChannelAPIURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want)
}
})
}
}
func TestChannelAPIURLForProtocolUsesGeminiDefault(t *testing.T) {
if got := ChannelAPIURLForProtocol("https://generativelanguage.googleapis.com", "/models/gemini:generateContent", model.ChannelInterfaceGeminiVeo); got != "https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent" {
t.Fatalf("Gemini URL = %q", got)
}
}
func TestChannelAPIURLForProtocolUsesAgnesOriginPollPath(t *testing.T) {
got := ChannelAPIURLForProtocol("https://apihub.agnes-ai.com/v1", "/agnesapi?video_id=video-1&model_name=agnes-video-2.5", model.ChannelInterfaceAgnesVideo)
if got != "https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5" {
t.Fatalf("Agnes poll URL = %q", got)
}
}
func TestProtocolRequestURLCanResolveSameOriginRootPath(t *testing.T) {
got, err := protocolRequestURL("https://apihub.agnes-ai.com/v1", protocol.RequestSpec{Path: "/agnesapi?video_id=video-1&model_name=agnes-video-2.5", OriginPath: true})
if err != nil {
t.Fatal(err)
}
if got != "https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5" {
t.Fatalf("root path URL = %q", got)
}
}
func TestRunVideoTaskUsesDeclarativeAgnesJSONProtocol(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatalf("newPluginRuntime() error = %v", err)
}
adapter, ok := center.registrySnapshot().Resolve("agnes-video")
if !ok {
t.Fatal("declarative Agnes adapter is missing")
}
if metadata := adapter.Metadata(); metadata.Version != "2.0.0" || metadata.Execution != "declarative" || !metadata.RequiresPublicMediaURLs {
t.Fatalf("Agnes runtime metadata = %#v", metadata)
}
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.RequestURI())
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode create body: %v", err)
}
want := map[string]any{
"model": "agnes-video-2.5", "prompt": "make it move", "mode": "keyframe",
"seconds": "5", "size": "720P", "aspect_ratio": "16:9", "n": float64(1),
"first_frame": server.URL + "/reference.png",
}
if !reflect.DeepEqual(body, want) {
t.Errorf("create body = %#v, want %#v", body, want)
}
for _, legacy := range []string{"input_reference", "input_reference[]", "preset", "resolution_name"} {
if _, exists := body[legacy]; exists {
t.Errorf("create body contains legacy field %q: %#v", legacy, body)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"video_id":"video-1","status":"queued"}`))
case "GET /agnesapi":
if r.URL.Query().Get("video_id") != "video-1" || r.URL.Query().Get("model_name") != "agnes-video-2.5" {
t.Errorf("poll query = %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"video_id":"video-1","status":"completed","metadata":{"url":"` + server.URL + `/video.mp4"}}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
ctx := withProtocolRegistry(context.Background(), center.registrySnapshot())
result, err := runVideoTaskForTest(ctx, canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", InterfaceType: "agnes-video", Model: "agnes-video-2.5", VideoSeconds: "5", Size: "16:9", VQuality: "720P"},
ReferenceImages: []providerMedia{{URL: server.URL + "/reference.png"}},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video, ok := result["video"].(map[string]interface{})
if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", result["video"])
}
wantPaths := "POST /v1/videos,GET /agnesapi?model_name=agnes-video-2.5&video_id=video-1,GET /video.mp4"
if got := strings.Join(paths, ","); got != wantPaths {
t.Fatalf("paths = %q, want %q", got, wantPaths)
}
}
func TestRunVideoTaskDownloadsAuthenticatedDeclarativeResult(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatal(err)
}
paths := make([]string, 0, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/v1/videos":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`))
case "/v1/videos/video-1/content":
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization = %q", got)
}
if got := r.Header.Get("Accept"); got != "video/mp4" {
t.Errorf("Accept = %q", got)
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
ctx := withProtocolRegistry(context.Background(), center.registrySnapshot())
result, err := runVideoTaskForTest(ctx, canvasGenerationInput{
Mode: "video", Prompt: "cinematic shot",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", InterfaceType: "newapi", Model: "sora-2", VideoSeconds: "5", Size: "1280x720"},
})
if err != nil {
t.Fatal(err)
}
video, _ := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
if got := strings.Join(paths, ","); got != "POST /v1/videos,GET /v1/videos/video-1/content" {
t.Fatalf("paths = %q", got)
}
}
func TestSystemChannelIDFromBaseURLSupportsShortAndLegacyProxyPaths(t *testing.T) {
for _, test := range []struct{ base, want string }{
{base: "/api/channel-1", want: "channel-1"},
{base: "/api/ai/system/channel-2", want: "channel-2"},
{base: "https://canvas.example.com/api/channel-3", want: "channel-3"},
} {
if got := systemChannelIDFromBaseURL(test.base); got != test.want {
t.Fatalf("systemChannelIDFromBaseURL(%q) = %q, want %q", test.base, got, test.want)
}
}
}
func TestWriteMediaPartSanitizesFilenameAndSetsMimeType(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writeMediaPart(writer, "image", providerMedia{ID: "image-1", Name: "提示词\n带换行.png", Type: "image/png", DataURL: testReferenceImageDataURL}); err != nil {
t.Fatalf("writeMediaPart() error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart.Writer.Close() error = %v", err)
}
request := httptest.NewRequest(http.MethodPost, "http://example.test", bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
if err := request.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("ParseMultipartForm() error = %v", err)
}
files := request.MultipartForm.File["image"]
if len(files) != 1 {
t.Fatalf("image files = %d, want 1", len(files))
}
file := files[0]
if file.Filename != "reference-image-1.png" || strings.ContainsAny(file.Filename, "\r\n") {
t.Fatalf("filename = %q", file.Filename)
}
if got := file.Header.Get("Content-Type"); got != "image/png" {
t.Fatalf("part Content-Type = %q, want image/png", got)
}
opened, err := file.Open()
if err != nil {
t.Fatalf("file.Open() error = %v", err)
}
defer opened.Close()
data, err := io.ReadAll(opened)
if err != nil {
t.Fatalf("io.ReadAll() error = %v", err)
}
if string(data) != "hello" {
t.Fatalf("file data = %q, want hello", data)
}
}
func TestParseTextEventStreamSupportsResponsesAndChat(t *testing.T) {
responses := []byte(`event: response.output_text.delta
data: {"delta":"{\"title\":\"分镜\""}
event: response.output_text.delta
data: {"delta":"}"}
data: [DONE]
`)
if got, err := parseTextEventStream(responses, "responses"); err != nil || got != `{"title":"分镜"}` {
t.Fatalf("Responses stream = %q, err = %v", got, err)
}
chat := []byte(`data: {"choices":[{"delta":{"content":"第一镜"}}]}
data: {"choices":[{"delta":{"content":":远景"}}]}
data: [DONE]
`)
if got, err := parseTextEventStream(chat, "chat-completion"); err != nil || got != "第一镜:远景" {
t.Fatalf("Chat stream = %q, err = %v", got, err)
}
claude := []byte(`event: content_block_delta
data: {"delta":{"type":"text_delta","text":"第一镜"}}
event: content_block_delta
data: {"delta":{"type":"text_delta","text":":远景"}}
`)
if got, err := parseTextEventStream(claude, "claude-api"); err != nil || got != "第一镜:远景" {
t.Fatalf("Claude stream = %q, err = %v", got, err)
}
}
func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) {
result, err := parseAgentToolPayload(map[string]interface{}{
"choices": []interface{}{map[string]interface{}{
"message": map[string]interface{}{
"content": "准备读取画布",
"tool_calls": []interface{}{map[string]interface{}{
"id": "call-1",
"function": map[string]interface{}{"name": "canvas_get_state", "arguments": `{}`},
}},
},
}},
}, "chat-completion")
if err != nil {
t.Fatalf("parseAgentToolPayload() error = %v", err)
}
if result["text"] != "准备读取画布" {
t.Fatalf("text = %v", result["text"])
}
calls, _ := result["toolCalls"].([]interface{})
if len(calls) != 1 {
t.Fatalf("toolCalls = %#v", result["toolCalls"])
}
call, _ := calls[0].(map[string]interface{})
function, _ := call["function"].(map[string]interface{})
if call["id"] != "call-1" || function["name"] != "canvas_get_state" || function["arguments"] != `{}` {
t.Fatalf("tool call = %#v", call)
}
}
func TestParseAgentToolPayloadSupportsResponses(t *testing.T) {
result, err := parseAgentToolPayload(map[string]interface{}{
"output": []interface{}{
map[string]interface{}{"type": "reasoning", "summary": []interface{}{map[string]interface{}{"type": "summary_text", "text": "先读取画布,再决定操作"}}},
map[string]interface{}{"type": "message", "content": []interface{}{map[string]interface{}{"type": "output_text", "text": "开始操作"}}},
map[string]interface{}{"type": "function_call", "call_id": "call-2", "name": "canvas_apply_ops", "arguments": `{"ops":[]}`},
},
}, "responses")
if err != nil {
t.Fatalf("parseAgentToolPayload() error = %v", err)
}
if result["text"] != "开始操作" {
t.Fatalf("text = %v", result["text"])
}
if result["reasoning"] != "先读取画布,再决定操作" {
t.Fatalf("reasoning = %v", result["reasoning"])
}
calls, _ := result["toolCalls"].([]interface{})
if len(calls) != 1 {
t.Fatalf("toolCalls = %#v", result["toolCalls"])
}
call, _ := calls[0].(map[string]interface{})
function, _ := call["function"].(map[string]interface{})
if call["id"] != "call-2" || function["name"] != "canvas_apply_ops" || function["arguments"] != `{"ops":[]}` {
t.Fatalf("tool call = %#v", call)
}
}
func TestParseAgentToolPayloadSupportsClaude(t *testing.T) {
result, err := parseAgentToolPayload(map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": "开始操作"},
map[string]interface{}{"type": "tool_use", "id": "call-3", "name": "canvas_get_state", "input": map[string]interface{}{}},
},
}, "claude-api")
if err != nil {
t.Fatalf("parseAgentToolPayload() error = %v", err)
}
if result["text"] != "开始操作" {
t.Fatalf("text = %v", result["text"])
}
calls, _ := result["toolCalls"].([]interface{})
if len(calls) != 1 {
t.Fatalf("toolCalls = %#v", result["toolCalls"])
}
call, _ := calls[0].(map[string]interface{})
function, _ := call["function"].(map[string]interface{})
if call["id"] != "call-3" || function["name"] != "canvas_get_state" || function["arguments"] != "{}" {
t.Fatalf("tool call = %#v", call)
}
}
func TestClaudeAgentBodyMapsOpenAIStyleTools(t *testing.T) {
body := claudeAgentBody(map[string]interface{}{
"messages": []interface{}{
map[string]interface{}{"role": "system", "content": "You are concise."},
map[string]interface{}{"role": "user", "content": "读取画布"},
map[string]interface{}{"role": "assistant", "content": nil, "tool_calls": []interface{}{map[string]interface{}{
"id": "call-4", "function": map[string]interface{}{"name": "canvas_get_state", "arguments": `{}`},
}}},
map[string]interface{}{"role": "tool", "tool_call_id": "call-4", "content": `{"nodes":[]}`},
},
"tools": []interface{}{map[string]interface{}{"type": "function", "function": map[string]interface{}{
"name": "canvas_get_state", "description": "读取画布", "parameters": map[string]interface{}{"type": "object"},
}}},
"tool_choice": "required",
})
if body["system"] != "You are concise." || body["max_tokens"] != 4096 {
t.Fatalf("body = %#v", body)
}
messages, _ := body["messages"].([]interface{})
if len(messages) != 3 {
t.Fatalf("messages = %#v", messages)
}
tools, _ := body["tools"].([]interface{})
tool, _ := tools[0].(map[string]interface{})
if tool["name"] != "canvas_get_state" || body["tool_choice"].(map[string]interface{})["type"] != "any" {
t.Fatalf("tools/choice = %#v / %#v", body["tools"], body["tool_choice"])
}
}
func TestCanonicalAgentBodiesPreserveAssistantToolCalls(t *testing.T) {
request := canonicalAgentRequest{
Messages: []map[string]any{
{"role": "user", "content": "读取画布"},
{"role": "assistant", "content": "我先查看当前内容。", "tool_calls": []cloudAgentCall{{
ID: "call-5",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "canvas_get_state", Arguments: `{}`},
}}},
{"role": "tool", "tool_call_id": "call-5", "content": `{"nodes":[]}`},
},
}
chat := canonicalAgentChatBody(&request, false)
chatMessages, _ := chat["messages"].([]interface{})
assistant, _ := chatMessages[1].(map[string]interface{})
if assistant["content"] != "我先查看当前内容。" || len(canonicalAgentToolCalls(assistant["tool_calls"])) != 1 {
t.Fatalf("chat assistant message lost text or tool call: %#v", assistant)
}
// Assert the actual JSON shape, not a helper that could repair missing fields.
raw, err := json.Marshal(chat)
if err != nil {
t.Fatal(err)
}
var wire map[string]interface{}
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatal(err)
}
wireAssistant := wire["messages"].([]interface{})[1].(map[string]interface{})
wireCall := wireAssistant["tool_calls"].([]interface{})[0].(map[string]interface{})
if wireCall["type"] != "function" || wireCall["id"] != "call-5" {
t.Fatalf("chat tool call missing wire fields: %#v", wireCall)
}
function := wireCall["function"].(map[string]interface{})
if function["name"] != "canvas_get_state" || function["arguments"] != "{}" {
t.Fatalf("chat tool call changed function: %#v", function)
}
claude := claudeAgentBody(canonicalAgentChatBody(&request, true))
claudeAssistant := claude["messages"].([]interface{})[1].(map[string]interface{})
blocks, ok := claudeAssistant["content"].([]interface{})
if !ok || len(blocks) != 1 || blocks[0].(map[string]interface{})["type"] != "tool_use" {
t.Fatalf("claude assistant lost tool use: %#v", claudeAssistant)
}
responses := canonicalAgentResponsesBody(&request)
responseInput, _ := responses["input"].([]interface{})
if len(responseInput) != 4 {
t.Fatalf("responses input = %#v", responseInput)
}
functionCall, _ := responseInput[2].(map[string]interface{})
if functionCall["type"] != "function_call" || functionCall["name"] != "canvas_get_state" {
t.Fatalf("responses function call = %#v", functionCall)
}
}
func TestRunAgentToolTaskOmitsToolChoiceBeforeThinkingRequest(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
forcedChoices := []interface{}{
"required",
map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": "canvas_get_state"}},
"auto",
}
for _, forcedChoice := range forcedChoices {
t.Run(fmt.Sprint(forcedChoice), func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if _, exists := body["tool_choice"]; exists {
t.Errorf("thinking request retained tool_choice: %#v", body["tool_choice"])
}
if body["reasoning_effort"] != "medium" {
t.Errorf("thinking was not forwarded: %#v", body["reasoning_effort"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"完成","tool_calls":[]}}]}`))
}))
result, err := runAgentToolTask(context.Background(), canvasGenerationInput{
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model"},
TextOptions: canvasTextOptions{Thinking: true},
AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": forcedChoice}},
})
server.Close()
if err != nil {
t.Fatalf("runAgentToolTask() error = %v", err)
}
if result["text"] != "完成" || requests != 1 {
t.Fatalf("result/requests = %#v / %d", result, requests)
}
})
}
}
func TestRunAgentToolTaskOmitsExplicitAutomaticToolChoice(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if _, exists := body["tool_choice"]; exists {
t.Errorf("automatic choice should use the protocol default: %#v", body["tool_choice"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"完成","tool_calls":[]}}]}`))
}))
defer server.Close()
result, err := runAgentToolTask(context.Background(), canvasGenerationInput{
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "model"},
AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": "auto"}},
})
if err != nil || result["text"] != "完成" {
t.Fatalf("result = %#v, err = %v", result, err)
}
}
func TestRunDeclarativeAgentTaskOmitsToolChoiceBeforeThinkingRequest(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if _, exists := body["tool_choice"]; exists {
t.Errorf("declarative thinking request retained tool_choice: %#v", body["tool_choice"])
}
if body["reasoning_effort"] != "medium" {
t.Errorf("thinking was not forwarded: %#v", body["reasoning_effort"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"完成","tool_calls":[]}}]}`))
}))
defer server.Close()
adapter, err := protocol.LoadManifest([]byte(`{
"apiVersion":"yingce.plugin/v1",
"id":"chat-completion","version":"1.0.0","name":"Chat Completion Test","author":"Test","documentation":"# Test",
"contributes":{"providers":[{"id":"chat-completion","label":"Chat Completion Test","capabilities":["text"],"scopes":["agent"],
"create":{"method":"POST","path":"/create"},
"agent":{"method":"POST","path":"/chat/completions","fields":{"model":"request.model","messages":"request.extra.agent.chatCompletion.messages","tools":"request.extra.agent.chatCompletion.tools","tool_choice":"request.extra.agent.chatCompletion.tool_choice"}},
"agentResponse":{"textPaths":["choices.0.message.content"],"toolCallsPath":"choices.0.message.tool_calls","toolCallIdPaths":["id"],"toolCallNamePaths":["function.name"],"toolCallArgumentsPaths":["function.arguments"]},"response":{}}]}
}`))
if err != nil {
t.Fatal(err)
}
registry, err := protocol.NewRegistry(adapter)
if err != nil {
t.Fatal(err)
}
result, err := runAgentToolTask(withProtocolRegistry(context.Background(), registry), canvasGenerationInput{
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model", InterfaceType: "chat-completion"},
TextOptions: canvasTextOptions{Thinking: true},
AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{
"messages": []interface{}{}, "tools": []interface{}{}, "tool_choice": "required",
}},
})
if err != nil {
t.Fatalf("runAgentToolTask() error = %v", err)
}
if result["text"] != "完成" {
t.Fatalf("result = %#v", result)
}
}
func TestRunAgentToolTaskFallsBackToolChoiceForImplicitThinking(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
var choices []interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
choice, exists := body["tool_choice"]
if _, exists := body["reasoning_effort"]; exists {
t.Errorf("undeclared thinking should not add reasoning options: %#v", body["reasoning_effort"])
}
if exists {
choices = append(choices, choice)
} else {
choices = append(choices, nil)
}
w.Header().Set("Content-Type", "application/json")
if len(choices) < 3 {
_, _ = w.Write([]byte(`{"error":{"message":"tool_choice is incompatible with thinking mode"}}`))
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"完成","tool_calls":[]}}]}`))
}))
defer server.Close()
config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model"}
result, err := runAgentToolTask(context.Background(), canvasGenerationInput{
Config: config,
AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": "required"}},
})
if err != nil {
t.Fatalf("runAgentToolTask() error = %v", err)
}
if result["text"] != "完成" {
t.Fatalf("text = %v", result["text"])
}
if len(choices) != 3 || choices[0] != "required" || choices[1] != "auto" || choices[2] != nil {
t.Fatalf("tool choices = %#v", choices)
}
}
func TestPostStreamingTextSetsStreamHeaders(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Accept"); got != "text/event-stream" {
t.Errorf("Accept = %q", got)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request body: %v", err)
}
if stream, ok := body["stream"].(bool); !ok || !stream {
t.Errorf("stream body field = %#v", body["stream"])
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"流式分镜"}}]}
data: [DONE]
`))
}))
defer server.Close()
var deltas strings.Builder
got, err := postStreamingText(context.Background(), providerConfig{BaseURL: server.URL, APIKey: "test-key"}, "/chat/completions", map[string]interface{}{"model": "test-model"}, "chat-completion", func(delta string) {
deltas.WriteString(delta)
})
if err != nil || got != "流式分镜" {
t.Fatalf("postStreamingText() = %q, err = %v", got, err)
}
if deltas.String() != "流式分镜" {
t.Fatalf("stream deltas = %q", deltas.String())
}
}
func TestTextThinkingOptionsAndReasoningParsing(t *testing.T) {
input := canvasGenerationInput{TextOptions: canvasTextOptions{Thinking: true}}
responsesBody := map[string]interface{}{}
applyTextThinking(responsesBody, input, "responses")
reasoning, _ := responsesBody["reasoning"].(map[string]interface{})
if reasoning["effort"] != "medium" || reasoning["summary"] != "auto" {
t.Fatalf("responses reasoning options = %#v", reasoning)
}
chatBody := map[string]interface{}{}
applyTextThinking(chatBody, input, "chat-completion")
if chatBody["reasoning_effort"] != "medium" {
t.Fatalf("chat reasoning options = %#v", chatBody)
}
result, err := parseAgentToolPayload(map[string]interface{}{"choices": []interface{}{map[string]interface{}{"message": map[string]interface{}{"content": "正文", "reasoning_content": "推理摘要"}}}}, "chat-completion")
if err != nil || result["text"] != "正文" || result["reasoning"] != "推理摘要" {
t.Fatalf("parsed reasoning result = %#v, err = %v", result, err)
}
}
func TestStreamingAgentParserReassemblesChatToolCallsAcrossChunks(t *testing.T) {
var deltas strings.Builder
parser := newStreamingAgentParser("chat-completion", func(delta string) {
deltas.WriteString(delta)
})
stream := `data: {"choices":[{"delta":{"content":"准备","tool_calls":[{"index":0,"id":"call-1","function":{"name":"canvas_apply_ops","arguments":"{\"ops\":"}}]}}]}
data: {"choices":[{"delta":{"content":"执行","tool_calls":[{"index":0,"function":{"arguments":"[]}"}}]}}]}
data: [DONE]
`
parser.consume("text/event-stream", []byte(stream[:47]))
parser.consume("text/event-stream", []byte(stream[47:]))
parser.flush()
result, err := parser.result()
if err != nil {
t.Fatalf("streamingAgentParser.result() error = %v", err)
}
if result["text"] != "准备执行" || deltas.String() != "准备执行" {
t.Fatalf("text = %v, deltas = %q", result["text"], deltas.String())
}
calls, _ := result["toolCalls"].([]interface{})
call, _ := calls[0].(map[string]interface{})
function, _ := call["function"].(map[string]interface{})
if call["id"] != "call-1" || function["name"] != "canvas_apply_ops" || function["arguments"] != `{"ops":[]}` {
t.Fatalf("tool call = %#v", call)
}
}
func TestStreamingAgentParserSeparatesResponsesReasoningFromVisibleText(t *testing.T) {
var deltas strings.Builder
parser := newStreamingAgentParser("responses", func(delta string) {
deltas.WriteString(delta)
})
parser.consume("text/event-stream", []byte(`event: response.reasoning_summary_text.delta
data: {"type":"response.reasoning_summary_text.delta","delta":"内部分析"}
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"可见回答"}
event: response.completed
data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"可见回答"}]}]}}
`))
parser.flush()
result, err := parser.result()
if err != nil {
t.Fatalf("streamingAgentParser.result() error = %v", err)
}
if result["text"] != "可见回答" || result["reasoning"] != "内部分析" || deltas.String() != "可见回答" {
t.Fatalf("result = %#v, deltas = %q", result, deltas.String())
}
}
func TestStreamingAgentParserWaitsForCompleteClaudeToolJSON(t *testing.T) {
parser := newStreamingAgentParser("claude-api", nil)
parser.consume("text/event-stream", []byte(`event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call-2","name":"canvas_get_state","input":{}}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"include\":"}}
`))
parser.flush()
if _, err := parser.result(); err == nil || !strings.Contains(err.Error(), "完整 JSON") {
t.Fatalf("incomplete tool arguments error = %v", err)
}
}
func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) {
message := (providerHTTPError{StatusCode: 524, Status: "524 A Timeout Occurred"}).Error()
if !strings.Contains(message, "可能仍在服务端执行并产生费用") || !strings.Contains(message, "请勿立即重试") {
t.Fatalf("providerHTTPError.Error() = %q", message)
}
}
func TestProviderHTTPErrorDoesNotExposeResponseBody(t *testing.T) {
message := (providerHTTPError{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Body: `{"error":{"message":"api-key=secret"}}`,
}).Error()
if strings.Contains(message, "api-key") || strings.Contains(message, "secret") || strings.Contains(message, `{"error"`) {
t.Fatalf("providerHTTPError exposed upstream response body: %q", message)
}
if !strings.Contains(message, "HTTP 502") {
t.Fatalf("providerHTTPError.Error() = %q", message)
}
}
func TestShouldFallbackTextToChatOnlyForMissingCapability(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented} {
if !shouldFallbackTextToChat(providerHTTPError{StatusCode: status}) {
t.Errorf("status %d should fallback to chat completions", status)
}
}
for _, status := range []int{http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusInternalServerError, http.StatusTooManyRequests} {
if shouldFallbackTextToChat(providerHTTPError{StatusCode: status}) {
t.Errorf("status %d should not fallback to chat completions", status)
}
}
if shouldFallbackTextToChat(errors.New("network")) {
t.Fatal("non-http error should not fallback")
}
}
func TestProviderPayloadErrorMessageUsesSafeActionableCategories(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "moderation", raw: "request blocked by content policy: prompt=private", want: "安全审核"},
{name: "quota", raw: "insufficient quota for api-key=secret", want: "额度不足"},
{name: "model access", raw: "model not found for tenant secret-id", want: "模型不存在"},
{name: "thinking mode rejects forced tool choice", raw: `{"error":{"message":"Thinking mode does not support this tool_choice","request_id":"secret-trace"}}`, want: "不支持强制工具调用"},
{name: "reasoning mode rejects forced tool choice", raw: `{"error":{"message":"tool_choice=required is not supported in reasoning mode"}}`, want: "不支持强制工具调用"},
{name: "unknown", raw: "trace_id=private internal stack", want: "模型服务返回失败"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
message := providerPayloadErrorMessage(tt.raw)
if !strings.Contains(message, tt.want) {
t.Fatalf("providerPayloadErrorMessage() = %q, want category %q", message, tt.want)
}
if strings.Contains(message, "secret") || strings.Contains(message, "private") {
t.Fatalf("provider payload detail leaked: %q", message)
}
})
}
}
func TestProviderPayloadErrorCategoryFlagsRealPersonRejection(t *testing.T) {
raw := `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input image 'content[1]' may contain real person. Request id: secret-trace"}}`
message, ok := providerPayloadErrorCategory(raw)
if !ok {
t.Fatalf("providerPayloadErrorCategory() ok = false, want true")
}
if !strings.Contains(message, "真人形象") {
t.Fatalf("providerPayloadErrorCategory() = %q, want 真人形象 category", message)
}
if strings.Contains(message, "secret") || strings.Contains(message, "content[1]") {
t.Fatalf("provider payload detail leaked: %q", message)
}
}
func TestProviderPayloadErrorCategoryReportsUnclassifiedBodies(t *testing.T) {
for _, raw := range []string{"", " ", "trace_id=private internal stack"} {
if message, ok := providerPayloadErrorCategory(raw); ok {
t.Fatalf("providerPayloadErrorCategory(%q) = %q, want no category", raw, message)
}
}
}
func TestProviderUserFacingErrorMessageClassifiesRejectedRequestBodies(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
want string
}{
{
name: "real person rejection",
statusCode: http.StatusBadRequest,
body: `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"input image may contain real person, secret-trace"}}`,
want: "真人形象",
},
{
name: "moderation rejection",
statusCode: http.StatusBadRequest,
body: `{"error":{"message":"request blocked by content policy, secret-trace"}}`,
want: "安全审核",
},
{
name: "unprocessable entity is classified too",
statusCode: http.StatusUnprocessableEntity,
body: `{"error":{"message":"insufficient balance, secret-trace"}}`,
want: "额度不足",
},
{
name: "unclassified body keeps the generic parameter hint",
statusCode: http.StatusBadRequest,
body: `{"error":{"message":"trace_id=secret-trace"}}`,
want: "请检查模型和参数",
},
{
name: "empty body keeps the generic parameter hint",
statusCode: http.StatusBadRequest,
body: "",
want: "请检查模型和参数",
},
{
name: "thinking mode rejects forced tool choice",
statusCode: http.StatusBadRequest,
body: `{"error":{"message":"Thinking mode does not support this tool_choice","request_id":"secret"}}`,
want: "不支持强制工具调用",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
message := providerUserFacingErrorMessage(providerHTTPError{StatusCode: tt.statusCode, Body: tt.body})
if !strings.Contains(message, tt.want) {
t.Fatalf("providerUserFacingErrorMessage() = %q, want category %q", message, tt.want)
}
if strings.Contains(message, "secret-trace") || strings.Contains(message, `{"error"`) {
t.Fatalf("provider response body leaked: %q", message)
}
})
}
}
func TestProviderUserFacingErrorMessageOnlyClassifiesValidationStatuses(t *testing.T) {
// 鉴权失败与网关错误的正文可能是密钥诊断或代理 HTML,不参与归类。
for _, statusCode := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway} {
message := providerUserFacingErrorMessage(providerHTTPError{
StatusCode: statusCode,
Body: `{"error":{"message":"blocked by content policy, api-key=secret"}}`,
})
if strings.Contains(message, "安全审核") {
t.Fatalf("status %d classified from response body: %q", statusCode, message)
}
if strings.Contains(message, "secret") || strings.Contains(message, "api-key") {
t.Fatalf("status %d leaked response body: %q", statusCode, message)
}
}
}
func TestProviderUserFacingErrorMessageClassifiesWrappedHTTPErrors(t *testing.T) {
wrapped := fmt.Errorf("视频任务创建失败:%w", providerHTTPError{
StatusCode: http.StatusBadRequest,
Body: `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"may contain real person","request_id":"secret-trace"}}`,
})
message := providerUserFacingErrorMessage(wrapped)
if !strings.Contains(message, "真人形象") {
t.Fatalf("providerUserFacingErrorMessage() = %q, want 真人形象 category", message)
}
if strings.Contains(message, "secret-trace") || strings.Contains(message, `{"error"`) {
t.Fatalf("provider response body leaked through wrapped error: %q", message)
}
}
// 正文经常回显用户提示词。肖像类词汇本身不能触发真人类目,
// 否则普通的参数错误或安全审核会被误报成肖像问题。
func TestProviderPayloadErrorCategoryIgnoresEchoedPortraitWording(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "echoed chinese portrait prompt stays a parameter error",
raw: `{"error":{"message":"invalid parameter: prompt=生成油画肖像"}}`,
want: "请检查模型和参数",
},
{
name: "echoed english likeness prompt stays a parameter error",
raw: `{"error":{"message":"invalid argument: style=likeness study"}}`,
want: "请检查模型和参数",
},
{
name: "moderation wins over echoed real person prompt",
raw: `{"error":{"message":"request blocked by content policy: prompt=real person portrait"}}`,
want: "安全审核",
},
{
name: "bare real person prose is not classified as likeness",
raw: `{"error":{"message":"this model does not support real people yet"}}`,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
message, ok := providerPayloadErrorCategory(tt.raw)
if tt.want == "" {
if ok {
t.Fatalf("providerPayloadErrorCategory() = %q, want no category", message)
}
return
}
if !ok {
t.Fatalf("providerPayloadErrorCategory() ok = false, want category %q", tt.want)
}
if strings.Contains(message, "真人形象") {
t.Fatalf("echoed portrait wording misclassified as likeness: %q", message)
}
if !strings.Contains(message, tt.want) {
t.Fatalf("providerPayloadErrorCategory() = %q, want category %q", message, tt.want)
}
})
}
}
// 供应商错误码与安全审核措辞同时出现时,以更具体的错误码为准。
func TestProviderPayloadErrorCategoryPrefersProviderCodeOverModerationWording(t *testing.T) {
raw := `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"blocked by content policy"}}`
message, ok := providerPayloadErrorCategory(raw)
if !ok || !strings.Contains(message, "真人形象") {
t.Fatalf("providerPayloadErrorCategory() = %q, ok = %v, want 真人形象 category", message, ok)
}
}
func TestRunVideoTaskRequiresOfficialPluginWhenRegistryEmpty(t *testing.T) {
ctx := withProtocolRegistry(context.Background(), emptyProtocolRegistry)
_, err := runVideoTaskForTest(ctx, canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: "https://example.com", APIKey: "key", Model: "MiniMax-H3", InterfaceType: "minimax-video"},
})
if err == nil || !strings.Contains(err.Error(), "插件未安装") {
t.Fatalf("runVideoTask() error = %v, want plugin missing", err)
}
}
func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) {
body, err := volcengineArkImageBody(canvasGenerationInput{
Prompt: "combine the references",
Config: providerConfig{Model: "doubao-seedream-test", Size: "3840x2160", SystemPrompt: "keep the subject"},
ReferenceImages: []providerMedia{
{URL: "https://example.com/first.png"},
{DataURL: testReferenceImageDataURL},
},
})
if err != nil {
t.Fatalf("volcengineArkImageBody() error = %v", err)
}
images, ok := body["image"].([]string)
if !ok || len(images) != 2 || images[0] != "https://example.com/first.png" || images[1] != testReferenceImageDataURL {
t.Fatalf("image = %#v", body["image"])
}
if body["prompt"] != "keep the subject\n\ncombine the references" {
t.Fatalf("prompt = %q", body["prompt"])
}
if watermark, ok := body["watermark"].(bool); !ok || watermark {
t.Fatalf("watermark = %#v, want false", body["watermark"])
}
if responseFormat, _ := body["response_format"].(string); responseFormat != "b64_json" {
t.Fatalf("response_format = %#v, want b64_json", body["response_format"])
}
size, _ := body["size"].(string)
parts := strings.Split(size, "x")
if len(parts) != 2 {
t.Fatalf("size = %q", size)
}
width, _ := strconv.Atoi(parts[0])
height, _ := strconv.Atoi(parts[1])
if width%2 != 0 || height%2 != 0 || int64(width)*int64(height) < volcengineArkImageMinPixels || int64(width)*int64(height) > volcengineArkImageMaxPixels {
t.Fatalf("downscaled size = %q", size)
}
}
func TestVolcengineArkImageBodyUpscalesPresetBelowMinimumPixels(t *testing.T) {
body, err := volcengineArkImageBody(canvasGenerationInput{
Prompt: "vertical image",
Config: providerConfig{Model: "doubao-seedream-test", Size: "9:16"},
})
if err != nil {
t.Fatalf("volcengineArkImageBody() error = %v", err)
}
size, _ := body["size"].(string)
parts := strings.Split(size, "x")
if len(parts) != 2 {
t.Fatalf("size = %q", size)
}
width, _ := strconv.Atoi(parts[0])
height, _ := strconv.Atoi(parts[1])
pixels := int64(width) * int64(height)
if width%2 != 0 || height%2 != 0 || pixels < volcengineArkImageMinPixels || pixels > volcengineArkImageMaxPixels {
t.Fatalf("normalized size = %q", size)
}
}
func TestVolcengineArkImageDataURLsDownloadsRemoteResult(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
imageBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
defer server.Close()
images, err := volcengineArkImageDataURLs(context.Background(), providerConfig{}, imageResponse{
Data: []map[string]interface{}{{"url": server.URL + "/generated.png"}},
})
if err != nil {
t.Fatalf("volcengineArkImageDataURLs() error = %v", err)
}
if len(images) != 1 || !strings.HasPrefix(images[0]["dataUrl"], "data:image/png;base64,") {
t.Fatalf("images = %#v", images)
}
svc := newResourceTestService(t)
stored, err := svc.persistGeneratedMediaResult("user-1", map[string]interface{}{"mode": "image", "images": images})
if err != nil {
t.Fatalf("persistGeneratedMediaResult() error = %v", err)
}
storedImages, ok := stored["images"].([]interface{})
if !ok || len(storedImages) != 1 {
t.Fatalf("stored images = %#v", stored["images"])
}
storedImage, ok := storedImages[0].(map[string]interface{})
if !ok || !strings.HasPrefix(stringField(storedImage, "storageKey"), "resource:") || !strings.HasPrefix(stringField(storedImage, "dataUrl"), "/api/resources/") {
t.Fatalf("stored image = %#v", storedImages[0])
}
}
func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) {
_, err := runImageTask(context.Background(), canvasGenerationInput{
Prompt: "edit only the masked area",
Config: providerConfig{InterfaceType: "volcengine-ark-image"},
Mask: &providerMedia{DataURL: testReferenceImageDataURL},
})
if err == nil || !strings.Contains(err.Error(), "不支持蒙版") {
t.Fatalf("runImageTask() error = %v", err)
}
}
func TestRunGeminiImageTaskUsesInlineDataAndImageConfig(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1beta/models/gemini-test:generateContent" {
t.Errorf("path = %q, want /v1beta/models/gemini-test:generateContent", r.URL.Path)
}
if got := r.Header.Get("x-goog-api-key"); got != "test-key" {
t.Errorf("x-goog-api-key = %q, want test-key", got)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
contents, ok := body["contents"].([]interface{})
if !ok || len(contents) != 1 {
t.Fatalf("contents = %#v", body["contents"])
}
content, _ := contents[0].(map[string]interface{})
parts, ok := content["parts"].([]interface{})
if !ok || len(parts) != 2 {
t.Fatalf("parts = %#v", content["parts"])
}
textPart, _ := parts[0].(map[string]interface{})
if textPart["text"] != "edit this image" {
t.Errorf("text part = %#v", textPart)
}
imagePart, _ := parts[1].(map[string]interface{})
inlineData, _ := imagePart["inlineData"].(map[string]interface{})
if inlineData["mimeType"] != "image/png" || inlineData["data"] != "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" {
t.Errorf("inlineData = %#v", inlineData)
}
generationConfig, _ := body["generationConfig"].(map[string]interface{})
modalities, _ := generationConfig["responseModalities"].([]interface{})
if !reflect.DeepEqual(modalities, []interface{}{"TEXT", "IMAGE"}) {
t.Errorf("responseModalities = %#v", modalities)
}
imageConfig, _ := generationConfig["imageConfig"].(map[string]interface{})
if imageConfig["aspectRatio"] != "16:9" || imageConfig["imageSize"] != "4K" {
t.Errorf("imageConfig = %#v", imageConfig)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`))
}))
defer server.Close()
result, err := runImageTask(context.Background(), canvasGenerationInput{
Prompt: "edit this image",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", APIFormat: "gemini", Model: "gemini-test", InterfaceType: "gemini-image", Size: "16:9", Quality: "high"},
ReferenceImages: []providerMedia{{DataURL: testGeminiReferenceImageDataURL}},
})
if err != nil {
t.Fatalf("runImageTask() error = %v", err)
}
images, _ := result["images"].([]map[string]string)
if len(images) != 1 || images[0]["dataUrl"] != testReferenceImageDataURL {
t.Fatalf("images = %#v", result["images"])
}
}
func TestRunGeminiImageTaskRejectsInvalidReferenceBeforeRequest(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
http.Error(w, "unexpected upstream request", http.StatusInternalServerError)
}))
defer server.Close()
_, err := runImageTask(context.Background(), canvasGenerationInput{
Prompt: "edit this image",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", APIFormat: "gemini", Model: "gemini-test", InterfaceType: "gemini-image"},
ReferenceImages: []providerMedia{{DataURL: "data:text/plain;base64,aGVsbG8="}},
})
if err == nil || !strings.Contains(err.Error(), "读取 Gemini Images 参考图失败") || !strings.Contains(err.Error(), "MIME 类型无效") {
t.Fatalf("runImageTask() error = %v", err)
}
if called {
t.Fatal("invalid reference image must be rejected before upstream request")
}
}
func TestRunImageTaskOmitsAutomaticQualityAndSize(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if _, exists := body["quality"]; exists {
t.Errorf("quality = %#v, want omitted for auto", body["quality"])
}
if _, exists := body["size"]; exists {
t.Errorf("size = %#v, want omitted for auto", body["size"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"b64_json":"aGVsbG8="}]}`))
}))
defer server.Close()
profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2")
profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"auto", "1024x1024"}, Default: "1024x1024", AllowCustom: false}
profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "high"}
_, err := runImageTask(context.Background(), canvasGenerationInput{
Prompt: "a product photo",
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-image-2", InterfaceType: "openai-image", Size: "auto", Quality: "auto"},
ImageCapability: profile,
})
if err != nil {
t.Fatalf("runImageTask() error = %v", err)
}
}
func TestRunOpenAIImageTaskUsesMultipartEditContract(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/images/edits" {
t.Errorf("path = %q, want /v1/images/edits", r.URL.Path)
}
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "multipart/form-data;") {
t.Errorf("Content-Type = %q, want multipart/form-data", contentType)
}
if err := r.ParseMultipartForm(2 << 20); err != nil {
t.Fatalf("ParseMultipartForm() error = %v", err)
}
if r.FormValue("model") != "gpt-image-2-high" || r.FormValue("prompt") != "make the reference clearer" || r.FormValue("n") != "1" {
t.Fatalf("form values = model:%q prompt:%q n:%q", r.FormValue("model"), r.FormValue("prompt"), r.FormValue("n"))
}
if r.FormValue("response_format") != "b64_json" || r.FormValue("output_format") != "png" || r.FormValue("size") != "1024x1024" {
t.Fatalf("format values = response_format:%q output_format:%q size:%q", r.FormValue("response_format"), r.FormValue("output_format"), r.FormValue("size"))
}
file, header, err := r.FormFile("image")
if err != nil {
t.Fatalf("FormFile(image) error = %v", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
t.Fatalf("ReadAll(image) error = %v", err)
}
if header.Filename != "reference-reference.png" || string(content) != "hello" {
t.Fatalf("image = filename:%q content:%q", header.Filename, string(content))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"b64_json":"aGVsbG8="}]}`))
}))
defer server.Close()
profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2-high")
result, err := runImageTask(context.Background(), canvasGenerationInput{
Mode: "image",
Prompt: "make the reference clearer",
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-image-2-high", InterfaceType: "openai-image", Size: "1024x1024"},
ImageCapability: profile,
ReferenceImages: []providerMedia{{Name: "reference.png", Type: "image/png", DataURL: testReferenceImageDataURL}},
})
if err != nil {
t.Fatalf("runImageTask() error = %v", err)
}
images, _ := result["images"].([]map[string]string)
if len(images) != 1 || images[0]["dataUrl"] != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("images = %#v", result["images"])
}
}
func TestRunGrokImageTaskUsesJSONEditContract(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/images/edits" {
t.Errorf("path = %q, want /v1/images/edits", r.URL.Path)
}
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var body grokImageRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body.Model != "grok-imagine-image-quality" || body.N != 1 || body.ResponseFormat != "url" {
t.Fatalf("request body = %#v", body)
}
if body.Image == nil || body.Image.URL != testReferenceImageDataURL {
t.Fatalf("image = %#v", body.Image)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/result.png"}]}`))
}))
defer server.Close()
result, err := runImageTask(context.Background(), canvasGenerationInput{
Mode: "image",
Prompt: "edit the reference",
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "grok-imagine-image-quality", InterfaceType: "grok-image"},
ReferenceImages: []providerMedia{{DataURL: testReferenceImageDataURL}},
})
if err != nil {
t.Fatalf("runImageTask() error = %v", err)
}
images, _ := result["images"].([]map[string]string)
if len(images) != 1 || images[0]["dataUrl"] != "https://example.com/result.png" {
t.Fatalf("images = %#v", result["images"])
}
}
func TestGrokImageRequestBodyMapsAspectRatio(t *testing.T) {
body, path, err := grokImageRequestBody(canvasGenerationInput{
Prompt: "a cat",
Config: providerConfig{Model: "grok-imagine-image", InterfaceType: "grok-image", Size: "9:16", Quality: "2k"},
})
if err != nil {
t.Fatalf("grokImageRequestBody() error = %v", err)
}
if path != "/images/generations" {
t.Fatalf("path = %q", path)
}
if body.AspectRatio != "9:16" || body.Resolution != "2k" {
t.Fatalf("body = %#v", body)
}
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
// Grok 使用 aspect_ratio;测试锁定请求 JSON,防止非法 size 字段再次混入。
if _, exists := payload["size"]; exists {
t.Fatalf("request body must not contain size: %s", encoded)
}
if got := normalizeGrokImageAspectRatio("1280x720"); got != "16:9" {
t.Fatalf("normalize 1280x720 = %q", got)
}
if got := normalizeGrokImageAspectRatio("720x1280"); got != "9:16" {
t.Fatalf("normalize 720x1280 = %q", got)
}
}
func TestNormalizeGrokImageAspectRatioPixelSizes(t *testing.T) {
// 像素尺寸路径必须覆盖 2:3 / 3:2 / 1:2 / 2:1:修复前 768x1152 会被 w>h 兜底错标成 9:16、
// 1152x768 错标成 16:9,xAI 按错比例裁切生成图。
cases := []struct {
size string
want string
}{
{"768x1152", "2:3"}, // 1280x1920 同族:竖图 2:3
{"1280x1920", "2:3"}, // 审查复现用例
{"1152x768", "3:2"},
{"1920x1280", "3:2"},
{"540x1080", "1:2"},
{"1080x540", "2:1"},
{"1280x720", "16:9"},
{"720x1280", "9:16"},
{"800x800", "1:1"},
}
for _, tc := range cases {
if got := normalizeGrokImageAspectRatio(tc.size); got != tc.want {
t.Errorf("normalizeGrokImageAspectRatio(%q) = %q, want %q", tc.size, got, tc.want)
}
}
// 冒号字符串路径回归:2:3 等比值字符串应原样透传。
for _, size := range []string{"2:3", "3:2", "1:2", "2:1"} {
if got := normalizeGrokImageAspectRatio(size); got != size {
t.Errorf("normalizeGrokImageAspectRatio(%q) = %q, want passthrough %q", size, got, size)
}
}
}
func TestNormalizeGrokImageResolution(t *testing.T) {
if got := normalizeGrokImageResolution("1k"); got != "1k" {
t.Fatalf("1k = %q", got)
}
if got := normalizeGrokImageResolution("high"); got != "2k" {
t.Fatalf("high = %q", got)
}
if got := normalizeGrokImageResolution("auto"); got != "" {
t.Fatalf("auto = %q", got)
}
}
func TestGrokImageRequestBodyRejectsMaskAndMultipleReferences(t *testing.T) {
if _, _, err := grokImageRequestBody(canvasGenerationInput{Config: providerConfig{InterfaceType: "grok-image"}, Mask: &providerMedia{DataURL: testReferenceImageDataURL}}); err == nil || !strings.Contains(err.Error(), "不支持蒙版") {
t.Fatalf("mask error = %v", err)
}
if _, _, err := grokImageRequestBody(canvasGenerationInput{Config: providerConfig{InterfaceType: "grok-image"}, ReferenceImages: []providerMedia{{DataURL: testReferenceImageDataURL}, {DataURL: testReferenceImageDataURL}}}); err == nil || !strings.Contains(err.Error(), "只支持 1 张") {
t.Fatalf("multiple reference error = %v", err)
}
}
func TestGrokImageRequestBodyPrefersPublicURL(t *testing.T) {
body, path, err := grokImageRequestBody(canvasGenerationInput{
Config: providerConfig{Model: "grok-imagine-image", InterfaceType: "grok-image"},
ReferenceImages: []providerMedia{{URL: "https://example.com/reference.png", DataURL: testReferenceImageDataURL}},
})
if err != nil {
t.Fatalf("grokImageRequestBody() error = %v", err)
}
if path != "/images/edits" || body.Image == nil || body.Image.URL != "https://example.com/reference.png" {
t.Fatalf("path = %q, image = %#v", path, body.Image)
}
}
func TestNormalizePixelSizeConvertsCanvasAspectRatios(t *testing.T) {
tests := map[string]string{
"1:1": "1024x1024",
"3:2": "1536x1024",
"2:3": "1024x1536",
"4:3": "1360x1024",
"3:4": "1024x1360",
"16:9": "1824x1024",
"9:16": "1024x1824",
"21:9": "2352x1008",
}
for input, want := range tests {
t.Run(input, func(t *testing.T) {
if got := normalizePixelSize(input); got != want {
t.Fatalf("normalizePixelSize(%q) = %q, want %q", input, got, want)
}
})
}
}
func TestDoBinaryRejectsOversizedProviderResponse(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", strconv.FormatInt(maxProviderResponseBytes+1, 10))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
_, _, err := getExternalBinary(context.Background(), server.URL)
if err == nil || !strings.Contains(err.Error(), "超过 64MB") {
t.Fatalf("getExternalBinary() error = %v", err)
}
}
func TestTextResponseInputIncludesReferenceMedia(t *testing.T) {
input := canvasGenerationInput{
Prompt: "describe this image",
Config: providerConfig{SystemPrompt: "answer in Chinese"},
ReferenceImages: []providerMedia{
{ID: "image-1", Name: "image.png", Type: "image/png", DataURL: testReferenceImageDataURL},
},
ReferenceVideos: []providerMedia{
{ID: "video-1", Name: "video.mp4", Type: "video/mp4", URL: "https://example.com/reference.mp4"},
},
}
value, err := textResponseInput(input)
if err != nil {
t.Fatalf("textResponseInput() error = %v", err)
}
messages, ok := value.([]map[string]interface{})
if !ok {
t.Fatalf("textResponseInput() = %T, want []map[string]interface{}", value)
}
if len(messages) != 2 {
t.Fatalf("len(messages) = %d, want 2", len(messages))
}
if messages[0]["role"] != "system" || messages[0]["content"] != "answer in Chinese" {
t.Fatalf("system message = %#v", messages[0])
}
content, ok := messages[1]["content"].([]map[string]interface{})
if !ok {
t.Fatalf("user content = %T, want []map[string]interface{}", messages[1]["content"])
}
if len(content) != 3 {
t.Fatalf("len(content) = %d, want 3", len(content))
}
if content[0]["type"] != "input_text" || content[0]["text"] != "describe this image" {
t.Fatalf("text content = %#v", content[0])
}
if content[1]["type"] != "input_image" || content[1]["image_url"] != testReferenceImageDataURL {
t.Fatalf("image content = %#v", content[1])
}
if content[2]["type"] != "input_video" || content[2]["video_url"] != "https://example.com/reference.mp4" {
t.Fatalf("video content = %#v", content[2])
}
}
func TestTextChatContentIncludesReferenceMedia(t *testing.T) {
input := canvasGenerationInput{
Prompt: "describe this image",
ReferenceImages: []providerMedia{
{ID: "image-1", Name: "image.png", Type: "image/png", DataURL: testReferenceImageDataURL},
},
ReferenceVideos: []providerMedia{
{ID: "video-1", Name: "video.mp4", Type: "video/mp4", URL: "https://example.com/reference.mp4"},
},
}
value, err := textChatContent(input)
if err != nil {
t.Fatalf("textChatContent() error = %v", err)
}
content, ok := value.([]map[string]interface{})
if !ok {
t.Fatalf("textChatContent() = %T, want []map[string]interface{}", value)
}
if len(content) != 3 {
t.Fatalf("len(content) = %d, want 3", len(content))
}
if content[0]["type"] != "text" || content[0]["text"] != "describe this image" {
t.Fatalf("text content = %#v", content[0])
}
imageURL, ok := content[1]["image_url"].(map[string]interface{})
if !ok {
t.Fatalf("image_url = %T, want map[string]interface{}", content[1]["image_url"])
}
if content[1]["type"] != "image_url" || imageURL["url"] != testReferenceImageDataURL {
t.Fatalf("image content = %#v", content[1])
}
videoURL, ok := content[2]["video_url"].(map[string]interface{})
if !ok {
t.Fatalf("video_url = %T, want map[string]interface{}", content[2]["video_url"])
}
if content[2]["type"] != "video_url" || videoURL["url"] != "https://example.com/reference.mp4" {
t.Fatalf("video content = %#v", content[2])
}
}
func TestTextReferenceImageRejectsInternalAssetURL(t *testing.T) {
_, err := textResponseInput(canvasGenerationInput{
Prompt: "describe this image",
ReferenceImages: []providerMedia{
{ID: "image-1", Name: "image.png", URL: "asset://local-image"},
},
})
if err == nil {
t.Fatal("textResponseInput() error = nil, want error")
}
}
func TestSeedanceVideosBodyUsesVideosEndpointFields(t *testing.T) {
body, err := seedanceVideosRequestBody(canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{
Model: "seedance-2.0-mini-480p",
Size: "9:16",
VideoSeconds: "8",
VideoGenerateAudio: "true",
},
ReferenceImages: []providerMedia{
{ID: "image-1", DataURL: testReferenceImageDataURL},
{ID: "image-2", DataURL: "data:image/png;base64,d29ybGQ="},
},
ReferenceVideos: []providerMedia{{ID: "video-1", URL: "https://example.com/ref.mp4"}},
ReferenceAudios: []providerMedia{{ID: "audio-1", DataURL: "data:audio/mpeg;base64,AAAA"}},
})
if err != nil {
t.Fatalf("seedanceVideosBody() error = %v", err)
}
if body.Model != "seedance-2.0-mini-480p" {
t.Fatalf("model = %#v", body.Model)
}
if body.AspectRatio != "9:16" || body.Duration != 8 {
t.Fatalf("size fields = %#v %#v", body.AspectRatio, body.Duration)
}
if body.GenerateAudio == nil || !*body.GenerateAudio {
t.Fatalf("generate_audio = %#v, want true", body.GenerateAudio)
}
if body.ImageURL != testReferenceImageDataURL {
t.Fatalf("image_url = %#v", body.ImageURL)
}
if len(body.ReferenceImageURLs) != 1 || body.ReferenceImageURLs[0] != "data:image/png;base64,d29ybGQ=" {
t.Fatalf("reference_image_urls = %#v", body.ReferenceImageURLs)
}
if len(body.ReferenceVideos) != 1 || body.ReferenceVideos[0] != "https://example.com/ref.mp4" {
t.Fatalf("reference_videos = %#v", body.ReferenceVideos)
}
if len(body.ReferenceAudios) != 1 || body.ReferenceAudios[0] != "data:audio/mpeg;base64,AAAA" {
t.Fatalf("reference_audios = %#v", body.ReferenceAudios)
}
}
func TestSeedanceVideosBodyHonorsGenerateAudio(t *testing.T) {
for _, test := range []struct {
name string
value string
want bool
}{
{name: "default enabled", want: true},
{name: "explicit enabled", value: "true", want: true},
{name: "explicit disabled", value: "false", want: false},
} {
t.Run(test.name, func(t *testing.T) {
body, err := seedanceVideosRequestBody(canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{
Model: "seedance-2.0-mini-480p",
VideoGenerateAudio: test.value,
},
})
if err != nil {
t.Fatalf("seedanceVideosBody() error = %v", err)
}
if body.GenerateAudio == nil || *body.GenerateAudio != test.want {
t.Fatalf("generate_audio = %#v, want %v", body.GenerateAudio, test.want)
}
})
}
}
func TestSeedanceVideosBodyUsesOrderedFrameImageURLsWhenConfigured(t *testing.T) {
body, err := seedanceVideosRequestBody(canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{Model: "seedance-2.0-mini-480p"},
ReferenceImages: []providerMedia{
{ID: "character", DataURL: "data:image/png;base64,Y2hhcmFjdGVy"},
{ID: "end-frame", DataURL: "data:image/png;base64,d29ybGQ="},
{ID: "front-frame", DataURL: testReferenceImageDataURL},
},
Metadata: map[string]interface{}{"videoStartFrameNodeId": "front-frame", "videoEndFrameNodeId": "end-frame"},
})
if err != nil {
t.Fatalf("seedanceVideosBody() error = %v", err)
}
imageURLs := body.ImageURLs
if len(imageURLs) != 3 {
t.Fatalf("image_urls = %#v", imageURLs)
}
want := []string{testReferenceImageDataURL, "data:image/png;base64,d29ybGQ=", "data:image/png;base64,Y2hhcmFjdGVy"}
for index := range want {
if imageURLs[index] != want[index] {
t.Fatalf("image_urls = %#v, want %#v", imageURLs, want)
}
}
if body.ImageURL != "" || body.ReferenceImageURLs != nil {
t.Fatalf("unexpected legacy image fields in body: %#v", body)
}
if body.Prompt != "make it move" {
t.Fatalf("prompt = %#v", body.Prompt)
}
}
func TestSeedanceVideosBodyKeepsProjectAssetsAsReferenceImages(t *testing.T) {
body, err := seedanceVideosRequestBody(canvasGenerationInput{
Prompt: "keep the character consistent",
Config: providerConfig{Model: "seedance-2.0"},
ReferenceImages: []providerMedia{
{ID: "character-1", DataURL: testReferenceImageDataURL},
{ID: "character-2", DataURL: "data:image/png;base64,d29ybGQ="},
},
Metadata: map[string]interface{}{
"videoEditOperation": "reference_to_video",
"videoStartFrameNodeId": "character-1",
},
})
if err != nil {
t.Fatalf("seedanceVideosRequestBody() error = %v", err)
}
want := []string{testReferenceImageDataURL, "data:image/png;base64,d29ybGQ="}
if !reflect.DeepEqual(body.ReferenceImageURLs, want) {
t.Fatalf("reference_image_urls = %#v, want %#v", body.ReferenceImageURLs, want)
}
if body.ImageURL != "" || body.ImageURLs != nil {
t.Fatalf("reference operation leaked frame fields: %#v", body)
}
}
func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse create body: %v", err)
}
if r.FormValue("model") != "custom-video-v1" || r.FormValue("prompt") != "make it move" {
t.Errorf("create form = %#v", r.MultipartForm.Value)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"video-1","status":"queued"}`))
case "GET /v1/videos/video-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`))
case "GET /v1/videos/video-1/content":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "custom-video-v1"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video, ok := result["video"].(map[string]interface{})
if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", result["video"])
}
want := "POST /v1/videos,GET /v1/videos/video-1,GET /v1/videos/video-1/content"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunVideoTaskRetriesTransientPollFailureWithoutRecreating(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
createCalls := 0
pollCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
createCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"video-1","status":"queued"}`))
case "GET /v1/videos/video-1":
pollCalls++
w.Header().Set("Content-Type", "application/json")
if pollCalls == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"message":"temporary outage"}`))
return
}
_, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`))
case "GET /v1/videos/video-1/content":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskWithPolicy(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "custom-video-v1"},
}, fastVideoPollPolicy())
if err != nil {
t.Fatal(err)
}
if result["mode"] != "video" || createCalls != 1 || pollCalls != 2 {
t.Fatalf("result = %#v, create calls = %d, poll calls = %d", result, createCalls, pollCalls)
}
}
func TestRunVideoTaskRetriesDownloadWithoutRepolling(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
pollCalls := 0
downloadCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"video-1","status":"queued"}`))
case "GET /v1/videos/video-1":
pollCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`))
case "GET /v1/videos/video-1/content":
downloadCalls++
if downloadCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskWithPolicy(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "custom-video-v1"},
}, fastVideoPollPolicy())
if err != nil {
t.Fatal(err)
}
if result["mode"] != "video" || pollCalls != 1 || downloadCalls != 2 {
t.Fatalf("result = %#v, poll calls = %d, download calls = %d", result, pollCalls, downloadCalls)
}
}
func TestRunVideoTaskSendsOnlyDeclaredResolutionName(t *testing.T) {
tests := []struct {
name string
resolutions []string
quality string
withoutProfile bool
want string
}{
{name: "catalog omits resolution capability", quality: "720"},
{name: "auto never invents 720p", resolutions: []string{"720p", "1080p"}, quality: "auto"},
{name: "legacy auto never invents 720p", quality: "auto", withoutProfile: true},
{name: "missing profile explicit 720 never invents resolution", quality: "720", withoutProfile: true},
{name: "declared HD resolution", resolutions: []string{"1080p"}, quality: "1080", want: "1080p"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
var got string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("ParseMultipartForm() error = %v", err)
}
got = r.FormValue("resolution_name")
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"queued"}`))
case "GET /v1/videos/video-resolution":
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"completed"}`))
case "GET /v1/videos/video-resolution/content":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
profile := DefaultModelCapabilityConfigForModel("newapi", "public-video").Video
profile.Resolutions = test.resolutions
profile.DefaultResolution = ""
if test.withoutProfile {
profile = nil
}
_, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "synthetic prompt",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "public-video", VideoSeconds: "8", Size: "16:9", VQuality: test.quality},
VideoCapability: profile,
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
if got != test.want {
t.Fatalf("resolution_name = %q; want %q", got, test.want)
}
})
}
}
func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"task_id":"video-1","status":"queued"}}`))
case "GET /v1/videos/video-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"video-1","status":"SUCCESS","result_url":"` + server.URL + `/v1/videos/video-1/content","data":{"status":"completed","url":"` + server.URL + `/files/video.mp4"}}}`))
case "GET /files/video.mp4":
if authorization := r.Header.Get("Authorization"); authorization != "Bearer test-key" {
t.Errorf("file Authorization = %q, want Bearer test-key", authorization)
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
case "GET /v1/videos/video-1/content":
http.Error(w, "forbidden", http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-imagine-video-1.5-1080p", VideoSeconds: "15"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video, ok := result["video"].(map[string]interface{})
if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", result["video"])
}
want := "POST /v1/videos,GET /v1/videos/video-1,GET /files/video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunVideoTaskUsesJSONForGrokVideo(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model"] != "grok-video" || body["prompt"] != "make it move" {
t.Errorf("request body = %#v", body)
}
if body["image"] != testReferenceImageDataURL {
t.Errorf("image = %#v", body["image"])
}
images, ok := body["images"].([]interface{})
if !ok || len(images) != 1 || images[0] != testReferenceImageDataURL {
t.Errorf("images = %#v", body["images"])
}
_, _ = w.Write([]byte(`{"id":"video-1","status":"queued"}`))
case "GET /v1/videos/video-1":
_, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`))
case "GET /v1/videos/video-1/content":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "grok-video", VideoSeconds: "10"},
ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}},
Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video, ok := result["video"].(map[string]interface{})
if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", result["video"])
}
}
func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos/generations":
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model"] != "grok-imagine-video-1.5" || body["prompt"] != "make it move" {
t.Errorf("request body = %#v", body)
}
if body["duration"] != float64(10) || body["aspect_ratio"] != "1:1" || body["resolution"] != "720p" {
t.Errorf("xAI settings = %#v", body)
}
for _, legacyField := range []string{"seconds", "size", "images"} {
if _, exists := body[legacyField]; exists {
t.Errorf("request body includes legacy field %q: %#v", legacyField, body)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"request_id":"video-1"}`))
case "GET /v1/videos/video-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"done","url":"` + server.URL + `/files/video.mp4"}`))
case "GET /files/video.mp4":
if authorization := r.Header.Get("Authorization"); authorization != "Bearer test-key" {
t.Errorf("file Authorization = %q, want Bearer test-key", authorization)
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{
BaseURL: server.URL + "/v1",
APIKey: "test-key",
Model: "grok-imagine-video-1.5",
InterfaceType: "xai-video",
VideoSeconds: "10",
Size: "1:1",
VQuality: "720p",
},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video, ok := result["video"].(map[string]interface{})
if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", result["video"])
}
want := "POST /v1/videos/generations,GET /v1/videos/video-1,GET /files/video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunVideoTaskXAIVideoRejectsUnreachableResultURL(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos/generations":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"request_id":"video-loopback"}`))
case "GET /v1/videos/video-loopback":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"done","url":"http://127.0.0.2:1/unreachable.mp4"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{
BaseURL: server.URL + "/v1",
APIKey: "test-key",
Model: "grok-imagine-video",
InterfaceType: "xai-video",
},
})
if err == nil {
t.Fatal("runVideoTask() error = nil, want unreachable result URL to fail")
}
}
func TestXAIVideoPluginMapsUnmarkedImagesToStartFrame(t *testing.T) {
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{
Model: "grok-imagine-video-1.5",
InterfaceType: "xai-video",
VideoSeconds: "20",
Size: "9:16",
VQuality: "1080p",
},
ReferenceImages: []providerMedia{
{ID: "image-1", DataURL: testReferenceImageDataURL},
{ID: "image-2", DataURL: testReferenceImageDataURL},
},
Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"},
})
if body["duration"] != float64(20) || body["aspect_ratio"] != "9:16" || body["resolution"] != "1080p" {
t.Fatalf("xAI settings = %#v", body)
}
image, _ := body["image"].(map[string]any)
if image["url"] != testReferenceImageDataURL {
t.Fatalf("image = %#v", body["image"])
}
}
func TestXAIVideoPluginKeepsStartFrameAsImage(t *testing.T) {
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Config: providerConfig{Model: "grok-imagine-video-1.5", InterfaceType: "xai-video"},
ReferenceImages: []providerMedia{
{ID: "image-1", DataURL: testReferenceImageDataURL},
},
Metadata: map[string]interface{}{"videoEditOperation": "image_to_video", "videoStartFrameNodeId": "image-1"},
})
image, _ := body["image"].(map[string]any)
if image["url"] != testReferenceImageDataURL {
t.Fatalf("image = %#v", body["image"])
}
if _, exists := body["reference_images"]; exists {
t.Fatalf("reference_images = %#v", body["reference_images"])
}
}
func TestXAIVideoPluginUsesReferenceImagesForReferenceOperation(t *testing.T) {
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Config: providerConfig{Model: "grok-imagine-video-1.5", InterfaceType: "xai-video"},
ReferenceImages: []providerMedia{
{ID: "character", DataURL: testReferenceImageDataURL},
},
Metadata: map[string]interface{}{"videoEditOperation": "reference_to_video", "videoStartFrameNodeId": "character"},
})
if body["image"] != nil {
t.Fatalf("image = %#v", body["image"])
}
refs, _ := body["reference_images"].([]any)
if len(refs) != 1 {
t.Fatalf("reference_images = %#v", body["reference_images"])
}
}
func TestNewAPIVideoPromptKeepsTextOnlyPromptUnchanged(t *testing.T) {
input := canvasGenerationInput{
Prompt: "make it move",
}
if prompt := newAPIVideoPromptText(input); prompt != "make it move" {
t.Fatalf("prompt = %q", prompt)
}
}
func TestVideoProviderPromptsKeepReferencePromptUnchanged(t *testing.T) {
input := canvasGenerationInput{
Prompt: "镜头缓慢前推,人物走向门口",
ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}},
Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"},
}
for name, prompt := range map[string]string{
"newapi": newAPIVideoPromptText(input),
"seedance-content": seedancePromptText(input),
"seedance-videos": seedanceVideosPromptText(input),
} {
if prompt != input.Prompt {
t.Fatalf("%s prompt = %q", name, prompt)
}
}
}
func TestNewAPIVideoOmitsImagesForTextToVideoOperation(t *testing.T) {
input := canvasGenerationInput{
Prompt: "make it move with the described character",
ReferenceImages: []providerMedia{
{ID: "image-1", DataURL: testReferenceImageDataURL},
},
Metadata: map[string]interface{}{"videoEditOperation": "text_to_video"},
}
if shouldSendNewAPIVideoImages(input) {
t.Fatal("shouldSendNewAPIVideoImages() = true, want false")
}
if prompt := newAPIVideoPromptText(input); strings.Contains(prompt, "@image1") {
t.Fatalf("prompt = %q", prompt)
}
}
func TestSeedanceVideosBodyRequiresImageForVideoOrAudioReferences(t *testing.T) {
_, err := seedanceVideosRequestBody(canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{Model: "seedance-2.0-mini-480p"},
ReferenceVideos: []providerMedia{{ID: "video-1", URL: "https://example.com/ref.mp4"}},
})
if err == nil {
t.Fatal("seedanceVideosBody() error = nil, want error")
}
}
func TestArkPlanConfigStaysSeparateFromSeedanceVideosEndpoint(t *testing.T) {
config := providerConfig{BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3", Model: "seedance-2.0-pro"}
if !isArkPlanVideoConfig(config) {
t.Fatal("isArkPlanVideoConfig() = false, want true")
}
if !isSeedanceVideoConfig(config) {
t.Fatal("isSeedanceVideoConfig() = false, want true")
}
}
func TestVolcengineArkVideoProtocolUsesContentTaskAndDownloadsResult(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /api/v3/contents/generations/tasks":
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
content, _ := body["content"].([]interface{})
if len(content) != 4 {
t.Errorf("body = %#v", body)
return
}
wantTypes := []string{"text", "image_url", "video_url", "audio_url"}
wantRoles := []string{"", "first_frame", "reference_video", "reference_audio"}
for index, item := range content {
entry, _ := item.(map[string]interface{})
if entry["type"] != wantTypes[index] || (wantRoles[index] != "" && entry["role"] != wantRoles[index]) {
t.Errorf("content[%d] = %#v", index, entry)
}
}
if body["model"] != "doubao-seedance-test" {
t.Errorf("body = %#v", body)
}
_, _ = w.Write([]byte(`{"id":"ark-task-1","status":"running"}`))
case "GET /api/v3/contents/generations/tasks/ark-task-1":
_, _ = w.Write([]byte(`{"id":"ark-task-1","status":"succeeded","content":{"video_url":"` + server.URL + `/result.mp4"}}`))
case "GET /result.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/api/v3", APIKey: "test-key", Model: "doubao-seedance-test", InterfaceType: "volcengine-ark-video"},
ReferenceImages: []providerMedia{{ID: "start", URL: server.URL + "/reference.png"}},
ReferenceVideos: []providerMedia{{ID: "motion", URL: server.URL + "/reference.mp4"}},
ReferenceAudios: []providerMedia{{ID: "music", URL: server.URL + "/reference.mp3"}},
Metadata: map[string]interface{}{"videoStartFrameNodeId": "start"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
want := "POST /api/v3/contents/generations/tasks,GET /api/v3/contents/generations/tasks/ark-task-1,GET /result.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestNewAPIChannel1VideoBodyMapsFramesAndReferences(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{Model: "seedance-2.0", InterfaceType: "newapi-channel-1", Size: "9:16", VQuality: "1080P", VideoSeconds: "15", VideoWatermark: "true"},
ReferenceImages: []providerMedia{
{ID: "first", URL: server.URL + "/first.png"},
{ID: "last", URL: server.URL + "/last.png"},
{ID: "character", URL: server.URL + "/character.png"},
},
ReferenceVideos: []providerMedia{{ID: "video", URL: server.URL + "/reference.mp4"}},
ReferenceAudios: []providerMedia{{ID: "voice", URL: server.URL + "/voice.mp3"}},
Metadata: map[string]interface{}{"videoStartFrameNodeId": "first", "videoEndFrameNodeId": "last"},
})
input := body["input"].(map[string]any)
media, _ := input["media"].([]any)
wantTypes := []string{"first_frame", "last_frame", "reference_image", "reference_video", "reference_audio"}
if len(media) != len(wantTypes) {
t.Fatalf("media = %#v", media)
}
for index, want := range wantTypes {
item, _ := media[index].(map[string]any)
if item["type"] != want {
t.Fatalf("media[%d].type = %q, want %q", index, item["type"], want)
}
}
parameters := body["parameters"].(map[string]any)
if parameters["resolution"] != "1080P" || parameters["ratio"] != "9:16" || parameters["duration"] != float64(15) || parameters["watermark"] != true {
t.Fatalf("parameters = %#v", parameters)
}
}
func TestProtocolRequestPreservesVideoImageIDsAndRoles(t *testing.T) {
request := protocolRequestFromInput(canvasGenerationInput{
Mode: "video",
ReferenceImages: []providerMedia{
{ID: "start", URL: "https://example.com/start.png"},
{ID: "character", URL: "https://example.com/character.png"},
},
Metadata: map[string]interface{}{
"videoEditOperation": "image_to_video",
"videoStartFrameNodeId": "start",
},
})
if len(request.Images) != 2 {
t.Fatalf("images = %#v", request.Images)
}
if request.Images[0].ID != "start" || request.Images[0].Role != "first_frame" {
t.Fatalf("start image = %#v", request.Images[0])
}
if request.Images[1].ID != "character" || request.Images[1].Role != "reference_image" {
t.Fatalf("unmarked image role = %#v", request.Images[1])
}
request = protocolRequestFromInput(canvasGenerationInput{
Mode: "video",
ReferenceImages: []providerMedia{{ID: "character", URL: "https://example.com/character.png"}},
Metadata: map[string]interface{}{"videoEditOperation": "reference_to_video", "videoStartFrameNodeId": "character"},
})
if request.Images[0].Role != "reference_image" {
t.Fatalf("reference operation image = %#v", request.Images[0])
}
}
func TestProtocolRequestRestoresDeclaredVideoResolutionEnum(t *testing.T) {
tests := []struct {
name string
quality string
resolutions []string
want string
}{
{name: "lowercase suffix", quality: "480", resolutions: []string{"480p", "720p"}, want: "480p"},
{name: "provider casing", quality: "1080", resolutions: []string{"720P", "1080P"}, want: "1080P"},
{name: "opaque enum", quality: "768p竖", resolutions: []string{"768p竖", "768p横"}, want: "768p竖"},
{name: "unmatched custom value", quality: "native", resolutions: []string{"720p"}, want: "native"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := protocolRequestFromInput(canvasGenerationInput{
Mode: "video",
Config: providerConfig{VQuality: test.quality},
VideoCapability: &VideoCapabilityConfig{Resolutions: test.resolutions},
})
if request.Resolution != test.want || request.Output.Resolution != test.want {
t.Fatalf("resolution = %q, output resolution = %q, want %q", request.Resolution, request.Output.Resolution, test.want)
}
})
}
}
func TestVolcengineArkDeclarativeRequestUsesResolutionSuffix(t *testing.T) {
profile := DefaultModelCapabilityConfigForModel("volcengine-ark-video", "doubao-seedance-2-0-fast-260128").Video
request := protocolRequestFromInput(canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{
InterfaceType: "volcengine-ark-video",
Model: "doubao-seedance-2-0-fast-260128",
Size: "16:9",
VQuality: "480",
VideoSeconds: "4",
},
VideoCapability: profile,
})
adapter, ok := loadOfficialFallbackRegistry().Resolve("volcengine-ark-video")
if !ok {
t.Fatal("Volcengine Ark declarative adapter is unavailable")
}
spec, err := adapter.BuildCreate(context.Background(), protocol.RequestContext{Request: request})
if err != nil {
t.Fatal(err)
}
body, ok := spec.Body.(map[string]any)
if !ok {
t.Fatalf("body = %#v", spec.Body)
}
if body["resolution"] != "480p" {
t.Fatalf("resolution = %#v, want 480p", body["resolution"])
}
}
func TestNewAPIChannel1VideoPluginKeepsInlineDataURL(t *testing.T) {
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{Model: "seedance-2.0", InterfaceType: "newapi-channel-1"},
ReferenceImages: []providerMedia{{ID: "image", DataURL: testReferenceImageDataURL}},
})
input := body["input"].(map[string]any)
media, _ := input["media"].([]any)
if len(media) != 1 {
t.Fatalf("media = %#v", media)
}
item, _ := media[0].(map[string]any)
if item["url"] != testReferenceImageDataURL {
t.Fatalf("media[0] = %#v", item)
}
}
func TestRunNewAPIChannel1VideoTaskDownloadsSucceededObject(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q", contentType)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model"] != "seedance-2.0" {
t.Errorf("body = %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"channel-1-task","task_id":"channel-1-task","status":"RUNNING"}`))
case "GET /v1/videos/channel-1-task":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"channel-1-task","status":"SUCCEEDED","url":"` + server.URL + `/video.mp4"}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "seedance-2.0", InterfaceType: "newapi-channel-1"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
want := "POST /v1/videos,GET /v1/videos/channel-1-task,GET /video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /v1/video/generations":
if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" {
t.Errorf("Authorization = %q", auth)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model"] != "grok-image-video" || body["seconds"] != "15" || body["aspect_ratio"] != "9:16" || body["resolution"] != "720p" {
t.Errorf("body = %#v", body)
}
images, ok := body["image_urls"].([]interface{})
if !ok || len(images) != 2 || images[0] != testReferenceImageDataURL {
t.Errorf("image_urls = %#v", body["image_urls"])
}
videos, _ := body["video_urls"].([]interface{})
audios, _ := body["audio_urls"].([]interface{})
if len(videos) != 1 || len(audios) != 1 || body["generate_audio"] != true {
t.Errorf("multi-reference body = %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"grok-task","status":"queued"}`))
case "GET /v1/video/generations/grok-task":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"grok-task","status":"SUCCESS","result_url":"` + server.URL + `/video.mp4"}}`))
case "GET /video.mp4":
if authorization := r.Header.Get("Authorization"); authorization != "Bearer test-key" {
t.Errorf("file Authorization = %q, want Bearer test-key", authorization)
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "grok-image-video").Video
profile.Resolutions = []string{"720p"}
profile.DefaultResolution = "720p"
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "9:16", VQuality: "720", VideoGenerateAudio: "true"},
ReferenceImages: []providerMedia{
{ID: "image-1", DataURL: testReferenceImageDataURL},
{ID: "image-2", DataURL: testReferenceImageDataURL},
},
ReferenceVideos: []providerMedia{{ID: "video-1", URL: server.URL + "/reference.mp4"}},
ReferenceAudios: []providerMedia{{ID: "audio-1", URL: server.URL + "/reference.mp3"}},
Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"},
VideoCapability: profile,
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
want := "POST /v1/video/generations,GET /v1/video/generations/grok-task,GET /video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunNewAPIChannel2VideoTaskResumesOriginalProviderTaskWithoutAnotherPost(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 2)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "GET /v1/video/generations/existing-provider-task":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"existing-provider-task","status":"SUCCESS","result_url":"` + server.URL + `/video.mp4"}}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
input := canvasGenerationInput{
Mode: "video",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "video-model", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2)},
}
inputJSON, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
ctx := withProviderAnalytics(context.Background(), nil, model.Task{
ID: "task-1", Type: "canvas_video", ProviderRequestID: "existing-provider-task", InputJSON: string(inputJSON),
})
result, err := runVideoTaskForTest(ctx, input)
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
if result["video"] == nil {
t.Fatalf("result = %#v", result)
}
want := "GET /v1/video/generations/existing-provider-task,GET /video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunNewAPIChannel2VideoTaskReturnsTypedDeadlineWhenPollingWindowEnds(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
ctx = withProviderAnalytics(ctx, nil, model.Task{ID: "task-1", Type: "canvas_video", ProviderRequestID: "existing-provider-task"})
_, err := runVideoTaskForTest(ctx, canvasGenerationInput{
Mode: "video",
Config: providerConfig{InterfaceType: string(model.ChannelInterfaceNewAPIChannel2), Model: "video-model", BaseURL: "https://example.com", APIKey: "key"},
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("runVideoTask() error = %v, want context deadline exceeded", err)
}
}
func TestRunGeminiVeoVideoTaskUsesLongRunningOperation(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
if r.Header.Get("x-goog-api-key") != "test-key" {
t.Errorf("x-goog-api-key = %q", r.Header.Get("x-goog-api-key"))
}
switch r.Method + " " + r.URL.Path {
case "POST /v1beta/models/veo-test:predictLongRunning":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"operations/op-1"}`))
case "GET /v1beta/operations/op-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"done":true,"response":{"videos":["` + server.URL + `/video.mp4"]}}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", APIFormat: "gemini", Model: "veo-test", InterfaceType: "gemini-veo", VideoSeconds: "6", Size: "16:9", VQuality: "720"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
want := "POST /v1beta/models/veo-test:predictLongRunning,GET /v1beta/operations/op-1,GET /video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestNewAPIChannel2PluginSendsDeclaredResolution(t *testing.T) {
profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "declared-video").Video
profile.Resolutions = []string{"1440p"}
profile.DefaultResolution = ""
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Config: providerConfig{Model: "declared-video", InterfaceType: "newapi-channel-2", VideoSeconds: "6", Size: "16:9", VQuality: "2K"},
VideoCapability: profile,
})
if body["resolution"] != "1440p" {
t.Fatalf("resolution = %#v, want 1440p", body["resolution"])
}
}
func TestNewAPIChannel2PluginSendsAllReferenceImages(t *testing.T) {
body := officialVideoCreateBody(t, canvasGenerationInput{
Mode: "video",
Config: providerConfig{Model: "Seedance 2 Mini", InterfaceType: "newapi-channel-2", VideoSeconds: "10"},
ReferenceImages: []providerMedia{
{ID: "character", DataURL: "data:image/png;base64,Y2hhcmFjdGVy"},
{ID: "last-frame", DataURL: "data:image/png;base64,bGFzdA=="},
{ID: "first-frame", DataURL: "data:image/png;base64,Zmlyc3Q="},
},
Metadata: map[string]interface{}{"videoStartFrameNodeId": "first-frame", "videoEndFrameNodeId": "last-frame", "videoEditOperation": "image_to_video"},
})
images, _ := body["image_urls"].([]any)
if len(images) != 3 {
t.Fatalf("image_urls = %#v", images)
}
}
func TestValidateGenerationInterfaceRejectsMismatchedType(t *testing.T) {
if err := validateGenerationInterface("video", "chat-completion"); err == nil {
t.Fatal("validateGenerationInterface() error = nil")
}
if err := validateGenerationInterface("video", "newapi-channel-1"); err != nil {
t.Fatalf("validateGenerationInterface() error = %v", err)
}
if err := validateGenerationInterface("video", "newapi-channel-2"); err != nil {
t.Fatalf("validateGenerationInterface() error = %v", err)
}
if err := validateGenerationInterface("video", "xai-video"); err != nil {
t.Fatalf("validateGenerationInterface() error = %v", err)
}
if err := validateGenerationInterface("image", "grok-image"); err != nil {
t.Fatalf("validateGenerationInterface() error = %v", err)
}
}
func TestProcessTaskValidatesInterfaceBeforeHydratingMedia(t *testing.T) {
input := canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{BaseURL: "https://8.8.8.8/v1", APIKey: "key", Model: "text-model", InterfaceType: "chat-completion"},
ReferenceImages: []providerMedia{{StorageKey: "resource:missing"}},
}
raw, _ := json.Marshal(input)
_, err := (&Service{}).processCanvasGenerationTask(context.Background(), "user-1", "", "video_generate", "", string(raw))
if err == nil || !strings.Contains(err.Error(), "不支持video生成") {
t.Fatalf("processCanvasGenerationTask() error = %v", err)
}
}
func TestResolveGenerationStyleExecutionUsesValidatedPromptAssets(t *testing.T) {
enabled := true
profile := styleProfileDocument{
Prompt: "base style",
ExecutionPolicy: "compatible-fallback",
Assets: []styleProfileAsset{
{ID: "prompt-1", Kind: "prompt", Title: "色彩约束", Provider: "builtin", Enabled: &enabled, Status: "validated", PromptFragment: "muted palette", TriggerWords: []string{"soft light"}},
{ID: "lora-1", Kind: "lora", Title: "东方角色 LoRA", Provider: "liblib", Enabled: &enabled, Status: "validated", SourceID: "model-1"},
},
}
prompt, status, warnings := resolveGenerationStyleExecution(profile, "image-model", "openai-image")
if prompt != "base style\nmuted palette\nsoft light" {
t.Fatalf("resolveGenerationStyleExecution() prompt = %q", prompt)
}
if status != "degraded" || len(warnings) != 1 || !strings.Contains(warnings[0], "LoRA") {
t.Fatalf("resolveGenerationStyleExecution() status = %q, warnings = %#v", status, warnings)
}
}
func TestResolveGenerationStyleExecutionStrictPolicyBlocksUnsupportedAsset(t *testing.T) {
profile := styleProfileDocument{
Prompt: "base style",
ExecutionPolicy: "strict-assets",
Assets: []styleProfileAsset{{ID: "reference-1", Kind: "reference", Title: "项目参考图", Provider: "project", Status: "validated", ReferenceResourceIDs: []string{"resource-1"}}},
}
_, status, warnings := resolveGenerationStyleExecution(profile, "image-model", "openai-image")
if status != "blocked" || len(warnings) != 1 {
t.Fatalf("resolveGenerationStyleExecution() status = %q, warnings = %#v", status, warnings)
}
}
func TestResolveGenerationStyleExecutionSkipsPromptAssetForOtherModel(t *testing.T) {
profile := styleProfileDocument{
Prompt: "base style",
ExecutionPolicy: "compatible-fallback",
Assets: []styleProfileAsset{{
ID: "template-1", Kind: "template", Title: "专用模板", Provider: "workflow", Status: "validated",
BaseModels: []string{"supported-model"}, PromptFragment: "must not be injected",
}},
}
prompt, status, warnings := resolveGenerationStyleExecution(profile, "other-model", "openai-image")
if prompt != "base style" || status != "degraded" || len(warnings) != 1 {
t.Fatalf("resolveGenerationStyleExecution() prompt = %q, status = %q, warnings = %#v", prompt, status, warnings)
}
}
func TestApplyGenerationStyleProfileRebuildsStaleClientPlanForResolvedModel(t *testing.T) {
enabled := true
profile := styleProfileDocument{
SchemaVersion: 1,
PresetID: "style-1",
Title: "项目画风",
Prompt: "base style",
ExecutionPolicy: "compatible-fallback",
Source: "user",
Revision: 1,
Assets: []styleProfileAsset{{
ID: "template-1", Kind: "template", Title: "旧模型模板", Provider: "workflow", Enabled: &enabled, Status: "validated",
BaseModels: []string{"client-model"}, PromptFragment: "client-only fragment",
}},
}
clientPrompt, clientStatus, _ := resolveGenerationStyleExecution(profile, "client-model", "openai-image")
input := canvasGenerationInput{
Mode: "image",
Prompt: "portrait\n\n【项目画风执行规范】\n" + clientPrompt,
Config: providerConfig{Model: "resolved-model", InterfaceType: "openai-image"},
Metadata: map[string]interface{}{
"styleProfileJson": prompts.MustStyleProfileJSON(profile),
"styleExecutionPlan": styleExecutionPlanDocument{
SchemaVersion: 1, ProfilePresetID: profile.PresetID, ProfileRevision: profile.Revision, Mode: "image",
Model: "client-model", InterfaceType: "openai-image", Status: clientStatus, Prompt: clientPrompt,
},
},
}
if err := (&Service{}).applyGenerationStyleProfile("user-1", "", &input); err != nil {
t.Fatalf("applyGenerationStyleProfile() error = %v", err)
}
if input.Prompt != "portrait\n\n【项目画风执行规范】\nbase style" {
t.Fatalf("applyGenerationStyleProfile() prompt = %q", input.Prompt)
}
}
func TestEquivalentStyleProfileJSONIgnoresObjectKeyOrder(t *testing.T) {
equal, err := equivalentStyleProfileJSON(`{"schemaVersion":1,"presetId":"style-1","assets":[]}`, `{"assets":[],"presetId":"style-1","schemaVersion":1}`)
if err != nil || !equal {
t.Fatalf("equivalentStyleProfileJSON() equal = %v, err = %v", equal, err)
}
}
func TestRunNovitaVideoTaskDownloadsSucceededVideo(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.String())
switch r.Method + " " + r.URL.Path {
case "POST /v3/video/create":
if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" {
t.Errorf("Authorization = %q", auth)
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["model_name"] != "kling2.5_turbo_pro_t2v" || body["prompt"] != "make it move" || body["duration"] != float64(5) || body["aspect_ratio"] != "16:9" {
t.Errorf("body = %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"novita-task-1"}`))
case "GET /v3/async/task-result":
if r.URL.Query().Get("task_id") != "novita-task-1" {
t.Errorf("task_id = %q", r.URL.Query().Get("task_id"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"TASK_STATUS_SUCCEED","video_url":"` + server.URL + `/video.mp4"}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "kling2.5_turbo_pro_t2v", InterfaceType: "novita-video", VideoSeconds: "5", Size: "16:9"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
want := "POST /v3/video/create,GET /v3/async/task-result?task_id=novita-task-1,GET /video.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestRunNovitaVideoTaskReturnsFailureReason(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v3/video/create":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"novita-task-2"}`))
case "GET /v3/async/task-result":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"failed","fail_reason":"content violates policy"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "kling2.5_turbo_pro_t2v", InterfaceType: "novita-video"},
})
if err == nil || !strings.Contains(err.Error(), "content violates policy") {
t.Fatalf("runVideoTask() error = %v, want reason in message", err)
}
}
func TestRunMiniMaxVideoTaskCreatesPollsAndDownloads(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /v2/video_generation":
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization = %q", got)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
content, _ := body["content"].([]any)
first := map[string]any{}
if len(content) > 0 {
first, _ = content[0].(map[string]any)
}
if body["model"] != "MiniMax-H3" || body["resolution"] != "768P" || body["duration"] != float64(5) || body["ratio"] != "16:9" || first["text"] != "make it move" {
t.Errorf("body = %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"minimax-task-1"}`))
case "GET /v2/query/video_generation/minimax-task-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task":{"id":"minimax-task-1","status":"succeeded","content":{"url":"` + server.URL + `/video.mp4"}}}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "5", VQuality: "768P", Size: "16:9"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
if got := strings.Join(paths, ","); got != "POST /v2/video_generation,GET /v2/query/video_generation/minimax-task-1,GET /video.mp4" {
t.Fatalf("paths = %q", got)
}
}
func TestRunMiniMaxVideoTaskSendsOfficial2KResolution(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
requested := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v2/video_generation" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
requested = true
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
if body["resolution"] != "2K" {
t.Errorf("resolution = %#v, want official API value 2K", body["resolution"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
_, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Mode: "video", Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "6", VQuality: "2K"},
})
if !requested || err == nil || !strings.Contains(err.Error(), "没有返回任务 ID") {
t.Fatalf("expected submission without task ID, requested=%v err=%v", requested, err)
}
}
func TestRunMiniMaxVideoTaskUsesExplicitReferenceRoles(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v2/video_generation":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
content, _ := body["content"].([]any)
if len(content) != 3 {
t.Fatalf("content = %#v", content)
}
image, _ := content[1].(map[string]any)
audio, _ := content[2].(map[string]any)
if image["role"] != "reference_image" || audio["role"] != "reference_audio" {
t.Errorf("content = %#v", content)
}
if body["ratio"] != "16:9" {
t.Errorf("ratio = %#v, want 16:9 for reference mode", body["ratio"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"minimax-reference-task"}`))
case "GET /v2/query/video_generation/minimax-reference-task":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task":{"status":"succeeded","content":{"url":"` + server.URL + `/video.mp4"}}}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Mode: "video",
Prompt: "保持角色一致",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "6", VQuality: "768P", Size: "16:9"},
ReferenceImages: []providerMedia{{ID: "character-1", URL: server.URL + "/character.png"}},
ReferenceAudios: []providerMedia{{ID: "voice-1", URL: server.URL + "/voice.mp3"}},
Metadata: map[string]interface{}{"videoEditOperation": "reference_to_video"},
})
if err != nil {
t.Fatal(err)
}
}
func TestRunMiniMaxVideoTaskReturnsFailureReason(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v2/video_generation":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"minimax-task-2"}`))
case "GET /v2/query/video_generation/minimax-task-2":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task":{"status":"failed","message":"content violates policy"},"error":{"message":"content violates policy"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, err := runVideoTaskForTest(context.Background(), canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "6"},
})
if err == nil || !strings.Contains(err.Error(), "content violates policy") {
t.Fatalf("runVideoTask() error = %v", err)
}
}