feat(kling): 接入 Omni 视频兼容接口
This commit is contained in:
@@ -338,8 +338,11 @@ func kelingVideoPayload(ctx context.Context, request Request) (map[string]any, s
|
||||
if value, ok := body["cfg_scale"]; ok && numericValue(value, 0) > 0 {
|
||||
payload["cfg_scale"] = value
|
||||
}
|
||||
if boolValue(body, "audio") || boolValue(body, "output_audio") {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
payload["sound"] = sound
|
||||
}
|
||||
if mode := kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")); mode != "" {
|
||||
payload["mode"] = mode
|
||||
@@ -432,7 +435,7 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
watermarkEnabled = boolValue(watermarkInfo, "enabled")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model_name": upstreamModelName(request.Candidate),
|
||||
"model_name": kelingOmniUpstreamModelName(request.Candidate),
|
||||
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||
@@ -458,8 +461,13 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
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 sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, nil, &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !hasVideo {
|
||||
payload["sound"] = sound
|
||||
}
|
||||
}
|
||||
if multiShot {
|
||||
payload["multi_shot"] = true
|
||||
@@ -728,6 +736,18 @@ func kelingIsOmniRequest(request Request) bool {
|
||||
request.Candidate.Capabilities["omni"] != nil
|
||||
}
|
||||
|
||||
func kelingOmniUpstreamModelName(candidate store.RuntimeModelCandidate) string {
|
||||
model := strings.TrimSpace(upstreamModelName(candidate))
|
||||
switch strings.ToLower(model) {
|
||||
case "kling-o1":
|
||||
return "kling-video-o1"
|
||||
case "kling-3.0-omni":
|
||||
return "kling-v3-omni"
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func kelingIs30TurboRequest(request Request) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
|
||||
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
|
||||
@@ -1073,6 +1093,54 @@ func kelingModeByResolution(resolution string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func kelingSoundSetting(body map[string]any) (string, bool) {
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromAny(body["sound"]))); sound == "on" || sound == "off" {
|
||||
return sound, true
|
||||
}
|
||||
for _, key := range []string{"audio", "output_audio", "generate_audio"} {
|
||||
if enabled, ok := kelingBoolFieldValue(body, key); ok {
|
||||
if enabled {
|
||||
return "on", true
|
||||
}
|
||||
return "off", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func kelingSupportsGeneratedSound(candidate store.RuntimeModelCandidate) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(candidate))) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func kelingWatermarkEnabled(body map[string]any) bool {
|
||||
if enabled, ok := kelingBoolFieldValue(body, "watermark"); ok {
|
||||
return enabled
|
||||
}
|
||||
info := mapFromAny(body["watermark_info"])
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
enabled, _ := kelingBoolFieldValue(info, "enabled")
|
||||
return enabled
|
||||
}
|
||||
|
||||
func kelingBoolFieldValue(body map[string]any, key string) (bool, bool) {
|
||||
if body == nil {
|
||||
return false, false
|
||||
}
|
||||
value, ok := body[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
typed, ok := value.(bool)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
func kelingCameraControl(body map[string]any) map[string]any {
|
||||
cameraControl := strings.TrimSpace(stringFromAny(body["camera_control"]))
|
||||
if cameraControl == "" {
|
||||
@@ -1182,7 +1250,7 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
|
||||
item["id"] = id
|
||||
}
|
||||
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||
if duration := firstPresent(video["duration"]); duration != nil && strings.TrimSpace(stringFromAny(duration)) != "" {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
|
||||
@@ -1194,11 +1262,15 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
modelName := upstreamModelName(request.Candidate)
|
||||
if kelingIsOmniRequest(request) {
|
||||
modelName = kelingOmniUpstreamModelName(request.Candidate)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"model": modelName,
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": items,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniPayloadPreservesCompatibleSettings(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "A product reveal",
|
||||
"duration": 3,
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"audio": false,
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"external_task_id": "external-1",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build compatible Omni payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 ||
|
||||
payload["model_name"] != "kling-video-o1" ||
|
||||
payload["mode"] != "std" ||
|
||||
payload["sound"] != "off" ||
|
||||
payload["duration"] != "3" ||
|
||||
payload["aspect_ratio"] != "16:9" ||
|
||||
payload["external_task_id"] != "external-1" {
|
||||
t.Fatalf("unexpected compatible Omni payload: %+v", payload)
|
||||
}
|
||||
watermark, _ := payload["watermark_info"].(map[string]any)
|
||||
if watermark["enabled"] != true {
|
||||
t.Fatalf("watermark setting was not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniUpstreamModelNameSeparatesGatewayAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"kling-o1": "kling-video-o1",
|
||||
"kling-video-o1": "kling-video-o1",
|
||||
"kling-3.0-omni": "kling-v3-omni",
|
||||
"kling-v3-omni": "kling-v3-omni",
|
||||
}
|
||||
for configured, want := range tests {
|
||||
got := kelingOmniUpstreamModelName(store.RuntimeModelCandidate{ProviderModelName: configured})
|
||||
if got != want {
|
||||
t.Fatalf("configured=%s got=%s want=%s", configured, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniRejectsGeneratedAudioForO1(t *testing.T) {
|
||||
_, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Body: map[string]any{
|
||||
"prompt": "A beach",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"},
|
||||
}, "token")
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
||||
t.Fatalf("expected generated-audio rejection for O1, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniResumeReturnsUpstreamFailureCodeAndModel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/videos/omni-video/remote-failed" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-key" {
|
||||
t.Fatalf("unexpected Authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "failure-request",
|
||||
"data": map[string]any{
|
||||
"task_id": "remote-failed",
|
||||
"task_status": "failed",
|
||||
"task_status_msg": "content policy rejection",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
RemoteTaskID: "remote-failed",
|
||||
RemoteTaskPayload: map[string]any{"endpoint": "/videos/omni-video"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Credentials: map[string]any{"apiKey": "upstream-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 10,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || ErrorCode(err) != "keling_task_failed" || !strings.Contains(err.Error(), "content policy rejection") {
|
||||
t.Fatalf("expected preserved Keling task failure, got code=%q err=%v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesIntelligentMultiShot(t *testing.T) {
|
||||
payload, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "Create three coherent shots",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"multi_shot": true,
|
||||
"shot_type": "intelligence",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build intelligent multi-shot payload: %v", err)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "intelligence" || payload["prompt"] != "Create three coherent shots" {
|
||||
t.Fatalf("unexpected intelligent multi-shot payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingVideoSuccessResultPreservesOfficialVideoMetadata(t *testing.T) {
|
||||
result := kelingVideoSuccessResult(Request{Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"}}, "remote-1", map[string]any{
|
||||
"data": map[string]any{
|
||||
"task_result": map[string]any{
|
||||
"videos": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "3",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
data, _ := result["data"].([]any)
|
||||
video, _ := data[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "3" {
|
||||
t.Fatalf("official video metadata was lost: %+v", video)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const kelingOmniCompatibilityMarker = "keling_omni_v1"
|
||||
|
||||
type kelingCompatRequestIDKey struct{}
|
||||
|
||||
type KelingOmniVideoRequest struct {
|
||||
ModelName string `json:"model_name" example:"kling-v3-omni"`
|
||||
Prompt string `json:"prompt" example:"A quiet street in the rain with natural ambient sound"`
|
||||
MultiShot bool `json:"multi_shot" example:"false"`
|
||||
ShotType string `json:"shot_type,omitempty" example:"customize"`
|
||||
MultiPrompt []KelingOmniMultiPrompt `json:"multi_prompt,omitempty"`
|
||||
ImageList []KelingOmniImageInput `json:"image_list,omitempty"`
|
||||
ElementList []KelingOmniElementInput `json:"element_list,omitempty"`
|
||||
VideoList []KelingOmniVideoInput `json:"video_list,omitempty"`
|
||||
Sound string `json:"sound" enums:"on,off" example:"on"`
|
||||
Mode string `json:"mode" enums:"std,pro,4k" example:"pro"`
|
||||
AspectRatio string `json:"aspect_ratio" enums:"16:9,9:16,1:1" example:"9:16"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"5"`
|
||||
WatermarkInfo KelingOmniWatermarkInfo `json:"watermark_info,omitempty"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
ExternalTask string `json:"external_task_id,omitempty"`
|
||||
}
|
||||
|
||||
type KelingOmniMultiPrompt struct {
|
||||
Index int `json:"index" example:"1"`
|
||||
Prompt string `json:"prompt" example:"A wide establishing shot"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"3"`
|
||||
}
|
||||
|
||||
type KelingOmniImageInput struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Type string `json:"type,omitempty" enums:"first_frame,end_frame"`
|
||||
}
|
||||
|
||||
type KelingOmniElementInput struct {
|
||||
ElementID any `json:"element_id"`
|
||||
}
|
||||
|
||||
type KelingOmniVideoInput struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
ReferType string `json:"refer_type,omitempty" enums:"base,feature"`
|
||||
KeepOriginalSound string `json:"keep_original_sound,omitempty" enums:"yes,no"`
|
||||
}
|
||||
|
||||
type KelingOmniWatermarkInfo struct {
|
||||
Enabled bool `json:"enabled" example:"false"`
|
||||
}
|
||||
|
||||
type KelingCompatibleEnvelope struct {
|
||||
Code int `json:"code" example:"0"`
|
||||
Message string `json:"message" example:"SUCCEED"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type kelingCompatError struct {
|
||||
HTTPStatus int
|
||||
Code int
|
||||
Message string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e *kelingCompatError) Error() string {
|
||||
if e == nil {
|
||||
return "keling compatibility error"
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func newKelingCompatError(status int, code int, message string) *kelingCompatError {
|
||||
return &kelingCompatError{HTTPStatus: status, Code: code, Message: message}
|
||||
}
|
||||
|
||||
func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := newKelingCompatRequestID(r)
|
||||
r = r.WithContext(context.WithValue(r.Context(), kelingCompatRequestIDKey{}, requestID))
|
||||
user, err := s.auth.Authenticate(r)
|
||||
if err != nil {
|
||||
code := 1002
|
||||
message := "Authorization is invalid"
|
||||
if strings.TrimSpace(r.Header.Get("Authorization")) == "" {
|
||||
code = 1001
|
||||
message = "Authorization is required"
|
||||
}
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, code, message))
|
||||
return
|
||||
}
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "a Gateway API Key is required"))
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusForbidden, 1103, "API Key scope does not allow video generation"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
// createKelingOmniVideo godoc
|
||||
// @Summary 创建 Kling Omni 视频任务
|
||||
// @Description 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body KelingOmniVideoRequest true "Kling Omni 官方兼容请求"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 400 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||||
// @Router /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 /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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A rainy street with natural ambience",
|
||||
"mode": "pro",
|
||||
"sound": "on",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"external_task_id": "external-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize Kling request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" ||
|
||||
normalized["modelType"] != "omni_video" ||
|
||||
normalized["resolution"] != "1080p" ||
|
||||
normalized["aspect_ratio"] != "9:16" ||
|
||||
normalized["duration"] != 5 ||
|
||||
normalized["audio"] != true ||
|
||||
normalized["sound"] != "on" ||
|
||||
normalized["watermark"] != true ||
|
||||
normalized["external_task_id"] != "external-1" ||
|
||||
normalized["_gateway_compatibility"] != kelingOmniCompatibilityMarker {
|
||||
t.Fatalf("unexpected normalized request: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("unexpected content: %+v", normalized["content"])
|
||||
}
|
||||
text, _ := content[0].(map[string]any)
|
||||
if text["type"] != "text" || text["text"] != "A rainy street with natural ambience" {
|
||||
t.Fatalf("unexpected text content: %+v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-3.0-omni",
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 5,
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": 1, "prompt": "First shot", "duration": "2"},
|
||||
map[string]any{"index": 2, "prompt": "Second shot", "duration": 3},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/reference.png"},
|
||||
},
|
||||
"element_list": []any{
|
||||
map[string]any{"element_id": float64(123)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize multi-shot request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" {
|
||||
t.Fatalf("unexpected multi-shot fields: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("expected image, element and two shot prompts, got %+v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestRejectsUnsupportedCombinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{
|
||||
name: "callback",
|
||||
body: map[string]any{"callback_url": "https://example.com/callback"},
|
||||
},
|
||||
{
|
||||
name: "unknown model",
|
||||
body: map[string]any{"model_name": "kling-unknown", "prompt": "x", "aspect_ratio": "16:9"},
|
||||
},
|
||||
{
|
||||
name: "o1 duration",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 11},
|
||||
},
|
||||
{
|
||||
name: "o1 text to video three seconds",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 3},
|
||||
},
|
||||
{
|
||||
name: "o1 generated audio",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "sound": "on", "aspect_ratio": "16:9", "duration": 5},
|
||||
},
|
||||
{
|
||||
name: "video sound",
|
||||
body: map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "edit",
|
||||
"sound": "on",
|
||||
"video_list": []any{map[string]any{"video_url": "https://example.com/base.mp4", "refer_type": "base"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "first frame ratio",
|
||||
body: map[string]any{
|
||||
"prompt": "animate",
|
||||
"aspect_ratio": "16:9",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
_, err := normalizeKelingOmniRequest(item.body)
|
||||
if err == nil || err.Code != 1201 && err.Code != 1203 {
|
||||
t.Fatalf("expected official compatibility error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingO1AllowsThreeSecondsWithReferenceImage(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "Use the landscape as a visual reference",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://placehold.co/1024x1024/png"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reference-image O1 request should allow three seconds: %v", err)
|
||||
}
|
||||
if normalized["duration"] != 3 {
|
||||
t.Fatalf("unexpected duration: %+v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatTaskDataAndOwnership(t *testing.T) {
|
||||
createdAt := time.Unix(100, 0)
|
||||
task := store.GatewayTask{
|
||||
ID: "task-1",
|
||||
Kind: "videos.generations",
|
||||
GatewayUserID: "user-1",
|
||||
Status: "succeeded",
|
||||
FinalChargeAmount: 2.5,
|
||||
Request: map[string]any{
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
"external_task_id": "external-1",
|
||||
"watermark": true,
|
||||
},
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "5",
|
||||
}}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Second),
|
||||
}
|
||||
if !isKelingCompatTask(task) || !kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-1"}) {
|
||||
t.Fatalf("expected task ownership and compatibility marker")
|
||||
}
|
||||
if kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-2"}) {
|
||||
t.Fatalf("cross-user task access must be rejected")
|
||||
}
|
||||
data := kelingCompatTaskData(task)
|
||||
if data["task_status"] != "succeed" || data["final_unit_deduction"] != "2.5" {
|
||||
t.Fatalf("unexpected task data: %+v", data)
|
||||
}
|
||||
result, _ := data["task_result"].(map[string]any)
|
||||
videos, _ := result["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "5" {
|
||||
t.Fatalf("unexpected compatible video: %+v", video)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireKelingAPIKeyWritesOfficialAuthEnvelope(t *testing.T) {
|
||||
server := &Server{auth: auth.New("secret", "", "")}
|
||||
server.auth.LocalAPIKeyVerifier = func(_ context.Context, key string) (*auth.User, error) {
|
||||
if key != "sk-gw-valid" {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
return &auth.User{ID: "user-1", GatewayUserID: "user-1", APIKeyID: "key-1", APIKeyScopes: []string{"video"}}, nil
|
||||
}
|
||||
handler := server.requireKelingAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.UserFromContext(r.Context()); !ok {
|
||||
t.Fatal("authenticated user is missing")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil))
|
||||
if missing.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing auth status=%d body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
var missingBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(missing.Body.Bytes(), &missingBody); err != nil || missingBody.Code != 1001 || missingBody.RequestID == "" {
|
||||
t.Fatalf("unexpected missing auth envelope: %+v err=%v", missingBody, err)
|
||||
}
|
||||
|
||||
invalid := httptest.NewRecorder()
|
||||
invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
invalidRequest.Header.Set("Authorization", "Bearer sk-gw-invalid")
|
||||
handler.ServeHTTP(invalid, invalidRequest)
|
||||
var invalidBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(invalid.Body.Bytes(), &invalidBody); err != nil || invalid.Code != http.StatusUnauthorized || invalidBody.Code != 1002 {
|
||||
t.Fatalf("unexpected invalid auth envelope: status=%d body=%+v err=%v", invalid.Code, invalidBody, err)
|
||||
}
|
||||
|
||||
valid := httptest.NewRecorder()
|
||||
validRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
validRequest.Header.Set("Authorization", "Bearer sk-gw-valid")
|
||||
handler.ServeHTTP(valid, validRequest)
|
||||
if valid.Code != http.StatusNoContent {
|
||||
t.Fatalf("valid API Key status=%d body=%s", valid.Code, valid.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatErrorImplementsError(t *testing.T) {
|
||||
err := newKelingCompatError(http.StatusBadRequest, 1201, "invalid")
|
||||
if !errors.Is(err, err) || err.Error() != "invalid" {
|
||||
t.Fatalf("unexpected error behavior: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatBusinessCodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
message string
|
||||
want int
|
||||
}{
|
||||
{code: "insufficient_balance", want: 1101},
|
||||
{code: "permission_denied", want: 1103},
|
||||
{code: "invalid_parameter", want: 1201},
|
||||
{code: "no_model_candidate", want: 1203},
|
||||
{code: "rate_limit_exceeded", want: 1302},
|
||||
{code: "concurrency_limit", want: 1303},
|
||||
{code: "network", want: 5001},
|
||||
{code: "unknown", want: 5000},
|
||||
}
|
||||
for _, item := range tests {
|
||||
if got := kelingCompatBusinessCode(item.code, item.message); got != item.want {
|
||||
t.Fatalf("code=%q message=%q got=%d want=%d", item.code, item.message, got, item.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatCandidatesSupport4K(t *testing.T) {
|
||||
candidates := []store.RuntimeModelCandidate{
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"720p", "1080p"}}}},
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"2160p"}}}},
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
t.Fatal("expected 2160p Keling capability to enable mode=4k")
|
||||
}
|
||||
if kelingCompatCandidatesSupport4K(candidates[:1]) {
|
||||
t.Fatal("mode=4k must stay disabled without an explicit capability")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniCompatibleHTTPFlow(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-compatible HTTP integration flow")
|
||||
}
|
||||
|
||||
var upstreamTaskSequence atomic.Int64
|
||||
var upstreamPayloadMu sync.Mutex
|
||||
var upstreamPayloads []map[string]any
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-keling-key" {
|
||||
t.Fatalf("unexpected upstream Authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/videos/omni-video":
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode upstream request: %v", err)
|
||||
}
|
||||
upstreamPayloadMu.Lock()
|
||||
upstreamPayloads = append(upstreamPayloads, payload)
|
||||
upstreamPayloadMu.Unlock()
|
||||
id := "upstream-" + strconv.FormatInt(upstreamTaskSequence.Add(1), 10)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "submit-" + id,
|
||||
"data": map[string]any{"task_id": id, "task_status": "submitted"},
|
||||
})
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/videos/omni-video/upstream-"):
|
||||
id := strings.TrimPrefix(r.URL.Path, "/videos/omni-video/")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "poll-" + id,
|
||||
"data": map[string]any{
|
||||
"task_id": id,
|
||||
"task_status": "succeed",
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
"task_result": map[string]any{"videos": []any{map[string]any{
|
||||
"id": "video-" + id,
|
||||
"url": "https://example.com/" + id + ".mp4",
|
||||
"watermark_url": "https://example.com/" + id + "-watermark.mp4",
|
||||
"duration": "3",
|
||||
}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
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()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
|
||||
Provider: "keling",
|
||||
PlatformKey: "keling-compatible-test-" + suffix,
|
||||
Name: "Kling Compatible Test",
|
||||
BaseURL: upstream.URL,
|
||||
AuthType: "APIKey",
|
||||
Credentials: map[string]any{"apiKey": "upstream-keling-key"},
|
||||
Config: map[string]any{
|
||||
"kelingPollIntervalMs": 100,
|
||||
"kelingPollTimeoutSeconds": 5,
|
||||
},
|
||||
Priority: 1,
|
||||
Status: "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform: %v", err)
|
||||
}
|
||||
_, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{
|
||||
PlatformID: platform.ID,
|
||||
CanonicalModelKey: "keling:kling-video-o1",
|
||||
ModelName: "kling-o1",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelAlias: "kling-o1",
|
||||
ModelType: store.StringList{"omni_video", "video_generate"},
|
||||
DisplayName: "Kling O1 Compatible Test",
|
||||
Capabilities: map[string]any{
|
||||
"omni_video": map[string]any{
|
||||
"supported_modes": []any{"text_to_video", "image_reference"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": false,
|
||||
"max_images": 7,
|
||||
},
|
||||
"video_generate": map[string]any{
|
||||
"supported_modes": []any{"text_to_video"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform model: %v", err)
|
||||
}
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
gateway := 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 gateway.Close()
|
||||
|
||||
firstUserToken, firstAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "first", suffix, true)
|
||||
_ = firstUserToken
|
||||
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
|
||||
|
||||
var created KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "A clean product reveal",
|
||||
"mode": "std",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": "3",
|
||||
"sound": "off",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/reference.png"}},
|
||||
"external_task_id": "compat-http-1",
|
||||
}, http.StatusOK, &created)
|
||||
createdData, _ := created.Data.(map[string]any)
|
||||
if created.Code != 0 || created.RequestID == "" || strings.TrimSpace(stringFromKelingCompat(createdData["task_id"])) == "" || createdData["task_status"] != "submitted" {
|
||||
t.Fatalf("unexpected compatible create response: %+v", created)
|
||||
}
|
||||
taskID := stringFromKelingCompat(createdData["task_id"])
|
||||
|
||||
var hidden KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
if hidden.Code != 1203 {
|
||||
t.Fatalf("cross-user task must be hidden: %+v", hidden)
|
||||
}
|
||||
|
||||
completed := waitForKelingCompatTask(t, gateway.URL, firstAPIKey, taskID, 5*time.Second)
|
||||
if completed.Code != 0 {
|
||||
t.Fatalf("compatible task failed: %+v", completed)
|
||||
}
|
||||
completedData, _ := completed.Data.(map[string]any)
|
||||
if completedData["task_status"] != "succeed" {
|
||||
t.Fatalf("compatible task did not succeed: %+v", completedData)
|
||||
}
|
||||
taskResult, _ := completedData["task_result"].(map[string]any)
|
||||
videos, _ := taskResult["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] == "" || video["watermark_url"] == "" || video["duration"] != "3" {
|
||||
t.Fatalf("compatible result lost video metadata: %+v", video)
|
||||
}
|
||||
|
||||
var standard struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A second product reveal",
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &standard)
|
||||
if standard.TaskID == "" {
|
||||
t.Fatal("standard video generation did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, standard.TaskID, []string{"succeeded"}, 5*time.Second)
|
||||
|
||||
var unsupportedAudio struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "An O1 request that must not silently ignore audio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &unsupportedAudio)
|
||||
if unsupportedAudio.TaskID == "" {
|
||||
t.Fatal("unsupported O1 audio request did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, unsupportedAudio.TaskID, []string{"failed"}, 5*time.Second)
|
||||
var failedAudioTask store.GatewayTask
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/tasks/"+unsupportedAudio.TaskID, firstAPIKey, nil, http.StatusOK, &failedAudioTask)
|
||||
if failedAudioTask.ErrorCode != "invalid_parameter" || !strings.Contains(failedAudioTask.ErrorMessage, "does not support generated audio") {
|
||||
t.Fatalf("O1 audio request must fail visibly before upstream submission: %+v", failedAudioTask)
|
||||
}
|
||||
|
||||
upstreamPayloadMu.Lock()
|
||||
defer upstreamPayloadMu.Unlock()
|
||||
if len(upstreamPayloads) != 2 {
|
||||
t.Fatalf("expected two upstream submissions, got %d", len(upstreamPayloads))
|
||||
}
|
||||
compatiblePayload := upstreamPayloads[0]
|
||||
if compatiblePayload["model_name"] != "kling-video-o1" || compatiblePayload["mode"] != "std" || compatiblePayload["sound"] != "off" || compatiblePayload["duration"] != "3" || compatiblePayload["aspect_ratio"] != "16:9" || compatiblePayload["external_task_id"] != "compat-http-1" {
|
||||
t.Fatalf("unexpected compatible upstream payload: %+v", compatiblePayload)
|
||||
}
|
||||
}
|
||||
|
||||
func createKelingCompatIntegrationUser(t *testing.T, ctx context.Context, db *store.Store, baseURL string, prefix string, suffix string, fund bool) (string, string) {
|
||||
t.Helper()
|
||||
username := fmt.Sprintf("kling_compat_%s_%s", prefix, suffix)
|
||||
password := "password123"
|
||||
var registered struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®istered)
|
||||
var apiKey struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/api-keys", registered.AccessToken, map[string]any{
|
||||
"name": "Kling compatible integration key",
|
||||
"scopes": []string{"video"},
|
||||
}, http.StatusCreated, &apiKey)
|
||||
if fund {
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote integration user: %v", err)
|
||||
}
|
||||
var loggedIn struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loggedIn)
|
||||
var gatewayUserID string
|
||||
if err := db.Pool().QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&gatewayUserID); err != nil {
|
||||
t.Fatalf("read integration user id: %v", err)
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loggedIn.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"balance": 1000,
|
||||
"reason": "seed Kling compatible integration wallet",
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
return registered.AccessToken, apiKey.Secret
|
||||
}
|
||||
|
||||
func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID string, timeout time.Duration) KelingCompatibleEnvelope {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var response KelingCompatibleEnvelope
|
||||
doJSON(t, baseURL, http.MethodGet, "/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response.Data.(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return response
|
||||
case "failed":
|
||||
t.Fatalf("Kling-compatible task failed: %+v", response)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for Kling-compatible task %s", taskID)
|
||||
return KelingCompatibleEnvelope{}
|
||||
}
|
||||
@@ -229,6 +229,9 @@ type TaskRequest struct {
|
||||
Size string `json:"size,omitempty" example:"1024x1024"`
|
||||
Duration int `json:"duration,omitempty" example:"5"`
|
||||
Resolution string `json:"resolution,omitempty" example:"720p"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
|
||||
Audio *bool `json:"audio,omitempty" example:"false"`
|
||||
Watermark *bool `json:"watermark,omitempty" example:"false"`
|
||||
MakeInstrumental bool `json:"makeInstrumental,omitempty" example:"false"`
|
||||
CustomMode bool `json:"customMode,omitempty" example:"false"`
|
||||
Style string `json:"style,omitempty" example:"city pop, bright synth"`
|
||||
|
||||
@@ -291,6 +291,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
|
||||
mux.Handle("GET /v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingO1GeneratedAudioIsRejectedInsteadOfSilentlyRemoved(t *testing.T) {
|
||||
result := preprocessRequestWithLog("videos.generations", map[string]any{
|
||||
"model": "kling-o1",
|
||||
"audio": true,
|
||||
}, store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelType: "video_generate",
|
||||
Capabilities: map[string]any{
|
||||
"video_generate": map[string]any{"output_audio": false},
|
||||
},
|
||||
})
|
||||
if result.Err == nil {
|
||||
t.Fatal("Keling O1 audio=true must be rejected")
|
||||
}
|
||||
if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].Action != "reject" {
|
||||
t.Fatalf("expected an auditable reject change, got %+v", result.Log.Changes)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type resolutionNormalizeProcessor struct{}
|
||||
@@ -691,6 +693,16 @@ func (audioProcessor) ShouldProcess(params map[string]any, modelType string, con
|
||||
}
|
||||
|
||||
func (audioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||
if context != nil && kelingO1GeneratedAudioRequested(params, context.candidate) {
|
||||
return context.reject(
|
||||
"AudioProcessor",
|
||||
"audio",
|
||||
params["audio"],
|
||||
"kling-video-o1 does not support generated audio",
|
||||
capabilityPath(modelType, "output_audio"),
|
||||
capabilityValue(context.modelCapability, modelType, "output_audio"),
|
||||
)
|
||||
}
|
||||
capability := capabilityForType(context.modelCapability, modelType)
|
||||
if capability == nil || !boolFromAny(capability["output_audio"]) {
|
||||
for _, key := range []string{"audio", "output_audio"} {
|
||||
@@ -712,6 +724,17 @@ func (audioProcessor) Process(params map[string]any, modelType string, context *
|
||||
return true
|
||||
}
|
||||
|
||||
func kelingO1GeneratedAudioRequested(params map[string]any, candidate store.RuntimeModelCandidate) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(candidate.Provider), "keling") {
|
||||
return false
|
||||
}
|
||||
model := strings.ToLower(strings.TrimSpace(candidate.ProviderModelName))
|
||||
if model != "kling-o1" && model != "kling-video-o1" {
|
||||
return false
|
||||
}
|
||||
return boolFromAny(params["audio"]) || boolFromAny(params["output_audio"])
|
||||
}
|
||||
|
||||
type imageCountProcessor struct{}
|
||||
|
||||
func (imageCountProcessor) Name() string { return "ImageCountProcessor" }
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Kling Omni 兼容接口
|
||||
|
||||
EasyAI AI Gateway 提供 Kling 旧版 Omni 协议兼容接口。调用方继续使用 Gateway API Key,任务仍经过网关候选选择、异步队列、审计和计费;响应中的 `task_id` 是网关任务 UUID,不是上游任务 ID。
|
||||
|
||||
## 模型与参数映射
|
||||
|
||||
| 请求 `model_name` | 网关模型别名 | TranStreams 原生 `model_name` | 时长范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| `kling-video-o1`、`kling-o1` | `kling-o1` | `kling-video-o1` | 3–10 秒 |
|
||||
| `kling-v3-omni`、`kling-3.0-omni` | `kling-3.0-omni` | `kling-v3-omni` | 3–15 秒 |
|
||||
|
||||
网关别名用于候选匹配,原生模型名用于发往 TranStreams 的 Kling Omni 请求;两类名称不会混用。
|
||||
|
||||
`kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 3–15 秒。
|
||||
|
||||
真实上游结果表明 `kling-video-o1` 不生成音频,因此该模型的 `sound=on` 会返回 `1201`,标准接口的 `audio=true` 也会在参数预处理阶段失败,避免静默返回无声视频。`kling-v3-omni` 支持 `sound=on/off`。
|
||||
|
||||
`mode` 映射为网关分辨率:`std` = 720p,`pro` = 1080p,`4k` = 2160p。4K 只有在平台模型能力也声明支持时才能执行。`sound=on/off` 映射为 `audio=true/false`,`duration` 同时接受 JSON 字符串和整数。
|
||||
|
||||
兼容字段包括:`prompt`、`multi_shot`、`shot_type`、`multi_prompt`、`image_list`、`element_list`、`video_list`、`sound`、`mode`、`aspect_ratio`、`duration`、`watermark_info`、`external_task_id`。`callback_url` 可以省略或传空字符串;非空值会返回业务码 `1201`,本期不投递回调。
|
||||
|
||||
## 创建任务
|
||||
|
||||
`POST /v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A quiet street in the rain with natural ambient sound",
|
||||
"mode": "pro",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"sound": "on",
|
||||
"watermark_info": {"enabled": false},
|
||||
"external_task_id": "client-job-001"
|
||||
}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "SUCCEED",
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"task_info": {"external_task_id": "client-job-001"},
|
||||
"task_status": "submitted",
|
||||
"created_at": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 查询任务
|
||||
|
||||
使用创建任务时的同一个 Gateway API Key 轮询。跨用户查询与不存在的任务统一返回 HTTP 404 和业务码 `1203`。
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_BASE_URL/v1/videos/omni-video/$TASK_ID"
|
||||
```
|
||||
|
||||
`task_status` 为 `submitted`、`processing`、`succeed` 或 `failed`。成功时结果位于 `data.task_result.videos`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "SUCCEED",
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"task_status": "succeed",
|
||||
"task_result": {
|
||||
"videos": [
|
||||
{
|
||||
"id": "...",
|
||||
"url": "https://.../video.mp4",
|
||||
"watermark_url": "https://.../watermark.mp4",
|
||||
"duration": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 网关标准视频接口
|
||||
|
||||
标准接口仍为 `POST /api/v1/videos/generations`。异步调用需要 `X-Async: true`,再通过 `GET /api/v1/tasks/{taskId}` 轮询。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Async: true" \
|
||||
-d '{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A product reveal in a daylight studio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
"watermark": false,
|
||||
"runMode": "real"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_BASE_URL/api/v1/tasks/$TASK_ID"
|
||||
```
|
||||
|
||||
## 错误格式
|
||||
|
||||
所有兼容接口错误都返回同一包络:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1201,
|
||||
"message": "duration must be between 3 and 10 seconds",
|
||||
"request_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
业务码分类:`1001/1002` 为鉴权错误,`1101/1103` 为余额或权限错误,`1201/1203` 为参数或资源错误,`1302/1303` 为限流错误,`5000/5001` 为网关或上游服务错误。HTTP 状态码仍反映错误类型。
|
||||
|
||||
OpenAPI 文档由服务的 `/openapi.json` 和 `/openapi.yaml` 提供。
|
||||
Reference in New Issue
Block a user