增加即梦的生图和生视频功能
This commit is contained in:
@@ -144,8 +144,9 @@ V1 接入图片生成能力:
|
||||
|
||||
后端统一走火山 Visual 异步任务:
|
||||
|
||||
- 提交:`CVSync2AsyncSubmitTask`
|
||||
- 查询:`CVSync2AsyncGetResult`
|
||||
- 提交:`JimengSeedream46CVToBSubmitTask`
|
||||
- 查询:`JimengSeedream46CVToBGetResult`
|
||||
- API Version:`2024-06-06`
|
||||
|
||||
未配置火山密钥时,服务会明确报告凭据未配置,不会生成占位图片。
|
||||
|
||||
@@ -166,6 +167,7 @@ V1 接入图片生成能力:
|
||||
- 一个提示词框:图片和视频都在同一创作面板内编辑最终提示词。
|
||||
- 模式切换:图片模式走即梦图片生成 4.6;视频模式走 Seedance 视频生成。
|
||||
- 素材统一上传:一个入口上传图片、视频或音频,不再拆分参考图、主体、分镜等栏目。
|
||||
- Seedance 2.0 单次最多提交 4 个素材(加上文本后 `content` 最多 5 项)。
|
||||
- `@素材` 引用:上传后自动绑定为 `@图片1`、`@视频1`、`@音频1`,chip 和 @ 候选项都显示缩略图。
|
||||
- 提示词校验:通过 `/api/prompt/assemble` 检查提示词中引用的素材是否已绑定。
|
||||
- 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。
|
||||
@@ -219,7 +221,7 @@ cp .env.example .env.local
|
||||
- `SEEDANCE_MODEL`
|
||||
- `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`
|
||||
- `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择
|
||||
- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p`
|
||||
- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p`
|
||||
- `ALI_OSS_*`:用于上传素材和生成结果转存
|
||||
- `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local`
|
||||
- `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串
|
||||
|
||||
@@ -193,8 +193,9 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码
|
||||
- `duration`:`4` 到 `15` 的整数秒
|
||||
- `duration=-1`:允许在环境变量或服务端归一化中表示模型自动选择
|
||||
- `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`
|
||||
- `resolution`:`480p`、`720p`、`1080p`、`4k`
|
||||
- `resolution`:`480p`、`720p`、`1080p`
|
||||
- Seedance 2.0 fast 不支持 `1080p`
|
||||
- 多模态输入最多 4 个素材(加上文本后 `content` 最多 5 项)
|
||||
|
||||
## 任务管理与开放 API
|
||||
|
||||
|
||||
@@ -39,7 +39,11 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func positiveInt64Env(getenv postgres.Getenv, name string, fallback int64) int64 {
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(getenv(name)), 10, 64)
|
||||
return positiveInt64Value(getenv(name), fallback)
|
||||
}
|
||||
|
||||
func positiveInt64Value(raw string, fallback int64) int64 {
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
@@ -180,9 +184,9 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
|
||||
Provider: "seedance",
|
||||
Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"),
|
||||
Settings: map[string]any{
|
||||
"ratio": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"),
|
||||
"duration": float64(positiveInt64Env(getenv, "SEEDANCE_DEFAULT_DURATION", 5)),
|
||||
"resolution": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"),
|
||||
"ratio": firstNonEmpty(getenv("SEEDANCE_RATIO"), getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"),
|
||||
"duration": float64(positiveInt64Value(firstNonEmpty(getenv("SEEDANCE_DURATION"), getenv("SEEDANCE_DEFAULT_DURATION")), 5)),
|
||||
"resolution": firstNonEmpty(getenv("SEEDANCE_RESOLUTION"), getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"),
|
||||
},
|
||||
},
|
||||
"bailian": {
|
||||
|
||||
@@ -273,6 +273,21 @@ func TestProviderTargetsNeverSelectRemovedProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderVideoTargetsUseDocumentedSeedanceDefaults(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"SEEDANCE_RATIO": "16:9",
|
||||
"SEEDANCE_DURATION": "12",
|
||||
"SEEDANCE_RESOLUTION": "1080p",
|
||||
}
|
||||
target := providerVideoTargets(func(name string) string { return values[name] })["seedance"]
|
||||
if target.Model != "doubao-seedance-2-0-260128" {
|
||||
t.Fatalf("model=%q", target.Model)
|
||||
}
|
||||
if target.Settings["ratio"] != "16:9" || target.Settings["duration"] != float64(12) || target.Settings["resolution"] != "1080p" {
|
||||
t.Fatalf("settings=%#v", target.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, engine, wantEngine, wantProvider, wantModel string
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -62,9 +63,16 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
|
||||
}
|
||||
}
|
||||
var result providers.Result
|
||||
phase := "submit"
|
||||
expectedStatus := job.Status
|
||||
if job.ProviderTaskID == "" {
|
||||
if job.ProviderDispatchStartedAt != nil {
|
||||
logProviderFailureDiagnostic(providerFailureDiagnostic{
|
||||
JobID: safeProviderLogToken(job.ID),
|
||||
Provider: safeProviderLogToken(job.Provider),
|
||||
Phase: "submit",
|
||||
ErrorClass: "unknown_outcome",
|
||||
})
|
||||
failed := StatusFailed
|
||||
failure := &JobError{Message: "provider submission outcome is unknown; refusing duplicate submission", Retryable: false}
|
||||
if p.Store == nil {
|
||||
@@ -83,12 +91,16 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
|
||||
}
|
||||
result, err = adapter.Submit(ctx, request)
|
||||
} else {
|
||||
phase = "query"
|
||||
if modeled, ok := adapter.(providers.ModelQueryAdapter); ok {
|
||||
result, err = modeled.QueryModel(ctx, job.ProviderTaskID, job.ReqKey)
|
||||
} else {
|
||||
result, err = adapter.Query(ctx, job.ProviderTaskID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logProviderFailure(job, phase, err)
|
||||
}
|
||||
if err != nil && job.ProviderTaskID == "" && p.Store != nil {
|
||||
failed := StatusFailed
|
||||
failure := &JobError{Message: "provider submission outcome is unknown; refusing duplicate submission", Retryable: false}
|
||||
@@ -140,6 +152,69 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
|
||||
return job, nil
|
||||
}
|
||||
|
||||
type providerFailureDiagnostic struct {
|
||||
JobID string
|
||||
Provider string
|
||||
Phase string
|
||||
Status int
|
||||
ErrorClass string
|
||||
}
|
||||
|
||||
func logProviderFailure(job Job, phase string, err error) {
|
||||
diagnostic := providerFailureDiagnostic{
|
||||
JobID: safeProviderLogToken(job.ID),
|
||||
Provider: safeProviderLogToken(job.Provider),
|
||||
Phase: safeProviderPhase(phase),
|
||||
ErrorClass: "provider",
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
diagnostic.ErrorClass = "canceled"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
diagnostic.ErrorClass = "timeout"
|
||||
} else {
|
||||
var providerError *providers.ProviderError
|
||||
if errors.As(err, &providerError) {
|
||||
diagnostic.Status = providerError.Status
|
||||
if providerError.Status > 0 {
|
||||
diagnostic.ErrorClass = "service"
|
||||
}
|
||||
}
|
||||
}
|
||||
logProviderFailureDiagnostic(diagnostic)
|
||||
}
|
||||
|
||||
func logProviderFailureDiagnostic(diagnostic providerFailureDiagnostic) {
|
||||
log.Printf(
|
||||
"zhinian-api generation provider failed jobId=%s provider=%s phase=%s status=%d errorClass=%s",
|
||||
diagnostic.JobID,
|
||||
diagnostic.Provider,
|
||||
diagnostic.Phase,
|
||||
diagnostic.Status,
|
||||
diagnostic.ErrorClass,
|
||||
)
|
||||
}
|
||||
|
||||
func safeProviderPhase(phase string) string {
|
||||
if phase == "query" {
|
||||
return "query"
|
||||
}
|
||||
return "submit"
|
||||
}
|
||||
|
||||
func safeProviderLogToken(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == 0 || len(value) > 96 {
|
||||
return "invalid"
|
||||
}
|
||||
for _, character := range value {
|
||||
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
|
||||
continue
|
||||
}
|
||||
return "invalid"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (p ProviderProcessor) refreshAssetURLs(ctx context.Context, job Job, request providers.Request) (providers.Request, error) {
|
||||
if p.AssetURLs == nil || len(job.InputAssetIDs) == 0 {
|
||||
return request, nil
|
||||
@@ -561,6 +636,9 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials []
|
||||
if engine != "" && engine != "seedance" {
|
||||
return nil, invalidPreparation("unsupported video engine")
|
||||
}
|
||||
if len(materials) > 4 {
|
||||
return nil, invalidPreparation("seedance supports at most 4 materials")
|
||||
}
|
||||
ratio := stringValue(input["ratio"])
|
||||
if ratio != "" && !oneOf(ratio, "9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive") {
|
||||
return nil, invalidPreparation("unsupported video ratio")
|
||||
@@ -576,7 +654,7 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials []
|
||||
settings["duration"] = duration
|
||||
}
|
||||
resolution := stringValue(input["resolution"])
|
||||
if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p", "4k") {
|
||||
if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p") {
|
||||
return nil, invalidPreparation("unsupported video resolution")
|
||||
}
|
||||
if resolution != "" {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -162,6 +166,16 @@ func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) {
|
||||
{name: "bailian requires frame", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{}}, message: "1 or 2 image materials"},
|
||||
{name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "1 or 2 image materials"},
|
||||
{name: "bad seedance settings", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"duration": 99.0}}, message: "video duration"},
|
||||
{name: "seedance unsupported 4k output", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"resolution": "4k"}}, message: "unsupported video resolution"},
|
||||
{name: "seedance too many materials", capability: "video.generate", body: map[string]any{
|
||||
"engine": "seedance", "prompt": "p", "materials": []any{
|
||||
map[string]any{"url": "https://in.test/1.png", "type": "image"},
|
||||
map[string]any{"url": "https://in.test/2.png", "type": "image"},
|
||||
map[string]any{"url": "https://in.test/3.png", "type": "image"},
|
||||
map[string]any{"url": "https://in.test/4.png", "type": "image"},
|
||||
map[string]any{"url": "https://in.test/5.png", "type": "image"},
|
||||
},
|
||||
}, message: "at most 4 materials"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -246,6 +260,79 @@ func TestProviderProcessorNeverResubmitsAfterPersistedDispatchIntent(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProcessorLogsSafeUnknownSubmissionOutcome(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
previousOutput, previousFlags := log.Writer(), log.Flags()
|
||||
log.SetOutput(&output)
|
||||
log.SetFlags(0)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetFlags(previousFlags)
|
||||
})
|
||||
|
||||
started := time.Now().UTC()
|
||||
job := Job{
|
||||
ID: "job-unknown-outcome", OwnerID: "private-owner", Provider: "volcengine-visual", ReqKey: "private-model",
|
||||
Capability: "image.generate", Status: StatusRunning, ProviderDispatchStartedAt: &started,
|
||||
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"private-model","prompt":"private prompt"}`),
|
||||
}
|
||||
adapter := &countingProvider{}
|
||||
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}}
|
||||
|
||||
got, err := processor.Advance(context.Background(), job)
|
||||
if err != nil || got.Status != StatusFailed || adapter.submits != 0 {
|
||||
t.Fatalf("got=%#v err=%v submits=%d", got, err, adapter.submits)
|
||||
}
|
||||
logged := output.String()
|
||||
for _, expected := range []string{"jobId=job-unknown-outcome", "provider=volcengine-visual", "phase=submit", "status=0", "errorClass=unknown_outcome"} {
|
||||
if !strings.Contains(logged, expected) {
|
||||
t.Fatalf("log %q does not contain %q", logged, expected)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"private-owner", "private-model", "private prompt"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("log leaks %q: %s", secret, logged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProcessorLogsSafeJobCorrelationWhenSubmitFails(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
previousOutput, previousFlags := log.Writer(), log.Flags()
|
||||
log.SetOutput(&output)
|
||||
log.SetFlags(0)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetFlags(previousFlags)
|
||||
})
|
||||
|
||||
store := newMemoryJobStore()
|
||||
job := Job{
|
||||
ID: "job-safe-log", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob",
|
||||
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker",
|
||||
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"private prompt"}`),
|
||||
}
|
||||
store.jobs[job.ID] = job
|
||||
adapter := &countingProvider{err: &providers.ProviderError{Operation: "volcengine request", Status: http.StatusForbidden}}
|
||||
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store}
|
||||
|
||||
got, err := processor.Advance(context.Background(), job)
|
||||
if err != nil || got.Status != StatusFailed {
|
||||
t.Fatalf("got=%#v err=%v", got, err)
|
||||
}
|
||||
logged := output.String()
|
||||
for _, expected := range []string{"jobId=job-safe-log", "provider=volcengine-visual", "phase=submit", "status=403", "errorClass=service"} {
|
||||
if !strings.Contains(logged, expected) {
|
||||
t.Fatalf("log %q does not contain %q", logged, expected)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"private prompt", "jimeng_seedream46_cvtob", "owner", "volcengine request"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("log leaks %q: %s", secret, logged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProcessorClearsTransientErrorAfterSuccessfulPoll(t *testing.T) {
|
||||
store := newMemoryJobStore()
|
||||
job := Job{ID: "job-recovered", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", Error: &JobError{Message: "temporary timeout", Retryable: true}, RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)}
|
||||
@@ -290,6 +377,46 @@ func TestProviderProcessorQueriesWithPersistedRequestModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProcessorCompletesOfficialVolcengineQueryResponse(t *testing.T) {
|
||||
var action string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
action = request.URL.Query().Get("Action")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"code":10000,
|
||||
"data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"},
|
||||
"message":"Success",
|
||||
"request_id":"request-1",
|
||||
"status":10000
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
store := newMemoryJobStore()
|
||||
job := Job{
|
||||
ID: "job-jimeng", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob",
|
||||
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderTaskID: "task-1",
|
||||
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"hello"}`),
|
||||
}
|
||||
store.jobs[job.ID] = job
|
||||
adapter := providers.NewVolcengine(providers.Config{
|
||||
BaseURL: server.URL, AccessKeyID: "ak", SecretAccessKey: "sk", Region: "cn-north-1", Service: "cv", Model: "jimeng_seedream46_cvtob",
|
||||
}, server.Client(), func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
|
||||
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store}
|
||||
|
||||
got, err := processor.Advance(context.Background(), job)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted providers.HTTPResult
|
||||
if err := json.Unmarshal(got.ResponsePayload, &persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action != "JimengSeedream46CVToBGetResult" || got.Status != StatusSucceeded || got.ProviderTaskID != "task-1" || len(persisted.OutputURLs) != 1 || persisted.OutputURLs[0] != "https://cdn.test/generated.png" {
|
||||
t.Fatalf("job=%#v response=%#v", got, persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderJobBuilderRejectsInvalidPublicWebhookURL(t *testing.T) {
|
||||
for _, value := range []string{"/internal/callback", "javascript:alert(1)", "https://user:pass@example.test/hook"} {
|
||||
_, err := testProviderBuilder().Build(context.Background(), "api:client", "client", "image.generate", "", map[string]any{"prompt": "hello", "webhookUrl": value})
|
||||
@@ -320,6 +447,7 @@ type countingProvider struct {
|
||||
submits int
|
||||
result providers.Result
|
||||
request providers.Request
|
||||
err error
|
||||
}
|
||||
|
||||
type recordingProviderAssetURLResolver struct {
|
||||
@@ -352,10 +480,10 @@ func (provider *modelQueryProvider) QueryModel(_ context.Context, _ string, mode
|
||||
func (p *countingProvider) Submit(_ context.Context, request providers.Request) (providers.Result, error) {
|
||||
p.submits++
|
||||
p.request = request
|
||||
return p.result, nil
|
||||
return p.result, p.err
|
||||
}
|
||||
func (p *countingProvider) Query(context.Context, string) (providers.Result, error) {
|
||||
return p.result, nil
|
||||
return p.result, p.err
|
||||
}
|
||||
|
||||
func testProviderBuilder() ProviderJobBuilder {
|
||||
|
||||
@@ -145,11 +145,7 @@ func NewSeedance(c Config, client HTTPClient) *Seedance {
|
||||
case MaterialAudio:
|
||||
materialType, urlKey, role = "audio_url", "audio_url", "reference_audio"
|
||||
}
|
||||
item := map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role}
|
||||
if material.Label != "" {
|
||||
item["label"] = material.Label
|
||||
}
|
||||
content = append(content, item)
|
||||
content = append(content, map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role})
|
||||
}
|
||||
p := map[string]any{"model": requestModel(r, c.Model), "content": content, "generate_audio": true, "watermark": false}
|
||||
for k, v := range r.Settings {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -128,6 +130,10 @@ func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) {
|
||||
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])
|
||||
@@ -142,6 +148,58 @@ func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -162,6 +220,240 @@ func TestVolcengineQueryUsesPersistedTaskModelInsteadOfAdapterFallback(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -264,7 +556,7 @@ func TestBailianDecodesCompatibleModeChoiceImages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) {
|
||||
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 {
|
||||
@@ -281,13 +573,19 @@ func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) {
|
||||
}
|
||||
for index, check := range checks {
|
||||
item := content[index+1].(map[string]any)
|
||||
if item["type"] != check.materialType || item["role"] != check.role || item["label"] != []string{"图片1", "视频1", "音频1"}[index] {
|
||||
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{
|
||||
@@ -298,7 +596,7 @@ func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) {
|
||||
{URL: "https://cdn.test/audio.mp3", Type: MaterialAudio, Label: "音频1"},
|
||||
},
|
||||
}
|
||||
if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "seed"}, client).Submit(context.Background(), request); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -21,6 +24,20 @@ type Volcengine struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
seedream46Model = "jimeng_seedream46_cvtob"
|
||||
seedream46SubmitAction = "JimengSeedream46CVToBSubmitTask"
|
||||
seedream46QueryAction = "JimengSeedream46CVToBGetResult"
|
||||
seedream46Version = "2024-06-06"
|
||||
visualLegacyVersion = "2022-08-31"
|
||||
)
|
||||
|
||||
type volcengineProtocol struct {
|
||||
submitAction string
|
||||
queryAction string
|
||||
version string
|
||||
}
|
||||
|
||||
func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengine {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
@@ -37,33 +54,57 @@ func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengin
|
||||
return &Volcengine{c, client, now}
|
||||
}
|
||||
func (v *Volcengine) Submit(ctx context.Context, r Request) (Result, error) {
|
||||
p := map[string]any{"req_key": requestModel(r, v.config.Model), "prompt": r.Prompt, "image_urls": r.InputURLs}
|
||||
model := requestModel(r, v.config.Model)
|
||||
protocol := volcengineProtocolForModel(model)
|
||||
p := map[string]any{"req_key": model, "prompt": r.Prompt}
|
||||
if len(r.InputURLs) > 0 {
|
||||
p["image_urls"] = r.InputURLs
|
||||
}
|
||||
for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "force_single"} {
|
||||
if value, exists := r.Settings[key]; exists && value != nil && value != "" {
|
||||
p[key] = value
|
||||
}
|
||||
}
|
||||
return v.call(ctx, "CVSync2AsyncSubmitTask", p)
|
||||
return v.call(ctx, protocol.submitAction, protocol.version, p)
|
||||
}
|
||||
func (v *Volcengine) Query(ctx context.Context, id string) (Result, error) {
|
||||
return v.QueryModel(ctx, id, v.config.Model)
|
||||
}
|
||||
func (v *Volcengine) QueryModel(ctx context.Context, id, model string) (Result, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return Result{}, errors.New("provider task id is required")
|
||||
}
|
||||
resolvedModel := requestModel(Request{Model: model}, v.config.Model)
|
||||
protocol := volcengineProtocolForModel(resolvedModel)
|
||||
queryOptions, _ := json.Marshal(map[string]any{
|
||||
"return_url": true,
|
||||
"logo_info": map[string]any{"add_logo": false, "position": 0, "language": 0, "opacity": 1},
|
||||
})
|
||||
return v.call(ctx, "CVSync2AsyncGetResult", map[string]any{"req_key": requestModel(Request{Model: model}, v.config.Model), "task_id": id, "req_json": string(queryOptions)})
|
||||
result, err := v.call(ctx, protocol.queryAction, protocol.version, map[string]any{"req_key": resolvedModel, "task_id": id, "req_json": string(queryOptions)})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if result.TaskID == "" {
|
||||
result.TaskID = id
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (v *Volcengine) call(ctx context.Context, action string, payload any) (Result, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
func (v *Volcengine) call(ctx context.Context, action, version string, payload any) (Result, error) {
|
||||
startedAt := time.Now()
|
||||
operation := volcengineOperation(action)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "encode", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request"}
|
||||
}
|
||||
endpoint, err := url.Parse(v.config.BaseURL)
|
||||
if err != nil {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, errors.New("invalid provider base URL")
|
||||
}
|
||||
q := endpoint.Query()
|
||||
q.Set("Action", action)
|
||||
q.Set("Version", "2022-08-31")
|
||||
q.Set("Version", version)
|
||||
endpoint.RawQuery = canonicalQuery(q)
|
||||
date := v.now().UTC()
|
||||
xdate := date.Format("20060102T150405Z")
|
||||
@@ -83,26 +124,251 @@ func (v *Volcengine) call(ctx context.Context, action string, payload any) (Resu
|
||||
key = hmacBytes(key, v.config.Service)
|
||||
key = hmacBytes(key, "request")
|
||||
signature := hex.EncodeToString(hmacBytes(key, stringToSign))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request"}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Content-Sha256", hash)
|
||||
req.Header.Set("X-Date", xdate)
|
||||
req.Header.Set("Authorization", "HMAC-SHA256 Credential="+v.config.AccessKeyID+"/"+scope+", SignedHeaders="+signed+", Signature="+signature)
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: classifyVolcengineTransportError(err), ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !json.Valid(raw) {
|
||||
limit := v.config.MaxResponseBytes
|
||||
if limit <= 0 {
|
||||
limit = 2 << 20
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_read", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
||||
}
|
||||
r := record(raw)
|
||||
d := object(r["data"])
|
||||
if int64(len(raw)) > limit {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_too_large", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
||||
}
|
||||
validJSON := json.Valid(raw)
|
||||
r := map[string]any{}
|
||||
if validJSON {
|
||||
r = record(raw)
|
||||
}
|
||||
response := inspectVolcengineResponse(r, resp.Header)
|
||||
diagnostic := volcengineDiagnostic{
|
||||
Operation: operation,
|
||||
Status: resp.StatusCode,
|
||||
Code: volcengineDiagnosticCode(response.code),
|
||||
CodeN: volcengineDiagnosticNumericCode(response.codeN),
|
||||
RequestID: safeVolcengineRequestID(response.requestID),
|
||||
ElapsedMS: elapsedMilliseconds(startedAt),
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
diagnostic.ErrorClass = "service"
|
||||
logVolcengineFailure(diagnostic)
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
||||
}
|
||||
if !validJSON {
|
||||
diagnostic.ErrorClass = "invalid_json"
|
||||
logVolcengineFailure(diagnostic)
|
||||
return Result{}, &ProviderError{Operation: "volcengine request"}
|
||||
}
|
||||
if response.code != nil && !volcengineRequestSucceeded(response.code) {
|
||||
diagnostic.ErrorClass = "service"
|
||||
logVolcengineFailure(diagnostic)
|
||||
return Result{}, &ProviderError{Operation: "volcengine request"}
|
||||
}
|
||||
d := object(first(response.business["data"], response.business["Data"]))
|
||||
out := []string{}
|
||||
collectURLs(d, &out)
|
||||
return Result{TaskID: stringValue(r["task_id"], d["task_id"]), Status: status(first(r["status"], d["status"])), OutputURLs: out, Raw: raw}, nil
|
||||
for _, value := range []any{d["image_urls"], d["image_url"], d["url"], d["result_url"], d["output"], d["outputs"]} {
|
||||
collectURLs(value, &out)
|
||||
}
|
||||
return Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}, nil
|
||||
}
|
||||
|
||||
type volcengineResponse struct {
|
||||
business map[string]any
|
||||
code any
|
||||
codeN any
|
||||
requestID any
|
||||
}
|
||||
|
||||
func inspectVolcengineResponse(root map[string]any, headers http.Header) volcengineResponse {
|
||||
business := root
|
||||
if wrapped := object(first(root["Result"], root["result"])); len(wrapped) > 0 {
|
||||
business = wrapped
|
||||
}
|
||||
metadata := object(first(root["ResponseMetadata"], root["response_metadata"]))
|
||||
gatewayError := object(first(metadata["Error"], metadata["error"]))
|
||||
return volcengineResponse{
|
||||
business: business,
|
||||
code: first(
|
||||
business["code"], business["Code"],
|
||||
root["code"], root["Code"],
|
||||
gatewayError["Code"], gatewayError["code"],
|
||||
),
|
||||
codeN: first(
|
||||
gatewayError["CodeN"], gatewayError["codeN"], gatewayError["code_n"],
|
||||
business["code_n"], business["codeN"], business["CodeN"],
|
||||
root["code_n"], root["codeN"], root["CodeN"],
|
||||
),
|
||||
requestID: first(
|
||||
business["request_id"], business["requestId"], business["RequestId"], business["RequestID"],
|
||||
root["request_id"], root["requestId"], root["RequestId"], root["RequestID"],
|
||||
metadata["RequestId"], metadata["RequestID"], metadata["request_id"], metadata["requestId"],
|
||||
headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func volcengineProtocolForModel(model string) volcengineProtocol {
|
||||
if strings.TrimSpace(model) == seedream46Model {
|
||||
return volcengineProtocol{
|
||||
submitAction: seedream46SubmitAction,
|
||||
queryAction: seedream46QueryAction,
|
||||
version: seedream46Version,
|
||||
}
|
||||
}
|
||||
return volcengineProtocol{
|
||||
submitAction: "CVSync2AsyncSubmitTask",
|
||||
queryAction: "CVSync2AsyncGetResult",
|
||||
version: visualLegacyVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func volcengineRequestSucceeded(value any) bool {
|
||||
return volcengineDiagnosticCode(value) == "10000"
|
||||
}
|
||||
|
||||
type volcengineDiagnostic struct {
|
||||
Operation string
|
||||
Status int
|
||||
Code string
|
||||
CodeN string
|
||||
RequestID string
|
||||
ErrorClass string
|
||||
ElapsedMS int64
|
||||
}
|
||||
|
||||
func logVolcengineFailure(diagnostic volcengineDiagnostic) {
|
||||
log.Printf(
|
||||
"zhinian-api Volcengine operation failed operation=%s status=%d code=%q codeN=%q requestId=%q errorClass=%s elapsedMs=%d",
|
||||
diagnostic.Operation,
|
||||
diagnostic.Status,
|
||||
diagnostic.Code,
|
||||
diagnostic.CodeN,
|
||||
diagnostic.RequestID,
|
||||
diagnostic.ErrorClass,
|
||||
diagnostic.ElapsedMS,
|
||||
)
|
||||
}
|
||||
|
||||
func volcengineOperation(action string) string {
|
||||
switch action {
|
||||
case "CVSync2AsyncSubmitTask", seedream46SubmitAction:
|
||||
return "submit"
|
||||
case "CVSync2AsyncGetResult", seedream46QueryAction:
|
||||
return "query"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func volcengineDiagnosticCode(value any) string {
|
||||
code := volcengineDiagnosticValue(value)
|
||||
if len(code) == 0 || len(code) > 64 {
|
||||
return ""
|
||||
}
|
||||
for _, character := range code {
|
||||
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func volcengineDiagnosticNumericCode(value any) string {
|
||||
code := volcengineDiagnosticValue(value)
|
||||
if len(code) == 0 || len(code) > 32 {
|
||||
return ""
|
||||
}
|
||||
for _, character := range code {
|
||||
if character < '0' || character > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func volcengineDiagnosticValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case json.Number:
|
||||
return string(typed)
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func safeVolcengineRequestID(value any) string {
|
||||
requestID := stringValue(value)
|
||||
if len(requestID) == 0 || len(requestID) > 128 {
|
||||
return ""
|
||||
}
|
||||
for _, character := range requestID {
|
||||
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return requestID
|
||||
}
|
||||
|
||||
func classifyVolcengineTransportError(err error) string {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "canceled"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
var networkError net.Error
|
||||
if errors.As(err, &networkError) && networkError.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
var dnsError *net.DNSError
|
||||
if errors.As(err, &dnsError) {
|
||||
return "dns"
|
||||
}
|
||||
var operationError *net.OpError
|
||||
if errors.As(err, &operationError) && operationError.Op == "dial" {
|
||||
return "connect"
|
||||
}
|
||||
return "transport"
|
||||
}
|
||||
|
||||
func elapsedMilliseconds(startedAt time.Time) int64 {
|
||||
elapsed := time.Since(startedAt).Milliseconds()
|
||||
if elapsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
func sha(b []byte) string { x := sha256.Sum256(b); return hex.EncodeToString(x[:]) }
|
||||
func hmacBytes(k []byte, s string) []byte {
|
||||
h := hmac.New(sha256.New, k)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"volcengine-visual": {"submitAction": "CVSync2AsyncSubmitTask", "queryAction": "CVSync2AsyncGetResult", "auth": "HMAC-SHA256"},
|
||||
"volcengine-visual": {"model": "jimeng_seedream46_cvtob", "submitAction": "JimengSeedream46CVToBSubmitTask", "queryAction": "JimengSeedream46CVToBGetResult", "version": "2024-06-06", "auth": "HMAC-SHA256"},
|
||||
"evolink": {"submit": "/v1/images/generations", "query": "/v1/tasks/{id}", "auth": "bearer"},
|
||||
"bailian": {"imageSubmit": "/api/v1/services/aigc/image-generation/generation", "videoSubmit": "/api/v1/services/aigc/video-generation/video-synthesis", "query": "/api/v1/tasks/{id}", "auth": "bearer"},
|
||||
"seedance": {"submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer"}
|
||||
"seedance": {"model": "doubao-seedance-2-0-260128", "submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer", "maxMaterials": 4, "resolutions": ["480p", "720p", "1080p"]}
|
||||
},
|
||||
"errors": {"generic": true, "secretSafe": true},
|
||||
"liveCallsInTests": false
|
||||
|
||||
@@ -139,12 +139,12 @@ export function materialContentForProvider(materials: PromptMaterial[], origin:
|
||||
return normalizeMaterials(materials).map((material) => {
|
||||
const url = toAbsoluteUrl(material.url, origin);
|
||||
if (material.type === "video") {
|
||||
return { type: "video_url", video_url: { url }, role: "reference_video", label: material.label };
|
||||
return { type: "video_url", video_url: { url }, role: "reference_video" };
|
||||
}
|
||||
if (material.type === "audio") {
|
||||
return { type: "audio_url", audio_url: { url }, role: "reference_audio", label: material.label };
|
||||
return { type: "audio_url", audio_url: { url }, role: "reference_audio" };
|
||||
}
|
||||
return { type: "image_url", image_url: { url }, role: "reference_image", label: material.label };
|
||||
return { type: "image_url", image_url: { url }, role: "reference_image" };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export type SeedanceCreateInput = {
|
||||
};
|
||||
|
||||
export type SeedanceQueryResult = {
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "cancelled" | "expired";
|
||||
resultUrl?: string;
|
||||
errorMessage?: string;
|
||||
usage?: SeedanceUsage;
|
||||
@@ -49,11 +49,15 @@ export function getSeedanceConfig() {
|
||||
export async function createSeedanceTask(input: SeedanceCreateInput) {
|
||||
const config = getSeedanceConfig();
|
||||
if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY。请在 .env.local 配置火山方舟 API Key。");
|
||||
const materialContent = materialContentForProvider(input.materials, input.origin);
|
||||
if (materialContent.length > 4) {
|
||||
throw new Error("Seedance 2.0 最多支持 4 个素材(加上文本后 content 最多 5 项)。");
|
||||
}
|
||||
const payload = {
|
||||
model: config.model,
|
||||
content: [
|
||||
{ type: "text", text: input.prompt },
|
||||
...materialContentForProvider(input.materials, input.origin)
|
||||
...materialContent
|
||||
],
|
||||
generate_audio: true,
|
||||
ratio: normalizeVideoRatio(input.settings.ratio, config.ratio),
|
||||
@@ -123,7 +127,8 @@ export function extractSeedanceUsage(value: unknown): SeedanceUsage | undefined
|
||||
function normalizeSeedanceStatus(status: unknown): SeedanceQueryResult["status"] {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["succeeded", "success", "completed"].includes(value)) return "succeeded";
|
||||
if (["failed", "error", "expired", "timeout"].includes(value)) return "failed";
|
||||
if (["expired", "timeout"].includes(value)) return "expired";
|
||||
if (["failed", "error"].includes(value)) return "failed";
|
||||
if (["cancelled", "canceled"].includes(value)) return "cancelled";
|
||||
if (["running", "processing", "generating"].includes(value)) return "running";
|
||||
return "queued";
|
||||
|
||||
@@ -11,7 +11,7 @@ export const VIDEO_RATIO_DEFAULT = "9:16";
|
||||
export const VIDEO_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive"] as const;
|
||||
|
||||
export const VIDEO_RESOLUTION_DEFAULT = "720p";
|
||||
export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p", "4k"] as const;
|
||||
export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p"] as const;
|
||||
export const VIDEO_FAST_RESOLUTIONS = ["720p", "480p"] as const;
|
||||
|
||||
type VideoDurationOptions = {
|
||||
|
||||
@@ -27,7 +27,8 @@ export async function submitVisualTask(
|
||||
config = getVisualClientConfig()
|
||||
): Promise<VisualTaskSubmitResponse> {
|
||||
if (!config) throw new Error("Volcengine Visual credentials are not configured.");
|
||||
return callVisualApi<VisualTaskSubmitResponse>("CVSync2AsyncSubmitTask", payload, config);
|
||||
const protocol = visualProtocolForPayload(payload);
|
||||
return callVisualApi<VisualTaskSubmitResponse>(protocol.submitAction, protocol.version, payload, config);
|
||||
}
|
||||
|
||||
export async function queryVisualTask(
|
||||
@@ -35,11 +36,38 @@ export async function queryVisualTask(
|
||||
config = getVisualClientConfig()
|
||||
): Promise<VisualTaskQueryResponse> {
|
||||
if (!config) throw new Error("Volcengine Visual credentials are not configured.");
|
||||
return callVisualApi<VisualTaskQueryResponse>("CVSync2AsyncGetResult", payload, config);
|
||||
const protocol = visualProtocolForPayload(payload);
|
||||
return callVisualApi<VisualTaskQueryResponse>(protocol.queryAction, protocol.version, payload, config);
|
||||
}
|
||||
|
||||
type VisualAction =
|
||||
| "CVSync2AsyncSubmitTask"
|
||||
| "CVSync2AsyncGetResult"
|
||||
| "JimengSeedream46CVToBSubmitTask"
|
||||
| "JimengSeedream46CVToBGetResult";
|
||||
|
||||
function visualProtocolForPayload(payload: Record<string, unknown>): {
|
||||
submitAction: VisualAction;
|
||||
queryAction: VisualAction;
|
||||
version: string;
|
||||
} {
|
||||
if (String(payload.req_key || "").trim() === "jimeng_seedream46_cvtob") {
|
||||
return {
|
||||
submitAction: "JimengSeedream46CVToBSubmitTask",
|
||||
queryAction: "JimengSeedream46CVToBGetResult",
|
||||
version: "2024-06-06"
|
||||
};
|
||||
}
|
||||
return {
|
||||
submitAction: "CVSync2AsyncSubmitTask",
|
||||
queryAction: "CVSync2AsyncGetResult",
|
||||
version: "2022-08-31"
|
||||
};
|
||||
}
|
||||
|
||||
async function callVisualApi<T>(
|
||||
action: "CVSync2AsyncSubmitTask" | "CVSync2AsyncGetResult",
|
||||
action: VisualAction,
|
||||
version: string,
|
||||
payload: Record<string, unknown>,
|
||||
config: VisualClientConfig
|
||||
): Promise<T> {
|
||||
@@ -49,7 +77,7 @@ async function callVisualApi<T>(
|
||||
endpoint: config.endpoint,
|
||||
query: {
|
||||
Action: action,
|
||||
Version: "2022-08-31"
|
||||
Version: version
|
||||
},
|
||||
body,
|
||||
accessKeyId: config.accessKeyId,
|
||||
|
||||
@@ -13,6 +13,16 @@ describe("create studio template interaction", () => {
|
||||
expect(applyTemplate).not.toMatch(/\bsetMode\s*\(/);
|
||||
expect(applyTemplate).toContain("[generateMode]: template.prompt");
|
||||
});
|
||||
|
||||
it("keeps the effect preview display-only instead of adding it to generation materials", async () => {
|
||||
const source = await readFile(createStudioUrl, "utf8");
|
||||
const applyTemplate = functionSource(source, "applyTemplateToConsole", "resetTemplateForm");
|
||||
const buildBody = functionSource(source, "buildGenerationBody", "submit");
|
||||
|
||||
expect(applyTemplate).not.toMatch(/\bsetMaterials\s*\(/);
|
||||
expect(applyTemplate).not.toContain("previewImageUrl");
|
||||
expect(buildBody).not.toContain("previewImageUrl");
|
||||
});
|
||||
});
|
||||
|
||||
function functionSource(source: string, name: string, nextName: string) {
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
describe("Go provider adapter contract",()=>{it("freezes bounded adapters and safe errors",async()=>{const c=JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json",import.meta.url),"utf8"));expect(Object.keys(c.providers)).toEqual(["volcengine-visual","evolink","bailian","seedance"]);expect(c.errors).toEqual({generic:true,secretSafe:true});expect(c.liveCallsInTests).toBe(false);});});
|
||||
|
||||
describe("Go provider adapter contract", () => {
|
||||
it("freezes bounded adapters and safe errors", async () => {
|
||||
const contract = JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance"]);
|
||||
expect(contract.providers["volcengine-visual"]).toMatchObject({
|
||||
model: "jimeng_seedream46_cvtob",
|
||||
submitAction: "JimengSeedream46CVToBSubmitTask",
|
||||
queryAction: "JimengSeedream46CVToBGetResult",
|
||||
version: "2024-06-06"
|
||||
});
|
||||
expect(contract.providers.seedance).toMatchObject({
|
||||
model: "doubao-seedance-2-0-260128",
|
||||
submit: "/contents/generations/tasks",
|
||||
query: "/contents/generations/tasks/{id}",
|
||||
maxMaterials: 4,
|
||||
resolutions: ["480p", "720p", "1080p"]
|
||||
});
|
||||
expect(contract.errors).toEqual({ generic: true, secretSafe: true });
|
||||
expect(contract.liveCallsInTests).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractSeedanceUsage } from "@/lib/seedance/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSeedanceTask, extractSeedanceUsage, querySeedanceTask } from "@/lib/seedance/client";
|
||||
|
||||
describe("Seedance usage extraction", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.SEEDANCE_API_KEY;
|
||||
delete process.env.SEEDANCE_BASE_URL;
|
||||
delete process.env.SEEDANCE_MODEL;
|
||||
});
|
||||
|
||||
it("reads completion_tokens from top-level and nested provider responses", () => {
|
||||
expect(extractSeedanceUsage({ usage: { completion_tokens: 12345 } })).toEqual({ completionTokens: 12345 });
|
||||
expect(extractSeedanceUsage({ data: { usage: { completionTokens: 67890 } } })).toEqual({ completionTokens: 67890 });
|
||||
expect(extractSeedanceUsage({ usage: { prompt_tokens: 100 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the official Seedance 2.0 multimodal payload without UI labels", async () => {
|
||||
process.env.SEEDANCE_API_KEY = "test-key";
|
||||
process.env.SEEDANCE_BASE_URL = "https://ark.test/api/v3";
|
||||
process.env.SEEDANCE_MODEL = "doubao-seedance-2-0-260128";
|
||||
let body: Record<string, unknown> | undefined;
|
||||
vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ id: "task-1" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}));
|
||||
|
||||
await createSeedanceTask({
|
||||
prompt: "combine",
|
||||
settings: { ratio: "16:9", duration: 8, resolution: "1080p" },
|
||||
materials: [
|
||||
{ type: "image", url: "/image.png", label: "@图片1" },
|
||||
{ type: "video", url: "/video.mp4", label: "@视频1" },
|
||||
{ type: "audio", url: "/audio.mp3", label: "@音频1" }
|
||||
],
|
||||
origin: "https://app.test"
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: "doubao-seedance-2-0-260128",
|
||||
generate_audio: true,
|
||||
ratio: "16:9",
|
||||
duration: 8,
|
||||
resolution: "1080p",
|
||||
watermark: false
|
||||
});
|
||||
const content = body?.content as Array<Record<string, unknown>>;
|
||||
expect(content).toHaveLength(4);
|
||||
expect(content.slice(1).every((item) => !("label" in item))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the provider expired status distinct from a failed task", async () => {
|
||||
process.env.SEEDANCE_API_KEY = "test-key";
|
||||
process.env.SEEDANCE_BASE_URL = "https://ark.test/api/v3";
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ id: "task-1", status: "expired" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})));
|
||||
|
||||
await expect(querySeedanceTask("task-1")).resolves.toMatchObject({ status: "expired" });
|
||||
});
|
||||
|
||||
it("rejects more materials than the Seedance 2.0 content limit before calling the provider", async () => {
|
||||
process.env.SEEDANCE_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(createSeedanceTask({
|
||||
prompt: "combine",
|
||||
settings: {},
|
||||
materials: Array.from({ length: 5 }, (_, index) => ({
|
||||
type: "image" as const,
|
||||
url: `/image-${index + 1}.png`,
|
||||
label: `@图片${index + 1}`
|
||||
})),
|
||||
origin: "https://app.test"
|
||||
})).rejects.toThrow("最多支持 4 个素材");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,10 +54,10 @@ describe("task management and public API helpers", () => {
|
||||
delete process.env.ALI_OSS_PUBLIC_BASE_URL;
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const target = String(input);
|
||||
if (target.includes("CVSync2AsyncSubmitTask")) {
|
||||
if (target.includes("JimengSeedream46CVToBSubmitTask")) {
|
||||
return new Response(JSON.stringify({ code: 10000, data: { task_id: "visual-task-1" } }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (target.includes("CVSync2AsyncGetResult")) {
|
||||
if (target.includes("JimengSeedream46CVToBGetResult")) {
|
||||
return new Response(JSON.stringify({ code: 10000, data: { status: "done", image_urls: ["https://cdn.test/result.png"] } }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (target === "https://cdn.test/result.png") {
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("video settings", () => {
|
||||
expect(normalizeVideoRatio("21:9")).toBe("21:9");
|
||||
expect(normalizeVideoRatio("bad-ratio")).toBe("9:16");
|
||||
expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-260128")).toBe("1080p");
|
||||
expect(normalizeVideoResolution("4k", "doubao-seedance-2-0-260128")).toBe("720p");
|
||||
expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-fast-260128")).toBe("720p");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sha256Hex, signVolcengineRequest } from "@/lib/volcengine/signature";
|
||||
import { queryVisualTask, submitVisualTask, type VisualClientConfig } from "@/lib/volcengine/visual-client";
|
||||
|
||||
describe("Volcengine Visual signing", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("creates canonical request and signed headers", () => {
|
||||
const signed = signVolcengineRequest({
|
||||
method: "POST",
|
||||
@@ -10,7 +15,7 @@ describe("Volcengine Visual signing", () => {
|
||||
Version: "2022-08-31",
|
||||
Action: "CVSync2AsyncSubmitTask"
|
||||
},
|
||||
body: JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" }),
|
||||
body: JSON.stringify({ req_key: "legacy-model", prompt: "test" }),
|
||||
accessKeyId: "ak",
|
||||
secretAccessKey: "sk",
|
||||
region: "cn-north-1",
|
||||
@@ -19,7 +24,34 @@ describe("Volcengine Visual signing", () => {
|
||||
});
|
||||
expect(signed.url).toBe("https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31");
|
||||
expect(signed.headers.Authorization).toContain("HMAC-SHA256 Credential=ak/20260528/cn-north-1/cv/request");
|
||||
expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" })));
|
||||
expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "legacy-model", prompt: "test" })));
|
||||
expect(signed.canonicalRequest).toContain("content-type;host;x-content-sha256;x-date");
|
||||
});
|
||||
|
||||
it("uses the dedicated Seedream 4.6 submit and query protocol", async () => {
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
return new Response(JSON.stringify({ code: 10000, data: { task_id: "task-1", status: "queued" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}));
|
||||
const config: VisualClientConfig = {
|
||||
accessKeyId: "ak",
|
||||
secretAccessKey: "sk",
|
||||
region: "cn-north-1",
|
||||
service: "cv",
|
||||
endpoint: "https://visual.volcengineapi.com"
|
||||
};
|
||||
|
||||
await submitVisualTask({ req_key: "jimeng_seedream46_cvtob", prompt: "draw", scale: 50 }, config);
|
||||
await queryVisualTask({ req_key: "jimeng_seedream46_cvtob", task_id: "task-1" }, config);
|
||||
|
||||
expect(urls).toEqual([
|
||||
"https://visual.volcengineapi.com/?Action=JimengSeedream46CVToBSubmitTask&Version=2024-06-06",
|
||||
"https://visual.volcengineapi.com/?Action=JimengSeedream46CVToBGetResult&Version=2024-06-06"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user