Files
easyai-ai-gateway/apps/api/internal/httpapi/keling_compat_handlers.go
T
easyai 7c5a999e32 feat(api): 统一公开接口为 /api/v1 前缀
将通用生成、Gemini、可灵、火山、健康检查与 OpenAPI 的推荐入口统一到 /api/v1,并保留历史路径作为兼容别名。同步更新代理配置、接入文档、接口清单和前缀回归测试。\n\n验证:go vet ./...;go test ./...;pnpm openapi;pnpm lint;pnpm test;pnpm build;公开 OpenAPI 71 个方法与接口清单机器比对一致。
2026-07-22 08:48:32 +08:00

886 lines
30 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.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 == "kling-o1" && 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 == "kling-o1" && (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) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "kling-video-o1", "kling-o1":
return "kling-o1", 10, true
case "kling-v3-omni", "kling-3.0-omni":
return "kling-3.0-omni", 15, true
default:
return "", 0, false
}
}
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,
})
}