feat(kling): 接入 Omni 视频兼容接口
This commit is contained in:
@@ -338,8 +338,11 @@ func kelingVideoPayload(ctx context.Context, request Request) (map[string]any, s
|
||||
if value, ok := body["cfg_scale"]; ok && numericValue(value, 0) > 0 {
|
||||
payload["cfg_scale"] = value
|
||||
}
|
||||
if boolValue(body, "audio") || boolValue(body, "output_audio") {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
payload["sound"] = sound
|
||||
}
|
||||
if mode := kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")); mode != "" {
|
||||
payload["mode"] = mode
|
||||
@@ -432,7 +435,7 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
watermarkEnabled = boolValue(watermarkInfo, "enabled")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model_name": upstreamModelName(request.Candidate),
|
||||
"model_name": kelingOmniUpstreamModelName(request.Candidate),
|
||||
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||
@@ -458,8 +461,13 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
if voices := mapListFromAny(body["voice_list"]); len(voices) > 0 {
|
||||
payload["voice_list"] = voices
|
||||
}
|
||||
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, nil, &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !hasVideo {
|
||||
payload["sound"] = sound
|
||||
}
|
||||
}
|
||||
if multiShot {
|
||||
payload["multi_shot"] = true
|
||||
@@ -728,6 +736,18 @@ func kelingIsOmniRequest(request Request) bool {
|
||||
request.Candidate.Capabilities["omni"] != nil
|
||||
}
|
||||
|
||||
func kelingOmniUpstreamModelName(candidate store.RuntimeModelCandidate) string {
|
||||
model := strings.TrimSpace(upstreamModelName(candidate))
|
||||
switch strings.ToLower(model) {
|
||||
case "kling-o1":
|
||||
return "kling-video-o1"
|
||||
case "kling-3.0-omni":
|
||||
return "kling-v3-omni"
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func kelingIs30TurboRequest(request Request) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
|
||||
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
|
||||
@@ -1073,6 +1093,54 @@ func kelingModeByResolution(resolution string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func kelingSoundSetting(body map[string]any) (string, bool) {
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromAny(body["sound"]))); sound == "on" || sound == "off" {
|
||||
return sound, true
|
||||
}
|
||||
for _, key := range []string{"audio", "output_audio", "generate_audio"} {
|
||||
if enabled, ok := kelingBoolFieldValue(body, key); ok {
|
||||
if enabled {
|
||||
return "on", true
|
||||
}
|
||||
return "off", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func kelingSupportsGeneratedSound(candidate store.RuntimeModelCandidate) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(candidate))) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func kelingWatermarkEnabled(body map[string]any) bool {
|
||||
if enabled, ok := kelingBoolFieldValue(body, "watermark"); ok {
|
||||
return enabled
|
||||
}
|
||||
info := mapFromAny(body["watermark_info"])
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
enabled, _ := kelingBoolFieldValue(info, "enabled")
|
||||
return enabled
|
||||
}
|
||||
|
||||
func kelingBoolFieldValue(body map[string]any, key string) (bool, bool) {
|
||||
if body == nil {
|
||||
return false, false
|
||||
}
|
||||
value, ok := body[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
typed, ok := value.(bool)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
func kelingCameraControl(body map[string]any) map[string]any {
|
||||
cameraControl := strings.TrimSpace(stringFromAny(body["camera_control"]))
|
||||
if cameraControl == "" {
|
||||
@@ -1182,7 +1250,7 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
|
||||
item["id"] = id
|
||||
}
|
||||
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||
if duration := firstPresent(video["duration"]); duration != nil && strings.TrimSpace(stringFromAny(duration)) != "" {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
|
||||
@@ -1194,11 +1262,15 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
modelName := upstreamModelName(request.Candidate)
|
||||
if kelingIsOmniRequest(request) {
|
||||
modelName = kelingOmniUpstreamModelName(request.Candidate)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"model": modelName,
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": items,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniPayloadPreservesCompatibleSettings(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "A product reveal",
|
||||
"duration": 3,
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"audio": false,
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"external_task_id": "external-1",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build compatible Omni payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 ||
|
||||
payload["model_name"] != "kling-video-o1" ||
|
||||
payload["mode"] != "std" ||
|
||||
payload["sound"] != "off" ||
|
||||
payload["duration"] != "3" ||
|
||||
payload["aspect_ratio"] != "16:9" ||
|
||||
payload["external_task_id"] != "external-1" {
|
||||
t.Fatalf("unexpected compatible Omni payload: %+v", payload)
|
||||
}
|
||||
watermark, _ := payload["watermark_info"].(map[string]any)
|
||||
if watermark["enabled"] != true {
|
||||
t.Fatalf("watermark setting was not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniUpstreamModelNameSeparatesGatewayAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"kling-o1": "kling-video-o1",
|
||||
"kling-video-o1": "kling-video-o1",
|
||||
"kling-3.0-omni": "kling-v3-omni",
|
||||
"kling-v3-omni": "kling-v3-omni",
|
||||
}
|
||||
for configured, want := range tests {
|
||||
got := kelingOmniUpstreamModelName(store.RuntimeModelCandidate{ProviderModelName: configured})
|
||||
if got != want {
|
||||
t.Fatalf("configured=%s got=%s want=%s", configured, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniRejectsGeneratedAudioForO1(t *testing.T) {
|
||||
_, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Body: map[string]any{
|
||||
"prompt": "A beach",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"},
|
||||
}, "token")
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
||||
t.Fatalf("expected generated-audio rejection for O1, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniResumeReturnsUpstreamFailureCodeAndModel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/videos/omni-video/remote-failed" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-key" {
|
||||
t.Fatalf("unexpected Authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "failure-request",
|
||||
"data": map[string]any{
|
||||
"task_id": "remote-failed",
|
||||
"task_status": "failed",
|
||||
"task_status_msg": "content policy rejection",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
RemoteTaskID: "remote-failed",
|
||||
RemoteTaskPayload: map[string]any{"endpoint": "/videos/omni-video"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Credentials: map[string]any{"apiKey": "upstream-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 10,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || ErrorCode(err) != "keling_task_failed" || !strings.Contains(err.Error(), "content policy rejection") {
|
||||
t.Fatalf("expected preserved Keling task failure, got code=%q err=%v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesIntelligentMultiShot(t *testing.T) {
|
||||
payload, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "Create three coherent shots",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"multi_shot": true,
|
||||
"shot_type": "intelligence",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build intelligent multi-shot payload: %v", err)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "intelligence" || payload["prompt"] != "Create three coherent shots" {
|
||||
t.Fatalf("unexpected intelligent multi-shot payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingVideoSuccessResultPreservesOfficialVideoMetadata(t *testing.T) {
|
||||
result := kelingVideoSuccessResult(Request{Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"}}, "remote-1", map[string]any{
|
||||
"data": map[string]any{
|
||||
"task_result": map[string]any{
|
||||
"videos": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "3",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
data, _ := result["data"].([]any)
|
||||
video, _ := data[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "3" {
|
||||
t.Fatalf("official video metadata was lost: %+v", video)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user