feat(kling): 接入 Omni 视频兼容接口
This commit is contained in:
@@ -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{}
|
||||
}
|
||||
Reference in New Issue
Block a user