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.requireProtocolUser(clients.ProtocolKlingV1Omni, http.HandlerFunc(next)) } // /api/v1 is the canonical public prefix. The historical /kling paths // remain registered below so existing clients can migrate without downtime. mux.Handle("POST /api/v1/kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo)) mux.Handle("GET /api/v1/kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos)) mux.Handle("GET /api/v1/kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo)) mux.Handle("POST /api/v1/kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo)) mux.Handle("GET /api/v1/kling/v2/tasks", handler(s.klingV2GetTasks)) mux.Handle("POST /api/v1/kling/v2/tasks", handler(s.klingV2ListTasks)) 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} KlingErrorEnvelope // @Router /api/v1/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} KlingErrorEnvelope // @Router /api/v1/kling/v2/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 targetProtocol := clients.ProtocolKlingV1Omni if version == "v2" { targetProtocol = clients.ProtocolKlingV2Omni } if !created.Replayed { if err := s.store.SetTaskCompatibilitySubmission(r.Context(), task.ID, store.CompatibilitySubmission{TargetProtocol: targetProtocol, PublicID: task.ID}); err != nil { writeKlingCompatError(w, http.StatusInternalServerError, "create compatibility metadata failed", "task_create_failed") return } if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil { writeKlingCompatError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed") return } task, err = s.waitForCompatibilitySubmission(r, task) if err != nil { if wire := clients.ErrorWireResponse(err); wireResponseMatches(wire, targetProtocol) { writeWireResponse(w, wire) return } writeKlingCompatError(w, statusFromRunError(err), err.Error(), clients.ErrorCode(err)) return } } else { w.Header().Set("Idempotent-Replayed", "true") } if version == "v2" { writeJSON(w, http.StatusOK, klingV2SubmissionEnvelope(task)) return } writeJSON(w, http.StatusOK, klingV1SubmissionEnvelope(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 if version == "v2" { body["_gateway_target_protocol"] = clients.ProtocolKlingV2Omni } else { body["_gateway_target_protocol"] = clients.ProtocolKlingV1Omni } 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 /api/v1/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 /api/v1/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 /api/v1/kling/v2/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 /api/v1/kling/v2/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 klingV1SubmissionEnvelope(task store.GatewayTask) map[string]any { data := klingV1TaskData(task) data["task_status"] = "submitted" delete(data, "task_status_msg") delete(data, "task_result") return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": data} } func klingV1TaskData(task store.GatewayTask) map[string]any { data := map[string]any{ "task_id": klingCompatibilityPublicID(task), "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 klingV2SubmissionEnvelope(task store.GatewayTask) map[string]any { data := klingV2TaskData(task) data["status"] = "submitted" delete(data, "message") delete(data, "outputs") return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": data} } func klingV2TaskData(task store.GatewayTask) map[string]any { data := map[string]any{ "id": klingCompatibilityPublicID(task), "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 klingCompatibilityPublicID(task store.GatewayTask) string { if task.CompatibilityProtocol == task.CompatibilitySourceProtocol { if remoteTaskID := strings.TrimSpace(task.RemoteTaskID); remoteTaskID != "" { return remoteTaskID } } return task.ID } func klingMap(value any) map[string]any { result, _ := value.(map[string]any) return result } 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) { return canonicalKlingOmniModel(pathModel) } func canonicalKlingOmniModel(value string) (string, bool) { switch strings.ToLower(strings.TrimSpace(value)) { 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 "" } }