feat(kling): 接入O1与3.0 Omni兼容接口
ci / verify (pull_request) Successful in 15m32s
ci / verify (pull_request) Successful in 15m32s
新增中国区可灵 V1 AK/SK Omni 协议与 API 2.0 兼容路径,补齐任务隔离、外部任务幂等、参数校验和 OpenAPI 文档。\n\n验证:O1 与 3.0 Omni 真实 V1 任务成功;Go、前端、依赖审计、迁移及 CI 脚本门禁通过。
This commit is contained in:
@@ -0,0 +1,908 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
klingCompatProvider = "kling"
|
||||
klingO1Model = "kling-video-o1"
|
||||
klingV3OmniModel = "kling-v3-omni"
|
||||
)
|
||||
|
||||
func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
handler := func(next http.HandlerFunc) http.Handler {
|
||||
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
|
||||
}
|
||||
mux.Handle("POST /kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
|
||||
// Kling API 2.0 uses model-specific paths and a shared /tasks resource.
|
||||
mux.Handle("POST /kling/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /kling/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /kling/tasks", handler(s.klingV2ListTasks))
|
||||
// Versioned aliases help clients that keep the protocol version in their base path.
|
||||
mux.Handle("POST /kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v2/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /kling/v2/tasks", handler(s.klingV2ListTasks))
|
||||
}
|
||||
|
||||
// klingV1CreateOmniVideo godoc
|
||||
// @Summary 创建可灵 V1 Omni 视频任务
|
||||
// @Description 兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK 调用上游。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "可灵 V1 Omni 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/v1/videos/omni-video [post]
|
||||
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(stringFromRequestAny(native["model_name"]))
|
||||
if model == "" {
|
||||
model = klingO1Model
|
||||
}
|
||||
s.createKlingCompatTask(w, r, "v1", model, native)
|
||||
}
|
||||
|
||||
// klingV2CreateOmniVideo godoc
|
||||
// @Summary 创建可灵 API 2.0 Omni 视频任务
|
||||
// @Description 兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param model path string true "模型路径(kling-o1 或 kling-v3-omni)"
|
||||
// @Param input body map[string]interface{} true "可灵 API 2.0 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/omni-video/{model} [post]
|
||||
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
model, ok := klingV2ProviderModel(r.PathValue("model"))
|
||||
if !ok {
|
||||
writeKlingCompatError(w, http.StatusNotFound, "unsupported Kling Omni model", "model_not_found")
|
||||
return
|
||||
}
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
s.createKlingCompatTask(w, r, "v2", model, native)
|
||||
}
|
||||
|
||||
func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, version string, model string, native map[string]any) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeKlingCompatError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKlingCompatError(w, http.StatusForbidden, "api key scope does not allow video generation", "permission_denied")
|
||||
return
|
||||
}
|
||||
body, externalTaskID, err := klingCompatTaskBody(version, model, native)
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
createInput := store.CreateTaskInput{
|
||||
Kind: "videos.generations",
|
||||
Model: model,
|
||||
ExternalTaskID: externalTaskID,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: true,
|
||||
Request: prepared.Body,
|
||||
}
|
||||
if idempotencyKey, hasKey, keyErr := optionalTaskIdempotencyKey(r); keyErr != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid Idempotency-Key", "invalid_idempotency_key")
|
||||
return
|
||||
} else if hasKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(createInput.Kind, true, false, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrIdempotencyKeyReused):
|
||||
writeKlingCompatError(w, http.StatusConflict, err.Error(), "idempotency_key_reused")
|
||||
case errors.Is(err, store.ErrExternalTaskIDReused):
|
||||
writeKlingCompatError(w, http.StatusConflict, err.Error(), "external_task_id_reused")
|
||||
default:
|
||||
s.logger.Error("create Kling compatibility task failed", "version", version, "model", model, "error", err)
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if !created.Replayed {
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
}
|
||||
if version == "v2" {
|
||||
writeJSON(w, http.StatusOK, klingV2Envelope(task))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, klingV1Envelope(task))
|
||||
}
|
||||
|
||||
func klingCompatTaskBody(version string, model string, native map[string]any) (map[string]any, string, error) {
|
||||
if model != klingO1Model && model != klingV3OmniModel {
|
||||
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "model_name must be kling-video-o1 or kling-v3-omni", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if native == nil {
|
||||
native = map[string]any{}
|
||||
}
|
||||
body := cloneMap(native)
|
||||
if version == "v2" {
|
||||
body = klingV2ToLegacyBody(native)
|
||||
}
|
||||
body["model"] = model
|
||||
body["modelType"] = "omni_video"
|
||||
body["_compat_provider"] = klingCompatProvider
|
||||
body["_kling_compat_version"] = version
|
||||
body["content"] = klingLegacyContent(body)
|
||||
mode := strings.TrimSpace(stringFromRequestAny(body["mode"]))
|
||||
if mode == "" && version == "v1" {
|
||||
// The legacy Omni API defaults to professional (1080p) mode.
|
||||
mode = "pro"
|
||||
body["mode"] = mode
|
||||
}
|
||||
if mode != "" {
|
||||
resolution, ok := klingResolutionFromMode(mode)
|
||||
if !ok {
|
||||
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "mode must be std, pro, or 4k", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
body["resolution"] = resolution
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
|
||||
body["audio"] = true
|
||||
}
|
||||
externalTaskID := strings.TrimSpace(stringFromRequestAny(body["external_task_id"]))
|
||||
if err := validateKlingCompatBody(model, body); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return body, externalTaskID, nil
|
||||
}
|
||||
|
||||
func klingV2ToLegacyBody(native map[string]any) map[string]any {
|
||||
body := map[string]any{}
|
||||
for _, key := range []string{"runMode", "simulation", "simulationDurationMs", "simulationProfile"} {
|
||||
if value, ok := native[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
settings, _ := native["settings"].(map[string]any)
|
||||
options, _ := native["options"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
if options == nil {
|
||||
options = map[string]any{}
|
||||
}
|
||||
if resolution := strings.TrimSpace(stringFromRequestAny(settings["resolution"])); resolution != "" {
|
||||
switch strings.ToLower(resolution) {
|
||||
case "720p":
|
||||
body["mode"] = "std"
|
||||
case "1080p":
|
||||
body["mode"] = "pro"
|
||||
case "4k", "2160p":
|
||||
body["mode"] = "4k"
|
||||
default:
|
||||
body["mode"] = resolution
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"aspect_ratio", "duration", "multi_shot", "shot_type", "multi_prompt"} {
|
||||
if value, ok := settings[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
audio := strings.ToLower(strings.TrimSpace(stringFromRequestAny(settings["audio"])))
|
||||
if audio == "native" || audio == "on" {
|
||||
body["sound"] = "on"
|
||||
} else {
|
||||
body["sound"] = "off"
|
||||
}
|
||||
for _, key := range []string{"callback_url", "external_task_id", "watermark_info"} {
|
||||
if value, ok := options[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
contents, _ := native["contents"].([]any)
|
||||
imageList := make([]any, 0)
|
||||
videoList := make([]any, 0)
|
||||
elementList := make([]any, 0)
|
||||
for _, raw := range contents {
|
||||
item, _ := raw.(map[string]any)
|
||||
kind := strings.ToLower(strings.TrimSpace(stringFromRequestAny(item["type"])))
|
||||
switch kind {
|
||||
case "prompt":
|
||||
body["prompt"] = stringFromRequestAny(item["text"])
|
||||
case "first_frame", "last_frame", "refer_image", "reference_image":
|
||||
image := map[string]any{"image_url": firstNonEmptyRequestString(item, "url", "image_url")}
|
||||
if kind == "first_frame" {
|
||||
image["type"] = "first_frame"
|
||||
} else if kind == "last_frame" {
|
||||
image["type"] = "end_frame"
|
||||
}
|
||||
imageList = append(imageList, image)
|
||||
case "feature_video", "base_video", "refer_video", "reference_video":
|
||||
referType := "feature"
|
||||
if kind == "base_video" {
|
||||
referType = "base"
|
||||
}
|
||||
video := map[string]any{
|
||||
"video_url": firstNonEmptyRequestString(item, "url", "video_url"),
|
||||
"refer_type": referType,
|
||||
"keep_original_sound": firstNonEmptyRequestString(item, "keep_original_sound", "keepOriginalSound"),
|
||||
}
|
||||
if audio == "original" && video["keep_original_sound"] == "" {
|
||||
video["keep_original_sound"] = "yes"
|
||||
}
|
||||
videoList = append(videoList, video)
|
||||
case "element":
|
||||
elementList = append(elementList, map[string]any{"element_id": firstPresentRequest(item["element_id"], item["id"])})
|
||||
}
|
||||
}
|
||||
if len(imageList) > 0 {
|
||||
body["image_list"] = imageList
|
||||
}
|
||||
if len(videoList) > 0 {
|
||||
body["video_list"] = videoList
|
||||
}
|
||||
if len(elementList) > 0 {
|
||||
body["element_list"] = elementList
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func klingLegacyContent(body map[string]any) []any {
|
||||
content := make([]any, 0)
|
||||
if prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"])); prompt != "" {
|
||||
content = append(content, map[string]any{"type": "text", "text": prompt})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["image_list"]) {
|
||||
role := "reference_image"
|
||||
switch strings.TrimSpace(stringFromRequestAny(raw["type"])) {
|
||||
case "first_frame":
|
||||
role = "first_frame"
|
||||
case "end_frame", "last_frame":
|
||||
role = "last_frame"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": role,
|
||||
"image_url": map[string]any{"url": firstNonEmptyRequestString(raw, "image_url", "url")},
|
||||
})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["video_list"]) {
|
||||
referType := firstNonEmptyRequestString(raw, "refer_type", "referType")
|
||||
role := "video_feature"
|
||||
if referType == "base" {
|
||||
role = "video_base"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "video_url", "role": role,
|
||||
"video_url": map[string]any{
|
||||
"url": firstNonEmptyRequestString(raw, "video_url", "url"),
|
||||
"refer_type": referType,
|
||||
"keep_original_sound": firstNonEmptyRequestString(raw, "keep_original_sound", "keepOriginalSound"),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["element_list"]) {
|
||||
content = append(content, map[string]any{
|
||||
"type": "element",
|
||||
"element": map[string]any{"element_id": firstPresentRequest(raw["element_id"], raw["id"])},
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func validateKlingCompatBody(model string, body map[string]any) error {
|
||||
maxDuration := 10
|
||||
if model == klingV3OmniModel {
|
||||
maxDuration = 15
|
||||
}
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && (duration < 3 || duration > maxDuration) {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("duration must be between 3 and %d seconds for %s", maxDuration, model), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingO1Model && boolFromRequestAny(body["multi_shot"]) {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support multi_shot", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingO1Model && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["mode"])), "4k") {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support 4k mode", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if ratio := strings.TrimSpace(stringFromRequestAny(body["aspect_ratio"])); ratio != "" && ratio != "16:9" && ratio != "9:16" && ratio != "1:1" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["sound"]))); sound != "" && sound != "on" && sound != "off" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be on or off", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"]))
|
||||
if utf8.RuneCountInString(prompt) > 2500 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt must not exceed 2500 characters", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
images := mapListFromRequest(body["image_list"])
|
||||
videos := mapListFromRequest(body["video_list"])
|
||||
elements := mapListFromRequest(body["element_list"])
|
||||
for _, image := range images {
|
||||
if firstNonEmptyRequestString(image, "image_url", "url") == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every image_list item requires image_url", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
for _, video := range videos {
|
||||
if firstNonEmptyRequestString(video, "video_url", "url") == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every video_list item requires video_url", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if referType := strings.TrimSpace(firstNonEmptyRequestString(video, "refer_type", "referType")); referType != "" && referType != "base" && referType != "feature" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "video refer_type must be base or feature", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
for _, element := range elements {
|
||||
if klingStringAny(firstPresentRequest(element["element_id"], element["id"])) == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every element_list item requires element_id", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if model == klingO1Model && len(images) == 0 && len(videos) == 0 && len(elements) == 0 {
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && duration != 5 && duration != 10 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 text-only generation supports duration 5 or 10 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if len(videos) > 1 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "video_list supports at most one video", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(images)+len(elements) > 7 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "image_list and element_list support at most seven combined references", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(videos) > 0 && len(images)+len(elements) > 4 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "requests with video input support at most four image and element references", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(videos) > 0 && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be off when video_list is provided", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingV3OmniModel && len(videos) > 0 {
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && duration > 10 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-v3-omni video-reference generation supports at most 10 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
multiShot := boolFromRequestAny(body["multi_shot"])
|
||||
shotType := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["shot_type"])))
|
||||
if shotType != "" && shotType != "customize" && shotType != "intelligence" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "shot_type must be customize or intelligence", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if multiShot && shotType == "customize" {
|
||||
multiPrompt := mapListFromRequest(body["multi_prompt"])
|
||||
if len(multiPrompt) == 0 || len(multiPrompt) > 6 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "customize multi-shot requires between one and six multi_prompt items", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
totalDuration := 0
|
||||
for _, shot := range multiPrompt {
|
||||
shotPrompt := strings.TrimSpace(stringFromRequestAny(shot["prompt"]))
|
||||
shotDuration, ok := klingCompatInt(shot["duration"])
|
||||
if shotPrompt == "" || utf8.RuneCountInString(shotPrompt) > 2500 || !ok || shotDuration <= 0 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every multi_prompt item requires prompt and a positive integer duration", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
totalDuration += shotDuration
|
||||
}
|
||||
if totalDuration < 3 || totalDuration > maxDuration {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("multi_prompt duration must total between 3 and %d seconds", maxDuration), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if (!multiShot || shotType == "intelligence" || shotType == "") && prompt == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt is required for single-shot and intelligence multi-shot generation", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// klingV1GetOmniVideo godoc
|
||||
// @Summary 查询可灵 V1 Omni 视频任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeKlingCompatError(w, http.StatusNotFound, "task not found", "task_not_found")
|
||||
return
|
||||
}
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, klingV1Envelope(task))
|
||||
}
|
||||
|
||||
// klingV1ListOmniVideos godoc
|
||||
// @Summary 分页查询可灵 V1 Omni 视频任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pageNum query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(30)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video [get]
|
||||
func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1)
|
||||
if err != nil || page > 1000 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageNum", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
pageSize, err := positiveQueryInt(r.URL.Query().Get("pageSize"), 30)
|
||||
if err != nil || pageSize > 500 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageSize", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{Provider: klingCompatProvider, Version: "v1", Page: page, PageSize: pageSize})
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
data := make([]any, 0, len(result.Items))
|
||||
for _, task := range result.Items {
|
||||
data = append(data, klingV1TaskData(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestID(result.Items), "data": data})
|
||||
}
|
||||
|
||||
// klingV2GetTasks godoc
|
||||
// @Summary 按 ID 查询可灵 API 2.0 任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param task_ids query string false "逗号分隔的任务 ID"
|
||||
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [get]
|
||||
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
|
||||
externalIDs := splitKlingIDs(r.URL.Query().Get("external_task_ids"))
|
||||
if (len(taskIDs) == 0) == (len(externalIDs) == 0) {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "choose exactly one of task_ids or external_task_ids", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
identifiers := taskIDs
|
||||
if len(externalIDs) > 0 {
|
||||
identifiers = externalIDs
|
||||
}
|
||||
data := make([]any, 0, len(identifiers))
|
||||
for _, identifier := range identifiers {
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v2", identifier)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
data = append(data, klingV2TaskData(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestIDFromAny(data), "data": data})
|
||||
}
|
||||
|
||||
// klingV2ListTasks godoc
|
||||
// @Summary 分页查询可灵 API 2.0 任务
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [post]
|
||||
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var body map[string]any
|
||||
if err := decodeKlingJSON(r, &body); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
page, err := klingCursorPage(stringFromRequestAny(body["cursor"]))
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid cursor", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
limit, ok := klingCompatInt(body["limit"])
|
||||
if !ok || limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "limit must not exceed 500", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
createdFrom, err := klingMillisTime(body["start_time"])
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid start_time", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
createdTo, err := klingMillisTime(body["end_time"])
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid end_time", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
statuses := klingInternalStatuses(body["filters"])
|
||||
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{
|
||||
Provider: klingCompatProvider, Version: "v2", Statuses: statuses,
|
||||
CreatedFrom: createdFrom, CreatedTo: createdTo, Page: page, PageSize: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
items := make([]any, 0, len(result.Items))
|
||||
for _, task := range result.Items {
|
||||
items = append(items, klingV2TaskData(task))
|
||||
}
|
||||
hasMore := page*limit < result.Total
|
||||
nextCursor := ""
|
||||
if hasMore {
|
||||
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(page + 1)))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"code": 0, "message": "success", "request_id": klingRequestID(result.Items),
|
||||
"data": map[string]any{"result": items, "count": len(items), "next_cursor": nextCursor, "has_more": hasMore},
|
||||
})
|
||||
}
|
||||
|
||||
func klingV1Envelope(task store.GatewayTask) map[string]any {
|
||||
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV1TaskData(task)}
|
||||
}
|
||||
|
||||
func klingV1TaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"task_id": task.ID, "task_status": klingV1Status(task.Status),
|
||||
"task_info": map[string]any{"external_task_id": task.ExternalTaskID},
|
||||
"created_at": task.CreatedAt.UnixMilli(), "updated_at": task.UpdatedAt.UnixMilli(),
|
||||
}
|
||||
if task.ErrorMessage != "" || task.Error != "" {
|
||||
data["task_status_msg"] = firstNonEmpty(task.ErrorMessage, task.Error)
|
||||
}
|
||||
if watermarkInfo, ok := task.Request["watermark_info"].(map[string]any); ok {
|
||||
data["watermark_info"] = watermarkInfo
|
||||
}
|
||||
videos := klingTaskVideos(task)
|
||||
if len(videos) > 0 {
|
||||
data["task_result"] = map[string]any{"videos": videos}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func klingV2Envelope(task store.GatewayTask) map[string]any {
|
||||
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV2TaskData(task)}
|
||||
}
|
||||
|
||||
func klingV2TaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": task.ID, "status": klingV2Status(task.Status),
|
||||
"create_time": task.CreatedAt.UnixMilli(), "update_time": task.UpdatedAt.UnixMilli(),
|
||||
"external_id": task.ExternalTaskID,
|
||||
}
|
||||
if message := firstNonEmpty(task.ErrorMessage, task.Error); message != "" {
|
||||
data["message"] = message
|
||||
}
|
||||
if outputs := klingV2Outputs(task); len(outputs) > 0 {
|
||||
data["outputs"] = outputs
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func klingTaskVideos(task store.GatewayTask) []any {
|
||||
items, _ := task.Result["data"].([]any)
|
||||
videos := make([]any, 0, len(items))
|
||||
for index, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
url := firstNonEmptyRequestString(item, "url", "video_url")
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
video := map[string]any{"id": firstNonEmptyRequestString(item, "id")}
|
||||
if video["id"] == "" {
|
||||
video["id"] = fmt.Sprintf("%s-%d", task.ID, index+1)
|
||||
}
|
||||
video["url"] = url
|
||||
if watermarkURL := firstNonEmptyRequestString(item, "watermark_url"); watermarkURL != "" {
|
||||
video["watermark_url"] = watermarkURL
|
||||
}
|
||||
if duration := klingStringAny(item["duration"]); duration != "" {
|
||||
video["duration"] = duration
|
||||
}
|
||||
videos = append(videos, video)
|
||||
}
|
||||
return videos
|
||||
}
|
||||
|
||||
func klingV2Outputs(task store.GatewayTask) []any {
|
||||
videos := klingTaskVideos(task)
|
||||
outputs := make([]any, 0, len(videos))
|
||||
for _, raw := range videos {
|
||||
video, _ := raw.(map[string]any)
|
||||
output := cloneMap(video)
|
||||
output["type"] = "video"
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func klingV1Status(status string) string {
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return "succeed"
|
||||
case "failed", "cancelled":
|
||||
return "failed"
|
||||
case "running":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func klingV2Status(status string) string {
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return "succeeded"
|
||||
case "failed", "cancelled":
|
||||
return "failed"
|
||||
case "running":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func klingV2ProviderModel(pathModel string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(pathModel)) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return klingO1Model, true
|
||||
case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni":
|
||||
return klingV3OmniModel, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func klingResolutionFromMode(mode string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "std":
|
||||
return "720p", true
|
||||
case "pro":
|
||||
return "1080p", true
|
||||
case "4k":
|
||||
return "2160p", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func decodeKlingJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("multiple json values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeKlingCompatError(w http.ResponseWriter, status int, message string, code string) {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
code = "invalid_request"
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"code": klingCompatErrorCode(status),
|
||||
"message": message,
|
||||
"request_id": "",
|
||||
"error": code,
|
||||
})
|
||||
}
|
||||
|
||||
func klingCompatErrorCode(status int) int {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return 1001
|
||||
case http.StatusUnauthorized:
|
||||
return 1100
|
||||
case http.StatusForbidden:
|
||||
return 1302
|
||||
case http.StatusNotFound:
|
||||
return 1201
|
||||
case http.StatusConflict:
|
||||
return 1200
|
||||
case http.StatusTooManyRequests:
|
||||
return 1400
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
func mapListFromRequest(value any) []map[string]any {
|
||||
items, _ := value.([]any)
|
||||
if len(items) == 0 {
|
||||
if typed, ok := value.([]map[string]any); ok {
|
||||
return typed
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if mapped, ok := item.(map[string]any); ok {
|
||||
out = append(out, mapped)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstPresentRequest(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
return value
|
||||
}
|
||||
continue
|
||||
}
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolFromRequestAny(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseBool(strings.TrimSpace(typed))
|
||||
return parsed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func klingCompatInt(value any) (int, bool) {
|
||||
text := klingStringAny(value)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
number, err := strconv.Atoi(text)
|
||||
return number, err == nil
|
||||
}
|
||||
|
||||
func splitKlingIDs(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func klingCursorPage(cursor string) (int, error) {
|
||||
cursor = strings.TrimSpace(cursor)
|
||||
if cursor == "" {
|
||||
return 1, nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
page, err := strconv.Atoi(string(decoded))
|
||||
if err != nil || page <= 0 {
|
||||
return 0, errors.New("invalid cursor")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func klingMillisTime(value any) (*time.Time, error) {
|
||||
text := klingStringAny(value)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
millis, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil || millis < 0 {
|
||||
return nil, errors.New("invalid millisecond timestamp")
|
||||
}
|
||||
parsed := time.UnixMilli(millis)
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func klingInternalStatuses(filters any) []string {
|
||||
statuses := make([]string, 0)
|
||||
for _, filter := range mapListFromRequest(filters) {
|
||||
if stringFromRequestAny(filter["key"]) != "status" {
|
||||
continue
|
||||
}
|
||||
values, _ := filter["values"].([]any)
|
||||
for _, value := range values {
|
||||
switch strings.ToLower(strings.TrimSpace(stringFromRequestAny(value))) {
|
||||
case "submitted":
|
||||
statuses = append(statuses, "queued")
|
||||
case "processing":
|
||||
statuses = append(statuses, "running")
|
||||
case "succeeded":
|
||||
statuses = append(statuses, "succeeded")
|
||||
case "failed":
|
||||
statuses = append(statuses, "failed", "cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func klingRequestID(tasks []store.GatewayTask) string {
|
||||
if len(tasks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(tasks[0].RequestID, tasks[0].ID)
|
||||
}
|
||||
|
||||
func klingRequestIDFromAny(items []any) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
return stringFromRequestAny(item["id"])
|
||||
}
|
||||
|
||||
func klingStringAny(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
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 int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling compatibility integration flow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "kling_compat_" + suffix
|
||||
password := "password123"
|
||||
var registerResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®isterResponse)
|
||||
var apiKeyResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
|
||||
"name": "Kling compatibility key",
|
||||
}, http.StatusCreated, &apiKeyResponse)
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote compatibility user: %v", err)
|
||||
}
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username, "password": password,
|
||||
}, http.StatusOK, &loginResponse)
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "keling",
|
||||
"platformKey": "kling-compat-" + suffix,
|
||||
"name": "Kling Compatibility Simulation",
|
||||
"baseUrl": "https://api-beijing.klingai.com/v1",
|
||||
"authType": "AccessKey-SecretKey",
|
||||
"credentials": map[string]any{"accessKey": "test-ak", "secretKey": "test-sk"},
|
||||
}, http.StatusCreated, &platform)
|
||||
for _, model := range []string{klingO1Model, klingV3OmniModel} {
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "keling:" + model,
|
||||
"modelName": model,
|
||||
"providerModelName": model,
|
||||
"modelAlias": model,
|
||||
"modelType": []string{"omni_video"},
|
||||
"displayName": model,
|
||||
}, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
createV1 := func(model string, duration int, externalID string) string {
|
||||
t.Helper()
|
||||
var response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": model,
|
||||
"prompt": "兼容接口模拟任务",
|
||||
"duration": duration,
|
||||
"mode": "std",
|
||||
"external_task_id": externalID,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusOK, &response)
|
||||
if response["code"] != float64(0) {
|
||||
t.Fatalf("unexpected V1 response: %#v", response)
|
||||
}
|
||||
data, _ := response["data"].(map[string]any)
|
||||
taskID, _ := data["task_id"].(string)
|
||||
if taskID == "" {
|
||||
t.Fatalf("V1 response missing task id: %#v", response)
|
||||
}
|
||||
return taskID
|
||||
}
|
||||
|
||||
o1TaskID := createV1(klingO1Model, 5, "compat-o1-"+suffix)
|
||||
v3TaskID := createV1(klingV3OmniModel, 15, "compat-v3-"+suffix)
|
||||
for _, taskID := range []string{o1TaskID, v3TaskID} {
|
||||
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
|
||||
}
|
||||
var listResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
items, _ := listResponse["data"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
|
||||
}
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
|
||||
"external_task_id": "compat-o1-" + suffix,
|
||||
"runMode": "simulation", "simulation": true,
|
||||
}, http.StatusConflict, &duplicateResponse)
|
||||
if duplicateResponse["code"] != float64(1200) || duplicateResponse["error"] != "external_task_id_reused" {
|
||||
t.Fatalf("unexpected duplicate external id response: %#v", duplicateResponse)
|
||||
}
|
||||
|
||||
var v2Response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
|
||||
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
|
||||
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusOK, &v2Response)
|
||||
v2Data, _ := v2Response["data"].(map[string]any)
|
||||
v2TaskID, _ := v2Data["id"].(string)
|
||||
if v2TaskID == "" {
|
||||
t.Fatalf("V2 response missing task id: %#v", v2Response)
|
||||
}
|
||||
waitKlingV2SimulationTask(t, server.URL, apiKeyResponse.Secret, v2TaskID)
|
||||
}
|
||||
|
||||
func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response["data"].(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return
|
||||
case "failed":
|
||||
t.Fatalf("V1 simulation task failed: %#v", response)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("V1 simulation task %s timed out", taskID)
|
||||
}
|
||||
|
||||
func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
items, _ := response["data"].([]any)
|
||||
if len(items) == 1 {
|
||||
data, _ := items[0].(map[string]any)
|
||||
switch data["status"] {
|
||||
case "succeeded":
|
||||
return
|
||||
case "failed":
|
||||
t.Fatalf("V2 simulation task failed: %#v", response)
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("V2 simulation task %s timed out", taskID)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKlingV1O1CompatibilityBody(t *testing.T) {
|
||||
body, externalID, err := klingCompatTaskBody("v1", klingO1Model, map[string]any{
|
||||
"prompt": "一只纸鹤飞过湖面",
|
||||
"duration": json.Number("10"),
|
||||
"aspect_ratio": "16:9",
|
||||
"sound": "on",
|
||||
"external_task_id": "client-o1-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build O1 compatibility body: %v", err)
|
||||
}
|
||||
if externalID != "client-o1-1" || body["model"] != klingO1Model || body["modelType"] != "omni_video" {
|
||||
t.Fatalf("unexpected identity fields: %#v", body)
|
||||
}
|
||||
if body["mode"] != "pro" || body["resolution"] != "1080p" || body["audio"] != true {
|
||||
t.Fatalf("unexpected V1 defaults: %#v", body)
|
||||
}
|
||||
content := mapListFromRequest(body["content"])
|
||||
if len(content) != 1 || content[0]["type"] != "text" || content[0]["text"] != "一只纸鹤飞过湖面" {
|
||||
t.Fatalf("unexpected canonical content: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingV1V3OmniCompatibilityBody(t *testing.T) {
|
||||
body, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"mode": "4k",
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": json.Number("1"), "prompt": "推近人物", "duration": json.Number("7")},
|
||||
map[string]any{"index": json.Number("2"), "prompt": "切到城市远景", "duration": json.Number("8")},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build 3.0 Omni compatibility body: %v", err)
|
||||
}
|
||||
if body["resolution"] != "2160p" {
|
||||
t.Fatalf("4k mode was not normalized: %#v", body)
|
||||
}
|
||||
content := mapListFromRequest(body["content"])
|
||||
if len(content) != 1 || content[0]["role"] != "first_frame" {
|
||||
t.Fatalf("image input was not normalized: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingV2CompatibilityBody(t *testing.T) {
|
||||
body, externalID, err := klingCompatTaskBody("v2", klingV3OmniModel, map[string]any{
|
||||
"contents": []any{
|
||||
map[string]any{"type": "prompt", "text": "让角色向镜头挥手"},
|
||||
map[string]any{"type": "first_frame", "url": "https://example.com/first.png"},
|
||||
map[string]any{"type": "element", "id": json.Number("42")},
|
||||
},
|
||||
"settings": map[string]any{
|
||||
"resolution": "1080p",
|
||||
"duration": json.Number("15"),
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": "native",
|
||||
},
|
||||
"options": map[string]any{
|
||||
"external_task_id": "client-v2-1",
|
||||
"callback_url": "https://example.com/callback",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build V2 compatibility body: %v", err)
|
||||
}
|
||||
if externalID != "client-v2-1" || body["mode"] != "pro" || body["audio"] != true {
|
||||
t.Fatalf("unexpected V2 settings: %#v", body)
|
||||
}
|
||||
if len(mapListFromRequest(body["image_list"])) != 1 || len(mapListFromRequest(body["element_list"])) != 1 {
|
||||
t.Fatalf("unexpected V2 references: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingCompatibilityValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
body map[string]any
|
||||
}{
|
||||
{name: "O1 duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 11}},
|
||||
{name: "O1 text-only flexible duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 3}},
|
||||
{name: "O1 4k", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 5, "mode": "4k"}},
|
||||
{name: "O1 multi-shot", model: klingO1Model, body: map[string]any{"multi_shot": true, "multi_prompt": []any{map[string]any{"prompt": "test", "duration": 3}}}},
|
||||
{name: "custom multi-shot without prompts", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "customize"}},
|
||||
{name: "intelligence multi-shot without prompt", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "intelligence"}},
|
||||
{name: "video with native audio", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "sound": "on", "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
||||
{name: "video duration too long", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "duration": 15, "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
||||
{name: "too many references", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "image_list": []any{
|
||||
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
|
||||
}}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, err := klingCompatTaskBody("v1", test.model, test.body); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{"prompt": "test", "duration": 15}); err != nil {
|
||||
t.Fatalf("3.0 Omni should allow a 15-second duration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKlingJSONRejectsTrailingData(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/kling/v1/videos/omni-video", strings.NewReader(`{"prompt":"ok"} trailing`))
|
||||
var body map[string]any
|
||||
if err := decodeKlingJSON(request, &body); err == nil {
|
||||
t.Fatal("expected trailing JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteKlingCompatErrorUsesOfficialNumericEnvelope(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writeKlingCompatError(recorder, http.StatusBadRequest, "bad request", "invalid_parameter")
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
if body["code"] != float64(1001) || body["error"] != "invalid_parameter" {
|
||||
t.Fatalf("unexpected error envelope: %#v", body)
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /api/v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
server.registerKlingCompatibilityRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
|
||||
Reference in New Issue
Block a user