886 lines
30 KiB
Go
886 lines
30 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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 kelingOmniCompatibilityMarker = "keling_omni_v1"
|
||
|
||
type kelingCompatRequestIDKey struct{}
|
||
|
||
type KelingOmniVideoRequest struct {
|
||
ModelName string `json:"model_name" example:"kling-v3-omni"`
|
||
Prompt string `json:"prompt" example:"A quiet street in the rain with natural ambient sound"`
|
||
MultiShot bool `json:"multi_shot" example:"false"`
|
||
ShotType string `json:"shot_type,omitempty" example:"customize"`
|
||
MultiPrompt []KelingOmniMultiPrompt `json:"multi_prompt,omitempty"`
|
||
ImageList []KelingOmniImageInput `json:"image_list,omitempty"`
|
||
ElementList []KelingOmniElementInput `json:"element_list,omitempty"`
|
||
VideoList []KelingOmniVideoInput `json:"video_list,omitempty"`
|
||
Sound string `json:"sound" enums:"on,off" example:"on"`
|
||
Mode string `json:"mode" enums:"std,pro,4k" example:"pro"`
|
||
AspectRatio string `json:"aspect_ratio" enums:"16:9,9:16,1:1" example:"9:16"`
|
||
Duration any `json:"duration" swaggertype:"string" example:"5"`
|
||
WatermarkInfo KelingOmniWatermarkInfo `json:"watermark_info,omitempty"`
|
||
CallbackURL string `json:"callback_url,omitempty"`
|
||
ExternalTask string `json:"external_task_id,omitempty"`
|
||
}
|
||
|
||
type KelingOmniMultiPrompt struct {
|
||
Index int `json:"index" example:"1"`
|
||
Prompt string `json:"prompt" example:"A wide establishing shot"`
|
||
Duration any `json:"duration" swaggertype:"string" example:"3"`
|
||
}
|
||
|
||
type KelingOmniImageInput struct {
|
||
ImageURL string `json:"image_url"`
|
||
Type string `json:"type,omitempty" enums:"first_frame,end_frame"`
|
||
}
|
||
|
||
type KelingOmniElementInput struct {
|
||
ElementID any `json:"element_id"`
|
||
}
|
||
|
||
type KelingOmniVideoInput struct {
|
||
VideoURL string `json:"video_url"`
|
||
ReferType string `json:"refer_type,omitempty" enums:"base,feature"`
|
||
KeepOriginalSound string `json:"keep_original_sound,omitempty" enums:"yes,no"`
|
||
}
|
||
|
||
type KelingOmniWatermarkInfo struct {
|
||
Enabled bool `json:"enabled" example:"false"`
|
||
}
|
||
|
||
type KelingCompatibleEnvelope struct {
|
||
Code int `json:"code" example:"0"`
|
||
Message string `json:"message" example:"SUCCEED"`
|
||
RequestID string `json:"request_id"`
|
||
Data any `json:"data,omitempty"`
|
||
}
|
||
|
||
type kelingCompatError struct {
|
||
HTTPStatus int
|
||
Code int
|
||
Message string
|
||
RequestID string
|
||
}
|
||
|
||
func (e *kelingCompatError) Error() string {
|
||
if e == nil {
|
||
return "keling compatibility error"
|
||
}
|
||
return e.Message
|
||
}
|
||
|
||
func newKelingCompatError(status int, code int, message string) *kelingCompatError {
|
||
return &kelingCompatError{HTTPStatus: status, Code: code, Message: message}
|
||
}
|
||
|
||
func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
requestID := newKelingCompatRequestID(r)
|
||
r = r.WithContext(context.WithValue(r.Context(), kelingCompatRequestIDKey{}, requestID))
|
||
user, err := s.auth.Authenticate(r)
|
||
if err != nil {
|
||
code := 1002
|
||
message := "Authorization is invalid"
|
||
if strings.TrimSpace(r.Header.Get("Authorization")) == "" {
|
||
code = 1001
|
||
message = "Authorization is required"
|
||
}
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, code, message))
|
||
return
|
||
}
|
||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "a Gateway API Key is required"))
|
||
return
|
||
}
|
||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusForbidden, 1103, "API Key scope does not allow video generation"))
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
||
})
|
||
}
|
||
|
||
// createKelingOmniVideo godoc
|
||
// @Summary 创建 Kling Omni 视频任务
|
||
// @Description 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。
|
||
// @Tags kling-compatible
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param input body KelingOmniVideoRequest true "Kling Omni 官方兼容请求"
|
||
// @Success 200 {object} KelingCompatibleEnvelope
|
||
// @Failure 400 {object} KelingCompatibleEnvelope
|
||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||
// @Router /api/v1/videos/omni-video [post]
|
||
func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||
requestID := kelingCompatRequestID(r)
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok || user == nil {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||
return
|
||
}
|
||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||
if err != nil {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, err.Error()))
|
||
return
|
||
}
|
||
normalized, compatErr := normalizeKelingOmniRequest(body)
|
||
if compatErr != nil {
|
||
writeKelingCompatError(w, requestID, compatErr)
|
||
return
|
||
}
|
||
model := strings.TrimSpace(stringFromKelingCompat(normalized["model"]))
|
||
if normalized["resolution"] == "2160p" {
|
||
candidates, candidateErr := s.store.ListModelCandidates(r.Context(), model, "omni_video", user)
|
||
if candidateErr != nil {
|
||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(candidateErr))
|
||
return
|
||
}
|
||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, "mode=4k is not enabled by the selected model capabilities"))
|
||
return
|
||
}
|
||
}
|
||
task, createErr := s.prepareAndCreateGatewayTask(
|
||
r.Context(),
|
||
r,
|
||
user,
|
||
"videos.generations",
|
||
model,
|
||
normalized,
|
||
true,
|
||
)
|
||
if createErr != nil {
|
||
var staged *gatewayTaskCreationError
|
||
if errors.As(createErr, &staged) && staged.Stage == gatewayTaskCreationPrepare {
|
||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(staged.Err))
|
||
return
|
||
}
|
||
s.logger.Error("create Kling-compatible task failed", "error", createErr)
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed"))
|
||
return
|
||
}
|
||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||
s.logger.Error("enqueue Kling-compatible task failed", "taskId", task.ID, "error", err)
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusServiceUnavailable, 5001, "video task queue is unavailable"))
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||
Code: 0,
|
||
Message: "SUCCEED",
|
||
RequestID: requestID,
|
||
Data: kelingCompatTaskData(task),
|
||
})
|
||
}
|
||
|
||
// getKelingOmniVideo godoc
|
||
// @Summary 查询 Kling Omni 视频任务
|
||
// @Description 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。
|
||
// @Tags kling-compatible
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param taskID path string true "网关任务 ID"
|
||
// @Success 200 {object} KelingCompatibleEnvelope
|
||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||
// @Failure 404 {object} KelingCompatibleEnvelope
|
||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||
// @Router /api/v1/videos/omni-video/{taskID} [get]
|
||
func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||
requestID := kelingCompatRequestID(r)
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok || user == nil {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||
return
|
||
}
|
||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||
if err != nil {
|
||
if store.IsNotFound(err) {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||
return
|
||
}
|
||
s.logger.Error("get Kling-compatible task failed", "error", err)
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "query task failed"))
|
||
return
|
||
}
|
||
if !kelingCompatTaskOwnedBy(task, user) || !isKelingCompatTask(task) {
|
||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||
Code: 0,
|
||
Message: "SUCCEED",
|
||
RequestID: requestID,
|
||
Data: kelingCompatTaskData(task),
|
||
})
|
||
}
|
||
|
||
func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCompatError) {
|
||
if input == nil {
|
||
input = map[string]any{}
|
||
}
|
||
if callbackURL := strings.TrimSpace(stringFromKelingCompat(input["callback_url"])); callbackURL != "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "callback_url is not supported by this Gateway endpoint")
|
||
}
|
||
requestedModel := strings.TrimSpace(stringFromKelingCompat(input["model_name"]))
|
||
if requestedModel == "" {
|
||
requestedModel = "kling-video-o1"
|
||
}
|
||
model, maxDuration, ok := kelingCompatModel(requestedModel)
|
||
if !ok {
|
||
return nil, newKelingCompatError(http.StatusNotFound, 1203, "unsupported model_name: "+requestedModel)
|
||
}
|
||
|
||
mode := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["mode"])))
|
||
if mode == "" {
|
||
mode = "pro"
|
||
}
|
||
resolutionByMode := map[string]string{"std": "720p", "pro": "1080p", "4k": "2160p"}
|
||
resolution := resolutionByMode[mode]
|
||
if resolution == "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "mode must be std, pro, or 4k")
|
||
}
|
||
|
||
sound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["sound"])))
|
||
if sound == "" {
|
||
sound = "off"
|
||
}
|
||
if sound != "on" && sound != "off" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off")
|
||
}
|
||
if model == klingO1Model && sound == "on" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off")
|
||
}
|
||
|
||
content := make([]any, 0)
|
||
prompt := strings.TrimSpace(stringFromKelingCompat(input["prompt"]))
|
||
images, hasFirstFrame, imageErr := normalizeKelingImageList(input["image_list"])
|
||
if imageErr != nil {
|
||
return nil, imageErr
|
||
}
|
||
content = append(content, images...)
|
||
elements, elementErr := normalizeKelingElementList(input["element_list"])
|
||
if elementErr != nil {
|
||
return nil, elementErr
|
||
}
|
||
content = append(content, elements...)
|
||
videos, hasBaseVideo, hasVideo, videoErr := normalizeKelingVideoList(input["video_list"])
|
||
if videoErr != nil {
|
||
return nil, videoErr
|
||
}
|
||
content = append(content, videos...)
|
||
if hasVideo && sound == "on" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be off when video_list is provided")
|
||
}
|
||
|
||
multiShot, multiShotPresent, boolErr := kelingCompatOptionalBool(input, "multi_shot")
|
||
if boolErr != nil {
|
||
return nil, boolErr
|
||
}
|
||
shotType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["shot_type"])))
|
||
multiPrompts, shotDuration, multiPromptErr := normalizeKelingMultiPrompts(input["multi_prompt"])
|
||
if multiPromptErr != nil {
|
||
return nil, multiPromptErr
|
||
}
|
||
if !multiShotPresent {
|
||
multiShot = false
|
||
}
|
||
if multiShot {
|
||
if shotType != "customize" && shotType != "intelligence" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type must be customize or intelligence when multi_shot is true")
|
||
}
|
||
if shotType == "customize" && len(multiPrompts) == 0 {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is required for customized multi-shot generation")
|
||
}
|
||
if shotType == "intelligence" && len(multiPrompts) > 0 {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is only supported when shot_type is customize")
|
||
}
|
||
} else if len(multiPrompts) > 0 || shotType != "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type and multi_prompt require multi_shot=true")
|
||
}
|
||
if (len(multiPrompts) == 0 || shotType == "intelligence") && prompt == "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "prompt is required")
|
||
}
|
||
if prompt != "" {
|
||
content = append([]any{map[string]any{"type": "text", "text": prompt}}, content...)
|
||
}
|
||
content = append(content, multiPrompts...)
|
||
|
||
duration, durationProvided, durationErr := kelingCompatOptionalInt(input, "duration")
|
||
if durationErr != nil {
|
||
return nil, durationErr
|
||
}
|
||
if hasBaseVideo {
|
||
if durationProvided {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration is not supported for base video editing")
|
||
}
|
||
} else {
|
||
if len(multiPrompts) > 0 {
|
||
if durationProvided && duration != shotDuration {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration must equal the sum of multi_prompt durations")
|
||
}
|
||
duration = shotDuration
|
||
} else if !durationProvided {
|
||
duration = 5
|
||
}
|
||
if duration < 3 || duration > maxDuration {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration))
|
||
}
|
||
if model == klingO1Model && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds")
|
||
}
|
||
}
|
||
|
||
aspectRatio := strings.TrimSpace(stringFromKelingCompat(input["aspect_ratio"]))
|
||
if aspectRatio != "" && aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio must be 16:9, 9:16, or 1:1")
|
||
}
|
||
if hasFirstFrame || hasBaseVideo {
|
||
if aspectRatio != "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is not supported with a first frame or base video")
|
||
}
|
||
} else if aspectRatio == "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is required when no first frame or base video is provided")
|
||
}
|
||
|
||
watermarkEnabled, watermarkErr := kelingCompatWatermarkEnabled(input["watermark_info"])
|
||
if watermarkErr != nil {
|
||
return nil, watermarkErr
|
||
}
|
||
externalTaskID := strings.TrimSpace(stringFromKelingCompat(input["external_task_id"]))
|
||
normalized := map[string]any{
|
||
"model": model,
|
||
"model_name": requestedModel,
|
||
"modelType": "omni_video",
|
||
"runMode": "real",
|
||
"content": content,
|
||
"resolution": resolution,
|
||
"mode": mode,
|
||
"sound": sound,
|
||
"audio": sound == "on",
|
||
"multi_shot": multiShot,
|
||
"watermark": watermarkEnabled,
|
||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||
"external_task_id": externalTaskID,
|
||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||
}
|
||
if prompt != "" {
|
||
normalized["prompt"] = prompt
|
||
}
|
||
if shotType != "" {
|
||
normalized["shot_type"] = shotType
|
||
}
|
||
if !hasBaseVideo {
|
||
normalized["duration"] = duration
|
||
}
|
||
if aspectRatio != "" {
|
||
normalized["aspect_ratio"] = aspectRatio
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
func normalizeKelingImageList(value any) ([]any, bool, *kelingCompatError) {
|
||
items, err := kelingCompatObjectList(value, "image_list")
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
out := make([]any, 0, len(items))
|
||
hasFirstFrame := false
|
||
hasEndFrame := false
|
||
for index, item := range items {
|
||
url := strings.TrimSpace(stringFromKelingCompat(item["image_url"]))
|
||
if url == "" {
|
||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].image_url is required", index))
|
||
}
|
||
frameType := strings.TrimSpace(stringFromKelingCompat(item["type"]))
|
||
role := "reference_image"
|
||
switch frameType {
|
||
case "":
|
||
case "first_frame":
|
||
role = "first_frame"
|
||
hasFirstFrame = true
|
||
case "end_frame":
|
||
role = "last_frame"
|
||
hasEndFrame = true
|
||
default:
|
||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].type must be first_frame or end_frame", index))
|
||
}
|
||
out = append(out, map[string]any{
|
||
"type": "image_url",
|
||
"role": role,
|
||
"image_url": map[string]any{"url": url},
|
||
})
|
||
}
|
||
if hasEndFrame && !hasFirstFrame {
|
||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, "end_frame requires first_frame")
|
||
}
|
||
return out, hasFirstFrame, nil
|
||
}
|
||
|
||
func normalizeKelingElementList(value any) ([]any, *kelingCompatError) {
|
||
items, err := kelingCompatObjectList(value, "element_list")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]any, 0, len(items))
|
||
for index, item := range items {
|
||
id := item["element_id"]
|
||
if strings.TrimSpace(stringFromKelingCompat(id)) == "" {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("element_list[%d].element_id is required", index))
|
||
}
|
||
out = append(out, map[string]any{"type": "element", "element": map[string]any{"element_id": id}})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func normalizeKelingVideoList(value any) ([]any, bool, bool, *kelingCompatError) {
|
||
items, err := kelingCompatObjectList(value, "video_list")
|
||
if err != nil {
|
||
return nil, false, false, err
|
||
}
|
||
if len(items) > 1 {
|
||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, "video_list supports at most one video")
|
||
}
|
||
out := make([]any, 0, len(items))
|
||
hasBase := false
|
||
for index, item := range items {
|
||
url := strings.TrimSpace(stringFromKelingCompat(item["video_url"]))
|
||
if url == "" {
|
||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].video_url is required", index))
|
||
}
|
||
referType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["refer_type"])))
|
||
if referType == "" {
|
||
referType = "base"
|
||
}
|
||
if referType != "base" && referType != "feature" {
|
||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].refer_type must be base or feature", index))
|
||
}
|
||
keepSound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["keep_original_sound"])))
|
||
if keepSound != "" && keepSound != "yes" && keepSound != "no" {
|
||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].keep_original_sound must be yes or no", index))
|
||
}
|
||
nested := map[string]any{"url": url, "refer_type": referType}
|
||
if keepSound != "" {
|
||
nested["keep_original_sound"] = keepSound
|
||
}
|
||
role := "video_feature"
|
||
if referType == "base" {
|
||
role = "video_base"
|
||
hasBase = true
|
||
}
|
||
out = append(out, map[string]any{"type": "video_url", "role": role, "video_url": nested})
|
||
}
|
||
return out, hasBase, len(items) > 0, nil
|
||
}
|
||
|
||
func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) {
|
||
items, err := kelingCompatObjectList(value, "multi_prompt")
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if len(items) > 6 {
|
||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt supports at most six shots")
|
||
}
|
||
out := make([]any, 0, len(items))
|
||
seen := map[int]bool{}
|
||
total := 0
|
||
for index, item := range items {
|
||
shotIndex, ok := kelingCompatInt(item["index"])
|
||
if !ok || shotIndex < 1 || shotIndex > 6 || seen[shotIndex] {
|
||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].index must be a unique integer from 1 to 6", index))
|
||
}
|
||
seen[shotIndex] = true
|
||
prompt := strings.TrimSpace(stringFromKelingCompat(item["prompt"]))
|
||
if prompt == "" {
|
||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].prompt is required", index))
|
||
}
|
||
duration, ok := kelingCompatInt(item["duration"])
|
||
if !ok || duration < 1 {
|
||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].duration must be an integer of at least 1 second", index))
|
||
}
|
||
total += duration
|
||
out = append(out, map[string]any{
|
||
"type": "text",
|
||
"role": "shot_prompt",
|
||
"shot_index": shotIndex,
|
||
"text": prompt,
|
||
"duration": duration,
|
||
})
|
||
}
|
||
return out, total, nil
|
||
}
|
||
|
||
func kelingCompatModel(value string) (string, int, bool) {
|
||
model, ok := canonicalKlingOmniModel(value)
|
||
if !ok {
|
||
return "", 0, false
|
||
}
|
||
if model == klingO1Model {
|
||
return model, 10, true
|
||
}
|
||
return model, 15, true
|
||
}
|
||
|
||
func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) {
|
||
if value == nil {
|
||
return nil, nil
|
||
}
|
||
raw, ok := value.([]any)
|
||
if !ok {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, field+" must be an array")
|
||
}
|
||
out := make([]map[string]any, 0, len(raw))
|
||
for index, item := range raw {
|
||
object, ok := item.(map[string]any)
|
||
if !ok {
|
||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("%s[%d] must be an object", field, index))
|
||
}
|
||
out = append(out, object)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func kelingCompatOptionalInt(body map[string]any, key string) (int, bool, *kelingCompatError) {
|
||
value, present := body[key]
|
||
if !present || value == nil || strings.TrimSpace(stringFromKelingCompat(value)) == "" {
|
||
return 0, false, nil
|
||
}
|
||
parsed, ok := kelingCompatInt(value)
|
||
if !ok {
|
||
return 0, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be an integer")
|
||
}
|
||
return parsed, true, nil
|
||
}
|
||
|
||
func kelingCompatOptionalBool(body map[string]any, key string) (bool, bool, *kelingCompatError) {
|
||
value, present := body[key]
|
||
if !present || value == nil {
|
||
return false, false, nil
|
||
}
|
||
parsed, ok := value.(bool)
|
||
if !ok {
|
||
return false, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be a boolean")
|
||
}
|
||
return parsed, true, nil
|
||
}
|
||
|
||
func kelingCompatWatermarkEnabled(value any) (bool, *kelingCompatError) {
|
||
if value == nil {
|
||
return false, nil
|
||
}
|
||
object, ok := value.(map[string]any)
|
||
if !ok {
|
||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info must be an object")
|
||
}
|
||
enabled, present := object["enabled"]
|
||
if !present {
|
||
return false, nil
|
||
}
|
||
result, ok := enabled.(bool)
|
||
if !ok {
|
||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info.enabled must be a boolean")
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func kelingCompatInt(value any) (int, bool) {
|
||
switch typed := value.(type) {
|
||
case int:
|
||
return typed, true
|
||
case int64:
|
||
return int(typed), true
|
||
case float64:
|
||
if math.Abs(typed-math.Round(typed)) > 1e-9 {
|
||
return 0, false
|
||
}
|
||
return int(math.Round(typed)), true
|
||
case json.Number:
|
||
parsed, err := strconv.Atoi(typed.String())
|
||
return parsed, err == nil
|
||
case string:
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||
return parsed, err == nil
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func stringFromKelingCompat(value any) string {
|
||
switch typed := value.(type) {
|
||
case string:
|
||
return typed
|
||
case json.Number:
|
||
return typed.String()
|
||
case float64:
|
||
if math.Abs(typed-math.Round(typed)) < 1e-9 {
|
||
return strconv.FormatInt(int64(math.Round(typed)), 10)
|
||
}
|
||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||
case int:
|
||
return strconv.Itoa(typed)
|
||
case int64:
|
||
return strconv.FormatInt(typed, 10)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func kelingCompatTaskData(task store.GatewayTask) map[string]any {
|
||
data := map[string]any{
|
||
"task_id": task.ID,
|
||
"task_status": kelingCompatTaskStatus(task.Status),
|
||
"task_info": map[string]any{
|
||
"external_task_id": strings.TrimSpace(stringFromKelingCompat(task.Request["external_task_id"])),
|
||
},
|
||
"created_at": task.CreatedAt.UnixMilli(),
|
||
"updated_at": task.UpdatedAt.UnixMilli(),
|
||
"watermark_info": map[string]any{
|
||
"enabled": kelingCompatTaskWatermark(task.Request),
|
||
},
|
||
}
|
||
if message := kelingCompatTaskMessage(task); message != "" {
|
||
data["task_status_msg"] = message
|
||
}
|
||
if kelingCompatTaskStatus(task.Status) == "failed" {
|
||
data["task_status_code"] = kelingCompatBusinessCode(task.ErrorCode, kelingCompatTaskMessage(task))
|
||
}
|
||
if videos := kelingCompatTaskVideos(task.Result); len(videos) > 0 {
|
||
data["task_result"] = map[string]any{"videos": videos}
|
||
}
|
||
if task.FinalChargeAmount > 0 {
|
||
data["final_unit_deduction"] = strconv.FormatFloat(task.FinalChargeAmount, 'f', -1, 64)
|
||
}
|
||
return data
|
||
}
|
||
|
||
func kelingCompatTaskStatus(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case "succeeded", "success", "completed":
|
||
return "succeed"
|
||
case "failed", "cancelled", "canceled":
|
||
return "failed"
|
||
case "running", "processing":
|
||
return "processing"
|
||
default:
|
||
return "submitted"
|
||
}
|
||
}
|
||
|
||
func kelingCompatTaskVideos(result map[string]any) []any {
|
||
raw, _ := result["data"].([]any)
|
||
out := make([]any, 0, len(raw))
|
||
for _, itemValue := range raw {
|
||
item, _ := itemValue.(map[string]any)
|
||
if item == nil {
|
||
continue
|
||
}
|
||
url := strings.TrimSpace(stringFromKelingCompat(firstKelingCompatValue(item["url"], item["video_url"])))
|
||
if url == "" {
|
||
continue
|
||
}
|
||
video := map[string]any{"url": url}
|
||
if id := strings.TrimSpace(stringFromKelingCompat(item["id"])); id != "" {
|
||
video["id"] = id
|
||
}
|
||
if watermarkURL := strings.TrimSpace(stringFromKelingCompat(item["watermark_url"])); watermarkURL != "" {
|
||
video["watermark_url"] = watermarkURL
|
||
}
|
||
if duration := strings.TrimSpace(stringFromKelingCompat(item["duration"])); duration != "" {
|
||
video["duration"] = duration
|
||
}
|
||
out = append(out, video)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func firstKelingCompatValue(values ...any) any {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(stringFromKelingCompat(value)) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func kelingCompatTaskMessage(task store.GatewayTask) string {
|
||
return strings.TrimSpace(firstNonEmpty(task.ErrorMessage, task.Error, task.Message))
|
||
}
|
||
|
||
func kelingCompatTaskWatermark(request map[string]any) bool {
|
||
if value, ok := request["watermark"].(bool); ok {
|
||
return value
|
||
}
|
||
if info, ok := request["watermark_info"].(map[string]any); ok {
|
||
value, _ := info["enabled"].(bool)
|
||
return value
|
||
}
|
||
return false
|
||
}
|
||
|
||
func kelingCompatCandidatesSupport4K(candidates []store.RuntimeModelCandidate) bool {
|
||
for _, candidate := range candidates {
|
||
if strings.ToLower(strings.TrimSpace(candidate.Provider)) != "keling" {
|
||
continue
|
||
}
|
||
capability, _ := candidate.Capabilities["omni_video"].(map[string]any)
|
||
if capability == nil {
|
||
capability, _ = candidate.Capabilities["omni"].(map[string]any)
|
||
}
|
||
for _, resolution := range kelingCompatStringList(capability["output_resolutions"]) {
|
||
switch strings.ToLower(strings.TrimSpace(resolution)) {
|
||
case "2160p", "4k":
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func kelingCompatStringList(value any) []string {
|
||
switch typed := value.(type) {
|
||
case []string:
|
||
return typed
|
||
case []any:
|
||
result := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
if text := strings.TrimSpace(stringFromKelingCompat(item)); text != "" {
|
||
result = append(result, text)
|
||
}
|
||
}
|
||
return result
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func kelingCompatGatewayError(err error) *kelingCompatError {
|
||
if err == nil {
|
||
return newKelingCompatError(http.StatusInternalServerError, 5000, "unknown gateway error")
|
||
}
|
||
codeText := clients.ErrorCode(err)
|
||
businessCode := kelingCompatBusinessCode(codeText, err.Error())
|
||
status := http.StatusInternalServerError
|
||
switch businessCode {
|
||
case 1101:
|
||
status = http.StatusPaymentRequired
|
||
case 1103:
|
||
status = http.StatusForbidden
|
||
case 1201:
|
||
status = http.StatusBadRequest
|
||
case 1203:
|
||
status = http.StatusNotFound
|
||
case 1302, 1303:
|
||
status = http.StatusTooManyRequests
|
||
case 5001:
|
||
status = http.StatusBadGateway
|
||
}
|
||
return newKelingCompatError(status, businessCode, err.Error())
|
||
}
|
||
|
||
func kelingCompatBusinessCode(errorCode string, message string) int {
|
||
combined := strings.ToLower(strings.TrimSpace(errorCode + " " + message))
|
||
containsAny := func(values ...string) bool {
|
||
for _, value := range values {
|
||
if strings.Contains(combined, value) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
switch {
|
||
case containsAny("insufficient_balance", "insufficient balance", "balance_not_enough", "wallet balance", "余额不足", "欠费", "quota exceeded"):
|
||
return 1101
|
||
case containsAny("permission_denied", "permission denied", "forbidden", "access denied", "scope does not allow"):
|
||
return 1103
|
||
case containsAny("concurrent", "concurrency"):
|
||
return 1303
|
||
case containsAny("rate_limit", "rate limit", "too many requests", "rpm", "tpm"):
|
||
return 1302
|
||
case containsAny("no_model_candidate", "no model candidate", "model_not_found", "unsupported model", "resource not found"):
|
||
return 1203
|
||
case containsAny("invalid_parameter", "invalid parameter", "bad_request", "parameter_preprocessing", "duration", "aspect_ratio"):
|
||
return 1201
|
||
case containsAny("upload_", "request_asset_", "network", "timeout", "upstream", "service unavailable", "bad gateway"):
|
||
return 5001
|
||
default:
|
||
return 5000
|
||
}
|
||
}
|
||
|
||
func isKelingCompatTask(task store.GatewayTask) bool {
|
||
return task.Kind == "videos.generations" && strings.TrimSpace(stringFromKelingCompat(task.Request["_gateway_compatibility"])) == kelingOmniCompatibilityMarker
|
||
}
|
||
|
||
func kelingCompatTaskOwnedBy(task store.GatewayTask, user *auth.User) bool {
|
||
if user == nil {
|
||
return false
|
||
}
|
||
taskOwner := strings.TrimSpace(firstNonEmpty(task.GatewayUserID, task.UserID))
|
||
requestOwner := strings.TrimSpace(firstNonEmpty(user.GatewayUserID, user.ID))
|
||
return taskOwner != "" && requestOwner != "" && taskOwner == requestOwner
|
||
}
|
||
|
||
func newKelingCompatRequestID(r *http.Request) string {
|
||
if r != nil {
|
||
if value := strings.TrimSpace(firstNonEmpty(r.Header.Get("X-Request-ID"), r.Header.Get("X-Request-Id"))); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
random := make([]byte, 16)
|
||
if _, err := rand.Read(random); err == nil {
|
||
return hex.EncodeToString(random)
|
||
}
|
||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||
}
|
||
|
||
func kelingCompatRequestID(r *http.Request) string {
|
||
if r != nil {
|
||
if value, ok := r.Context().Value(kelingCompatRequestIDKey{}).(string); ok && strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return newKelingCompatRequestID(r)
|
||
}
|
||
|
||
func writeKelingCompatError(w http.ResponseWriter, requestID string, err *kelingCompatError) {
|
||
if err == nil {
|
||
err = newKelingCompatError(http.StatusInternalServerError, 5000, "internal error")
|
||
}
|
||
if err.RequestID != "" {
|
||
requestID = err.RequestID
|
||
}
|
||
if requestID == "" {
|
||
requestID = newKelingCompatRequestID(nil)
|
||
}
|
||
status := err.HTTPStatus
|
||
if status == 0 {
|
||
status = http.StatusInternalServerError
|
||
}
|
||
writeJSON(w, status, KelingCompatibleEnvelope{
|
||
Code: err.Code,
|
||
Message: err.Message,
|
||
RequestID: requestID,
|
||
})
|
||
}
|