Files
2026-10-02 19:56:32 +08:00

935 lines
40 KiB
Go

package providers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"reflect"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) Do(r *http.Request) (*http.Response, error) { return f(r) }
func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) {
tests := []struct {
name string
adapter Adapter
wantSubmitPath, wantQueryPath, response string
}{
{"evolink", NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, nil), "/v1/images/generations", "/v1/tasks/task-1", `{"id":"task-1","status":"completed","results":[{"url":"https://cdn.test/a.png"}]}`},
{"bailian", NewBailian(Config{BaseURL: "https://b.test", APIKey: "secret", Model: "wan"}, nil), "/api/v1/services/aigc/image-generation/generation", "/api/v1/tasks/task-1", `{"output":{"task_id":"task-1","task_status":"SUCCEEDED","results":[{"url":"https://cdn.test/a.png"}]}}`},
{"seedance", NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "seed"}, nil), "/api/v3/contents/generations/tasks", "/api/v3/contents/generations/tasks/task-1", `{"id":"task-1","status":"succeeded","content":{"video_url":"https://cdn.test/a.mp4"}}`},
{"minimax", NewMinimax(Config{BaseURL: "https://m.test", APIKey: "secret", Model: MinimaxH3Model}, nil), "/v2/video_generation", "/v2/query/video_generation/task-1", `{"task":{"id":"task-1","status":"succeeded","content":{"url":"https://cdn.test/a.mp4"}}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls int
client := roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
want := tt.wantSubmitPath
if calls == 2 {
want = tt.wantQueryPath
}
if r.URL.Path != want {
t.Fatalf("path=%s want=%s", r.URL.Path, want)
}
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
t.Fatal("missing bearer")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(tt.response)), Header: http.Header{}}, nil
})
switch a := tt.adapter.(type) {
case *EvoLink:
a.client = client
case *Bailian:
a.client = client
case *Seedance:
a.client = client
case *Minimax:
a.client = client
}
submitted, err := tt.adapter.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "hello"})
if err != nil || submitted.TaskID != "task-1" {
t.Fatalf("Submit=%#v,%v", submitted, err)
}
queried, err := tt.adapter.Query(context.Background(), "task-1")
if err != nil || queried.Status != "succeeded" || len(queried.OutputURLs) != 1 {
t.Fatalf("Query=%#v,%v", queried, err)
}
})
}
}
func TestMinimaxH3PayloadAndUsageMatchOfficialV2Contract(t *testing.T) {
var calls int
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
calls++
if request.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("authorization=%q", request.Header.Get("Authorization"))
}
if calls == 1 {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
content := body["content"].([]any)
image := content[1].(map[string]any)
imageURL := image["image_url"].(map[string]any)
if body["model"] != MinimaxH3Model || body["resolution"] != "2K" || body["duration"] != float64(8) || body["ratio"] != "adaptive" || body["aigc_watermark"] != false || len(content) != 2 || image["role"] != "first_frame" || imageURL["url"] != "https://cdn.test/first.png" {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"task_id":"task-h3"}`)), Header: http.Header{}}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"task":{"id":"task-h3","model":"MiniMax-H3","status":"succeeded","content":{"url":"https://cdn.test/h3.mp4"},"resolution":"2K","duration":8,"usage":{"total_seconds":8,"input_seconds":0,"output_seconds":8,"input_image_count":1,"completion_tokens":1234},"ratio":"16:9"}}`)), Header: http.Header{}}, nil
})
adapter := NewMinimax(Config{BaseURL: "https://m.test", APIKey: "secret", Model: MinimaxH3Model}, client)
submitted, err := adapter.Submit(context.Background(), Request{
Capability: "video.generate", Prompt: "animate", Materials: []Material{{URL: "https://cdn.test/first.png", Type: MaterialImage}},
Settings: map[string]any{"resolution": "2K", "duration": float64(8), "ratio": "adaptive"},
})
if err != nil || submitted.TaskID != "task-h3" || submitted.Status != StatusQueued {
t.Fatalf("submitted=%#v err=%v", submitted, err)
}
queried, err := adapter.Query(context.Background(), submitted.TaskID)
if err != nil || queried.Status != StatusSucceeded || !reflect.DeepEqual(queried.OutputURLs, []string{"https://cdn.test/h3.mp4"}) || queried.Usage["outputSeconds"] != 8 || queried.Usage["inputImageCount"] != 1 {
t.Fatalf("queried=%#v err=%v", queried, err)
}
}
func TestMinimaxErrorDiagnosticsExtractSafeNumericCode(t *testing.T) {
raw := []byte(`{"type":"error","error":{"type":"insufficient_balance_error","message":"insufficient balance (1008)","http_code":"402"},"request_id":"request-safe"}`)
code, requestID, errorType := inspectHTTPProviderFailure(raw, http.Header{})
if code != "1008" || requestID != "request-safe" || errorType != "insufficient_balance_error" {
t.Fatalf("diagnostics=%q/%q/%q", code, requestID, errorType)
}
}
func TestBailianOnlyEnablesAsyncModeWhenSubmitting(t *testing.T) {
var requests []*http.Request
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
requests = append(requests, request.Clone(request.Context()))
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"output":{"task_id":"task-1","task_status":"PENDING"}}`)),
Header: http.Header{},
}, nil
})
adapter := NewBailian(Config{BaseURL: "https://b.test", APIKey: "secret", Model: "wan"}, client)
if _, err := adapter.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "hello"}); err != nil {
t.Fatal(err)
}
if _, err := adapter.Query(context.Background(), "task-1"); err != nil {
t.Fatal(err)
}
if len(requests) != 2 {
t.Fatalf("requests=%d want=2", len(requests))
}
if got := requests[0].Header.Get("X-DashScope-Async"); got != "enable" {
t.Fatalf("submit X-DashScope-Async=%q want=enable", got)
}
if got := requests[1].Header.Get("X-DashScope-Async"); got != "" {
t.Fatalf("query X-DashScope-Async=%q want empty", got)
}
}
func TestVolcengineSignsSubmitAndMapsResult(t *testing.T) {
var request *http.Request
client := roundTripFunc(func(r *http.Request) (*http.Response, error) {
request = r
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":{"task_id":"task-1","status":"done","image_urls":["https://cdn.test/a.png"]}}`)), Header: http.Header{}}, nil
})
a := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Region: "cn-north-1", Service: "cv", Model: "jimeng"}, client, func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) })
got, err := a.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "hello"})
if err != nil || got.TaskID != "task-1" {
t.Fatalf("Submit=%#v,%v", got, err)
}
if request.URL.Query().Get("Action") != "CVSync2AsyncSubmitTask" || !strings.Contains(request.Header.Get("Authorization"), "Credential=ak/") || request.Header.Get("X-Date") != "20260813T000000Z" {
t.Fatalf("request=%#v headers=%#v", request.URL, request.Header)
}
}
func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) {
var bodies []map[string]any
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
bodies = append(bodies, body)
response := `{"data":{"task_id":"task-1","status":"queued"}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: http.Header{}}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "draw", InputURLs: []string{"https://cdn.test/ref.png"}, Settings: map[string]any{"width": 1024, "height": 768, "force_single": true, "ignored": "value"}}); err != nil {
t.Fatal(err)
}
if _, err := adapter.Query(context.Background(), "task-1"); err != nil {
t.Fatal(err)
}
if bodies[0]["width"] != float64(1024) || bodies[0]["height"] != float64(768) || bodies[0]["force_single"] != true || bodies[0]["ignored"] != nil {
t.Fatalf("submit body=%#v", bodies[0])
}
imageURLs, ok := bodies[0]["image_urls"].([]any)
if !ok || len(imageURLs) != 1 || imageURLs[0] != "https://cdn.test/ref.png" {
t.Fatalf("submit image_urls=%#v", bodies[0]["image_urls"])
}
queryJSON, ok := bodies[1]["req_json"].(string)
if !ok || queryJSON == "" {
t.Fatalf("query body=%#v", bodies[1])
}
var queryOptions map[string]any
if err := json.Unmarshal([]byte(queryJSON), &queryOptions); err != nil {
t.Fatal(err)
}
logo := queryOptions["logo_info"].(map[string]any)
if queryOptions["return_url"] != true || logo["add_logo"] != false || logo["opacity"] != float64(1) {
t.Fatalf("query options=%#v", queryOptions)
}
}
func TestVolcengineSeedream46UsesDedicatedActionsAndVersion(t *testing.T) {
var requests []*http.Request
var bodies []map[string]any
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
requests = append(requests, request.Clone(request.Context()))
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
bodies = append(bodies, body)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"code":10000,"data":{"task_id":"task-1","status":"queued"}}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng_seedream46_cvtob",
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{
Prompt: "draw", Settings: map[string]any{"scale": 50, "width": 2048, "height": 2048, "force_single": true},
}); err != nil {
t.Fatal(err)
}
if _, err := adapter.QueryModel(context.Background(), "task-1", "jimeng_seedream46_cvtob"); err != nil {
t.Fatal(err)
}
wantActions := []string{"JimengSeedream46CVToBSubmitTask", "JimengSeedream46CVToBGetResult"}
if len(requests) != len(wantActions) {
t.Fatalf("requests=%d want=%d", len(requests), len(wantActions))
}
for index, wantAction := range wantActions {
if got := requests[index].URL.Query().Get("Action"); got != wantAction {
t.Fatalf("request[%d] action=%q want=%q", index, got, wantAction)
}
if got := requests[index].URL.Query().Get("Version"); got != "2024-06-06" {
t.Fatalf("request[%d] version=%q want=2024-06-06", index, got)
}
}
if bodies[0]["req_key"] != "jimeng_seedream46_cvtob" || bodies[0]["scale"] != float64(50) {
t.Fatalf("submit body=%#v", bodies[0])
}
if _, exists := bodies[0]["image_urls"]; exists {
t.Fatalf("text-to-image submit contains empty image_urls: %#v", bodies[0])
}
if bodies[1]["req_key"] != "jimeng_seedream46_cvtob" || bodies[1]["task_id"] != "task-1" {
t.Fatalf("query body=%#v", bodies[1])
}
}
func TestVolcengineQueryUsesPersistedTaskModelInsteadOfAdapterFallback(t *testing.T) {
var body map[string]any
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":{"task_id":"task-1","status":"queued"}}`)), Header: http.Header{}}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "fallback-model-b"}, client, func() time.Time {
return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
})
if _, err := adapter.QueryModel(context.Background(), "task-1", "persisted-model-a"); err != nil {
t.Fatal(err)
}
if body["req_key"] != "persisted-model-a" || body["task_id"] != "task-1" {
t.Fatalf("query body=%#v", body)
}
}
func TestVolcengineDecodesOfficialQueryResponse(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Query().Get("Action") != seedream46QueryAction || request.URL.Query().Get("Version") != seedream46Version {
t.Fatalf("action=%q", request.URL.Query().Get("Action"))
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"code":10000,
"data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"},
"message":"Success",
"request_id":"request-1",
"status":10000,
"time_elapsed":"508.312154ms"
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time {
return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
})
result, err := adapter.QueryModel(context.Background(), "task-1", "jimeng_seedream46_cvtob")
if err != nil {
t.Fatal(err)
}
if result.TaskID != "task-1" || result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/generated.png" {
t.Fatalf("result=%#v", result)
}
}
func TestVolcengineDecodesGatewayWrappedBusinessResponse(t *testing.T) {
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"ResponseMetadata":{"RequestId":"request-wrapper-1"},
"Result":{
"code":10000,
"data":{"task_id":"task-wrapper-1","status":"done","image_urls":["https://cdn.test/wrapped.png"]},
"message":"Success",
"request_id":"request-business-1"
}
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: seedream46Model}, client, func() time.Time {
return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
})
result, err := adapter.Submit(context.Background(), Request{Prompt: "draw"})
if err != nil {
t.Fatal(err)
}
if result.TaskID != "task-wrapper-1" || result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/wrapped.png" {
t.Fatalf("result=%#v", result)
}
}
func TestVolcengineRejectsOfficialBusinessErrorOnHTTP200(t *testing.T) {
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"code":50413,
"data":null,
"message":"Post Text Risk Not Pass",
"request_id":"request-2",
"status":50413
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time {
return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
})
if _, err := adapter.Submit(context.Background(), Request{Prompt: "blocked prompt"}); err == nil {
t.Fatal("expected Volcengine business error")
}
}
func TestVolcengineServiceFailureLogsOnlySafeDiagnosticFields(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(strings.NewReader(`{
"code":50400,
"message":"Authorization failed for private prompt",
"request_id":"request-safe-1",
"data":null
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: "jimeng",
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine service error")
}
got := output.String()
for _, expected := range []string{"operation=submit", "status=403", `code="50400"`, `requestId="request-safe-1"`, "errorClass=service", "elapsedMs="} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-access-key", "private-secret-key", "Authorization failed", "private prompt", "Signature=", "Credential="} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestVolcengineServiceFailureLogsNestedGatewayDiagnosticFields(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{
"ResponseMetadata": {
"RequestId": "request-nested-1",
"Error": {
"CodeN": 100025,
"Code": "InvalidCredential",
"Message": "Authorization contains private-secret-key and private prompt"
}
}
}`)),
Header: http.Header{"X-Tt-Logid": []string{"request-header-ignored"}},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: seedream46Model,
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine gateway error")
}
got := output.String()
for _, expected := range []string{"operation=submit", "status=400", `code="InvalidCredential"`, `codeN="100025"`, `requestId="request-nested-1"`, "errorClass=service"} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-access-key", "private-secret-key", "Authorization contains", "private prompt", "Signature=", "Credential=", "request-header-ignored"} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestVolcengineServiceFailureFallsBackToSafeResponseHeaderRequestID(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader(`{"ResponseMetadata":{"Error":{"Code":"InternalServiceError","CodeN":"100023"}}}`)),
Header: http.Header{"X-Tt-Logid": []string{"request-header-1"}},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: seedream46Model,
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine gateway error")
}
got := output.String()
for _, expected := range []string{`code="InternalServiceError"`, `codeN="100023"`, `requestId="request-header-1"`} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
}
func TestVolcengineTimeoutLogsSafeTransportClassification(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: "jimeng"}, client, nil)
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine timeout")
}
got := output.String()
for _, expected := range []string{"operation=submit", "status=0", `code=""`, `requestId=""`, "errorClass=timeout", "elapsedMs="} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-access-key", "private-secret-key", "private prompt", "deadline exceeded"} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestProviderErrorsAreGenericAndDoNotLeakSecrets(t *testing.T) {
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader(`{"message":"secret upstream detail"}`)), Header: http.Header{}}, nil
})
a := NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "very-secret", Model: "m"}, client)
_, err := a.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "p"})
if err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("error=%v", err)
}
}
func TestSeedreamServiceFailureLogsOnlySafeDiagnosticFields(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{
"error": {
"code": "InvalidParameter",
"type": "BadRequest",
"message": "private prompt rejected for https://private.test/source.png?Signature=private-signature"
},
"request_id": "request-safe-seedream-1"
}`)),
Header: http.Header{},
}, nil
})
adapter := NewSeedream(Config{
BaseURL: "https://ark.test/api/v3", APIKey: "private-api-key", Model: Seedream50ProModel,
}, client)
_, err := adapter.Submit(context.Background(), Request{
Prompt: "private prompt",
InputURLs: []string{"https://private.test/source.png?Signature=private-signature"},
})
if err == nil {
t.Fatal("expected Seedream service error")
}
if strings.Contains(err.Error(), "InvalidParameter") || strings.Contains(err.Error(), "private") {
t.Fatalf("provider error leaks upstream detail: %v", err)
}
got := output.String()
for _, expected := range []string{
"provider=seedream",
"operation=submit",
"status=400",
`code="InvalidParameter"`,
`requestId="request-safe-seedream-1"`,
`errorType="BadRequest"`,
"errorClass=service",
"elapsedMs=",
} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{
"private-api-key",
"private prompt",
"private.test",
"private-signature",
"Signature=",
"rejected",
} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestSeedreamServiceFailureUsesSafeHeaderRequestIDFallback(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"InvalidImage","message":"private response detail"}}`)),
Header: http.Header{"X-Tt-Logid": []string{"request-header-seedream-1"}},
}, nil
})
adapter := NewSeedream(Config{BaseURL: "https://ark.test/api/v3", APIKey: "private-api-key", Model: Seedream50ProModel}, client)
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Seedream service error")
}
got := output.String()
for _, expected := range []string{`code="InvalidImage"`, `requestId="request-header-seedream-1"`} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
if strings.Contains(got, "private") {
t.Fatalf("log leaks private detail: %s", got)
}
}
func TestSeedreamTimeoutLogsSafeClassificationAndRetainsCause(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})
adapter := NewSeedream(Config{BaseURL: "https://ark.test/api/v3", APIKey: "private-api-key", Model: Seedream50ProModel}, client)
_, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error does not retain deadline cause: %v", err)
}
got := output.String()
for _, expected := range []string{"provider=seedream", "operation=submit", "status=0", "errorClass=timeout", "elapsedMs="} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-api-key", "private prompt", "deadline exceeded"} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestBailianUsesThePreparedRequestModelForImageAndVideo(t *testing.T) {
models := []string{}
client := roundTripFunc(func(r *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
models = append(models, body["model"].(string))
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"output":{"task_id":"task-1","task_status":"PENDING"}}`)), Header: http.Header{}}, nil
})
adapter := NewBailian(Config{BaseURL: "https://b.test", APIKey: "secret", Model: "fallback-model"}, client)
if _, err := adapter.Submit(context.Background(), Request{Capability: "image.generate", Model: "image-model", Prompt: "image"}); err != nil {
t.Fatal(err)
}
if _, err := adapter.Submit(context.Background(), Request{Capability: "video.generate", Model: "video-model", Prompt: "video"}); err != nil {
t.Fatal(err)
}
if len(models) != 2 || models[0] != "image-model" || models[1] != "video-model" {
t.Fatalf("models=%#v", models)
}
}
func TestBailianPayloadsMatchImageAndVideoProtocols(t *testing.T) {
tests := []struct {
name string
request Request
check func(*testing.T, map[string]any)
}{
{
name: "image messages and parameters",
request: Request{Capability: "image.generate", Model: "wan-image", Prompt: "draw", InputURLs: []string{"https://cdn.test/ref.png"}, Settings: map[string]any{
"width": 1024, "height": 768,
}},
check: func(t *testing.T, body map[string]any) {
input := body["input"].(map[string]any)
messages := input["messages"].([]any)
content := messages[0].(map[string]any)["content"].([]any)
parameters := body["parameters"].(map[string]any)
if len(content) != 2 || content[0].(map[string]any)["image"] != "https://cdn.test/ref.png" || content[1].(map[string]any)["text"] != "draw" {
t.Fatalf("content=%#v", content)
}
if parameters["size"] != "1024*768" || parameters["n"] != float64(1) || parameters["watermark"] != false {
t.Fatalf("parameters=%#v", parameters)
}
if _, exists := parameters["thinking_mode"]; exists {
t.Fatalf("editing parameters=%#v", parameters)
}
},
},
{
name: "video first and last frames",
request: Request{Capability: "video.generate", Model: "wan-video", Prompt: "move", InputURLs: []string{"https://cdn.test/first.png", "https://cdn.test/last.png"}, Settings: map[string]any{
"resolution": "1080p", "duration": 8,
}},
check: func(t *testing.T, body map[string]any) {
input := body["input"].(map[string]any)
media := input["media"].([]any)
parameters := body["parameters"].(map[string]any)
if len(media) != 2 || media[0].(map[string]any)["type"] != "first_frame" || media[1].(map[string]any)["type"] != "last_frame" {
t.Fatalf("media=%#v", media)
}
if parameters["resolution"] != "1080P" || parameters["duration"] != float64(8) || parameters["prompt_extend"] != true || parameters["watermark"] != false {
t.Fatalf("parameters=%#v", parameters)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
test.check(t, body)
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"output":{"task_id":"task-1","task_status":"PENDING"}}`)), Header: http.Header{}}, nil
})
if _, err := NewBailian(Config{BaseURL: "https://b.test", APIKey: "secret"}, client).Submit(context.Background(), test.request); err != nil {
t.Fatal(err)
}
})
}
}
func TestBailianDecodesCompatibleModeChoiceImages(t *testing.T) {
result := decodeBailian([]byte(`{"output":{"task_id":"task-1","task_status":"SUCCEEDED","choices":[{"message":{"content":[{"image":"https://cdn.test/choice.png"}]}}]}}`))
if result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/choice.png" {
t.Fatalf("result=%#v", result)
}
}
func TestBailianWan30ReferencePayloadAndActualDuration(t *testing.T) {
adapter := NewBailian(Config{Model: "wan2.7-i2v-2026-04-25"}, nil)
request := Request{Capability: "video.generate", Model: "wan3.0-video", Prompt: "combine", InputURLs: []string{"https://cdn.test/a.png", "https://cdn.test/b.png"}, Settings: map[string]any{"resolution": "480P", "duration": 20, "ratio": "9:16"}}
payload := adapter.payload(request).(map[string]any)
media := payload["input"].(map[string]any)["media"].([]any)
if payload["model"] != "wan3.0-video" || len(media) != 2 || media[0].(map[string]any)["type"] != "reference_image" || media[1].(map[string]any)["type"] != "reference_image" {
t.Fatalf("payload=%#v", payload)
}
if payload["parameters"].(map[string]any)["ratio"] != "9:16" {
t.Fatalf("parameters=%#v", payload["parameters"])
}
request.Settings["inputMode"] = "frames"
media = adapter.payload(request).(map[string]any)["input"].(map[string]any)["media"].([]any)
if media[0].(map[string]any)["type"] != "first_frame" || media[1].(map[string]any)["type"] != "last_frame" {
t.Fatalf("media=%#v", media)
}
request.InputURLs = nil
request.Settings["inputMode"] = "reference"
if _, exists := adapter.payload(request).(map[string]any)["input"].(map[string]any)["media"]; exists {
t.Fatal("Wan 3.0 text-only request should omit media")
}
result := decodeBailian([]byte(`{"output":{"task_id":"task-1","task_status":"SUCCEEDED","video_url":"https://cdn.test/v.mp4"},"usage":{"output_video_duration":5.25,"duration":8}}`))
if result.Usage["outputMilliseconds"] != 5250 || result.Usage["outputSeconds"] != 6 {
t.Fatalf("usage=%#v", result.Usage)
}
}
func TestSeedance20PayloadMatchesOfficialMultimodalContract(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
content := body["content"].([]any)
if len(content) != 4 {
t.Fatalf("content=%#v", content)
}
checks := []struct{ materialType, urlKey, role string }{
{"image_url", "image_url", "reference_image"},
{"video_url", "video_url", "reference_video"},
{"audio_url", "audio_url", "reference_audio"},
}
for index, check := range checks {
item := content[index+1].(map[string]any)
if item["type"] != check.materialType || item["role"] != check.role {
t.Fatalf("content[%d]=%#v", index+1, item)
}
if _, exists := item["label"]; exists {
t.Fatalf("content[%d] contains unsupported label: %#v", index+1, item)
}
if object := item[check.urlKey].(map[string]any); object["url"] == "" {
t.Fatalf("content[%d]=%#v", index+1, item)
}
}
if body["model"] != "doubao-seedance-2-0-260128" || body["generate_audio"] != true || body["watermark"] != false {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-1","status":"queued"}`)), Header: http.Header{}}, nil
})
request := Request{
Capability: "video.generate", Prompt: "combine",
Materials: []Material{
{URL: "https://cdn.test/image.png", Type: MaterialImage, Label: "图片1"},
{URL: "https://cdn.test/video.mp4", Type: MaterialVideo, Label: "视频1"},
{URL: "https://cdn.test/audio.mp3", Type: MaterialAudio, Label: "音频1"},
},
}
if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "doubao-seedance-2-0-260128"}, client).Submit(context.Background(), request); err != nil {
t.Fatal(err)
}
}
func TestSeedance25PayloadSelectsAutomaticOmniReferenceTask(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["model"] != "doubao-seedance-2-5-260628" || body["omni_reference_task_type"] != "auto" {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-25","status":"queued"}`)), Header: http.Header{}}, nil
})
request := Request{
Capability: "video.generate", Model: "doubao-seedance-2-5-260628", Prompt: "combine",
Materials: []Material{{URL: "https://cdn.test/image.png", Type: MaterialImage, Label: "图片1"}},
}
if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "doubao-seedance-2-0-260128"}, client).Submit(context.Background(), request); err != nil {
t.Fatal(err)
}
}
func TestEvoLinkPayloadIncludesQualityAndSize(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["quality"] != "high" || body["size"] != "16:9" || body["resolution"] != "1K" {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-1","status":"queued"}`)), Header: http.Header{}}, nil
})
_, err := NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, client).Submit(context.Background(), Request{
Capability: "image.generate", Prompt: "draw", Settings: map[string]any{"quality": " high ", "width": 1920, "height": 1080},
})
if err != nil {
t.Fatal(err)
}
}
func TestEvoLinkImage25PayloadUsesSelectedModelAndOneK(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
images, ok := body["image_urls"].([]any)
if request.URL.Path != "/v1/images/generations" || body["model"] != "gpt-image-2.5-flare" || body["quality"] != "medium" || body["n"] != float64(1) || body["resolution"] != "1K" || body["size"] != "848x1200" || !ok || len(images) != 1 {
t.Fatalf("payload=%#v", body)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-25","status":"queued"}`)), Header: http.Header{}}, nil
})
_, err := NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, client).Submit(context.Background(), Request{
Capability: "image.generate", Model: "gpt-image-2.5-flare", Prompt: "draw", InputURLs: []string{"https://in.test/ref.png"},
Settings: map[string]any{"quality": "medium", "width": 848, "height": 1200, "imageCount": float64(1)},
})
if err != nil {
t.Fatal(err)
}
}
func TestSeedreamPayloadAndSynchronousResponse(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != "/api/v3/images/generations" || request.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("request=%s auth=%q", request.URL.Path, request.Header.Get("Authorization"))
}
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
images, ok := body["image"].([]any)
optimize := body["optimize_prompt_options"].(map[string]any)
if body["model"] != Seedream50ProModel || body["prompt"] != "draw" || body["size"] != "2K" || body["output_format"] != "jpeg" || body["response_format"] != "url" || body["watermark"] != false || optimize["mode"] != "fast" || !ok || len(images) != 2 {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"created":1,"data":[{"url":"https://cdn.test/a.jpeg"}]}`)), Header: http.Header{}}, nil
})
result, err := NewSeedream(Config{BaseURL: "https://ark.test/api/v3", APIKey: "secret", Model: Seedream50ProModel}, client).Submit(context.Background(), Request{
Capability: "image.generate", Prompt: "draw", InputURLs: []string{"https://in.test/a.png", "https://in.test/b.png"},
Settings: map[string]any{"size": "2K", "outputFormat": "jpeg", "optimizeMode": "fast"},
})
if err != nil || result.Status != StatusSucceeded || !reflect.DeepEqual(result.OutputURLs, []string{"https://cdn.test/a.jpeg"}) {
t.Fatalf("result=%#v err=%v", result, err)
}
}
func TestSeedreamLayerDecompositionPayloadAndResponseMetadata(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["image"] != "https://in.test/source.png" || body["layer_decomposition"] != true || body["prompt"] != nil || body["optimize_prompt_options"] != nil {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{
"data":[
{"url":"https://cdn.test/base.jpeg","size":"2048x2048","z_index":0},
{"url":"https://cdn.test/title.png","size":"500x100","z_index":1,"name":"标题"}
]
}`)), Header: http.Header{}}, nil
})
result, err := NewSeedream(Config{BaseURL: "https://ark.test/api/v3", APIKey: "secret", Model: Seedream50ProModel}, client).Submit(context.Background(), Request{
Capability: "image.generate", InputURLs: []string{"https://in.test/source.png"},
Settings: map[string]any{"size": "2K", "outputFormat": "jpeg", "layerDecomposition": true},
})
if err != nil || result.Status != StatusSucceeded || !reflect.DeepEqual(result.OutputURLs, []string{"https://cdn.test/base.jpeg", "https://cdn.test/title.png"}) || !strings.Contains(string(result.Raw), `"z_index":1`) {
t.Fatalf("result=%#v err=%v", result, err)
}
}
func TestMockIsDeterministic(t *testing.T) {
m := NewMock("fixture")
a, _ := m.Submit(context.Background(), Request{Capability: "video.generate", Prompt: "hello"})
b, _ := m.Submit(context.Background(), Request{Capability: "video.generate", Prompt: "hello"})
if a.TaskID != b.TaskID {
t.Fatalf("task IDs differ: %s %s", a.TaskID, b.TaskID)
}
got, _ := m.Query(context.Background(), a.TaskID)
if got.Status != "succeeded" || len(got.OutputURLs) != 1 {
t.Fatalf("Query=%#v", got)
}
}