Merge pull request 'feat(kling): 接入 O1 与 3.0 Omni 兼容接口' (#14) from codex/kling-o1-v3-compat into main
ci / verify (push) Successful in 35m39s
release-ci / verify-tag (push) Successful in 13m50s

This commit was merged in pull request #14.
This commit is contained in:
2026-07-22 00:09:49 +08:00
15 changed files with 2113 additions and 23 deletions
+7
View File
@@ -49,6 +49,13 @@ AI_GATEWAY_API_RUNTIME_IMAGE=alpine:3.22
AI_GATEWAY_NODE_BUILD_IMAGE=node:22-alpine
AI_GATEWAY_WEB_RUNTIME_IMAGE=nginx:1.27-alpine
# Opt-in, billable China Kling V1 integration tests. Keep real AK/SK only in
# .env.local (gitignored); never commit them.
KELING_LIVE_TEST=0
KELING_TEST_BASE_URL=https://api-beijing.klingai.com/v1
KELING_TEST_ACCESS_KEY=
KELING_TEST_SECRET_KEY=
# Used when the gateway delegates OpenAPI sk-* validation, user/group sync, file upload, and settlement callbacks.
SERVER_MAIN_BASE_URL=http://localhost:3000
SERVER_MAIN_INTERNAL_TOKEN=change-me
+2
View File
@@ -125,6 +125,8 @@ AI_GATEWAY_COMPOSE_DATABASE_URL='postgresql://easyai:easyai2025@postgres:5432/ea
pnpm openapi
```
中国区可灵 O1 / 3.0 Omni 的 V1 AK/SK 与 API 2.0 兼容接入方式见 [可灵兼容接口说明](docs/kling-compatible-api.md)。
默认 EasyAI 部署里,`easyai-pgvector` 在容器网络内的连接串是:
+275
View File
@@ -8359,6 +8359,278 @@
}
}
},
"/kling/omni-video/{model}": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"kling-compatible"
],
"summary": "创建可灵 API 2.0 Omni 视频任务",
"parameters": [
{
"type": "string",
"description": "模型路径(kling-o1 或 kling-v3-omni",
"name": "model",
"in": "path",
"required": true
},
{
"description": "可灵 API 2.0 请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"type": "object",
"additionalProperties": true
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/kling/tasks": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"kling-compatible"
],
"summary": "按 ID 查询可灵 API 2.0 任务",
"parameters": [
{
"type": "string",
"description": "逗号分隔的任务 ID",
"name": "task_ids",
"in": "query"
},
{
"type": "string",
"description": "逗号分隔的外部任务 ID",
"name": "external_task_ids",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"kling-compatible"
],
"summary": "分页查询可灵 API 2.0 任务",
"parameters": [
{
"description": "游标、数量、时间范围和筛选条件",
"name": "input",
"in": "body",
"required": true,
"schema": {
"type": "object",
"additionalProperties": true
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/kling/v1/videos/omni-video": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"kling-compatible"
],
"summary": "分页查询可灵 V1 Omni 视频任务",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "pageNum",
"in": "query"
},
{
"type": "integer",
"default": 30,
"description": "每页数量",
"name": "pageSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK 调用上游。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"kling-compatible"
],
"summary": "创建可灵 V1 Omni 视频任务",
"parameters": [
{
"description": "可灵 V1 Omni 请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"type": "object",
"additionalProperties": true
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/kling/v1/videos/omni-video/{taskID}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"kling-compatible"
],
"summary": "查询可灵 V1 Omni 视频任务",
"parameters": [
{
"type": "string",
"description": "任务 ID",
"name": "taskID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/music/generations": {
"post": {
"security": [
@@ -13538,6 +13810,9 @@
"executionLeaseExpiresAt": {
"type": "string"
},
"externalTaskId": {
"type": "string"
},
"finalChargeAmount": {
"type": "number"
},
+177
View File
@@ -1984,6 +1984,8 @@ definitions:
type: string
executionLeaseExpiresAt:
type: string
externalTaskId:
type: string
finalChargeAmount:
type: number
finishedAt:
@@ -8491,6 +8493,181 @@ paths:
summary: 创建或执行 AI 任务
tags:
- tasks
/kling/omni-video/{model}:
post:
consumes:
- application/json
description: 兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。
parameters:
- description: 模型路径(kling-o1 或 kling-v3-omni
in: path
name: model
required: true
type: string
- description: 可灵 API 2.0 请求
in: body
name: input
required: true
schema:
additionalProperties: true
type: object
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 创建可灵 API 2.0 Omni 视频任务
tags:
- kling-compatible
/kling/tasks:
get:
parameters:
- description: 逗号分隔的任务 ID
in: query
name: task_ids
type: string
- description: 逗号分隔的外部任务 ID
in: query
name: external_task_ids
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 按 ID 查询可灵 API 2.0 任务
tags:
- kling-compatible
post:
consumes:
- application/json
parameters:
- description: 游标、数量、时间范围和筛选条件
in: body
name: input
required: true
schema:
additionalProperties: true
type: object
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 分页查询可灵 API 2.0 任务
tags:
- kling-compatible
/kling/v1/videos/omni-video:
get:
parameters:
- default: 1
description: 页码
in: query
name: pageNum
type: integer
- default: 30
description: 每页数量
in: query
name: pageSize
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 分页查询可灵 V1 Omni 视频任务
tags:
- kling-compatible
post:
consumes:
- application/json
description: 兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK
调用上游。
parameters:
- description: 可灵 V1 Omni 请求
in: body
name: input
required: true
schema:
additionalProperties: true
type: object
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 创建可灵 V1 Omni 视频任务
tags:
- kling-compatible
/kling/v1/videos/omni-video/{taskID}:
get:
parameters:
- description: 任务 ID
in: path
name: taskID
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 查询可灵 V1 Omni 视频任务
tags:
- kling-compatible
/music/generations:
post:
consumes:
+41
View File
@@ -2612,6 +2612,47 @@ func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
}
}
func TestKelingOmniPayloadPreservesLegacyV1Options(t *testing.T) {
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
Kind: "videos.generations",
ModelType: "omni_video",
Body: map[string]any{
"multi_shot": true,
"shot_type": "customize",
"multi_prompt": []any{
map[string]any{"index": 1, "prompt": "镜头一", "duration": 7},
map[string]any{"index": 2, "prompt": "镜头二", "duration": 8},
},
"resolution": "1080p",
"callback_url": "https://example.com/callback",
"external_task_id": "client-task-1",
"watermark_info": map[string]any{"enabled": true},
"voice_list": []any{map[string]any{"voice_id": "voice-1"}},
},
Candidate: store.RuntimeModelCandidate{
Provider: "keling",
ProviderModelName: "kling-v3-omni",
Capabilities: map[string]any{"omni_video": map[string]any{}},
},
}, "token")
if err != nil {
t.Fatalf("build keling legacy V1 payload: %v", err)
}
if len(cleanupIDs) != 0 {
t.Fatalf("unexpected cleanup ids: %+v", cleanupIDs)
}
if payload["multi_shot"] != true || payload["shot_type"] != "customize" || payload["duration"] != "15" {
t.Fatalf("unexpected multi-shot payload: %+v", payload)
}
if payload["callback_url"] != "https://example.com/callback" || payload["external_task_id"] != "client-task-1" {
t.Fatalf("legacy task options were not preserved: %+v", payload)
}
watermark := mapFromAny(payload["watermark_info"])
if watermark["enabled"] != true || len(mapListFromAny(payload["voice_list"])) != 1 {
t.Fatalf("watermark or voice options were not preserved: %+v", payload)
}
}
func TestKelingClientVideoResumePollsWithoutSubmitting(t *testing.T) {
var submitCalled bool
var pollPath string
+64 -19
View File
@@ -420,15 +420,21 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
}
uploadedElementIDs = append(uploadedElementIDs, createdIDs...)
shots := kelingShotPrompts(content)
hasMultiPrompt := len(shots) > 0
rawMultiPrompt := mapListFromAny(body["multi_prompt"])
hasMultiPrompt := len(shots) > 0 || len(rawMultiPrompt) > 0
multiShot := boolValue(body, "multi_shot") || hasMultiPrompt
hasVideo := len(videos) > 0
hasVideoEdit := kelingHasBaseVideo(videos)
hasFirstFrame := kelingHasFirstFrame(images)
watermarkEnabled := boolValue(body, "watermark")
if watermarkInfo := mapFromAny(body["watermark_info"]); watermarkInfo != nil {
watermarkEnabled = boolValue(watermarkInfo, "enabled")
}
payload := map[string]any{
"model_name": upstreamModelName(request.Candidate),
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
"watermark_info": map[string]any{"enabled": false},
"watermark_info": map[string]any{"enabled": watermarkEnabled},
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
}
if !hasMultiPrompt {
@@ -449,29 +455,62 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
if len(elements) > 0 {
payload["element_list"] = elements
}
if voices := mapListFromAny(body["voice_list"]); len(voices) > 0 {
payload["voice_list"] = voices
}
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
payload["sound"] = "on"
}
if hasMultiPrompt {
if multiShot {
payload["multi_shot"] = true
payload["shot_type"] = "customize"
total := 0.0
multiPrompt := make([]any, 0, len(shots))
for index, shot := range shots {
duration := shot.duration
if duration <= 0 {
duration = 5
shotType := strings.TrimSpace(firstNonEmptyStringValue(body, "shot_type", "shotType"))
if shotType == "" {
if hasMultiPrompt {
shotType = "customize"
} else {
shotType = "intelligence"
}
total += duration
multiPrompt = append(multiPrompt, map[string]any{
"index": index + 1,
"prompt": shot.text,
"duration": fmtDuration(duration, 5),
})
}
delete(payload, "prompt")
payload["multi_prompt"] = multiPrompt
payload["duration"] = fmtDuration(total, 0)
payload["shot_type"] = shotType
if shotType == "customize" {
total := 0.0
multiPrompt := make([]any, 0, len(rawMultiPrompt)+len(shots))
if len(rawMultiPrompt) > 0 {
for index, item := range rawMultiPrompt {
duration := numericValue(item["duration"], 0)
total += duration
multiPrompt = append(multiPrompt, map[string]any{
"index": int(math.Round(numericValue(item["index"], float64(index+1)))),
"prompt": strings.TrimSpace(stringFromAny(item["prompt"])),
"duration": fmtDuration(duration, 0),
})
}
} else {
for index, shot := range shots {
duration := shot.duration
if duration <= 0 {
duration = 5
}
total += duration
multiPrompt = append(multiPrompt, map[string]any{
"index": index + 1,
"prompt": shot.text,
"duration": fmtDuration(duration, 5),
})
}
}
delete(payload, "prompt")
payload["multi_prompt"] = multiPrompt
if total > 0 {
payload["duration"] = fmtDuration(total, 0)
}
}
}
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
payload["callback_url"] = callbackURL
}
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
payload["external_task_id"] = externalTaskID
}
deleteEmptyStringFields(payload)
if hasVideoEdit {
@@ -1140,9 +1179,15 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
continue
}
item := map[string]any{"url": url, "video_url": url, "type": "video"}
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
item["id"] = id
}
if duration := intFromAny(video["duration"]); duration > 0 {
item["duration"] = duration
}
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
item["watermark_url"] = watermarkURL
}
items = append(items, item)
}
created := intFromAny(data["created_at"])
@@ -0,0 +1,83 @@
package clients
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
// TestKelingLegacyV1LiveOmni is opt-in because it creates billable upstream
// video tasks. Credentials must only be supplied through local environment
// variables; the test never prints them.
func TestKelingLegacyV1LiveOmni(t *testing.T) {
if strings.TrimSpace(os.Getenv("KELING_LIVE_TEST")) != "1" {
t.Skip("set KELING_LIVE_TEST=1 to run billable Kling V1 integration tests")
}
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("KELING_TEST_BASE_URL")), "/")
accessKey := strings.TrimSpace(os.Getenv("KELING_TEST_ACCESS_KEY"))
secretKey := strings.TrimSpace(os.Getenv("KELING_TEST_SECRET_KEY"))
if baseURL == "" || accessKey == "" || secretKey == "" {
t.Fatal("KELING_TEST_BASE_URL, KELING_TEST_ACCESS_KEY, and KELING_TEST_SECRET_KEY are required")
}
models := []string{"kling-video-o1", "kling-v3-omni"}
if selected := strings.TrimSpace(os.Getenv("KELING_LIVE_TEST_MODELS")); selected != "" {
models = strings.Split(selected, ",")
}
for _, model := range models {
model = strings.TrimSpace(model)
t.Run(model, func(t *testing.T) {
duration := 3
if model == "kling-video-o1" {
duration = 5
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
response, err := (KelingClient{}).Run(ctx, Request{
Kind: "videos.generations",
ModelType: "omni_video",
Model: model,
Body: map[string]any{
"prompt": "清晨的湖面上,一只白色纸鹤缓慢飞过,镜头平稳推进",
"duration": duration,
"aspect_ratio": "16:9",
"resolution": "720p",
"sound": "off",
},
Candidate: store.RuntimeModelCandidate{
BaseURL: baseURL,
Provider: "keling",
AuthType: "AccessKey-SecretKey",
ProviderModelName: model,
Credentials: map[string]any{
"accessKey": accessKey,
"secretKey": secretKey,
},
Capabilities: map[string]any{"omni_video": map[string]any{}},
PlatformConfig: map[string]any{
"kelingPollIntervalMs": 5000,
"kelingPollTimeoutSeconds": 840,
},
},
})
if err != nil {
t.Fatalf("Kling V1 %s live task failed: %v", model, err)
}
items, _ := response.Result["data"].([]any)
if len(items) == 0 {
t.Fatalf("Kling V1 %s returned no video", model)
}
item, _ := items[0].(map[string]any)
if strings.TrimSpace(stringFromAny(item["url"])) == "" {
t.Fatalf("Kling V1 %s returned an empty video URL", model)
}
if strings.TrimSpace(response.RequestID) == "" {
t.Fatalf("Kling V1 %s returned an empty request id", model)
}
})
}
}
+908
View File
@@ -0,0 +1,908 @@
package httpapi
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
klingCompatProvider = "kling"
klingO1Model = "kling-video-o1"
klingV3OmniModel = "kling-v3-omni"
)
func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
handler := func(next http.HandlerFunc) http.Handler {
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
}
mux.Handle("POST /kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
mux.Handle("GET /kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
// Kling API 2.0 uses model-specific paths and a shared /tasks resource.
mux.Handle("POST /kling/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
mux.Handle("GET /kling/tasks", handler(s.klingV2GetTasks))
mux.Handle("POST /kling/tasks", handler(s.klingV2ListTasks))
// Versioned aliases help clients that keep the protocol version in their base path.
mux.Handle("POST /kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
mux.Handle("GET /kling/v2/tasks", handler(s.klingV2GetTasks))
mux.Handle("POST /kling/v2/tasks", handler(s.klingV2ListTasks))
}
// klingV1CreateOmniVideo godoc
// @Summary 创建可灵 V1 Omni 视频任务
// @Description 兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK 调用上游。
// @Tags kling-compatible
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param input body map[string]interface{} true "可灵 V1 Omni 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope
// @Router /kling/v1/videos/omni-video [post]
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
var native map[string]any
if err := decodeKlingJSON(r, &native); err != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
return
}
model := strings.TrimSpace(stringFromRequestAny(native["model_name"]))
if model == "" {
model = klingO1Model
}
s.createKlingCompatTask(w, r, "v1", model, native)
}
// klingV2CreateOmniVideo godoc
// @Summary 创建可灵 API 2.0 Omni 视频任务
// @Description 兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。
// @Tags kling-compatible
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param model path string true "模型路径(kling-o1 或 kling-v3-omni"
// @Param input body map[string]interface{} true "可灵 API 2.0 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope
// @Router /kling/omni-video/{model} [post]
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
model, ok := klingV2ProviderModel(r.PathValue("model"))
if !ok {
writeKlingCompatError(w, http.StatusNotFound, "unsupported Kling Omni model", "model_not_found")
return
}
var native map[string]any
if err := decodeKlingJSON(r, &native); err != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
return
}
s.createKlingCompatTask(w, r, "v2", model, native)
}
func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, version string, model string, native map[string]any) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeKlingCompatError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
return
}
if !apiKeyScopeAllowed(user, "videos.generations") {
writeKlingCompatError(w, http.StatusForbidden, "api key scope does not allow video generation", "permission_denied")
return
}
body, externalTaskID, err := klingCompatTaskBody(version, model, native)
if err != nil {
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
return
}
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
if err != nil {
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
return
}
createInput := store.CreateTaskInput{
Kind: "videos.generations",
Model: model,
ExternalTaskID: externalTaskID,
RunMode: runModeFromRequest(prepared.Body),
Async: true,
Request: prepared.Body,
}
if idempotencyKey, hasKey, keyErr := optionalTaskIdempotencyKey(r); keyErr != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid Idempotency-Key", "invalid_idempotency_key")
return
} else if hasKey {
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(createInput.Kind, true, false, prepared.Body)
}
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
if err != nil {
switch {
case errors.Is(err, store.ErrIdempotencyKeyReused):
writeKlingCompatError(w, http.StatusConflict, err.Error(), "idempotency_key_reused")
case errors.Is(err, store.ErrExternalTaskIDReused):
writeKlingCompatError(w, http.StatusConflict, err.Error(), "external_task_id_reused")
default:
s.logger.Error("create Kling compatibility task failed", "version", version, "model", model, "error", err)
writeKlingCompatError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
}
return
}
task := created.Task
if !created.Replayed {
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
writeKlingCompatError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
return
}
} else {
w.Header().Set("Idempotent-Replayed", "true")
}
if version == "v2" {
writeJSON(w, http.StatusOK, klingV2Envelope(task))
return
}
writeJSON(w, http.StatusOK, klingV1Envelope(task))
}
func klingCompatTaskBody(version string, model string, native map[string]any) (map[string]any, string, error) {
if model != klingO1Model && model != klingV3OmniModel {
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "model_name must be kling-video-o1 or kling-v3-omni", StatusCode: 400, Retryable: false}
}
if native == nil {
native = map[string]any{}
}
body := cloneMap(native)
if version == "v2" {
body = klingV2ToLegacyBody(native)
}
body["model"] = model
body["modelType"] = "omni_video"
body["_compat_provider"] = klingCompatProvider
body["_kling_compat_version"] = version
body["content"] = klingLegacyContent(body)
mode := strings.TrimSpace(stringFromRequestAny(body["mode"]))
if mode == "" && version == "v1" {
// The legacy Omni API defaults to professional (1080p) mode.
mode = "pro"
body["mode"] = mode
}
if mode != "" {
resolution, ok := klingResolutionFromMode(mode)
if !ok {
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "mode must be std, pro, or 4k", StatusCode: 400, Retryable: false}
}
body["resolution"] = resolution
}
if strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
body["audio"] = true
}
externalTaskID := strings.TrimSpace(stringFromRequestAny(body["external_task_id"]))
if err := validateKlingCompatBody(model, body); err != nil {
return nil, "", err
}
return body, externalTaskID, nil
}
func klingV2ToLegacyBody(native map[string]any) map[string]any {
body := map[string]any{}
for _, key := range []string{"runMode", "simulation", "simulationDurationMs", "simulationProfile"} {
if value, ok := native[key]; ok {
body[key] = value
}
}
settings, _ := native["settings"].(map[string]any)
options, _ := native["options"].(map[string]any)
if settings == nil {
settings = map[string]any{}
}
if options == nil {
options = map[string]any{}
}
if resolution := strings.TrimSpace(stringFromRequestAny(settings["resolution"])); resolution != "" {
switch strings.ToLower(resolution) {
case "720p":
body["mode"] = "std"
case "1080p":
body["mode"] = "pro"
case "4k", "2160p":
body["mode"] = "4k"
default:
body["mode"] = resolution
}
}
for _, key := range []string{"aspect_ratio", "duration", "multi_shot", "shot_type", "multi_prompt"} {
if value, ok := settings[key]; ok {
body[key] = value
}
}
audio := strings.ToLower(strings.TrimSpace(stringFromRequestAny(settings["audio"])))
if audio == "native" || audio == "on" {
body["sound"] = "on"
} else {
body["sound"] = "off"
}
for _, key := range []string{"callback_url", "external_task_id", "watermark_info"} {
if value, ok := options[key]; ok {
body[key] = value
}
}
contents, _ := native["contents"].([]any)
imageList := make([]any, 0)
videoList := make([]any, 0)
elementList := make([]any, 0)
for _, raw := range contents {
item, _ := raw.(map[string]any)
kind := strings.ToLower(strings.TrimSpace(stringFromRequestAny(item["type"])))
switch kind {
case "prompt":
body["prompt"] = stringFromRequestAny(item["text"])
case "first_frame", "last_frame", "refer_image", "reference_image":
image := map[string]any{"image_url": firstNonEmptyRequestString(item, "url", "image_url")}
if kind == "first_frame" {
image["type"] = "first_frame"
} else if kind == "last_frame" {
image["type"] = "end_frame"
}
imageList = append(imageList, image)
case "feature_video", "base_video", "refer_video", "reference_video":
referType := "feature"
if kind == "base_video" {
referType = "base"
}
video := map[string]any{
"video_url": firstNonEmptyRequestString(item, "url", "video_url"),
"refer_type": referType,
"keep_original_sound": firstNonEmptyRequestString(item, "keep_original_sound", "keepOriginalSound"),
}
if audio == "original" && video["keep_original_sound"] == "" {
video["keep_original_sound"] = "yes"
}
videoList = append(videoList, video)
case "element":
elementList = append(elementList, map[string]any{"element_id": firstPresentRequest(item["element_id"], item["id"])})
}
}
if len(imageList) > 0 {
body["image_list"] = imageList
}
if len(videoList) > 0 {
body["video_list"] = videoList
}
if len(elementList) > 0 {
body["element_list"] = elementList
}
return body
}
func klingLegacyContent(body map[string]any) []any {
content := make([]any, 0)
if prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"])); prompt != "" {
content = append(content, map[string]any{"type": "text", "text": prompt})
}
for _, raw := range mapListFromRequest(body["image_list"]) {
role := "reference_image"
switch strings.TrimSpace(stringFromRequestAny(raw["type"])) {
case "first_frame":
role = "first_frame"
case "end_frame", "last_frame":
role = "last_frame"
}
content = append(content, map[string]any{
"type": "image_url", "role": role,
"image_url": map[string]any{"url": firstNonEmptyRequestString(raw, "image_url", "url")},
})
}
for _, raw := range mapListFromRequest(body["video_list"]) {
referType := firstNonEmptyRequestString(raw, "refer_type", "referType")
role := "video_feature"
if referType == "base" {
role = "video_base"
}
content = append(content, map[string]any{
"type": "video_url", "role": role,
"video_url": map[string]any{
"url": firstNonEmptyRequestString(raw, "video_url", "url"),
"refer_type": referType,
"keep_original_sound": firstNonEmptyRequestString(raw, "keep_original_sound", "keepOriginalSound"),
},
})
}
for _, raw := range mapListFromRequest(body["element_list"]) {
content = append(content, map[string]any{
"type": "element",
"element": map[string]any{"element_id": firstPresentRequest(raw["element_id"], raw["id"])},
})
}
return content
}
func validateKlingCompatBody(model string, body map[string]any) error {
maxDuration := 10
if model == klingV3OmniModel {
maxDuration = 15
}
if duration, ok := klingCompatInt(body["duration"]); ok && (duration < 3 || duration > maxDuration) {
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("duration must be between 3 and %d seconds for %s", maxDuration, model), StatusCode: 400, Retryable: false}
}
if model == klingO1Model && boolFromRequestAny(body["multi_shot"]) {
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support multi_shot", StatusCode: 400, Retryable: false}
}
if model == klingO1Model && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["mode"])), "4k") {
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support 4k mode", StatusCode: 400, Retryable: false}
}
if ratio := strings.TrimSpace(stringFromRequestAny(body["aspect_ratio"])); ratio != "" && ratio != "16:9" && ratio != "9:16" && ratio != "1:1" {
return &clients.ClientError{Code: "invalid_parameter", Message: "aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
}
if sound := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["sound"]))); sound != "" && sound != "on" && sound != "off" {
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be on or off", StatusCode: 400, Retryable: false}
}
prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"]))
if utf8.RuneCountInString(prompt) > 2500 {
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt must not exceed 2500 characters", StatusCode: 400, Retryable: false}
}
images := mapListFromRequest(body["image_list"])
videos := mapListFromRequest(body["video_list"])
elements := mapListFromRequest(body["element_list"])
for _, image := range images {
if firstNonEmptyRequestString(image, "image_url", "url") == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "every image_list item requires image_url", StatusCode: 400, Retryable: false}
}
}
for _, video := range videos {
if firstNonEmptyRequestString(video, "video_url", "url") == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "every video_list item requires video_url", StatusCode: 400, Retryable: false}
}
if referType := strings.TrimSpace(firstNonEmptyRequestString(video, "refer_type", "referType")); referType != "" && referType != "base" && referType != "feature" {
return &clients.ClientError{Code: "invalid_parameter", Message: "video refer_type must be base or feature", StatusCode: 400, Retryable: false}
}
}
for _, element := range elements {
if klingStringAny(firstPresentRequest(element["element_id"], element["id"])) == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "every element_list item requires element_id", StatusCode: 400, Retryable: false}
}
}
if model == klingO1Model && len(images) == 0 && len(videos) == 0 && len(elements) == 0 {
if duration, ok := klingCompatInt(body["duration"]); ok && duration != 5 && duration != 10 {
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 text-only generation supports duration 5 or 10 seconds", StatusCode: 400, Retryable: false}
}
}
if len(videos) > 1 {
return &clients.ClientError{Code: "invalid_parameter", Message: "video_list supports at most one video", StatusCode: 400, Retryable: false}
}
if len(images)+len(elements) > 7 {
return &clients.ClientError{Code: "invalid_parameter", Message: "image_list and element_list support at most seven combined references", StatusCode: 400, Retryable: false}
}
if len(videos) > 0 && len(images)+len(elements) > 4 {
return &clients.ClientError{Code: "invalid_parameter", Message: "requests with video input support at most four image and element references", StatusCode: 400, Retryable: false}
}
if len(videos) > 0 && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be off when video_list is provided", StatusCode: 400, Retryable: false}
}
if model == klingV3OmniModel && len(videos) > 0 {
if duration, ok := klingCompatInt(body["duration"]); ok && duration > 10 {
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-v3-omni video-reference generation supports at most 10 seconds", StatusCode: 400, Retryable: false}
}
}
multiShot := boolFromRequestAny(body["multi_shot"])
shotType := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["shot_type"])))
if shotType != "" && shotType != "customize" && shotType != "intelligence" {
return &clients.ClientError{Code: "invalid_parameter", Message: "shot_type must be customize or intelligence", StatusCode: 400, Retryable: false}
}
if multiShot && shotType == "customize" {
multiPrompt := mapListFromRequest(body["multi_prompt"])
if len(multiPrompt) == 0 || len(multiPrompt) > 6 {
return &clients.ClientError{Code: "invalid_parameter", Message: "customize multi-shot requires between one and six multi_prompt items", StatusCode: 400, Retryable: false}
}
totalDuration := 0
for _, shot := range multiPrompt {
shotPrompt := strings.TrimSpace(stringFromRequestAny(shot["prompt"]))
shotDuration, ok := klingCompatInt(shot["duration"])
if shotPrompt == "" || utf8.RuneCountInString(shotPrompt) > 2500 || !ok || shotDuration <= 0 {
return &clients.ClientError{Code: "invalid_parameter", Message: "every multi_prompt item requires prompt and a positive integer duration", StatusCode: 400, Retryable: false}
}
totalDuration += shotDuration
}
if totalDuration < 3 || totalDuration > maxDuration {
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("multi_prompt duration must total between 3 and %d seconds", maxDuration), StatusCode: 400, Retryable: false}
}
}
if (!multiShot || shotType == "intelligence" || shotType == "") && prompt == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt is required for single-shot and intelligence multi-shot generation", StatusCode: 400, Retryable: false}
}
return nil
}
// klingV1GetOmniVideo godoc
// @Summary 查询可灵 V1 Omni 视频任务
// @Tags kling-compatible
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /kling/v1/videos/omni-video/{taskID} [get]
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID"))
if err != nil {
if store.IsNotFound(err) {
writeKlingCompatError(w, http.StatusNotFound, "task not found", "task_not_found")
return
}
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
return
}
writeJSON(w, http.StatusOK, klingV1Envelope(task))
}
// klingV1ListOmniVideos godoc
// @Summary 分页查询可灵 V1 Omni 视频任务
// @Tags kling-compatible
// @Produce json
// @Security BearerAuth
// @Param pageNum query int false "页码" default(1)
// @Param pageSize query int false "每页数量" default(30)
// @Success 200 {object} map[string]interface{}
// @Router /kling/v1/videos/omni-video [get]
func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1)
if err != nil || page > 1000 {
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageNum", "invalid_parameter")
return
}
pageSize, err := positiveQueryInt(r.URL.Query().Get("pageSize"), 30)
if err != nil || pageSize > 500 {
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageSize", "invalid_parameter")
return
}
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{Provider: klingCompatProvider, Version: "v1", Page: page, PageSize: pageSize})
if err != nil {
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
return
}
data := make([]any, 0, len(result.Items))
for _, task := range result.Items {
data = append(data, klingV1TaskData(task))
}
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestID(result.Items), "data": data})
}
// klingV2GetTasks godoc
// @Summary 按 ID 查询可灵 API 2.0 任务
// @Tags kling-compatible
// @Produce json
// @Security BearerAuth
// @Param task_ids query string false "逗号分隔的任务 ID"
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
// @Success 200 {object} map[string]interface{}
// @Router /kling/tasks [get]
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
externalIDs := splitKlingIDs(r.URL.Query().Get("external_task_ids"))
if (len(taskIDs) == 0) == (len(externalIDs) == 0) {
writeKlingCompatError(w, http.StatusBadRequest, "choose exactly one of task_ids or external_task_ids", "invalid_parameter")
return
}
identifiers := taskIDs
if len(externalIDs) > 0 {
identifiers = externalIDs
}
data := make([]any, 0, len(identifiers))
for _, identifier := range identifiers {
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v2", identifier)
if err != nil {
if store.IsNotFound(err) {
continue
}
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
return
}
data = append(data, klingV2TaskData(task))
}
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestIDFromAny(data), "data": data})
}
// klingV2ListTasks godoc
// @Summary 分页查询可灵 API 2.0 任务
// @Tags kling-compatible
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
// @Success 200 {object} map[string]interface{}
// @Router /kling/tasks [post]
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
var body map[string]any
if err := decodeKlingJSON(r, &body); err != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
return
}
page, err := klingCursorPage(stringFromRequestAny(body["cursor"]))
if err != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid cursor", "invalid_parameter")
return
}
limit, ok := klingCompatInt(body["limit"])
if !ok || limit <= 0 {
limit = 100
}
if limit > 500 {
writeKlingCompatError(w, http.StatusBadRequest, "limit must not exceed 500", "invalid_parameter")
return
}
createdFrom, err := klingMillisTime(body["start_time"])
if err != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid start_time", "invalid_parameter")
return
}
createdTo, err := klingMillisTime(body["end_time"])
if err != nil {
writeKlingCompatError(w, http.StatusBadRequest, "invalid end_time", "invalid_parameter")
return
}
statuses := klingInternalStatuses(body["filters"])
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{
Provider: klingCompatProvider, Version: "v2", Statuses: statuses,
CreatedFrom: createdFrom, CreatedTo: createdTo, Page: page, PageSize: limit,
})
if err != nil {
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
return
}
items := make([]any, 0, len(result.Items))
for _, task := range result.Items {
items = append(items, klingV2TaskData(task))
}
hasMore := page*limit < result.Total
nextCursor := ""
if hasMore {
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(page + 1)))
}
writeJSON(w, http.StatusOK, map[string]any{
"code": 0, "message": "success", "request_id": klingRequestID(result.Items),
"data": map[string]any{"result": items, "count": len(items), "next_cursor": nextCursor, "has_more": hasMore},
})
}
func klingV1Envelope(task store.GatewayTask) map[string]any {
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV1TaskData(task)}
}
func klingV1TaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"task_id": task.ID, "task_status": klingV1Status(task.Status),
"task_info": map[string]any{"external_task_id": task.ExternalTaskID},
"created_at": task.CreatedAt.UnixMilli(), "updated_at": task.UpdatedAt.UnixMilli(),
}
if task.ErrorMessage != "" || task.Error != "" {
data["task_status_msg"] = firstNonEmpty(task.ErrorMessage, task.Error)
}
if watermarkInfo, ok := task.Request["watermark_info"].(map[string]any); ok {
data["watermark_info"] = watermarkInfo
}
videos := klingTaskVideos(task)
if len(videos) > 0 {
data["task_result"] = map[string]any{"videos": videos}
}
return data
}
func klingV2Envelope(task store.GatewayTask) map[string]any {
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV2TaskData(task)}
}
func klingV2TaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"id": task.ID, "status": klingV2Status(task.Status),
"create_time": task.CreatedAt.UnixMilli(), "update_time": task.UpdatedAt.UnixMilli(),
"external_id": task.ExternalTaskID,
}
if message := firstNonEmpty(task.ErrorMessage, task.Error); message != "" {
data["message"] = message
}
if outputs := klingV2Outputs(task); len(outputs) > 0 {
data["outputs"] = outputs
}
return data
}
func klingTaskVideos(task store.GatewayTask) []any {
items, _ := task.Result["data"].([]any)
videos := make([]any, 0, len(items))
for index, raw := range items {
item, _ := raw.(map[string]any)
url := firstNonEmptyRequestString(item, "url", "video_url")
if url == "" {
continue
}
video := map[string]any{"id": firstNonEmptyRequestString(item, "id")}
if video["id"] == "" {
video["id"] = fmt.Sprintf("%s-%d", task.ID, index+1)
}
video["url"] = url
if watermarkURL := firstNonEmptyRequestString(item, "watermark_url"); watermarkURL != "" {
video["watermark_url"] = watermarkURL
}
if duration := klingStringAny(item["duration"]); duration != "" {
video["duration"] = duration
}
videos = append(videos, video)
}
return videos
}
func klingV2Outputs(task store.GatewayTask) []any {
videos := klingTaskVideos(task)
outputs := make([]any, 0, len(videos))
for _, raw := range videos {
video, _ := raw.(map[string]any)
output := cloneMap(video)
output["type"] = "video"
outputs = append(outputs, output)
}
return outputs
}
func klingV1Status(status string) string {
switch status {
case "succeeded":
return "succeed"
case "failed", "cancelled":
return "failed"
case "running":
return "processing"
default:
return "submitted"
}
}
func klingV2Status(status string) string {
switch status {
case "succeeded":
return "succeeded"
case "failed", "cancelled":
return "failed"
case "running":
return "processing"
default:
return "submitted"
}
}
func klingV2ProviderModel(pathModel string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(pathModel)) {
case "kling-o1", "kling-video-o1":
return klingO1Model, true
case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni":
return klingV3OmniModel, true
default:
return "", false
}
}
func klingResolutionFromMode(mode string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "std":
return "720p", true
case "pro":
return "1080p", true
case "4k":
return "2160p", true
default:
return "", false
}
}
func decodeKlingJSON(r *http.Request, target any) error {
decoder := json.NewDecoder(r.Body)
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err != nil {
return err
}
return errors.New("multiple json values")
}
return nil
}
func writeKlingCompatError(w http.ResponseWriter, status int, message string, code string) {
if strings.TrimSpace(code) == "" {
code = "invalid_request"
}
writeJSON(w, status, map[string]any{
"code": klingCompatErrorCode(status),
"message": message,
"request_id": "",
"error": code,
})
}
func klingCompatErrorCode(status int) int {
switch status {
case http.StatusBadRequest:
return 1001
case http.StatusUnauthorized:
return 1100
case http.StatusForbidden:
return 1302
case http.StatusNotFound:
return 1201
case http.StatusConflict:
return 1200
case http.StatusTooManyRequests:
return 1400
default:
return 5000
}
}
func mapListFromRequest(value any) []map[string]any {
items, _ := value.([]any)
if len(items) == 0 {
if typed, ok := value.([]map[string]any); ok {
return typed
}
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if mapped, ok := item.(map[string]any); ok {
out = append(out, mapped)
}
}
return out
}
func firstPresentRequest(values ...any) any {
for _, value := range values {
if value == nil {
continue
}
if text, ok := value.(string); ok {
if strings.TrimSpace(text) != "" {
return value
}
continue
}
return value
}
return nil
}
func boolFromRequestAny(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, _ := strconv.ParseBool(strings.TrimSpace(typed))
return parsed
default:
return false
}
}
func klingCompatInt(value any) (int, bool) {
text := klingStringAny(value)
if text == "" {
return 0, false
}
number, err := strconv.Atoi(text)
return number, err == nil
}
func splitKlingIDs(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func klingCursorPage(cursor string) (int, error) {
cursor = strings.TrimSpace(cursor)
if cursor == "" {
return 1, nil
}
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0, err
}
page, err := strconv.Atoi(string(decoded))
if err != nil || page <= 0 {
return 0, errors.New("invalid cursor")
}
return page, nil
}
func klingMillisTime(value any) (*time.Time, error) {
text := klingStringAny(value)
if text == "" {
return nil, nil
}
millis, err := strconv.ParseInt(text, 10, 64)
if err != nil || millis < 0 {
return nil, errors.New("invalid millisecond timestamp")
}
parsed := time.UnixMilli(millis)
return &parsed, nil
}
func klingInternalStatuses(filters any) []string {
statuses := make([]string, 0)
for _, filter := range mapListFromRequest(filters) {
if stringFromRequestAny(filter["key"]) != "status" {
continue
}
values, _ := filter["values"].([]any)
for _, value := range values {
switch strings.ToLower(strings.TrimSpace(stringFromRequestAny(value))) {
case "submitted":
statuses = append(statuses, "queued")
case "processing":
statuses = append(statuses, "running")
case "succeeded":
statuses = append(statuses, "succeeded")
case "failed":
statuses = append(statuses, "failed", "cancelled")
}
}
}
return statuses
}
func klingRequestID(tasks []store.GatewayTask) string {
if len(tasks) == 0 {
return ""
}
return firstNonEmpty(tasks[0].RequestID, tasks[0].ID)
}
func klingRequestIDFromAny(items []any) string {
if len(items) == 0 {
return ""
}
item, _ := items[0].(map[string]any)
return stringFromRequestAny(item["id"])
}
func klingStringAny(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case int32:
return strconv.FormatInt(int64(typed), 10)
default:
return ""
}
}
@@ -0,0 +1,193 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestKlingCompatibilitySimulationFlow(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling compatibility integration flow")
}
ctx := context.Background()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
serverCtx, cancelServer := context.WithCancel(ctx)
defer cancelServer()
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-secret",
CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
username := "kling_compat_" + suffix
password := "password123"
var registerResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": username,
"email": username + "@example.com",
"password": password,
}, http.StatusCreated, &registerResponse)
var apiKeyResponse struct {
Secret string `json:"secret"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
"name": "Kling compatibility key",
}, http.StatusCreated, &apiKeyResponse)
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
t.Fatalf("promote compatibility user: %v", err)
}
var loginResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username, "password": password,
}, http.StatusOK, &loginResponse)
var platform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "keling",
"platformKey": "kling-compat-" + suffix,
"name": "Kling Compatibility Simulation",
"baseUrl": "https://api-beijing.klingai.com/v1",
"authType": "AccessKey-SecretKey",
"credentials": map[string]any{"accessKey": "test-ak", "secretKey": "test-sk"},
}, http.StatusCreated, &platform)
for _, model := range []string{klingO1Model, klingV3OmniModel} {
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "keling:" + model,
"modelName": model,
"providerModelName": model,
"modelAlias": model,
"modelType": []string{"omni_video"},
"displayName": model,
}, http.StatusCreated, nil)
}
createV1 := func(model string, duration int, externalID string) string {
t.Helper()
var response map[string]any
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
"model_name": model,
"prompt": "兼容接口模拟任务",
"duration": duration,
"mode": "std",
"external_task_id": externalID,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
}, http.StatusOK, &response)
if response["code"] != float64(0) {
t.Fatalf("unexpected V1 response: %#v", response)
}
data, _ := response["data"].(map[string]any)
taskID, _ := data["task_id"].(string)
if taskID == "" {
t.Fatalf("V1 response missing task id: %#v", response)
}
return taskID
}
o1TaskID := createV1(klingO1Model, 5, "compat-o1-"+suffix)
v3TaskID := createV1(klingV3OmniModel, 15, "compat-v3-"+suffix)
for _, taskID := range []string{o1TaskID, v3TaskID} {
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
}
var listResponse map[string]any
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
items, _ := listResponse["data"].([]any)
if len(items) < 2 {
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
}
var duplicateResponse map[string]any
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
"external_task_id": "compat-o1-" + suffix,
"runMode": "simulation", "simulation": true,
}, http.StatusConflict, &duplicateResponse)
if duplicateResponse["code"] != float64(1200) || duplicateResponse["error"] != "external_task_id_reused" {
t.Fatalf("unexpected duplicate external id response: %#v", duplicateResponse)
}
var v2Response map[string]any
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
}, http.StatusOK, &v2Response)
v2Data, _ := v2Response["data"].(map[string]any)
v2TaskID, _ := v2Data["id"].(string)
if v2TaskID == "" {
t.Fatalf("V2 response missing task id: %#v", v2Response)
}
waitKlingV2SimulationTask(t, server.URL, apiKeyResponse.Secret, v2TaskID)
}
func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var response map[string]any
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
data, _ := response["data"].(map[string]any)
switch data["task_status"] {
case "succeed":
return
case "failed":
t.Fatalf("V1 simulation task failed: %#v", response)
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("V1 simulation task %s timed out", taskID)
}
func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var response map[string]any
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
items, _ := response["data"].([]any)
if len(items) == 1 {
data, _ := items[0].(map[string]any)
switch data["status"] {
case "succeeded":
return
case "failed":
t.Fatalf("V2 simulation task failed: %#v", response)
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("V2 simulation task %s timed out", taskID)
}
@@ -0,0 +1,137 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestKlingV1O1CompatibilityBody(t *testing.T) {
body, externalID, err := klingCompatTaskBody("v1", klingO1Model, map[string]any{
"prompt": "一只纸鹤飞过湖面",
"duration": json.Number("10"),
"aspect_ratio": "16:9",
"sound": "on",
"external_task_id": "client-o1-1",
"watermark_info": map[string]any{"enabled": true},
})
if err != nil {
t.Fatalf("build O1 compatibility body: %v", err)
}
if externalID != "client-o1-1" || body["model"] != klingO1Model || body["modelType"] != "omni_video" {
t.Fatalf("unexpected identity fields: %#v", body)
}
if body["mode"] != "pro" || body["resolution"] != "1080p" || body["audio"] != true {
t.Fatalf("unexpected V1 defaults: %#v", body)
}
content := mapListFromRequest(body["content"])
if len(content) != 1 || content[0]["type"] != "text" || content[0]["text"] != "一只纸鹤飞过湖面" {
t.Fatalf("unexpected canonical content: %#v", content)
}
}
func TestKlingV1V3OmniCompatibilityBody(t *testing.T) {
body, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{
"multi_shot": true,
"shot_type": "customize",
"mode": "4k",
"multi_prompt": []any{
map[string]any{"index": json.Number("1"), "prompt": "推近人物", "duration": json.Number("7")},
map[string]any{"index": json.Number("2"), "prompt": "切到城市远景", "duration": json.Number("8")},
},
"image_list": []any{
map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"},
},
})
if err != nil {
t.Fatalf("build 3.0 Omni compatibility body: %v", err)
}
if body["resolution"] != "2160p" {
t.Fatalf("4k mode was not normalized: %#v", body)
}
content := mapListFromRequest(body["content"])
if len(content) != 1 || content[0]["role"] != "first_frame" {
t.Fatalf("image input was not normalized: %#v", content)
}
}
func TestKlingV2CompatibilityBody(t *testing.T) {
body, externalID, err := klingCompatTaskBody("v2", klingV3OmniModel, map[string]any{
"contents": []any{
map[string]any{"type": "prompt", "text": "让角色向镜头挥手"},
map[string]any{"type": "first_frame", "url": "https://example.com/first.png"},
map[string]any{"type": "element", "id": json.Number("42")},
},
"settings": map[string]any{
"resolution": "1080p",
"duration": json.Number("15"),
"aspect_ratio": "9:16",
"audio": "native",
},
"options": map[string]any{
"external_task_id": "client-v2-1",
"callback_url": "https://example.com/callback",
},
})
if err != nil {
t.Fatalf("build V2 compatibility body: %v", err)
}
if externalID != "client-v2-1" || body["mode"] != "pro" || body["audio"] != true {
t.Fatalf("unexpected V2 settings: %#v", body)
}
if len(mapListFromRequest(body["image_list"])) != 1 || len(mapListFromRequest(body["element_list"])) != 1 {
t.Fatalf("unexpected V2 references: %#v", body)
}
}
func TestKlingCompatibilityValidation(t *testing.T) {
tests := []struct {
name string
model string
body map[string]any
}{
{name: "O1 duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 11}},
{name: "O1 text-only flexible duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 3}},
{name: "O1 4k", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 5, "mode": "4k"}},
{name: "O1 multi-shot", model: klingO1Model, body: map[string]any{"multi_shot": true, "multi_prompt": []any{map[string]any{"prompt": "test", "duration": 3}}}},
{name: "custom multi-shot without prompts", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "customize"}},
{name: "intelligence multi-shot without prompt", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "intelligence"}},
{name: "video with native audio", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "sound": "on", "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
{name: "video duration too long", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "duration": 15, "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
{name: "too many references", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "image_list": []any{
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
}}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := klingCompatTaskBody("v1", test.model, test.body); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{"prompt": "test", "duration": 15}); err != nil {
t.Fatalf("3.0 Omni should allow a 15-second duration: %v", err)
}
}
func TestDecodeKlingJSONRejectsTrailingData(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/kling/v1/videos/omni-video", strings.NewReader(`{"prompt":"ok"} trailing`))
var body map[string]any
if err := decodeKlingJSON(request, &body); err == nil {
t.Fatal("expected trailing JSON error")
}
}
func TestWriteKlingCompatErrorUsesOfficialNumericEnvelope(t *testing.T) {
recorder := httptest.NewRecorder()
writeKlingCompatError(recorder, http.StatusBadRequest, "bad request", "invalid_parameter")
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode error response: %v", err)
}
if body["code"] != float64(1001) || body["error"] != "invalid_parameter" {
t.Fatalf("unexpected error envelope: %#v", body)
}
}
+1
View File
@@ -265,6 +265,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("POST /api/v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
server.registerGeminiGenerateContentRoutes(mux)
server.registerKlingCompatibilityRoutes(mux)
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
+11 -4
View File
@@ -63,6 +63,7 @@ var (
ErrBalanceBelowFrozen = errors.New("wallet balance cannot be below frozen balance")
ErrInvalidWalletAmount = errors.New("wallet amount must be a decimal with at most nine fractional digits")
ErrIdempotencyKeyReused = errors.New("idempotency key was reused for a different request")
ErrExternalTaskIDReused = errors.New("external task id was reused")
ErrTaskExecutionLeaseUnavailable = errors.New("task execution lease is unavailable")
ErrTaskExecutionLeaseLost = errors.New("task execution lease was lost")
ErrTaskExecutionFinished = errors.New("task execution already finished")
@@ -465,6 +466,7 @@ type RateLimitWindow struct {
type CreateTaskInput struct {
Kind string `json:"kind"`
Model string `json:"model"`
ExternalTaskID string `json:"externalTaskId,omitempty"`
RunMode string `json:"runMode"`
Async bool `json:"async"`
Request map[string]any `json:"request"`
@@ -482,6 +484,7 @@ type CreateTaskResult struct {
type GatewayTask struct {
ID string `json:"id"`
ExternalTaskID string `json:"externalTaskId,omitempty"`
Kind string `json:"kind"`
RunMode string `json:"runMode"`
UserID string `json:"userId"`
@@ -541,7 +544,7 @@ type GatewayTask struct {
}
const gatewayTaskColumns = `
id::text, kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
id::text, COALESCE(external_task_id, ''), kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
COALESCE(api_key_id, ''), COALESCE(api_key_name, ''), COALESCE(api_key_prefix, ''),
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model,
@@ -1948,15 +1951,15 @@ func (s *Store) CreateTaskIdempotent(ctx context.Context, input CreateTaskInput,
task, err := scanGatewayTask(tx.QueryRow(ctx, `
INSERT INTO gateway_tasks (
kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
external_task_id, kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
api_key_id, api_key_name, api_key_prefix, user_group_id, user_group_key,
model, requested_model, request, async_mode, status, result, billings, conversation_id, new_message_count,
idempotency_key_hash, idempotency_request_hash, finished_at
)
VALUES ($1, $2, $3, NULLIF($4, '')::uuid, COALESCE(NULLIF($5, ''), 'gateway'), NULLIF($6, '')::uuid, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, '')::uuid, NULLIF($13, ''), $14, $14, $15, $16, $17, $18::jsonb, $19::jsonb, NULLIF($20, '')::uuid, $21, NULLIF($22, ''), NULLIF($23, ''), NULL)
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5, '')::uuid, COALESCE(NULLIF($6, ''), 'gateway'), NULLIF($7, '')::uuid, NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, '')::uuid, NULLIF($14, ''), $15, $15, $16, $17, $18, $19::jsonb, $20::jsonb, NULLIF($21, '')::uuid, $22, NULLIF($23, ''), NULLIF($24, ''), NULL)
ON CONFLICT (user_id, idempotency_key_hash) WHERE idempotency_key_hash IS NOT NULL DO NOTHING
RETURNING `+gatewayTaskColumns,
input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
strings.TrimSpace(input.ExternalTaskID), input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
))
replayed := false
if errors.Is(err, pgx.ErrNoRows) && strings.TrimSpace(input.IdempotencyKeyHash) != "" {
@@ -1977,6 +1980,9 @@ FROM gateway_tasks
WHERE user_id = $1 AND idempotency_key_hash = $2`, user.ID, strings.TrimSpace(input.IdempotencyKeyHash)))
replayed = true
}
if isUniqueViolation(err) && strings.TrimSpace(input.ExternalTaskID) != "" {
return CreateTaskResult{}, ErrExternalTaskIDReused
}
if err != nil {
return CreateTaskResult{}, err
}
@@ -2041,6 +2047,7 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
var remoteTaskPayloadBytes []byte
if err := scanner.Scan(
&task.ID,
&task.ExternalTaskID,
&task.Kind,
&task.RunMode,
&task.UserID,
+126
View File
@@ -29,6 +29,16 @@ type TaskListResult struct {
PageSize int
}
type CompatTaskListFilter struct {
Provider string
Version string
Statuses []string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int
PageSize int
}
func (s *Store) ListTasks(ctx context.Context, user *auth.User, filter TaskListFilter) (TaskListResult, error) {
page := filter.Page
if page <= 0 {
@@ -146,6 +156,122 @@ LIMIT $8 OFFSET $9`, queryArgs...)
}, nil
}
func (s *Store) GetCompatTask(ctx context.Context, user *auth.User, provider string, version string, identifier string) (GatewayTask, error) {
gatewayUserID := localGatewayUserID(user)
apiKeyID := ""
userID := ""
if user != nil {
apiKeyID = strings.TrimSpace(user.APIKeyID)
userID = strings.TrimSpace(user.ID)
}
if gatewayUserID == "" && userID == "" {
return GatewayTask{}, ErrLocalUserRequired
}
task, err := scanGatewayTask(s.pool.QueryRow(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
WHERE (
(NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2)
)
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
AND request->>'_compat_provider' = $4
AND request->>'_kling_compat_version' = $5
AND (id::text = $6 OR external_task_id = $6)
ORDER BY created_at DESC
LIMIT 1`, gatewayUserID, userID, apiKeyID, strings.TrimSpace(provider), strings.TrimSpace(version), strings.TrimSpace(identifier)))
if err != nil {
return GatewayTask{}, err
}
attempts, err := s.ListTaskAttempts(ctx, task.ID)
if err != nil {
return GatewayTask{}, err
}
task.Attempts = attempts
return task, nil
}
func (s *Store) ListCompatTasks(ctx context.Context, user *auth.User, filter CompatTaskListFilter) (TaskListResult, error) {
page := filter.Page
if page <= 0 {
page = 1
}
pageSize := filter.PageSize
if pageSize <= 0 {
pageSize = 30
}
if pageSize > 500 {
pageSize = 500
}
offset := (page - 1) * pageSize
gatewayUserID := localGatewayUserID(user)
apiKeyID := ""
userID := ""
if user != nil {
apiKeyID = strings.TrimSpace(user.APIKeyID)
userID = strings.TrimSpace(user.ID)
}
if gatewayUserID == "" && userID == "" {
return TaskListResult{}, ErrLocalUserRequired
}
statuses := filter.Statuses
if len(statuses) == 0 {
statuses = nil
}
args := []any{
gatewayUserID,
userID,
apiKeyID,
strings.TrimSpace(filter.Provider),
strings.TrimSpace(filter.Version),
nullableTaskListTime(filter.CreatedFrom),
nullableTaskListTime(filter.CreatedTo),
statuses,
}
whereSQL := `
WHERE (
(NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2)
)
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
AND request->>'_compat_provider' = $4
AND request->>'_kling_compat_version' = $5
AND ($6::timestamptz IS NULL OR created_at >= $6::timestamptz)
AND ($7::timestamptz IS NULL OR created_at <= $7::timestamptz)
AND ($8::text[] IS NULL OR status = ANY($8::text[]))`
var total int
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil {
return TaskListResult{}, err
}
queryArgs := append(args, pageSize, offset)
rows, err := s.pool.Query(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
`+whereSQL+`
ORDER BY created_at DESC
LIMIT $9 OFFSET $10`, queryArgs...)
if err != nil {
return TaskListResult{}, err
}
defer rows.Close()
items := make([]GatewayTask, 0)
for rows.Next() {
task, err := scanGatewayTask(rows)
if err != nil {
return TaskListResult{}, err
}
items = append(items, task)
}
if err := rows.Err(); err != nil {
return TaskListResult{}, err
}
items, err = s.attachTaskAttempts(ctx, items)
if err != nil {
return TaskListResult{}, err
}
return TaskListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func nullableTaskListTime(value *time.Time) any {
if value == nil {
return nil
@@ -0,0 +1,9 @@
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_tasks_kling_compat_external
ON gateway_tasks (
COALESCE(gateway_user_id::text, user_id),
external_task_id
)
WHERE external_task_id IS NOT NULL
AND external_task_id <> ''
AND request->>'_compat_provider' = 'kling'
AND request->>'_kling_compat_version' IN ('v1', 'v2');
+79
View File
@@ -0,0 +1,79 @@
# 可灵 O1 / 3.0 Omni 兼容接口
网关兼容中国区可灵 V1 AK/SK Omni API 和 API 2.0 的调用路径。调用方只需要把可灵客户端的 `baseURL` 改为网关地址,并把认证改为 EasyAI Gateway API Key;中国区可灵 AK/SK 仅保存在网关平台配置中,不下发给调用方。
生产环境统一配置:
```text
baseURL = https://ai.51easyai.com/gateway-api/kling
Authorization = Bearer <EasyAI Gateway API Key>
```
本地环境使用 `baseURL = http://localhost:8088/kling`
## V1AK/SK 旧版协议兼容)
创建任务:
```http
POST /v1/videos/omni-video
Authorization: Bearer <EasyAI Gateway API Key>
Content-Type: application/json
{
"model_name": "kling-video-o1",
"prompt": "一只白色纸鹤飞过清晨的湖面",
"mode": "pro",
"duration": "5",
"aspect_ratio": "16:9",
"sound": "off",
"external_task_id": "client-task-001"
}
```
`model_name` 支持:
- `kling-video-o1`:O1,时长 3–10 秒;不带参考素材的纯文本任务只支持 5 或 10 秒。
- `kling-v3-omni`3.0 Omni,时长 3–15 秒,支持多镜头。
查询和列表接口:
```http
GET /v1/videos/omni-video/{task_id}
GET /v1/videos/omni-video?pageNum=1&pageSize=30
```
V1 支持并透传 `prompt``multi_shot``shot_type``multi_prompt``image_list``element_list``video_list``voice_list``sound``mode``aspect_ratio``duration``watermark_info``callback_url``external_task_id`。网关会校验模型时长、引用素材数量、参考视频数量以及参考视频与原生音频的互斥约束。
## API 2.0
创建任务:
```http
POST /omni-video/kling-o1
POST /omni-video/kling-v3-omni
```
同时提供带显式版本前缀的等价别名:
```http
POST /v2/omni-video/kling-o1
POST /v2/omni-video/kling-v3-omni
```
任务查询和列表:
```http
GET /tasks?task_ids=<task_id>
GET /tasks?external_task_ids=<external_task_id>
POST /tasks
```
上述任务接口也提供 `/v2/tasks` 别名。API 2.0 请求使用 `contents``settings``options`;网关会转换到相同的 V1 AK/SK 上游链路,因此 O1 和 3.0 Omni 使用同一套中国区平台凭据。
## 安全约束
- 调用方只使用 EasyAI Gateway API Key,不接触上游 AK/SK。
- 任务查询按用户和当前 API Key 隔离,不能读取其他 Key 创建的任务。
- `external_task_id` 在同一用户的可灵兼容任务范围内唯一。
- 真实联调测试是显式启用且会产生可灵费用;测试凭据只放在 `.env.local`