Files
easyai-ai-gateway/apps/api/internal/httpapi/keling_compat_handlers.go
T
easyai e07a997aa9 feat(api): 统一官方兼容接口响应协议
兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。

同时修正异步提交状态边界,持久化兼容公开任务标识和官方提交响应,并新增迁移、流式响应及协议契约测试。

验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
2026-07-22 15:34:59 +08:00

928 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-videoBearer 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.store.SetTaskCompatibilitySubmission(r.Context(), task.ID, store.CompatibilitySubmission{
TargetProtocol: clients.ProtocolKlingV1Omni,
PublicID: task.ID,
}); err != nil {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create compatibility metadata 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
}
task, createErr = s.waitForCompatibilitySubmission(r, task)
if createErr != nil {
if wire := clients.ErrorWireResponse(createErr); wireResponseMatches(wire, clients.ProtocolKlingV1Omni) {
writeWireResponse(w, wire)
return
}
writeKelingCompatError(w, requestID, kelingCompatGatewayError(createErr))
return
}
if kelingTaskUsesNativeProtocol(task) && len(task.CompatibilitySubmitBody) > 0 {
writeJSON(w, task.CompatibilitySubmitHTTPStatus, task.CompatibilitySubmitBody)
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.GetCompatibilityTask(r.Context(), clients.ProtocolKlingV1Omni, strings.TrimSpace(r.PathValue("taskID")))
if err != nil && store.IsNotFound(err) {
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
}
if kelingTaskUsesNativeProtocol(task) {
if raw, ok := task.Result["raw"].(map[string]any); ok && len(raw) > 0 {
writeJSON(w, http.StatusOK, raw)
return
}
if len(task.RemoteTaskPayload) > 0 {
writeJSON(w, http.StatusOK, task.RemoteTaskPayload)
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,
"_gateway_target_protocol": clients.ProtocolKlingV1Omni,
}
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": kelingCompatPublicID(task),
"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}
}
return data
}
func kelingCompatPublicID(task store.GatewayTask) string {
if publicID := strings.TrimSpace(task.CompatibilityPublicID); publicID != "" {
return publicID
}
return task.ID
}
func kelingTaskUsesNativeProtocol(task store.GatewayTask) bool {
return task.CompatibilityProtocol == clients.ProtocolKlingV1Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV1Omni
}
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,
})
}