Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c2c58c67 | ||
|
|
9d4501bc42 | ||
|
|
e280c0875c | ||
|
|
142dcc7932 | ||
|
|
e3dfe8162b | ||
|
|
69b0c107d3 | ||
|
|
e533ec2367 | ||
|
|
bfa17a3aba | ||
|
|
86c374b5c2 | ||
|
|
505b074b47 | ||
|
|
8c38714296 | ||
|
|
1e55f7df8b |
@@ -49,6 +49,13 @@ AI_GATEWAY_API_RUNTIME_IMAGE=alpine:3.22
|
||||
AI_GATEWAY_NODE_BUILD_IMAGE=node:22-alpine
|
||||
AI_GATEWAY_WEB_RUNTIME_IMAGE=nginx:1.27-alpine
|
||||
|
||||
# Opt-in, billable China Kling V1 integration tests. Keep real AK/SK only in
|
||||
# .env.local (gitignored); never commit them.
|
||||
KELING_LIVE_TEST=0
|
||||
KELING_TEST_BASE_URL=https://api-beijing.klingai.com/v1
|
||||
KELING_TEST_ACCESS_KEY=
|
||||
KELING_TEST_SECRET_KEY=
|
||||
|
||||
# Used when the gateway delegates OpenAPI sk-* validation, user/group sync, file upload, and settlement callbacks.
|
||||
SERVER_MAIN_BASE_URL=http://localhost:3000
|
||||
SERVER_MAIN_INTERNAL_TOKEN=change-me
|
||||
|
||||
@@ -125,6 +125,8 @@ AI_GATEWAY_COMPOSE_DATABASE_URL='postgresql://easyai:easyai2025@postgres:5432/ea
|
||||
pnpm openapi
|
||||
```
|
||||
|
||||
中国区可灵 O1 / 3.0 Omni 的 V1 AK/SK 与 API 2.0 兼容接入方式见 [可灵兼容接口说明](docs/kling-compatible-api.md)。
|
||||
|
||||
|
||||
默认 EasyAI 部署里,`easyai-pgvector` 在容器网络内的连接串是:
|
||||
|
||||
|
||||
@@ -5208,6 +5208,12 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8353,6 +8359,278 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/kling/omni-video/{model}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "创建可灵 API 2.0 Omni 视频任务",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模型路径(kling-o1 或 kling-v3-omni)",
|
||||
"name": "model",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "可灵 API 2.0 请求",
|
||||
"name": "input",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/kling/tasks": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "按 ID 查询可灵 API 2.0 任务",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "逗号分隔的任务 ID",
|
||||
"name": "task_ids",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "逗号分隔的外部任务 ID",
|
||||
"name": "external_task_ids",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "分页查询可灵 API 2.0 任务",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "游标、数量、时间范围和筛选条件",
|
||||
"name": "input",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/kling/v1/videos/omni-video": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "分页查询可灵 V1 Omni 视频任务",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "页码",
|
||||
"name": "pageNum",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"description": "每页数量",
|
||||
"name": "pageSize",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK 调用上游。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "创建可灵 V1 Omni 视频任务",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "可灵 V1 Omni 请求",
|
||||
"name": "input",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/kling/v1/videos/omni-video/{taskID}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "查询可灵 V1 Omni 视频任务",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
"name": "taskID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/music/generations": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -13532,6 +13810,9 @@
|
||||
"executionLeaseExpiresAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTaskId": {
|
||||
"type": "string"
|
||||
},
|
||||
"finalChargeAmount": {
|
||||
"type": "number"
|
||||
},
|
||||
|
||||
@@ -1984,6 +1984,8 @@ definitions:
|
||||
type: string
|
||||
executionLeaseExpiresAt:
|
||||
type: string
|
||||
externalTaskId:
|
||||
type: string
|
||||
finalChargeAmount:
|
||||
type: number
|
||||
finishedAt:
|
||||
@@ -6441,6 +6443,10 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
summary: 本地登录
|
||||
tags:
|
||||
- auth
|
||||
@@ -8487,6 +8493,181 @@ paths:
|
||||
summary: 创建或执行 AI 任务
|
||||
tags:
|
||||
- tasks
|
||||
/kling/omni-video/{model}:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。
|
||||
parameters:
|
||||
- description: 模型路径(kling-o1 或 kling-v3-omni)
|
||||
in: path
|
||||
name: model
|
||||
required: true
|
||||
type: string
|
||||
- description: 可灵 API 2.0 请求
|
||||
in: body
|
||||
name: input
|
||||
required: true
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 创建可灵 API 2.0 Omni 视频任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
/kling/tasks:
|
||||
get:
|
||||
parameters:
|
||||
- description: 逗号分隔的任务 ID
|
||||
in: query
|
||||
name: task_ids
|
||||
type: string
|
||||
- description: 逗号分隔的外部任务 ID
|
||||
in: query
|
||||
name: external_task_ids
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 按 ID 查询可灵 API 2.0 任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 游标、数量、时间范围和筛选条件
|
||||
in: body
|
||||
name: input
|
||||
required: true
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 分页查询可灵 API 2.0 任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
/kling/v1/videos/omni-video:
|
||||
get:
|
||||
parameters:
|
||||
- default: 1
|
||||
description: 页码
|
||||
in: query
|
||||
name: pageNum
|
||||
type: integer
|
||||
- default: 30
|
||||
description: 每页数量
|
||||
in: query
|
||||
name: pageSize
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 分页查询可灵 V1 Omni 视频任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK
|
||||
调用上游。
|
||||
parameters:
|
||||
- description: 可灵 V1 Omni 请求
|
||||
in: body
|
||||
name: input
|
||||
required: true
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 创建可灵 V1 Omni 视频任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
/kling/v1/videos/omni-video/{taskID}:
|
||||
get:
|
||||
parameters:
|
||||
- description: 任务 ID
|
||||
in: path
|
||||
name: taskID
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 查询可灵 V1 Omni 视频任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
/music/generations:
|
||||
post:
|
||||
consumes:
|
||||
|
||||
@@ -2612,6 +2612,47 @@ func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesLegacyV1Options(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": 1, "prompt": "镜头一", "duration": 7},
|
||||
map[string]any{"index": 2, "prompt": "镜头二", "duration": 8},
|
||||
},
|
||||
"resolution": "1080p",
|
||||
"callback_url": "https://example.com/callback",
|
||||
"external_task_id": "client-task-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"voice_list": []any{map[string]any{"voice_id": "voice-1"}},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build keling legacy V1 payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 {
|
||||
t.Fatalf("unexpected cleanup ids: %+v", cleanupIDs)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "customize" || payload["duration"] != "15" {
|
||||
t.Fatalf("unexpected multi-shot payload: %+v", payload)
|
||||
}
|
||||
if payload["callback_url"] != "https://example.com/callback" || payload["external_task_id"] != "client-task-1" {
|
||||
t.Fatalf("legacy task options were not preserved: %+v", payload)
|
||||
}
|
||||
watermark := mapFromAny(payload["watermark_info"])
|
||||
if watermark["enabled"] != true || len(mapListFromAny(payload["voice_list"])) != 1 {
|
||||
t.Fatalf("watermark or voice options were not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingClientVideoResumePollsWithoutSubmitting(t *testing.T) {
|
||||
var submitCalled bool
|
||||
var pollPath string
|
||||
|
||||
@@ -420,15 +420,21 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
}
|
||||
uploadedElementIDs = append(uploadedElementIDs, createdIDs...)
|
||||
shots := kelingShotPrompts(content)
|
||||
hasMultiPrompt := len(shots) > 0
|
||||
rawMultiPrompt := mapListFromAny(body["multi_prompt"])
|
||||
hasMultiPrompt := len(shots) > 0 || len(rawMultiPrompt) > 0
|
||||
multiShot := boolValue(body, "multi_shot") || hasMultiPrompt
|
||||
hasVideo := len(videos) > 0
|
||||
hasVideoEdit := kelingHasBaseVideo(videos)
|
||||
hasFirstFrame := kelingHasFirstFrame(images)
|
||||
|
||||
watermarkEnabled := boolValue(body, "watermark")
|
||||
if watermarkInfo := mapFromAny(body["watermark_info"]); watermarkInfo != nil {
|
||||
watermarkEnabled = boolValue(watermarkInfo, "enabled")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model_name": upstreamModelName(request.Candidate),
|
||||
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||
"watermark_info": map[string]any{"enabled": false},
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||
}
|
||||
if !hasMultiPrompt {
|
||||
@@ -449,29 +455,62 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
if len(elements) > 0 {
|
||||
payload["element_list"] = elements
|
||||
}
|
||||
if voices := mapListFromAny(body["voice_list"]); len(voices) > 0 {
|
||||
payload["voice_list"] = voices
|
||||
}
|
||||
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
|
||||
payload["sound"] = "on"
|
||||
}
|
||||
if hasMultiPrompt {
|
||||
if multiShot {
|
||||
payload["multi_shot"] = true
|
||||
payload["shot_type"] = "customize"
|
||||
total := 0.0
|
||||
multiPrompt := make([]any, 0, len(shots))
|
||||
for index, shot := range shots {
|
||||
duration := shot.duration
|
||||
if duration <= 0 {
|
||||
duration = 5
|
||||
shotType := strings.TrimSpace(firstNonEmptyStringValue(body, "shot_type", "shotType"))
|
||||
if shotType == "" {
|
||||
if hasMultiPrompt {
|
||||
shotType = "customize"
|
||||
} else {
|
||||
shotType = "intelligence"
|
||||
}
|
||||
total += duration
|
||||
multiPrompt = append(multiPrompt, map[string]any{
|
||||
"index": index + 1,
|
||||
"prompt": shot.text,
|
||||
"duration": fmtDuration(duration, 5),
|
||||
})
|
||||
}
|
||||
delete(payload, "prompt")
|
||||
payload["multi_prompt"] = multiPrompt
|
||||
payload["duration"] = fmtDuration(total, 0)
|
||||
payload["shot_type"] = shotType
|
||||
if shotType == "customize" {
|
||||
total := 0.0
|
||||
multiPrompt := make([]any, 0, len(rawMultiPrompt)+len(shots))
|
||||
if len(rawMultiPrompt) > 0 {
|
||||
for index, item := range rawMultiPrompt {
|
||||
duration := numericValue(item["duration"], 0)
|
||||
total += duration
|
||||
multiPrompt = append(multiPrompt, map[string]any{
|
||||
"index": int(math.Round(numericValue(item["index"], float64(index+1)))),
|
||||
"prompt": strings.TrimSpace(stringFromAny(item["prompt"])),
|
||||
"duration": fmtDuration(duration, 0),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for index, shot := range shots {
|
||||
duration := shot.duration
|
||||
if duration <= 0 {
|
||||
duration = 5
|
||||
}
|
||||
total += duration
|
||||
multiPrompt = append(multiPrompt, map[string]any{
|
||||
"index": index + 1,
|
||||
"prompt": shot.text,
|
||||
"duration": fmtDuration(duration, 5),
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(payload, "prompt")
|
||||
payload["multi_prompt"] = multiPrompt
|
||||
if total > 0 {
|
||||
payload["duration"] = fmtDuration(total, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
|
||||
payload["callback_url"] = callbackURL
|
||||
}
|
||||
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
|
||||
payload["external_task_id"] = externalTaskID
|
||||
}
|
||||
deleteEmptyStringFields(payload)
|
||||
if hasVideoEdit {
|
||||
@@ -1140,9 +1179,15 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
continue
|
||||
}
|
||||
item := map[string]any{"url": url, "video_url": url, "type": "video"}
|
||||
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
|
||||
item["id"] = id
|
||||
}
|
||||
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
|
||||
item["watermark_url"] = watermarkURL
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
created := intFromAny(data["created_at"])
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
// TestKelingLegacyV1LiveOmni is opt-in because it creates billable upstream
|
||||
// video tasks. Credentials must only be supplied through local environment
|
||||
// variables; the test never prints them.
|
||||
func TestKelingLegacyV1LiveOmni(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("KELING_LIVE_TEST")) != "1" {
|
||||
t.Skip("set KELING_LIVE_TEST=1 to run billable Kling V1 integration tests")
|
||||
}
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("KELING_TEST_BASE_URL")), "/")
|
||||
accessKey := strings.TrimSpace(os.Getenv("KELING_TEST_ACCESS_KEY"))
|
||||
secretKey := strings.TrimSpace(os.Getenv("KELING_TEST_SECRET_KEY"))
|
||||
if baseURL == "" || accessKey == "" || secretKey == "" {
|
||||
t.Fatal("KELING_TEST_BASE_URL, KELING_TEST_ACCESS_KEY, and KELING_TEST_SECRET_KEY are required")
|
||||
}
|
||||
|
||||
models := []string{"kling-video-o1", "kling-v3-omni"}
|
||||
if selected := strings.TrimSpace(os.Getenv("KELING_LIVE_TEST_MODELS")); selected != "" {
|
||||
models = strings.Split(selected, ",")
|
||||
}
|
||||
for _, model := range models {
|
||||
model = strings.TrimSpace(model)
|
||||
t.Run(model, func(t *testing.T) {
|
||||
duration := 3
|
||||
if model == "kling-video-o1" {
|
||||
duration = 5
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
response, err := (KelingClient{}).Run(ctx, Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Model: model,
|
||||
Body: map[string]any{
|
||||
"prompt": "清晨的湖面上,一只白色纸鹤缓慢飞过,镜头平稳推进",
|
||||
"duration": duration,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "720p",
|
||||
"sound": "off",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: baseURL,
|
||||
Provider: "keling",
|
||||
AuthType: "AccessKey-SecretKey",
|
||||
ProviderModelName: model,
|
||||
Credentials: map[string]any{
|
||||
"accessKey": accessKey,
|
||||
"secretKey": secretKey,
|
||||
},
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 5000,
|
||||
"kelingPollTimeoutSeconds": 840,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Kling V1 %s live task failed: %v", model, err)
|
||||
}
|
||||
items, _ := response.Result["data"].([]any)
|
||||
if len(items) == 0 {
|
||||
t.Fatalf("Kling V1 %s returned no video", model)
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
if strings.TrimSpace(stringFromAny(item["url"])) == "" {
|
||||
t.Fatalf("Kling V1 %s returned an empty video URL", model)
|
||||
}
|
||||
if strings.TrimSpace(response.RequestID) == "" {
|
||||
t.Fatalf("Kling V1 %s returned an empty request id", model)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestReadyReturnsPostgresUnavailableWithinTwoSeconds(t *testing.T) {
|
||||
db := newExhaustedPostgresStore(t)
|
||||
server := &Server{store: db, logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}
|
||||
requestContext, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/readyz", nil).WithContext(requestContext)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
startedAt := time.Now()
|
||||
server.ready(recorder, request)
|
||||
elapsed := time.Since(startedAt)
|
||||
|
||||
assertUnavailableResponse(t, recorder, "POSTGRES_UNAVAILABLE", "postgres unavailable")
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("readiness timeout took %s, want no more than 3s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReturnsAuthStoreUnavailableWithinFiveSeconds(t *testing.T) {
|
||||
db := newExhaustedPostgresStore(t)
|
||||
var logs bytes.Buffer
|
||||
server := &Server{store: db, logger: slog.New(slog.NewJSONHandler(&logs, nil))}
|
||||
requestContext, cancel := context.WithTimeout(context.Background(), 7*time.Second)
|
||||
defer cancel()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"account":"timeout-test-account","password":"timeout-test-password"}`)).WithContext(requestContext)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
startedAt := time.Now()
|
||||
server.login(recorder, request)
|
||||
elapsed := time.Since(startedAt)
|
||||
|
||||
assertUnavailableResponse(t, recorder, "AUTH_STORE_UNAVAILABLE", "authentication service temporarily unavailable")
|
||||
if elapsed > 6*time.Second {
|
||||
t.Fatalf("login timeout took %s, want no more than 6s", elapsed)
|
||||
}
|
||||
logOutput := logs.String()
|
||||
for _, field := range []string{"postgres_pool_max_connections", "postgres_pool_acquired_connections", "postgres_pool_idle_connections", "postgres_pool_empty_acquire_count", "postgres_pool_canceled_acquire_count"} {
|
||||
if !strings.Contains(logOutput, field) {
|
||||
t.Fatalf("login failure log did not include %q: %s", field, logOutput)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logOutput, "timeout-test-account") || strings.Contains(logOutput, "timeout-test-password") {
|
||||
t.Fatalf("login failure log exposed credentials: %s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func newExhaustedPostgresStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run PostgreSQL availability timeout tests")
|
||||
}
|
||||
parsed, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test database URL: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("pool_max_conns", "1")
|
||||
query.Set("pool_min_conns", "0")
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
db, err := store.Connect(ctx, parsed.String())
|
||||
if err != nil {
|
||||
t.Fatalf("connect timeout test store: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
connection, err := db.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("exhaust timeout test pool: %v", err)
|
||||
}
|
||||
t.Cleanup(connection.Release)
|
||||
return db
|
||||
}
|
||||
|
||||
func assertUnavailableResponse(t *testing.T, recorder *httptest.ResponseRecorder, expectedCode, expectedMessage string) {
|
||||
t.Helper()
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope ErrorEnvelope
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode unavailable response: %v", err)
|
||||
}
|
||||
if envelope.Error.Code != expectedCode {
|
||||
t.Fatalf("error code = %q, want %q; body=%s", envelope.Error.Code, expectedCode, recorder.Body.String())
|
||||
}
|
||||
if envelope.Error.Message != expectedMessage {
|
||||
t.Fatalf("error message = %q, want %q; body=%s", envelope.Error.Message, expectedMessage, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1654,6 +1654,17 @@ WHERE m.platform_id = $1::uuid
|
||||
t.Fatal("task progress callback outbox should receive events")
|
||||
}
|
||||
|
||||
restartModel := "worker-restart-" + suffixText
|
||||
createSimulationTextPlatformModel(
|
||||
t,
|
||||
server.URL,
|
||||
loginResponse.AccessToken,
|
||||
"openai-worker-restart-"+suffixText,
|
||||
"OpenAI Worker Restart",
|
||||
restartModel,
|
||||
1,
|
||||
nil,
|
||||
)
|
||||
var restartAsyncTask struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Task struct {
|
||||
@@ -1663,7 +1674,7 @@ WHERE m.platform_id = $1::uuid
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/responses", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultTextModel,
|
||||
"model": restartModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 2000,
|
||||
|
||||
@@ -17,6 +17,14 @@ import (
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
postgresReadinessTimeout = 2 * time.Second
|
||||
localLoginStoreTimeout = 5 * time.Second
|
||||
errorCodePostgresDown = "POSTGRES_UNAVAILABLE"
|
||||
errorCodeAuthStoreDown = "AUTH_STORE_UNAVAILABLE"
|
||||
authStoreUnavailableError = "authentication service temporarily unavailable"
|
||||
)
|
||||
|
||||
// health godoc
|
||||
// @Summary 健康检查
|
||||
// @Description 返回服务进程、运行环境和身份模式,供负载均衡或人工排障使用。
|
||||
@@ -42,8 +50,11 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /readyz [get]
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.Ping(r.Context()); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "postgres unavailable")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout)
|
||||
defer cancel()
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
s.logPostgresUnavailable("postgres readiness check failed")
|
||||
writeError(w, http.StatusServiceUnavailable, "postgres unavailable", errorCodePostgresDown)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -121,6 +132,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.LocalLoginInput
|
||||
@@ -128,12 +140,19 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
user, err := s.store.AuthenticateLocalUser(r.Context(), input)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), localLoginStoreTimeout)
|
||||
defer cancel()
|
||||
user, err := s.store.AuthenticateLocalUser(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid account or password")
|
||||
return
|
||||
}
|
||||
if store.IsPostgresUnavailable(err) {
|
||||
s.logPostgresUnavailable("login authentication store unavailable")
|
||||
writeError(w, http.StatusServiceUnavailable, authStoreUnavailableError, errorCodeAuthStoreDown)
|
||||
return
|
||||
}
|
||||
s.logger.Error("login local user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
@@ -145,6 +164,22 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeAuthResponse(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) logPostgresUnavailable(message string) {
|
||||
if s.logger == nil || s.store == nil || s.store.Pool() == nil {
|
||||
return
|
||||
}
|
||||
statistics := s.store.Pool().Stat()
|
||||
s.logger.Error(message,
|
||||
"error_category", "postgres_unavailable",
|
||||
"postgres_pool_max_connections", statistics.MaxConns(),
|
||||
"postgres_pool_total_connections", statistics.TotalConns(),
|
||||
"postgres_pool_acquired_connections", statistics.AcquiredConns(),
|
||||
"postgres_pool_idle_connections", statistics.IdleConns(),
|
||||
"postgres_pool_empty_acquire_count", statistics.EmptyAcquireCount(),
|
||||
"postgres_pool_canceled_acquire_count", statistics.CanceledAcquireCount(),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) localIdentityEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(s.cfg.IdentityMode))
|
||||
return mode == "" || mode == "standalone" || mode == "hybrid"
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
klingCompatProvider = "kling"
|
||||
klingO1Model = "kling-video-o1"
|
||||
klingV3OmniModel = "kling-v3-omni"
|
||||
)
|
||||
|
||||
func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
handler := func(next http.HandlerFunc) http.Handler {
|
||||
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
|
||||
}
|
||||
mux.Handle("POST /kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
|
||||
// Kling API 2.0 uses model-specific paths and a shared /tasks resource.
|
||||
mux.Handle("POST /kling/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /kling/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /kling/tasks", handler(s.klingV2ListTasks))
|
||||
// Versioned aliases help clients that keep the protocol version in their base path.
|
||||
mux.Handle("POST /kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v2/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /kling/v2/tasks", handler(s.klingV2ListTasks))
|
||||
}
|
||||
|
||||
// klingV1CreateOmniVideo godoc
|
||||
// @Summary 创建可灵 V1 Omni 视频任务
|
||||
// @Description 兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK 调用上游。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "可灵 V1 Omni 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/v1/videos/omni-video [post]
|
||||
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(stringFromRequestAny(native["model_name"]))
|
||||
if model == "" {
|
||||
model = klingO1Model
|
||||
}
|
||||
s.createKlingCompatTask(w, r, "v1", model, native)
|
||||
}
|
||||
|
||||
// klingV2CreateOmniVideo godoc
|
||||
// @Summary 创建可灵 API 2.0 Omni 视频任务
|
||||
// @Description 兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param model path string true "模型路径(kling-o1 或 kling-v3-omni)"
|
||||
// @Param input body map[string]interface{} true "可灵 API 2.0 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/omni-video/{model} [post]
|
||||
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
model, ok := klingV2ProviderModel(r.PathValue("model"))
|
||||
if !ok {
|
||||
writeKlingCompatError(w, http.StatusNotFound, "unsupported Kling Omni model", "model_not_found")
|
||||
return
|
||||
}
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
s.createKlingCompatTask(w, r, "v2", model, native)
|
||||
}
|
||||
|
||||
func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, version string, model string, native map[string]any) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeKlingCompatError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKlingCompatError(w, http.StatusForbidden, "api key scope does not allow video generation", "permission_denied")
|
||||
return
|
||||
}
|
||||
body, externalTaskID, err := klingCompatTaskBody(version, model, native)
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
createInput := store.CreateTaskInput{
|
||||
Kind: "videos.generations",
|
||||
Model: model,
|
||||
ExternalTaskID: externalTaskID,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: true,
|
||||
Request: prepared.Body,
|
||||
}
|
||||
if idempotencyKey, hasKey, keyErr := optionalTaskIdempotencyKey(r); keyErr != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid Idempotency-Key", "invalid_idempotency_key")
|
||||
return
|
||||
} else if hasKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(createInput.Kind, true, false, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrIdempotencyKeyReused):
|
||||
writeKlingCompatError(w, http.StatusConflict, err.Error(), "idempotency_key_reused")
|
||||
case errors.Is(err, store.ErrExternalTaskIDReused):
|
||||
writeKlingCompatError(w, http.StatusConflict, err.Error(), "external_task_id_reused")
|
||||
default:
|
||||
s.logger.Error("create Kling compatibility task failed", "version", version, "model", model, "error", err)
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if !created.Replayed {
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
}
|
||||
if version == "v2" {
|
||||
writeJSON(w, http.StatusOK, klingV2Envelope(task))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, klingV1Envelope(task))
|
||||
}
|
||||
|
||||
func klingCompatTaskBody(version string, model string, native map[string]any) (map[string]any, string, error) {
|
||||
if model != klingO1Model && model != klingV3OmniModel {
|
||||
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "model_name must be kling-video-o1 or kling-v3-omni", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if native == nil {
|
||||
native = map[string]any{}
|
||||
}
|
||||
body := cloneMap(native)
|
||||
if version == "v2" {
|
||||
body = klingV2ToLegacyBody(native)
|
||||
}
|
||||
body["model"] = model
|
||||
body["modelType"] = "omni_video"
|
||||
body["_compat_provider"] = klingCompatProvider
|
||||
body["_kling_compat_version"] = version
|
||||
body["content"] = klingLegacyContent(body)
|
||||
mode := strings.TrimSpace(stringFromRequestAny(body["mode"]))
|
||||
if mode == "" && version == "v1" {
|
||||
// The legacy Omni API defaults to professional (1080p) mode.
|
||||
mode = "pro"
|
||||
body["mode"] = mode
|
||||
}
|
||||
if mode != "" {
|
||||
resolution, ok := klingResolutionFromMode(mode)
|
||||
if !ok {
|
||||
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "mode must be std, pro, or 4k", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
body["resolution"] = resolution
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
|
||||
body["audio"] = true
|
||||
}
|
||||
externalTaskID := strings.TrimSpace(stringFromRequestAny(body["external_task_id"]))
|
||||
if err := validateKlingCompatBody(model, body); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return body, externalTaskID, nil
|
||||
}
|
||||
|
||||
func klingV2ToLegacyBody(native map[string]any) map[string]any {
|
||||
body := map[string]any{}
|
||||
for _, key := range []string{"runMode", "simulation", "simulationDurationMs", "simulationProfile"} {
|
||||
if value, ok := native[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
settings, _ := native["settings"].(map[string]any)
|
||||
options, _ := native["options"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
if options == nil {
|
||||
options = map[string]any{}
|
||||
}
|
||||
if resolution := strings.TrimSpace(stringFromRequestAny(settings["resolution"])); resolution != "" {
|
||||
switch strings.ToLower(resolution) {
|
||||
case "720p":
|
||||
body["mode"] = "std"
|
||||
case "1080p":
|
||||
body["mode"] = "pro"
|
||||
case "4k", "2160p":
|
||||
body["mode"] = "4k"
|
||||
default:
|
||||
body["mode"] = resolution
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"aspect_ratio", "duration", "multi_shot", "shot_type", "multi_prompt"} {
|
||||
if value, ok := settings[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
audio := strings.ToLower(strings.TrimSpace(stringFromRequestAny(settings["audio"])))
|
||||
if audio == "native" || audio == "on" {
|
||||
body["sound"] = "on"
|
||||
} else {
|
||||
body["sound"] = "off"
|
||||
}
|
||||
for _, key := range []string{"callback_url", "external_task_id", "watermark_info"} {
|
||||
if value, ok := options[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
contents, _ := native["contents"].([]any)
|
||||
imageList := make([]any, 0)
|
||||
videoList := make([]any, 0)
|
||||
elementList := make([]any, 0)
|
||||
for _, raw := range contents {
|
||||
item, _ := raw.(map[string]any)
|
||||
kind := strings.ToLower(strings.TrimSpace(stringFromRequestAny(item["type"])))
|
||||
switch kind {
|
||||
case "prompt":
|
||||
body["prompt"] = stringFromRequestAny(item["text"])
|
||||
case "first_frame", "last_frame", "refer_image", "reference_image":
|
||||
image := map[string]any{"image_url": firstNonEmptyRequestString(item, "url", "image_url")}
|
||||
if kind == "first_frame" {
|
||||
image["type"] = "first_frame"
|
||||
} else if kind == "last_frame" {
|
||||
image["type"] = "end_frame"
|
||||
}
|
||||
imageList = append(imageList, image)
|
||||
case "feature_video", "base_video", "refer_video", "reference_video":
|
||||
referType := "feature"
|
||||
if kind == "base_video" {
|
||||
referType = "base"
|
||||
}
|
||||
video := map[string]any{
|
||||
"video_url": firstNonEmptyRequestString(item, "url", "video_url"),
|
||||
"refer_type": referType,
|
||||
"keep_original_sound": firstNonEmptyRequestString(item, "keep_original_sound", "keepOriginalSound"),
|
||||
}
|
||||
if audio == "original" && video["keep_original_sound"] == "" {
|
||||
video["keep_original_sound"] = "yes"
|
||||
}
|
||||
videoList = append(videoList, video)
|
||||
case "element":
|
||||
elementList = append(elementList, map[string]any{"element_id": firstPresentRequest(item["element_id"], item["id"])})
|
||||
}
|
||||
}
|
||||
if len(imageList) > 0 {
|
||||
body["image_list"] = imageList
|
||||
}
|
||||
if len(videoList) > 0 {
|
||||
body["video_list"] = videoList
|
||||
}
|
||||
if len(elementList) > 0 {
|
||||
body["element_list"] = elementList
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func klingLegacyContent(body map[string]any) []any {
|
||||
content := make([]any, 0)
|
||||
if prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"])); prompt != "" {
|
||||
content = append(content, map[string]any{"type": "text", "text": prompt})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["image_list"]) {
|
||||
role := "reference_image"
|
||||
switch strings.TrimSpace(stringFromRequestAny(raw["type"])) {
|
||||
case "first_frame":
|
||||
role = "first_frame"
|
||||
case "end_frame", "last_frame":
|
||||
role = "last_frame"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": role,
|
||||
"image_url": map[string]any{"url": firstNonEmptyRequestString(raw, "image_url", "url")},
|
||||
})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["video_list"]) {
|
||||
referType := firstNonEmptyRequestString(raw, "refer_type", "referType")
|
||||
role := "video_feature"
|
||||
if referType == "base" {
|
||||
role = "video_base"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "video_url", "role": role,
|
||||
"video_url": map[string]any{
|
||||
"url": firstNonEmptyRequestString(raw, "video_url", "url"),
|
||||
"refer_type": referType,
|
||||
"keep_original_sound": firstNonEmptyRequestString(raw, "keep_original_sound", "keepOriginalSound"),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["element_list"]) {
|
||||
content = append(content, map[string]any{
|
||||
"type": "element",
|
||||
"element": map[string]any{"element_id": firstPresentRequest(raw["element_id"], raw["id"])},
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func validateKlingCompatBody(model string, body map[string]any) error {
|
||||
maxDuration := 10
|
||||
if model == klingV3OmniModel {
|
||||
maxDuration = 15
|
||||
}
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && (duration < 3 || duration > maxDuration) {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("duration must be between 3 and %d seconds for %s", maxDuration, model), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingO1Model && boolFromRequestAny(body["multi_shot"]) {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support multi_shot", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingO1Model && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["mode"])), "4k") {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support 4k mode", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if ratio := strings.TrimSpace(stringFromRequestAny(body["aspect_ratio"])); ratio != "" && ratio != "16:9" && ratio != "9:16" && ratio != "1:1" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["sound"]))); sound != "" && sound != "on" && sound != "off" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be on or off", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"]))
|
||||
if utf8.RuneCountInString(prompt) > 2500 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt must not exceed 2500 characters", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
images := mapListFromRequest(body["image_list"])
|
||||
videos := mapListFromRequest(body["video_list"])
|
||||
elements := mapListFromRequest(body["element_list"])
|
||||
for _, image := range images {
|
||||
if firstNonEmptyRequestString(image, "image_url", "url") == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every image_list item requires image_url", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
for _, video := range videos {
|
||||
if firstNonEmptyRequestString(video, "video_url", "url") == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every video_list item requires video_url", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if referType := strings.TrimSpace(firstNonEmptyRequestString(video, "refer_type", "referType")); referType != "" && referType != "base" && referType != "feature" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "video refer_type must be base or feature", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
for _, element := range elements {
|
||||
if klingStringAny(firstPresentRequest(element["element_id"], element["id"])) == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every element_list item requires element_id", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if model == klingO1Model && len(images) == 0 && len(videos) == 0 && len(elements) == 0 {
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && duration != 5 && duration != 10 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 text-only generation supports duration 5 or 10 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if len(videos) > 1 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "video_list supports at most one video", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(images)+len(elements) > 7 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "image_list and element_list support at most seven combined references", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(videos) > 0 && len(images)+len(elements) > 4 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "requests with video input support at most four image and element references", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(videos) > 0 && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be off when video_list is provided", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingV3OmniModel && len(videos) > 0 {
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && duration > 10 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-v3-omni video-reference generation supports at most 10 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
multiShot := boolFromRequestAny(body["multi_shot"])
|
||||
shotType := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["shot_type"])))
|
||||
if shotType != "" && shotType != "customize" && shotType != "intelligence" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "shot_type must be customize or intelligence", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if multiShot && shotType == "customize" {
|
||||
multiPrompt := mapListFromRequest(body["multi_prompt"])
|
||||
if len(multiPrompt) == 0 || len(multiPrompt) > 6 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "customize multi-shot requires between one and six multi_prompt items", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
totalDuration := 0
|
||||
for _, shot := range multiPrompt {
|
||||
shotPrompt := strings.TrimSpace(stringFromRequestAny(shot["prompt"]))
|
||||
shotDuration, ok := klingCompatInt(shot["duration"])
|
||||
if shotPrompt == "" || utf8.RuneCountInString(shotPrompt) > 2500 || !ok || shotDuration <= 0 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every multi_prompt item requires prompt and a positive integer duration", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
totalDuration += shotDuration
|
||||
}
|
||||
if totalDuration < 3 || totalDuration > maxDuration {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("multi_prompt duration must total between 3 and %d seconds", maxDuration), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if (!multiShot || shotType == "intelligence" || shotType == "") && prompt == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt is required for single-shot and intelligence multi-shot generation", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// klingV1GetOmniVideo godoc
|
||||
// @Summary 查询可灵 V1 Omni 视频任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeKlingCompatError(w, http.StatusNotFound, "task not found", "task_not_found")
|
||||
return
|
||||
}
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, klingV1Envelope(task))
|
||||
}
|
||||
|
||||
// klingV1ListOmniVideos godoc
|
||||
// @Summary 分页查询可灵 V1 Omni 视频任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pageNum query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(30)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video [get]
|
||||
func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1)
|
||||
if err != nil || page > 1000 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageNum", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
pageSize, err := positiveQueryInt(r.URL.Query().Get("pageSize"), 30)
|
||||
if err != nil || pageSize > 500 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageSize", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{Provider: klingCompatProvider, Version: "v1", Page: page, PageSize: pageSize})
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
data := make([]any, 0, len(result.Items))
|
||||
for _, task := range result.Items {
|
||||
data = append(data, klingV1TaskData(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestID(result.Items), "data": data})
|
||||
}
|
||||
|
||||
// klingV2GetTasks godoc
|
||||
// @Summary 按 ID 查询可灵 API 2.0 任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param task_ids query string false "逗号分隔的任务 ID"
|
||||
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [get]
|
||||
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
|
||||
externalIDs := splitKlingIDs(r.URL.Query().Get("external_task_ids"))
|
||||
if (len(taskIDs) == 0) == (len(externalIDs) == 0) {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "choose exactly one of task_ids or external_task_ids", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
identifiers := taskIDs
|
||||
if len(externalIDs) > 0 {
|
||||
identifiers = externalIDs
|
||||
}
|
||||
data := make([]any, 0, len(identifiers))
|
||||
for _, identifier := range identifiers {
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v2", identifier)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
data = append(data, klingV2TaskData(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestIDFromAny(data), "data": data})
|
||||
}
|
||||
|
||||
// klingV2ListTasks godoc
|
||||
// @Summary 分页查询可灵 API 2.0 任务
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [post]
|
||||
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var body map[string]any
|
||||
if err := decodeKlingJSON(r, &body); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
page, err := klingCursorPage(stringFromRequestAny(body["cursor"]))
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid cursor", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
limit, ok := klingCompatInt(body["limit"])
|
||||
if !ok || limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "limit must not exceed 500", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
createdFrom, err := klingMillisTime(body["start_time"])
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid start_time", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
createdTo, err := klingMillisTime(body["end_time"])
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid end_time", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
statuses := klingInternalStatuses(body["filters"])
|
||||
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{
|
||||
Provider: klingCompatProvider, Version: "v2", Statuses: statuses,
|
||||
CreatedFrom: createdFrom, CreatedTo: createdTo, Page: page, PageSize: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
items := make([]any, 0, len(result.Items))
|
||||
for _, task := range result.Items {
|
||||
items = append(items, klingV2TaskData(task))
|
||||
}
|
||||
hasMore := page*limit < result.Total
|
||||
nextCursor := ""
|
||||
if hasMore {
|
||||
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(page + 1)))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"code": 0, "message": "success", "request_id": klingRequestID(result.Items),
|
||||
"data": map[string]any{"result": items, "count": len(items), "next_cursor": nextCursor, "has_more": hasMore},
|
||||
})
|
||||
}
|
||||
|
||||
func klingV1Envelope(task store.GatewayTask) map[string]any {
|
||||
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV1TaskData(task)}
|
||||
}
|
||||
|
||||
func klingV1TaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"task_id": task.ID, "task_status": klingV1Status(task.Status),
|
||||
"task_info": map[string]any{"external_task_id": task.ExternalTaskID},
|
||||
"created_at": task.CreatedAt.UnixMilli(), "updated_at": task.UpdatedAt.UnixMilli(),
|
||||
}
|
||||
if task.ErrorMessage != "" || task.Error != "" {
|
||||
data["task_status_msg"] = firstNonEmpty(task.ErrorMessage, task.Error)
|
||||
}
|
||||
if watermarkInfo, ok := task.Request["watermark_info"].(map[string]any); ok {
|
||||
data["watermark_info"] = watermarkInfo
|
||||
}
|
||||
videos := klingTaskVideos(task)
|
||||
if len(videos) > 0 {
|
||||
data["task_result"] = map[string]any{"videos": videos}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func klingV2Envelope(task store.GatewayTask) map[string]any {
|
||||
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV2TaskData(task)}
|
||||
}
|
||||
|
||||
func klingV2TaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": task.ID, "status": klingV2Status(task.Status),
|
||||
"create_time": task.CreatedAt.UnixMilli(), "update_time": task.UpdatedAt.UnixMilli(),
|
||||
"external_id": task.ExternalTaskID,
|
||||
}
|
||||
if message := firstNonEmpty(task.ErrorMessage, task.Error); message != "" {
|
||||
data["message"] = message
|
||||
}
|
||||
if outputs := klingV2Outputs(task); len(outputs) > 0 {
|
||||
data["outputs"] = outputs
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func klingTaskVideos(task store.GatewayTask) []any {
|
||||
items, _ := task.Result["data"].([]any)
|
||||
videos := make([]any, 0, len(items))
|
||||
for index, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
url := firstNonEmptyRequestString(item, "url", "video_url")
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
video := map[string]any{"id": firstNonEmptyRequestString(item, "id")}
|
||||
if video["id"] == "" {
|
||||
video["id"] = fmt.Sprintf("%s-%d", task.ID, index+1)
|
||||
}
|
||||
video["url"] = url
|
||||
if watermarkURL := firstNonEmptyRequestString(item, "watermark_url"); watermarkURL != "" {
|
||||
video["watermark_url"] = watermarkURL
|
||||
}
|
||||
if duration := klingStringAny(item["duration"]); duration != "" {
|
||||
video["duration"] = duration
|
||||
}
|
||||
videos = append(videos, video)
|
||||
}
|
||||
return videos
|
||||
}
|
||||
|
||||
func klingV2Outputs(task store.GatewayTask) []any {
|
||||
videos := klingTaskVideos(task)
|
||||
outputs := make([]any, 0, len(videos))
|
||||
for _, raw := range videos {
|
||||
video, _ := raw.(map[string]any)
|
||||
output := cloneMap(video)
|
||||
output["type"] = "video"
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func klingV1Status(status string) string {
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return "succeed"
|
||||
case "failed", "cancelled":
|
||||
return "failed"
|
||||
case "running":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func klingV2Status(status string) string {
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return "succeeded"
|
||||
case "failed", "cancelled":
|
||||
return "failed"
|
||||
case "running":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func klingV2ProviderModel(pathModel string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(pathModel)) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return klingO1Model, true
|
||||
case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni":
|
||||
return klingV3OmniModel, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func klingResolutionFromMode(mode string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "std":
|
||||
return "720p", true
|
||||
case "pro":
|
||||
return "1080p", true
|
||||
case "4k":
|
||||
return "2160p", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func decodeKlingJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("multiple json values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeKlingCompatError(w http.ResponseWriter, status int, message string, code string) {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
code = "invalid_request"
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"code": klingCompatErrorCode(status),
|
||||
"message": message,
|
||||
"request_id": "",
|
||||
"error": code,
|
||||
})
|
||||
}
|
||||
|
||||
func klingCompatErrorCode(status int) int {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return 1001
|
||||
case http.StatusUnauthorized:
|
||||
return 1100
|
||||
case http.StatusForbidden:
|
||||
return 1302
|
||||
case http.StatusNotFound:
|
||||
return 1201
|
||||
case http.StatusConflict:
|
||||
return 1200
|
||||
case http.StatusTooManyRequests:
|
||||
return 1400
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
func mapListFromRequest(value any) []map[string]any {
|
||||
items, _ := value.([]any)
|
||||
if len(items) == 0 {
|
||||
if typed, ok := value.([]map[string]any); ok {
|
||||
return typed
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if mapped, ok := item.(map[string]any); ok {
|
||||
out = append(out, mapped)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstPresentRequest(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
return value
|
||||
}
|
||||
continue
|
||||
}
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolFromRequestAny(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseBool(strings.TrimSpace(typed))
|
||||
return parsed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func klingCompatInt(value any) (int, bool) {
|
||||
text := klingStringAny(value)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
number, err := strconv.Atoi(text)
|
||||
return number, err == nil
|
||||
}
|
||||
|
||||
func splitKlingIDs(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func klingCursorPage(cursor string) (int, error) {
|
||||
cursor = strings.TrimSpace(cursor)
|
||||
if cursor == "" {
|
||||
return 1, nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
page, err := strconv.Atoi(string(decoded))
|
||||
if err != nil || page <= 0 {
|
||||
return 0, errors.New("invalid cursor")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func klingMillisTime(value any) (*time.Time, error) {
|
||||
text := klingStringAny(value)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
millis, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil || millis < 0 {
|
||||
return nil, errors.New("invalid millisecond timestamp")
|
||||
}
|
||||
parsed := time.UnixMilli(millis)
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func klingInternalStatuses(filters any) []string {
|
||||
statuses := make([]string, 0)
|
||||
for _, filter := range mapListFromRequest(filters) {
|
||||
if stringFromRequestAny(filter["key"]) != "status" {
|
||||
continue
|
||||
}
|
||||
values, _ := filter["values"].([]any)
|
||||
for _, value := range values {
|
||||
switch strings.ToLower(strings.TrimSpace(stringFromRequestAny(value))) {
|
||||
case "submitted":
|
||||
statuses = append(statuses, "queued")
|
||||
case "processing":
|
||||
statuses = append(statuses, "running")
|
||||
case "succeeded":
|
||||
statuses = append(statuses, "succeeded")
|
||||
case "failed":
|
||||
statuses = append(statuses, "failed", "cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func klingRequestID(tasks []store.GatewayTask) string {
|
||||
if len(tasks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(tasks[0].RequestID, tasks[0].ID)
|
||||
}
|
||||
|
||||
func klingRequestIDFromAny(items []any) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
return stringFromRequestAny(item["id"])
|
||||
}
|
||||
|
||||
func klingStringAny(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling compatibility integration flow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "kling_compat_" + suffix
|
||||
password := "password123"
|
||||
var registerResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®isterResponse)
|
||||
var apiKeyResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
|
||||
"name": "Kling compatibility key",
|
||||
}, http.StatusCreated, &apiKeyResponse)
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote compatibility user: %v", err)
|
||||
}
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username, "password": password,
|
||||
}, http.StatusOK, &loginResponse)
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "keling",
|
||||
"platformKey": "kling-compat-" + suffix,
|
||||
"name": "Kling Compatibility Simulation",
|
||||
"baseUrl": "https://api-beijing.klingai.com/v1",
|
||||
"authType": "AccessKey-SecretKey",
|
||||
"credentials": map[string]any{"accessKey": "test-ak", "secretKey": "test-sk"},
|
||||
}, http.StatusCreated, &platform)
|
||||
for _, model := range []string{klingO1Model, klingV3OmniModel} {
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "keling:" + model,
|
||||
"modelName": model,
|
||||
"providerModelName": model,
|
||||
"modelAlias": model,
|
||||
"modelType": []string{"omni_video"},
|
||||
"displayName": model,
|
||||
}, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
createV1 := func(model string, duration int, externalID string) string {
|
||||
t.Helper()
|
||||
var response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": model,
|
||||
"prompt": "兼容接口模拟任务",
|
||||
"duration": duration,
|
||||
"mode": "std",
|
||||
"external_task_id": externalID,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusOK, &response)
|
||||
if response["code"] != float64(0) {
|
||||
t.Fatalf("unexpected V1 response: %#v", response)
|
||||
}
|
||||
data, _ := response["data"].(map[string]any)
|
||||
taskID, _ := data["task_id"].(string)
|
||||
if taskID == "" {
|
||||
t.Fatalf("V1 response missing task id: %#v", response)
|
||||
}
|
||||
return taskID
|
||||
}
|
||||
|
||||
o1TaskID := createV1(klingO1Model, 5, "compat-o1-"+suffix)
|
||||
v3TaskID := createV1(klingV3OmniModel, 15, "compat-v3-"+suffix)
|
||||
for _, taskID := range []string{o1TaskID, v3TaskID} {
|
||||
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
|
||||
}
|
||||
var listResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
items, _ := listResponse["data"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
|
||||
}
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
|
||||
"external_task_id": "compat-o1-" + suffix,
|
||||
"runMode": "simulation", "simulation": true,
|
||||
}, http.StatusConflict, &duplicateResponse)
|
||||
if duplicateResponse["code"] != float64(1200) || duplicateResponse["error"] != "external_task_id_reused" {
|
||||
t.Fatalf("unexpected duplicate external id response: %#v", duplicateResponse)
|
||||
}
|
||||
|
||||
var v2Response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
|
||||
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
|
||||
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusOK, &v2Response)
|
||||
v2Data, _ := v2Response["data"].(map[string]any)
|
||||
v2TaskID, _ := v2Data["id"].(string)
|
||||
if v2TaskID == "" {
|
||||
t.Fatalf("V2 response missing task id: %#v", v2Response)
|
||||
}
|
||||
waitKlingV2SimulationTask(t, server.URL, apiKeyResponse.Secret, v2TaskID)
|
||||
}
|
||||
|
||||
func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response["data"].(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return
|
||||
case "failed":
|
||||
t.Fatalf("V1 simulation task failed: %#v", response)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("V1 simulation task %s timed out", taskID)
|
||||
}
|
||||
|
||||
func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
items, _ := response["data"].([]any)
|
||||
if len(items) == 1 {
|
||||
data, _ := items[0].(map[string]any)
|
||||
switch data["status"] {
|
||||
case "succeeded":
|
||||
return
|
||||
case "failed":
|
||||
t.Fatalf("V2 simulation task failed: %#v", response)
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("V2 simulation task %s timed out", taskID)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKlingV1O1CompatibilityBody(t *testing.T) {
|
||||
body, externalID, err := klingCompatTaskBody("v1", klingO1Model, map[string]any{
|
||||
"prompt": "一只纸鹤飞过湖面",
|
||||
"duration": json.Number("10"),
|
||||
"aspect_ratio": "16:9",
|
||||
"sound": "on",
|
||||
"external_task_id": "client-o1-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build O1 compatibility body: %v", err)
|
||||
}
|
||||
if externalID != "client-o1-1" || body["model"] != klingO1Model || body["modelType"] != "omni_video" {
|
||||
t.Fatalf("unexpected identity fields: %#v", body)
|
||||
}
|
||||
if body["mode"] != "pro" || body["resolution"] != "1080p" || body["audio"] != true {
|
||||
t.Fatalf("unexpected V1 defaults: %#v", body)
|
||||
}
|
||||
content := mapListFromRequest(body["content"])
|
||||
if len(content) != 1 || content[0]["type"] != "text" || content[0]["text"] != "一只纸鹤飞过湖面" {
|
||||
t.Fatalf("unexpected canonical content: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingV1V3OmniCompatibilityBody(t *testing.T) {
|
||||
body, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"mode": "4k",
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": json.Number("1"), "prompt": "推近人物", "duration": json.Number("7")},
|
||||
map[string]any{"index": json.Number("2"), "prompt": "切到城市远景", "duration": json.Number("8")},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build 3.0 Omni compatibility body: %v", err)
|
||||
}
|
||||
if body["resolution"] != "2160p" {
|
||||
t.Fatalf("4k mode was not normalized: %#v", body)
|
||||
}
|
||||
content := mapListFromRequest(body["content"])
|
||||
if len(content) != 1 || content[0]["role"] != "first_frame" {
|
||||
t.Fatalf("image input was not normalized: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingV2CompatibilityBody(t *testing.T) {
|
||||
body, externalID, err := klingCompatTaskBody("v2", klingV3OmniModel, map[string]any{
|
||||
"contents": []any{
|
||||
map[string]any{"type": "prompt", "text": "让角色向镜头挥手"},
|
||||
map[string]any{"type": "first_frame", "url": "https://example.com/first.png"},
|
||||
map[string]any{"type": "element", "id": json.Number("42")},
|
||||
},
|
||||
"settings": map[string]any{
|
||||
"resolution": "1080p",
|
||||
"duration": json.Number("15"),
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": "native",
|
||||
},
|
||||
"options": map[string]any{
|
||||
"external_task_id": "client-v2-1",
|
||||
"callback_url": "https://example.com/callback",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build V2 compatibility body: %v", err)
|
||||
}
|
||||
if externalID != "client-v2-1" || body["mode"] != "pro" || body["audio"] != true {
|
||||
t.Fatalf("unexpected V2 settings: %#v", body)
|
||||
}
|
||||
if len(mapListFromRequest(body["image_list"])) != 1 || len(mapListFromRequest(body["element_list"])) != 1 {
|
||||
t.Fatalf("unexpected V2 references: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingCompatibilityValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
body map[string]any
|
||||
}{
|
||||
{name: "O1 duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 11}},
|
||||
{name: "O1 text-only flexible duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 3}},
|
||||
{name: "O1 4k", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 5, "mode": "4k"}},
|
||||
{name: "O1 multi-shot", model: klingO1Model, body: map[string]any{"multi_shot": true, "multi_prompt": []any{map[string]any{"prompt": "test", "duration": 3}}}},
|
||||
{name: "custom multi-shot without prompts", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "customize"}},
|
||||
{name: "intelligence multi-shot without prompt", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "intelligence"}},
|
||||
{name: "video with native audio", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "sound": "on", "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
||||
{name: "video duration too long", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "duration": 15, "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
||||
{name: "too many references", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "image_list": []any{
|
||||
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
|
||||
}}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, err := klingCompatTaskBody("v1", test.model, test.body); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{"prompt": "test", "duration": 15}); err != nil {
|
||||
t.Fatalf("3.0 Omni should allow a 15-second duration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKlingJSONRejectsTrailingData(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/kling/v1/videos/omni-video", strings.NewReader(`{"prompt":"ok"} trailing`))
|
||||
var body map[string]any
|
||||
if err := decodeKlingJSON(request, &body); err == nil {
|
||||
t.Fatal("expected trailing JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteKlingCompatErrorUsesOfficialNumericEnvelope(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writeKlingCompatError(recorder, http.StatusBadRequest, "bad request", "invalid_parameter")
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
if body["code"] != float64(1001) || body["error"] != "invalid_parameter" {
|
||||
t.Fatalf("unexpected error envelope: %#v", body)
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /api/v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
server.registerKlingCompatibilityRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const videoBillingUnitSeconds = 5
|
||||
|
||||
type EstimateResult struct {
|
||||
Items []any `json:"items"`
|
||||
Resolver string `json:"resolver"`
|
||||
@@ -135,7 +137,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
baseKey = "videoBase"
|
||||
duration, durationSource := billingDurationSeconds(body, response)
|
||||
audioEnabled, audioSource := billingAudioEnabled(body, response)
|
||||
durationUnits := math.Max(1, math.Ceil(duration/5))
|
||||
durationUnits := videoDurationUnits(duration)
|
||||
amount := float64(count) *
|
||||
durationUnits *
|
||||
resourcePrice(config, resource, baseKey, "basePrice") *
|
||||
@@ -144,7 +146,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
resourceWeight(config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))) *
|
||||
resourceWeight(config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled))) *
|
||||
discount
|
||||
return []any{billingLineWithDetails(candidate, resource, unit, count*int(durationUnits), roundPrice(amount), discount, simulated, map[string]any{
|
||||
return []any{billingLineWithDetails(candidate, resource, unit, videoDurationQuantity(duration, count), roundPrice(amount), discount, simulated, map[string]any{
|
||||
"count": count,
|
||||
"audio": audioEnabled,
|
||||
"audioSource": audioSource,
|
||||
@@ -416,6 +418,16 @@ func weightValueAliases(key string, name string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func videoDurationUnits(durationSeconds float64) float64 {
|
||||
return videoDurationQuantity(durationSeconds, 1)
|
||||
}
|
||||
|
||||
func videoDurationQuantity(durationSeconds float64, count int) float64 {
|
||||
const durationPrecision = 1_000_000_000
|
||||
scaledDuration := math.Round(durationSeconds * durationPrecision)
|
||||
return scaledDuration * float64(count) / (durationPrecision * videoBillingUnitSeconds)
|
||||
}
|
||||
|
||||
func requestOutputCount(body map[string]any) int {
|
||||
for _, key := range []string{"n", "count", "batch_size", "batchSize"} {
|
||||
if value := int(math.Ceil(floatFromAny(body[key]))); value > 0 {
|
||||
@@ -476,11 +488,7 @@ func generatedVideoDurationSeconds(result map[string]any) (float64, bool) {
|
||||
if duration <= 0 {
|
||||
continue
|
||||
}
|
||||
rounded := math.Round(duration)
|
||||
if rounded <= 0 {
|
||||
rounded = 1
|
||||
}
|
||||
return rounded, true
|
||||
return duration, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestImageBillingEstimateUsesCountResolutionAndQuality(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
|
||||
func TestVideoBillingEstimateProratesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelName: "video-model",
|
||||
@@ -67,13 +67,13 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
|
||||
}, candidate, clients.Response{}, true)
|
||||
|
||||
line := firstBillingLine(t, items)
|
||||
if got, want := floatFromAny(line["amount"]), 1620.0; got != want {
|
||||
if got, want := floatFromAny(line["amount"]), 1296.0; got != want {
|
||||
t.Fatalf("video estimated amount = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 3.0; got != want {
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 2.4; got != want {
|
||||
t.Fatalf("video duration units = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := line["quantity"], 3; got != want {
|
||||
if got, want := floatFromAny(line["quantity"]), 2.4; got != want {
|
||||
t.Fatalf("video quantity = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := line["durationSource"], "preprocessed_request"; got != want {
|
||||
@@ -172,13 +172,13 @@ func TestVideoBillingPrefersGeneratedDuration(t *testing.T) {
|
||||
}, false)
|
||||
|
||||
line := firstBillingLine(t, items)
|
||||
if got, want := floatFromAny(line["durationSeconds"]), 7.0; got != want {
|
||||
if got, want := floatFromAny(line["durationSeconds"]), 6.6; got != want {
|
||||
t.Fatalf("video generated duration = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 2.0; got != want {
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 1.32; got != want {
|
||||
t.Fatalf("video generated duration units = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := floatFromAny(line["amount"]), 200.0; got != want {
|
||||
if got, want := floatFromAny(line["amount"]), 132.0; got != want {
|
||||
t.Fatalf("video generated duration amount = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := line["durationSource"], "generated_video"; got != want {
|
||||
|
||||
@@ -184,6 +184,22 @@ func multiplyFixedAmountRatio(amount fixedAmount, numerator int, denominator int
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(int64(denominator))))
|
||||
}
|
||||
|
||||
func multiplyFixedProductRatio(base fixedAmount, integerFactors []int, fixedFactors []fixedAmount, denominator int) (fixedAmount, error) {
|
||||
if denominator == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
product := big.NewInt(int64(base))
|
||||
for _, factor := range integerFactors {
|
||||
product.Mul(product, big.NewInt(int64(factor)))
|
||||
}
|
||||
divisor := big.NewInt(int64(denominator))
|
||||
for _, factor := range fixedFactors {
|
||||
product.Mul(product, big.NewInt(int64(factor)))
|
||||
divisor.Mul(divisor, big.NewInt(fixedScale))
|
||||
}
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, divisor))
|
||||
}
|
||||
|
||||
func fixedAmountFromBigInt(value *big.Int) (fixedAmount, error) {
|
||||
if value == nil || !value.IsInt64() {
|
||||
return 0, errFixedAmountOverflow
|
||||
@@ -649,7 +665,11 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
baseKey = "videoBase"
|
||||
duration, durationSource := billingDurationSeconds(body, response)
|
||||
audioEnabled, audioSource := billingAudioEnabled(body, response)
|
||||
durationUnits := int(math.Max(1, math.Ceil(duration/5)))
|
||||
durationUnits := videoDurationUnits(duration)
|
||||
durationFixed, durationErr := fixedAmountFromAny(duration)
|
||||
if durationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, durationErr)
|
||||
}
|
||||
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
@@ -670,11 +690,16 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
amount, calculationErr := pricing.calculate(resource, price, []int{count, durationUnits}, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount)
|
||||
amount, calculationErr := multiplyFixedProductRatio(
|
||||
price,
|
||||
[]int{count},
|
||||
[]fixedAmount{durationFixed, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount},
|
||||
videoBillingUnitSeconds,
|
||||
)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, calculationErr)
|
||||
}
|
||||
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
|
||||
item := buildLine(resource, unit, videoDurationQuantity(duration, count), amount, map[string]any{
|
||||
"count": count, "audio": audioEnabled, "audioSource": audioSource,
|
||||
"durationSeconds": duration, "durationSource": durationSource,
|
||||
"durationUnit": "5s", "durationUnitCount": durationUnits,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -39,6 +41,9 @@ func TestFixedAmountOperationsRejectOverflow(t *testing.T) {
|
||||
if _, err := pricing.calculate("image", maximum, []int{2}); !isPricingUnavailable(err) {
|
||||
t.Fatalf("pricing overflow should be unavailable: %v", err)
|
||||
}
|
||||
if _, err := multiplyFixedProductRatio(maximum, []int{2}, nil, 1); !errors.Is(err, errFixedAmountOverflow) {
|
||||
t.Fatalf("product ratio overflow error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatedOutputTokensUsesAliasesAndCapabilityFallback(t *testing.T) {
|
||||
@@ -138,6 +143,108 @@ func TestPricingWeightsUseFixedPrecisionAndRejectInvalidValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBillingV2ProratesFiveSecondPriceByActualDuration(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
|
||||
pricing := resolvedPricing{
|
||||
Config: map[string]any{
|
||||
"video": map[string]any{
|
||||
"basePrice": 100,
|
||||
"dynamicWeight": map[string]any{
|
||||
"audioWeights": map[string]any{"true": 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
Currency: "resource",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
duration float64
|
||||
wantUnits float64
|
||||
wantAmount float64
|
||||
}{
|
||||
{name: "three seconds uses zero point six units", duration: 3, wantUnits: 0.6, wantAmount: 120},
|
||||
{name: "five seconds uses one unit", duration: 5, wantUnits: 1, wantAmount: 200},
|
||||
{name: "six seconds uses one point two units", duration: 6, wantUnits: 1.2, wantAmount: 240},
|
||||
{name: "fractional seconds retain fixed amount precision", duration: 6.000000001, wantUnits: 1.2000000002, wantAmount: 240.00000004},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
items, total, _, err := service.billingsWithResolvedPricingV2(
|
||||
context.Background(), nil, "videos.generations",
|
||||
map[string]any{"duration": test.duration, "audio": true},
|
||||
candidate, clients.Response{}, true, pricing,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bill video: %v", err)
|
||||
}
|
||||
line := firstBillingLine(t, items)
|
||||
if got := total.Float64(); math.Abs(got-test.wantAmount) > 1e-9 {
|
||||
t.Fatalf("total amount=%v, want %v", got, test.wantAmount)
|
||||
}
|
||||
if got := floatFromAny(line["amount"]); math.Abs(got-test.wantAmount) > 1e-9 {
|
||||
t.Fatalf("line amount=%v, want %v", got, test.wantAmount)
|
||||
}
|
||||
if got := floatFromAny(line["quantity"]); math.Abs(got-test.wantUnits) > 1e-12 {
|
||||
t.Fatalf("quantity=%v, want %v", got, test.wantUnits)
|
||||
}
|
||||
if got := floatFromAny(line["durationUnitCount"]); math.Abs(got-test.wantUnits) > 1e-12 {
|
||||
t.Fatalf("duration units=%v, want %v", got, test.wantUnits)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBillingV2RoundsOnlyAfterApplyingDurationCountAndWeights(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
pricing resolvedPricing
|
||||
wantAmount string
|
||||
}{
|
||||
{
|
||||
name: "count preserves a sub-nano duration share",
|
||||
body: map[string]any{"duration": 1, "count": 5},
|
||||
pricing: resolvedPricing{Config: map[string]any{
|
||||
"video": map[string]any{"basePrice": "0.000000001"},
|
||||
}},
|
||||
wantAmount: "0.000000001",
|
||||
},
|
||||
{
|
||||
name: "weight does not amplify a rounded duration share",
|
||||
body: map[string]any{"duration": 3, "audio": true},
|
||||
pricing: resolvedPricing{Config: map[string]any{
|
||||
"video": map[string]any{
|
||||
"basePrice": "0.000000001",
|
||||
"dynamicWeight": map[string]any{
|
||||
"audioWeights": map[string]any{"true": 2},
|
||||
},
|
||||
},
|
||||
}},
|
||||
wantAmount: "0.000000001",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, total, _, err := service.billingsWithResolvedPricingV2(
|
||||
context.Background(), nil, "videos.generations", test.body,
|
||||
candidate, clients.Response{}, true, test.pricing,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bill video: %v", err)
|
||||
}
|
||||
if got := total.String(); got != test.wantAmount {
|
||||
t.Fatalf("total amount=%s, want %s", got, test.wantAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustFixedAmount(t *testing.T, value string) fixedAmount {
|
||||
t.Helper()
|
||||
amount, err := parseFixedAmount(value)
|
||||
|
||||
@@ -156,7 +156,7 @@ func (s *Service) recoverAsyncRiverJobs(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
result, err := s.riverClient.Insert(ctx, asyncTaskArgs{TaskID: item.ID}, asyncTaskRecoveryInsertOpts(item))
|
||||
result, err := s.riverClient.Insert(ctx, asyncTaskArgs{TaskID: item.ID}, asyncTaskRecoveryInsertOpts(item, time.Now()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -196,9 +196,11 @@ func asyncTaskInsertOpts(task store.GatewayTask) *river.InsertOpts {
|
||||
}
|
||||
}
|
||||
|
||||
func asyncTaskRecoveryInsertOpts(item store.AsyncTaskQueueItem) *river.InsertOpts {
|
||||
func asyncTaskRecoveryInsertOpts(item store.AsyncTaskQueueItem, now time.Time) *river.InsertOpts {
|
||||
opts := asyncTaskInsertOpts(store.GatewayTask{ID: item.ID})
|
||||
opts.ScheduledAt = item.NextRunAt
|
||||
if item.NextRunAt.After(now) {
|
||||
opts.ScheduledAt = item.NextRunAt
|
||||
}
|
||||
// A replacement process must not be blocked by a River row that the dead
|
||||
// process left in running state. PostgreSQL execution leases still ensure
|
||||
// that only one recovery job can call the upstream provider.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestAsyncTaskRecoveryInsertOptsMakesDueTaskImmediatelyAvailable(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 21, 3, 30, 0, 0, time.UTC)
|
||||
opts := asyncTaskRecoveryInsertOpts(store.AsyncTaskQueueItem{
|
||||
ID: "due-task",
|
||||
NextRunAt: now.Add(-time.Second),
|
||||
}, now)
|
||||
|
||||
if !opts.ScheduledAt.IsZero() {
|
||||
t.Fatalf("due recovery task should be immediately available, scheduled at %s", opts.ScheduledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncTaskRecoveryInsertOptsPreservesFutureDelay(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 21, 3, 30, 0, 0, time.UTC)
|
||||
nextRunAt := now.Add(30 * time.Second)
|
||||
opts := asyncTaskRecoveryInsertOpts(store.AsyncTaskQueueItem{
|
||||
ID: "delayed-task",
|
||||
NextRunAt: nextRunAt,
|
||||
}, now)
|
||||
|
||||
if !opts.ScheduledAt.Equal(nextRunAt) {
|
||||
t.Fatalf("future recovery task should stay delayed until %s, got %s", nextRunAt, opts.ScheduledAt)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -166,16 +166,16 @@ The response has this shape:
|
||||
"platformModelId": "<platform-model-id>",
|
||||
"resourceType": "video",
|
||||
"unit": "5s_video",
|
||||
"quantity": 3,
|
||||
"quantity": 2.4,
|
||||
"amount": 12.5,
|
||||
"currency": "resource",
|
||||
"discountFactor": 0.8,
|
||||
"simulated": true,
|
||||
"durationSeconds": 12,
|
||||
"durationUnitCount": 3
|
||||
"durationUnitCount": 2.4
|
||||
}
|
||||
],
|
||||
"resolver": "effective-pricing-v1",
|
||||
"resolver": "effective-pricing-v2",
|
||||
"totalAmount": 12.5,
|
||||
"currency": "resource"
|
||||
}
|
||||
@@ -197,7 +197,7 @@ Useful calculation checks:
|
||||
text input = input tokens / 1000 × input price × discount
|
||||
text output = output tokens / 1000 × output price × discount
|
||||
image = count × base price × quality/size/resolution weights × discount
|
||||
video = count × ceil(duration seconds / 5) × base price × applicable weights × discount
|
||||
video = count × (duration seconds / 5) × base price × applicable weights × discount
|
||||
speech = Unicode character count × audio price × discount
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestVerifyLocalAPIKeyWorksWithSingleConnectionPool(t *testing.T) {
|
||||
db, verificationStore, created, user := newLocalAPIKeyVerificationFixture(t, 1)
|
||||
ctx := context.Background()
|
||||
|
||||
verifyCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
verified, err := verificationStore.VerifyLocalAPIKey(verifyCtx, created.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("verify local API key with one connection: %v", err)
|
||||
}
|
||||
if verified.APIKeyID != created.APIKey.ID || verified.GatewayUserID != user.ID {
|
||||
t.Fatalf("verified identity = %+v, want API key %q and user %q", verified, created.APIKey.ID, user.ID)
|
||||
}
|
||||
|
||||
var lastUsedAt *time.Time
|
||||
if err := db.pool.QueryRow(ctx, `SELECT last_used_at FROM gateway_api_keys WHERE id=$1::uuid`, created.APIKey.ID).Scan(&lastUsedAt); err != nil {
|
||||
t.Fatalf("read API key last_used_at: %v", err)
|
||||
}
|
||||
if lastUsedAt == nil {
|
||||
t.Fatal("successful API key verification did not update last_used_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLocalAPIKeyHandlesEightConcurrentRequestsWithFourConnections(t *testing.T) {
|
||||
_, verificationStore, created, _ := newLocalAPIKeyVerificationFixture(t, 4)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const requestCount = 8
|
||||
start := make(chan struct{})
|
||||
errorsByRequest := make(chan error, requestCount)
|
||||
var requests sync.WaitGroup
|
||||
requests.Add(requestCount)
|
||||
for range requestCount {
|
||||
go func() {
|
||||
defer requests.Done()
|
||||
<-start
|
||||
_, err := verificationStore.VerifyLocalAPIKey(ctx, created.Secret)
|
||||
errorsByRequest <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
requests.Wait()
|
||||
close(errorsByRequest)
|
||||
|
||||
for err := range errorsByRequest {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent API key verification failed: %v", err)
|
||||
}
|
||||
}
|
||||
if acquired := verificationStore.pool.Stat().AcquiredConns(); acquired != 0 {
|
||||
t.Fatalf("API key verification left %d connections acquired", acquired)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalAPIKeyVerificationFixture(t *testing.T, maxConnections int32) (*Store, *Store, CreatedAPIKey, GatewayUser) {
|
||||
t.Helper()
|
||||
db := newIdentityPairingPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
user, err := db.RegisterLocalUser(ctx, LocalRegisterInput{
|
||||
Username: "api-key-verification-user",
|
||||
Password: "api-key-verification-password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register API key verification user: %v", err)
|
||||
}
|
||||
created, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "API key verification fixture"}, &auth.User{
|
||||
ID: user.ID,
|
||||
GatewayUserID: user.ID,
|
||||
GatewayTenantID: user.GatewayTenantID,
|
||||
TenantID: user.TenantID,
|
||||
TenantKey: user.TenantKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create API key verification fixture: %v", err)
|
||||
}
|
||||
|
||||
config := db.pool.Config()
|
||||
config.MaxConns = maxConnections
|
||||
config.MinConns = 0
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
t.Fatalf("create verification pool with %d connections: %v", maxConnections, err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return db, &Store{pool: pool}, created, user
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestVerifyLocalAPIKeyClosesCandidateRowsBeforeUpdatingUsage(t *testing.T) {
|
||||
secret := "sk-gw-matching-secret"
|
||||
wrongHash, err := bcrypt.GenerateFromPassword([]byte("sk-gw-different-secret"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash non-matching API key: %v", err)
|
||||
}
|
||||
matchingHash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash matching API key: %v", err)
|
||||
}
|
||||
rows := &fakeLocalAPIKeyRows{candidates: []localAPIKeyCandidate{
|
||||
{apiKeyID: "wrong-key", hash: string(wrongHash), keyPrefix: apiKeyPrefix(secret)},
|
||||
{
|
||||
apiKeyID: "matching-key",
|
||||
hash: string(matchingHash),
|
||||
keyPrefix: apiKeyPrefix(secret),
|
||||
keyName: "Matching key",
|
||||
scopesBytes: []byte(`["chat"]`),
|
||||
userGroupID: "group-id",
|
||||
gatewayUserID: "user-id",
|
||||
username: "api-key-user",
|
||||
rolesBytes: []byte(`["user"]`),
|
||||
gatewayTenantID: "gateway-tenant-id",
|
||||
tenantID: "tenant-id",
|
||||
tenantKey: "tenant-key",
|
||||
},
|
||||
}}
|
||||
database := &fakeLocalAPIKeyDatabase{rows: rows}
|
||||
|
||||
user, err := verifyLocalAPIKey(context.Background(), database, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("verify local API key: %v", err)
|
||||
}
|
||||
if !rows.closed {
|
||||
t.Fatal("candidate rows remained open after API key verification")
|
||||
}
|
||||
if database.updatedAPIKeyID != "matching-key" {
|
||||
t.Fatalf("updated API key = %q, want matching-key", database.updatedAPIKeyID)
|
||||
}
|
||||
if user.APIKeyID != "matching-key" || user.GatewayUserID != "user-id" {
|
||||
t.Fatalf("verified user = %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLocalAPIKeyReturnsUnauthorizedAfterClosingCandidateRows(t *testing.T) {
|
||||
secret := "sk-gw-unknown-secret"
|
||||
wrongHash, err := bcrypt.GenerateFromPassword([]byte("sk-gw-different-secret"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash non-matching API key: %v", err)
|
||||
}
|
||||
rows := &fakeLocalAPIKeyRows{candidates: []localAPIKeyCandidate{{
|
||||
apiKeyID: "wrong-key",
|
||||
hash: string(wrongHash),
|
||||
}}}
|
||||
database := &fakeLocalAPIKeyDatabase{rows: rows}
|
||||
|
||||
_, err = verifyLocalAPIKey(context.Background(), database, secret)
|
||||
if !errors.Is(err, auth.ErrUnauthorized) {
|
||||
t.Fatalf("verify error = %v, want unauthorized", err)
|
||||
}
|
||||
if !rows.closed {
|
||||
t.Fatal("candidate rows remained open after unsuccessful API key verification")
|
||||
}
|
||||
if database.updatedAPIKeyID != "" {
|
||||
t.Fatalf("unexpected API key usage update for %q", database.updatedAPIKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLocalAPIKeyDatabase struct {
|
||||
rows *fakeLocalAPIKeyRows
|
||||
updatedAPIKeyID string
|
||||
}
|
||||
|
||||
func (database *fakeLocalAPIKeyDatabase) Query(context.Context, string, ...any) (pgx.Rows, error) {
|
||||
return database.rows, nil
|
||||
}
|
||||
|
||||
func (database *fakeLocalAPIKeyDatabase) Exec(_ context.Context, _ string, arguments ...any) (pgconn.CommandTag, error) {
|
||||
if !database.rows.closed {
|
||||
return pgconn.CommandTag{}, errors.New("API key usage update started before candidate rows closed")
|
||||
}
|
||||
database.updatedAPIKeyID, _ = arguments[0].(string)
|
||||
return pgconn.NewCommandTag("UPDATE 1"), nil
|
||||
}
|
||||
|
||||
type fakeLocalAPIKeyRows struct {
|
||||
candidates []localAPIKeyCandidate
|
||||
current int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) Close() {
|
||||
rows.closed = true
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) CommandTag() pgconn.CommandTag {
|
||||
return pgconn.CommandTag{}
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) FieldDescriptions() []pgconn.FieldDescription {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) Next() bool {
|
||||
if rows.current >= len(rows.candidates) {
|
||||
rows.Close()
|
||||
return false
|
||||
}
|
||||
rows.current++
|
||||
return true
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) Scan(destinations ...any) error {
|
||||
candidate := rows.candidates[rows.current-1]
|
||||
values := []any{
|
||||
candidate.apiKeyID,
|
||||
candidate.hash,
|
||||
candidate.keyPrefix,
|
||||
candidate.keyName,
|
||||
candidate.scopesBytes,
|
||||
candidate.userGroupID,
|
||||
candidate.gatewayUserID,
|
||||
candidate.username,
|
||||
candidate.rolesBytes,
|
||||
candidate.gatewayTenantID,
|
||||
candidate.tenantID,
|
||||
candidate.tenantKey,
|
||||
}
|
||||
for index, value := range values {
|
||||
switch destination := destinations[index].(type) {
|
||||
case *string:
|
||||
*destination = value.(string)
|
||||
case *[]byte:
|
||||
*destination = value.([]byte)
|
||||
default:
|
||||
return errors.New("unsupported fake row destination")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) Values() ([]any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) RawValues() [][]byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rows *fakeLocalAPIKeyRows) Conn() *pgx.Conn {
|
||||
return nil
|
||||
}
|
||||
@@ -685,11 +685,13 @@ func newIdentityPairingPostgresTestStore(t *testing.T) *Store {
|
||||
migrationDirectory := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
|
||||
for _, migrationName := range []string{
|
||||
"0001_init.sql",
|
||||
"0017_task_record_enrichment.sql",
|
||||
"0061_oidc_server_sessions.sql",
|
||||
"0065_identity_configuration_revisions.sql",
|
||||
"0066_identity_onboarding_exchanges.sql",
|
||||
"0067_identity_secret_cleanup_queue.sql",
|
||||
"0068_identity_pairing_start_reservation.sql",
|
||||
"0069_billing_correctness_v2.sql",
|
||||
} {
|
||||
migration, err := os.ReadFile(filepath.Join(migrationDirectory, migrationName))
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -22,6 +23,11 @@ type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
const (
|
||||
postgresApplicationName = "easyai-ai-gateway"
|
||||
postgresConnectTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
func defaultAPIKeyScopes() []string {
|
||||
return []string{"chat", "embedding", "rerank", "image", "video", "music", "audio", "voice_clone"}
|
||||
}
|
||||
@@ -57,6 +63,7 @@ var (
|
||||
ErrBalanceBelowFrozen = errors.New("wallet balance cannot be below frozen balance")
|
||||
ErrInvalidWalletAmount = errors.New("wallet amount must be a decimal with at most nine fractional digits")
|
||||
ErrIdempotencyKeyReused = errors.New("idempotency key was reused for a different request")
|
||||
ErrExternalTaskIDReused = errors.New("external task id was reused")
|
||||
ErrTaskExecutionLeaseUnavailable = errors.New("task execution lease is unavailable")
|
||||
ErrTaskExecutionLeaseLost = errors.New("task execution lease was lost")
|
||||
ErrTaskExecutionFinished = errors.New("task execution already finished")
|
||||
@@ -67,7 +74,11 @@ var (
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
config, err := postgresPoolConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,6 +89,38 @@ func Connect(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func postgresPoolConfig(databaseURL string) (*pgxpool.Config, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.ConnConfig.ConnectTimeout = postgresConnectTimeout
|
||||
config.ConnConfig.RuntimeParams["application_name"] = postgresApplicationName
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func IsPostgresUnavailable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var connectError *pgconn.ConnectError
|
||||
if errors.As(err, &connectError) {
|
||||
return true
|
||||
}
|
||||
var networkError net.Error
|
||||
if errors.As(err, &networkError) {
|
||||
return true
|
||||
}
|
||||
var postgresError *pgconn.PgError
|
||||
if errors.As(err, &postgresError) {
|
||||
return strings.HasPrefix(postgresError.Code, "08") || postgresError.Code == "53300" || strings.HasPrefix(postgresError.Code, "57P0")
|
||||
}
|
||||
return pgconn.SafeToRetry(err)
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
@@ -423,6 +466,7 @@ type RateLimitWindow struct {
|
||||
type CreateTaskInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Model string `json:"model"`
|
||||
ExternalTaskID string `json:"externalTaskId,omitempty"`
|
||||
RunMode string `json:"runMode"`
|
||||
Async bool `json:"async"`
|
||||
Request map[string]any `json:"request"`
|
||||
@@ -440,6 +484,7 @@ type CreateTaskResult struct {
|
||||
|
||||
type GatewayTask struct {
|
||||
ID string `json:"id"`
|
||||
ExternalTaskID string `json:"externalTaskId,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
RunMode string `json:"runMode"`
|
||||
UserID string `json:"userId"`
|
||||
@@ -499,7 +544,7 @@ type GatewayTask struct {
|
||||
}
|
||||
|
||||
const gatewayTaskColumns = `
|
||||
id::text, kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||
id::text, COALESCE(external_task_id, ''), kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(api_key_id, ''), COALESCE(api_key_name, ''), COALESCE(api_key_prefix, ''),
|
||||
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model,
|
||||
@@ -1519,11 +1564,35 @@ WHERE subject_type = 'api_key' AND subject_id = $1::uuid`, apiKeyID); err != nil
|
||||
}
|
||||
|
||||
func (s *Store) VerifyLocalAPIKey(ctx context.Context, secret string) (*auth.User, error) {
|
||||
return verifyLocalAPIKey(ctx, s.pool, secret)
|
||||
}
|
||||
|
||||
type localAPIKeyDatabase interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type localAPIKeyCandidate struct {
|
||||
apiKeyID string
|
||||
hash string
|
||||
keyPrefix string
|
||||
keyName string
|
||||
scopesBytes []byte
|
||||
userGroupID string
|
||||
gatewayUserID string
|
||||
username string
|
||||
rolesBytes []byte
|
||||
gatewayTenantID string
|
||||
tenantID string
|
||||
tenantKey string
|
||||
}
|
||||
|
||||
func verifyLocalAPIKey(ctx context.Context, database localAPIKeyDatabase, secret string) (*auth.User, error) {
|
||||
prefix := apiKeyPrefix(secret)
|
||||
if prefix == "" {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
rows, err := database.Query(ctx, `
|
||||
SELECT k.id::text, k.key_hash, k.key_prefix, k.name, k.scopes, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''),
|
||||
u.id::text, u.username, u.roles, COALESCE(u.gateway_tenant_id::text, ''),
|
||||
COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, '')
|
||||
@@ -1538,49 +1607,51 @@ WHERE k.key_prefix = $1
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
candidates, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (localAPIKeyCandidate, error) {
|
||||
var candidate localAPIKeyCandidate
|
||||
err := row.Scan(
|
||||
&candidate.apiKeyID,
|
||||
&candidate.hash,
|
||||
&candidate.keyPrefix,
|
||||
&candidate.keyName,
|
||||
&candidate.scopesBytes,
|
||||
&candidate.userGroupID,
|
||||
&candidate.gatewayUserID,
|
||||
&candidate.username,
|
||||
&candidate.rolesBytes,
|
||||
&candidate.gatewayTenantID,
|
||||
&candidate.tenantID,
|
||||
&candidate.tenantKey,
|
||||
)
|
||||
return candidate, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var apiKeyID string
|
||||
var hash string
|
||||
var keyPrefix string
|
||||
var keyName string
|
||||
var scopesBytes []byte
|
||||
var userGroupID string
|
||||
var gatewayUserID string
|
||||
var username string
|
||||
var rolesBytes []byte
|
||||
var gatewayTenantID string
|
||||
var tenantID string
|
||||
var tenantKey string
|
||||
if err := rows.Scan(&apiKeyID, &hash, &keyPrefix, &keyName, &scopesBytes, &userGroupID, &gatewayUserID, &username, &rolesBytes, &gatewayTenantID, &tenantID, &tenantKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) != nil {
|
||||
for _, candidate := range candidates {
|
||||
if bcrypt.CompareHashAndPassword([]byte(candidate.hash), []byte(secret)) != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE gateway_api_keys SET last_used_at = now(), updated_at = now() WHERE id = $1::uuid`, apiKeyID); err != nil {
|
||||
if _, err := database.Exec(ctx, `UPDATE gateway_api_keys SET last_used_at = now(), updated_at = now() WHERE id = $1::uuid`, candidate.apiKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth.User{
|
||||
ID: gatewayUserID,
|
||||
Username: username,
|
||||
Roles: decodeStringArray(rolesBytes),
|
||||
TenantID: tenantID,
|
||||
GatewayTenantID: gatewayTenantID,
|
||||
TenantKey: tenantKey,
|
||||
ID: candidate.gatewayUserID,
|
||||
Username: candidate.username,
|
||||
Roles: decodeStringArray(candidate.rolesBytes),
|
||||
TenantID: candidate.tenantID,
|
||||
GatewayTenantID: candidate.gatewayTenantID,
|
||||
TenantKey: candidate.tenantKey,
|
||||
Source: "gateway",
|
||||
GatewayUserID: gatewayUserID,
|
||||
UserGroupID: userGroupID,
|
||||
APIKeyID: apiKeyID,
|
||||
APIKeyName: keyName,
|
||||
APIKeyPrefix: keyPrefix,
|
||||
APIKeyScopes: decodeStringArray(scopesBytes),
|
||||
GatewayUserID: candidate.gatewayUserID,
|
||||
UserGroupID: candidate.userGroupID,
|
||||
APIKeyID: candidate.apiKeyID,
|
||||
APIKeyName: candidate.keyName,
|
||||
APIKeyPrefix: candidate.keyPrefix,
|
||||
APIKeyScopes: decodeStringArray(candidate.scopesBytes),
|
||||
}, nil
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
@@ -1880,15 +1951,15 @@ func (s *Store) CreateTaskIdempotent(ctx context.Context, input CreateTaskInput,
|
||||
|
||||
task, err := scanGatewayTask(tx.QueryRow(ctx, `
|
||||
INSERT INTO gateway_tasks (
|
||||
kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||
external_task_id, kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||
api_key_id, api_key_name, api_key_prefix, user_group_id, user_group_key,
|
||||
model, requested_model, request, async_mode, status, result, billings, conversation_id, new_message_count,
|
||||
idempotency_key_hash, idempotency_request_hash, finished_at
|
||||
)
|
||||
VALUES ($1, $2, $3, NULLIF($4, '')::uuid, COALESCE(NULLIF($5, ''), 'gateway'), NULLIF($6, '')::uuid, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, '')::uuid, NULLIF($13, ''), $14, $14, $15, $16, $17, $18::jsonb, $19::jsonb, NULLIF($20, '')::uuid, $21, NULLIF($22, ''), NULLIF($23, ''), NULL)
|
||||
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5, '')::uuid, COALESCE(NULLIF($6, ''), 'gateway'), NULLIF($7, '')::uuid, NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, '')::uuid, NULLIF($14, ''), $15, $15, $16, $17, $18, $19::jsonb, $20::jsonb, NULLIF($21, '')::uuid, $22, NULLIF($23, ''), NULLIF($24, ''), NULL)
|
||||
ON CONFLICT (user_id, idempotency_key_hash) WHERE idempotency_key_hash IS NOT NULL DO NOTHING
|
||||
RETURNING `+gatewayTaskColumns,
|
||||
input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
|
||||
strings.TrimSpace(input.ExternalTaskID), input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, input.Async, status, resultBody, billingsBody, input.ConversationID, input.NewMessageCount, strings.TrimSpace(input.IdempotencyKeyHash), strings.TrimSpace(input.IdempotencyRequestHash),
|
||||
))
|
||||
replayed := false
|
||||
if errors.Is(err, pgx.ErrNoRows) && strings.TrimSpace(input.IdempotencyKeyHash) != "" {
|
||||
@@ -1909,6 +1980,9 @@ FROM gateway_tasks
|
||||
WHERE user_id = $1 AND idempotency_key_hash = $2`, user.ID, strings.TrimSpace(input.IdempotencyKeyHash)))
|
||||
replayed = true
|
||||
}
|
||||
if isUniqueViolation(err) && strings.TrimSpace(input.ExternalTaskID) != "" {
|
||||
return CreateTaskResult{}, ErrExternalTaskIDReused
|
||||
}
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
@@ -1973,6 +2047,7 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
|
||||
var remoteTaskPayloadBytes []byte
|
||||
if err := scanner.Scan(
|
||||
&task.ID,
|
||||
&task.ExternalTaskID,
|
||||
&task.Kind,
|
||||
&task.RunMode,
|
||||
&task.UserID,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func TestPostgresPoolConfigSetsDiagnosticAndConnectTimeout(t *testing.T) {
|
||||
config, err := postgresPoolConfig("postgresql://gateway:password@localhost:5432/gateway?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("parse PostgreSQL pool config: %v", err)
|
||||
}
|
||||
if config.ConnConfig.ConnectTimeout != 5*time.Second {
|
||||
t.Fatalf("connect timeout = %s, want 5s", config.ConnConfig.ConnectTimeout)
|
||||
}
|
||||
if applicationName := config.ConnConfig.RuntimeParams["application_name"]; applicationName != "easyai-ai-gateway" {
|
||||
t.Fatalf("application_name = %q, want easyai-ai-gateway", applicationName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresPoolConfigRejectsMalformedURL(t *testing.T) {
|
||||
if _, err := postgresPoolConfig("://malformed"); err == nil {
|
||||
t.Fatal("expected malformed PostgreSQL URL to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPostgresUnavailableClassifiesConnectivityFailures(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{name: "deadline", err: context.DeadlineExceeded},
|
||||
{name: "connection exception", err: &pgconn.PgError{Code: "08006"}},
|
||||
{name: "too many connections", err: &pgconn.PgError{Code: "53300"}},
|
||||
{name: "cannot connect now", err: &pgconn.PgError{Code: "57P03"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if !IsPostgresUnavailable(testCase.err) {
|
||||
t.Fatalf("error %v was not classified as PostgreSQL unavailable", testCase.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if IsPostgresUnavailable(&pgconn.PgError{Code: "42601"}) {
|
||||
t.Fatal("SQL syntax error was incorrectly classified as PostgreSQL unavailable")
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ type TaskListResult struct {
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type CompatTaskListFilter struct {
|
||||
Provider string
|
||||
Version string
|
||||
Statuses []string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (s *Store) ListTasks(ctx context.Context, user *auth.User, filter TaskListFilter) (TaskListResult, error) {
|
||||
page := filter.Page
|
||||
if page <= 0 {
|
||||
@@ -146,6 +156,122 @@ LIMIT $8 OFFSET $9`, queryArgs...)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCompatTask(ctx context.Context, user *auth.User, provider string, version string, identifier string) (GatewayTask, error) {
|
||||
gatewayUserID := localGatewayUserID(user)
|
||||
apiKeyID := ""
|
||||
userID := ""
|
||||
if user != nil {
|
||||
apiKeyID = strings.TrimSpace(user.APIKeyID)
|
||||
userID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
if gatewayUserID == "" && userID == "" {
|
||||
return GatewayTask{}, ErrLocalUserRequired
|
||||
}
|
||||
task, err := scanGatewayTask(s.pool.QueryRow(ctx, `
|
||||
SELECT `+gatewayTaskColumns+`
|
||||
FROM gateway_tasks
|
||||
WHERE (
|
||||
(NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2)
|
||||
)
|
||||
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
|
||||
AND request->>'_compat_provider' = $4
|
||||
AND request->>'_kling_compat_version' = $5
|
||||
AND (id::text = $6 OR external_task_id = $6)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`, gatewayUserID, userID, apiKeyID, strings.TrimSpace(provider), strings.TrimSpace(version), strings.TrimSpace(identifier)))
|
||||
if err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
attempts, err := s.ListTaskAttempts(ctx, task.ID)
|
||||
if err != nil {
|
||||
return GatewayTask{}, err
|
||||
}
|
||||
task.Attempts = attempts
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCompatTasks(ctx context.Context, user *auth.User, filter CompatTaskListFilter) (TaskListResult, error) {
|
||||
page := filter.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 30
|
||||
}
|
||||
if pageSize > 500 {
|
||||
pageSize = 500
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
gatewayUserID := localGatewayUserID(user)
|
||||
apiKeyID := ""
|
||||
userID := ""
|
||||
if user != nil {
|
||||
apiKeyID = strings.TrimSpace(user.APIKeyID)
|
||||
userID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
if gatewayUserID == "" && userID == "" {
|
||||
return TaskListResult{}, ErrLocalUserRequired
|
||||
}
|
||||
statuses := filter.Statuses
|
||||
if len(statuses) == 0 {
|
||||
statuses = nil
|
||||
}
|
||||
args := []any{
|
||||
gatewayUserID,
|
||||
userID,
|
||||
apiKeyID,
|
||||
strings.TrimSpace(filter.Provider),
|
||||
strings.TrimSpace(filter.Version),
|
||||
nullableTaskListTime(filter.CreatedFrom),
|
||||
nullableTaskListTime(filter.CreatedTo),
|
||||
statuses,
|
||||
}
|
||||
whereSQL := `
|
||||
WHERE (
|
||||
(NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2)
|
||||
)
|
||||
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
|
||||
AND request->>'_compat_provider' = $4
|
||||
AND request->>'_kling_compat_version' = $5
|
||||
AND ($6::timestamptz IS NULL OR created_at >= $6::timestamptz)
|
||||
AND ($7::timestamptz IS NULL OR created_at <= $7::timestamptz)
|
||||
AND ($8::text[] IS NULL OR status = ANY($8::text[]))`
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
queryArgs := append(args, pageSize, offset)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+gatewayTaskColumns+`
|
||||
FROM gateway_tasks
|
||||
`+whereSQL+`
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $9 OFFSET $10`, queryArgs...)
|
||||
if err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]GatewayTask, 0)
|
||||
for rows.Next() {
|
||||
task, err := scanGatewayTask(rows)
|
||||
if err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
items = append(items, task)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
items, err = s.attachTaskAttempts(ctx, items)
|
||||
if err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
return TaskListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func nullableTaskListTime(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
UPDATE model_pricing_rules
|
||||
SET formula_config = jsonb_set(
|
||||
COALESCE(formula_config, '{}'::jsonb),
|
||||
'{formula}',
|
||||
to_jsonb(replace(
|
||||
formula_config->>'formula',
|
||||
'ceil(duration_seconds / 5)',
|
||||
'(duration_seconds / 5)'
|
||||
)),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE resource_type = 'video'
|
||||
AND strpos(formula_config->>'formula', 'ceil(duration_seconds / 5)') > 0;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_tasks_kling_compat_external
|
||||
ON gateway_tasks (
|
||||
COALESCE(gateway_user_id::text, user_id),
|
||||
external_task_id
|
||||
)
|
||||
WHERE external_task_id IS NOT NULL
|
||||
AND external_task_id <> ''
|
||||
AND request->>'_compat_provider' = 'kling'
|
||||
AND request->>'_kling_compat_version' IN ('v1', 'v2');
|
||||
@@ -9,12 +9,39 @@ import {
|
||||
getAPITask,
|
||||
getCurrentUser,
|
||||
getOpsManagementSkillMetadata,
|
||||
loginLocalAccount,
|
||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||
startIdentityPairing,
|
||||
retireIdentityPairingSecurityEventConflict,
|
||||
validateIdentityRevision,
|
||||
} from './api';
|
||||
|
||||
describe('local login transport', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('aborts after ten seconds and returns a stable login timeout message', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const login = loginLocalAccount({ account: 'timeout-test-account', password: 'timeout-test-password' });
|
||||
const rejection = expect(login).rejects.toMatchObject({
|
||||
message: '登录请求超时,请稍后重试',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await rejection;
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(init.signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gateway provisioning errors', () => {
|
||||
const cases = [
|
||||
['GATEWAY_USER_NOT_PROVISIONED', '该账号尚未开通 EasyAI Gateway'],
|
||||
|
||||
+30
-15
@@ -121,6 +121,7 @@ export async function loginLocalAccount(input: { account: string; password: stri
|
||||
auth: false,
|
||||
body: input,
|
||||
method: 'POST',
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1206,7 +1207,7 @@ export async function deleteFileStorageChannel(token: string, channelId: string)
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal } = {},
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal; timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
||||
if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) {
|
||||
@@ -1215,21 +1216,35 @@ async function request<T>(
|
||||
if (options.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
credentials: 'include',
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`));
|
||||
const controller = options.timeoutMs ? new AbortController() : undefined;
|
||||
const timeout = controller ? globalThis.setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
||||
const signal = controller && options.signal
|
||||
? AbortSignal.any([controller.signal, options.signal])
|
||||
: controller?.signal ?? options.signal;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
credentials: 'include',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`));
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
} catch (error) {
|
||||
if (controller?.signal.aborted) {
|
||||
throw new GatewayApiError('登录请求超时,请稍后重试');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout !== undefined) globalThis.clearTimeout(timeout);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function authorizationHeader(token: string): Record<string, string> {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterModelsForMode } from './PlaygroundPage';
|
||||
|
||||
function model(id: string, modelType: string[]) {
|
||||
return { id, modelType } as PlatformModel;
|
||||
}
|
||||
|
||||
function modelIds(models: PlatformModel[]) {
|
||||
return models.map((item) => item.id);
|
||||
}
|
||||
|
||||
describe('playground model filtering', () => {
|
||||
const models = [
|
||||
model('image-generation', ['image_generate']),
|
||||
model('legacy-image', ['image']),
|
||||
model('image-edit', ['image_edit']),
|
||||
model('image-to-video', ['video_generate', 'image_to_video']),
|
||||
model('text-to-video', ['text_to_video']),
|
||||
];
|
||||
|
||||
it('only shows image generation models when no reference image is present', () => {
|
||||
expect(modelIds(filterModelsForMode(models, 'image', false, 'text_to_video'))).toEqual([
|
||||
'image-generation',
|
||||
'legacy-image',
|
||||
]);
|
||||
});
|
||||
|
||||
it('only shows image editing models when a reference image is present', () => {
|
||||
expect(modelIds(filterModelsForMode(models, 'image', true, 'text_to_video'))).toEqual([
|
||||
'legacy-image',
|
||||
'image-edit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fall back to image-to-video models when no image model is available', () => {
|
||||
const videoOnlyModels = [
|
||||
model('kling-3-turbo', ['video_generate', 'image_to_video']),
|
||||
model('kling-1-5', ['image_to_video']),
|
||||
];
|
||||
|
||||
expect(filterModelsForMode(videoOnlyModels, 'image', false, 'text_to_video')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps image-to-video models available for the matching video mode', () => {
|
||||
expect(modelIds(filterModelsForMode(models, 'video', false, 'first_last_frame'))).toContain('image-to-video');
|
||||
});
|
||||
});
|
||||
@@ -810,7 +810,7 @@ function Composer(props: {
|
||||
<Select className="playgroundModelSelect" value={props.selectedModel ?? ''} disabled={!props.modelOptions.length} onChange={(event) => props.onModelChange(event.target.value)}>
|
||||
{props.modelOptions.length ? props.modelOptions.map((item) => (
|
||||
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
||||
)) : <option value="">模型选择</option>}
|
||||
)) : <option value="">{props.compact ? '模型选择' : '暂无可用模型'}</option>}
|
||||
</Select>
|
||||
{props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && (
|
||||
<MediaSettingsPopover
|
||||
@@ -977,25 +977,25 @@ function mediaPromptPlaceholder(mode: PlaygroundMode) {
|
||||
return placeholderByMode.chat;
|
||||
}
|
||||
|
||||
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
||||
export function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
||||
if (mode === 'chat') {
|
||||
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
|
||||
return filterModelsByType(models, ['text_generate', 'chat', 'responses', 'text']);
|
||||
}
|
||||
if (mode === 'image') {
|
||||
const preferredTypes = hasReference ? ['image_edit', 'images.edits'] : ['image_generate', 'images.generations'];
|
||||
return filterWithFallback(models, [...preferredTypes, 'image']);
|
||||
return filterModelsByType(models, [...preferredTypes, 'image']);
|
||||
}
|
||||
const videoTypesByMode: Record<VideoCreateMode, string[]> = {
|
||||
first_last_frame: ['video_first_last_frame', 'image_to_video', 'video_generate'],
|
||||
omni_reference: ['omni_video', 'video_reference', 'video_generate'],
|
||||
text_to_video: ['text_to_video', 'video_generate'],
|
||||
};
|
||||
return filterWithFallback(models, [...videoTypesByMode[videoMode], 'video']);
|
||||
return filterModelsByType(models, [...videoTypesByMode[videoMode], 'video']);
|
||||
}
|
||||
|
||||
function filterWithFallback(models: PlatformModel[], modelTypes: string[]) {
|
||||
const exact = models.filter((model) => model.modelType.some((type) => modelTypes.includes(type)));
|
||||
return exact.length ? exact : models.filter((model) => modelTypes.some((type) => model.modelType.some((modelType) => modelType.includes(type) || type.includes(modelType))));
|
||||
function filterModelsByType(models: PlatformModel[], modelTypes: string[]) {
|
||||
const acceptedTypes = new Set(modelTypes);
|
||||
return models.filter((model) => model.modelType.some((type) => acceptedTypes.has(type)));
|
||||
}
|
||||
|
||||
function buildModelOptions(models: PlatformModel[]): ModelOption[] {
|
||||
|
||||
@@ -56,7 +56,7 @@ const modeDefinitions: ModeDefinition[] = [
|
||||
formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率、音频、参考视频、音色等计费参数。',
|
||||
match: (rule) => rule.resourceType === 'video',
|
||||
templates: (currency) => [
|
||||
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
|
||||
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * (duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
|
||||
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
|
||||
audioWeights: { true: 2, false: 1 },
|
||||
referenceVideoWeights: { true: 1.5, false: 1 },
|
||||
|
||||
@@ -33,6 +33,20 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /gateway-api/api/v1/auth/login {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 15s;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://api:8088/api/v1/auth/login;
|
||||
}
|
||||
|
||||
location /gateway-api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+1
-1
@@ -1430,7 +1430,7 @@ effective price = rule price(request dimensions) * platform/model discount * use
|
||||
- 基础单位建议为 `5s` 或 `second`,与原 provider 配置保持可映射。
|
||||
- 动态权重至少支持:时长、分辨率、是否包含音频、是否使用参考视频、是否指定声音/音色、生成数量。
|
||||
- 分辨率示例:`480p`、`720p`、`1080p`、`2160p`。
|
||||
- 公式示例:`count * ceil(durationSeconds / unitSeconds) * basePrice * resolutionWeight * audioWeight * referenceWeight`。
|
||||
- 公式示例:`count * (durationSeconds / unitSeconds) * basePrice * resolutionWeight * audioWeight * referenceWeight`。
|
||||
- 规则维度:`durationSeconds`、`resolution`、`count`、`hasAudio`、`hasReferenceVideo`、`hasReferenceImage`、`voice`。
|
||||
|
||||
音频、音乐、数字人、3D 模型:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 可灵 O1 / 3.0 Omni 兼容接口
|
||||
|
||||
网关兼容中国区可灵 V1 AK/SK Omni API 和 API 2.0 的调用路径。调用方只需要把可灵客户端的 `baseURL` 改为网关地址,并把认证改为 EasyAI Gateway API Key;中国区可灵 AK/SK 仅保存在网关平台配置中,不下发给调用方。
|
||||
|
||||
生产环境统一配置:
|
||||
|
||||
```text
|
||||
baseURL = https://ai.51easyai.com/gateway-api/kling
|
||||
Authorization = Bearer <EasyAI Gateway API Key>
|
||||
```
|
||||
|
||||
本地环境使用 `baseURL = http://localhost:8088/kling`。
|
||||
|
||||
## V1(AK/SK 旧版协议兼容)
|
||||
|
||||
创建任务:
|
||||
|
||||
```http
|
||||
POST /v1/videos/omni-video
|
||||
Authorization: Bearer <EasyAI Gateway API Key>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "一只白色纸鹤飞过清晨的湖面",
|
||||
"mode": "pro",
|
||||
"duration": "5",
|
||||
"aspect_ratio": "16:9",
|
||||
"sound": "off",
|
||||
"external_task_id": "client-task-001"
|
||||
}
|
||||
```
|
||||
|
||||
`model_name` 支持:
|
||||
|
||||
- `kling-video-o1`:O1,时长 3–10 秒;不带参考素材的纯文本任务只支持 5 或 10 秒。
|
||||
- `kling-v3-omni`:3.0 Omni,时长 3–15 秒,支持多镜头。
|
||||
|
||||
查询和列表接口:
|
||||
|
||||
```http
|
||||
GET /v1/videos/omni-video/{task_id}
|
||||
GET /v1/videos/omni-video?pageNum=1&pageSize=30
|
||||
```
|
||||
|
||||
V1 支持并透传 `prompt`、`multi_shot`、`shot_type`、`multi_prompt`、`image_list`、`element_list`、`video_list`、`voice_list`、`sound`、`mode`、`aspect_ratio`、`duration`、`watermark_info`、`callback_url` 和 `external_task_id`。网关会校验模型时长、引用素材数量、参考视频数量以及参考视频与原生音频的互斥约束。
|
||||
|
||||
## API 2.0
|
||||
|
||||
创建任务:
|
||||
|
||||
```http
|
||||
POST /omni-video/kling-o1
|
||||
POST /omni-video/kling-v3-omni
|
||||
```
|
||||
|
||||
同时提供带显式版本前缀的等价别名:
|
||||
|
||||
```http
|
||||
POST /v2/omni-video/kling-o1
|
||||
POST /v2/omni-video/kling-v3-omni
|
||||
```
|
||||
|
||||
任务查询和列表:
|
||||
|
||||
```http
|
||||
GET /tasks?task_ids=<task_id>
|
||||
GET /tasks?external_task_ids=<external_task_id>
|
||||
POST /tasks
|
||||
```
|
||||
|
||||
上述任务接口也提供 `/v2/tasks` 别名。API 2.0 请求使用 `contents`、`settings` 和 `options`;网关会转换到相同的 V1 AK/SK 上游链路,因此 O1 和 3.0 Omni 使用同一套中国区平台凭据。
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 调用方只使用 EasyAI Gateway API Key,不接触上游 AK/SK。
|
||||
- 任务查询按用户和当前 API Key 隔离,不能读取其他 Key 创建的任务。
|
||||
- `external_task_id` 在同一用户的可灵兼容任务范围内唯一。
|
||||
- 真实联调测试是显式启用且会产生可灵费用;测试凭据只放在 `.env.local`。
|
||||
Reference in New Issue
Block a user