feat: 合并网关能力与兼容性优化 #15
@@ -4971,6 +4971,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/api-keys/assignable-models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"api-keys"
|
||||
],
|
||||
"summary": "列出 API Key 可分配模型",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.PlatformModelListResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/api-keys/{apiKeyID}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
@@ -10516,6 +10559,139 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/videos/omni-video": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "创建 Kling Omni 视频任务",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Kling Omni 官方兼容请求",
|
||||
"name": "input",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingOmniVideoRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/videos/omni-video/{taskID}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"kling-compatible"
|
||||
],
|
||||
"summary": "查询 Kling Omni 视频任务",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "网关任务 ID",
|
||||
"name": "taskID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.KelingCompatibleEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/voice_clone": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -11485,6 +11661,176 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingCompatibleEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"example": 0
|
||||
},
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "SUCCEED"
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingOmniElementInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"element_id": {}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingOmniImageInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"first_frame",
|
||||
"end_frame"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingOmniMultiPrompt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"example": "3"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"example": "A wide establishing shot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingOmniVideoInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keep_original_sound": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"yes",
|
||||
"no"
|
||||
]
|
||||
},
|
||||
"refer_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"base",
|
||||
"feature"
|
||||
]
|
||||
},
|
||||
"video_url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingOmniVideoRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aspect_ratio": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"16:9",
|
||||
"9:16",
|
||||
"1:1"
|
||||
],
|
||||
"example": "9:16"
|
||||
},
|
||||
"callback_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"example": "5"
|
||||
},
|
||||
"element_list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/httpapi.KelingOmniElementInput"
|
||||
}
|
||||
},
|
||||
"external_task_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"image_list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/httpapi.KelingOmniImageInput"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"std",
|
||||
"pro",
|
||||
"4k"
|
||||
],
|
||||
"example": "pro"
|
||||
},
|
||||
"model_name": {
|
||||
"type": "string",
|
||||
"example": "kling-v3-omni"
|
||||
},
|
||||
"multi_prompt": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/httpapi.KelingOmniMultiPrompt"
|
||||
}
|
||||
},
|
||||
"multi_shot": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"example": "A quiet street in the rain with natural ambient sound"
|
||||
},
|
||||
"shot_type": {
|
||||
"type": "string",
|
||||
"example": "customize"
|
||||
},
|
||||
"sound": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on",
|
||||
"off"
|
||||
],
|
||||
"example": "on"
|
||||
},
|
||||
"video_list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/httpapi.KelingOmniVideoInput"
|
||||
}
|
||||
},
|
||||
"watermark_info": {
|
||||
"$ref": "#/definitions/httpapi.KelingOmniWatermarkInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.KelingOmniWatermarkInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpapi.ModelCatalogFilterOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12242,6 +12588,14 @@
|
||||
"httpapi.TaskRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aspect_ratio": {
|
||||
"type": "string",
|
||||
"example": "16:9"
|
||||
},
|
||||
"audio": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"audioWeight": {
|
||||
"type": "number",
|
||||
"example": 0.65
|
||||
@@ -12368,6 +12722,10 @@
|
||||
"type": "number",
|
||||
"example": 1
|
||||
},
|
||||
"watermark": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"weirdnessConstraint": {
|
||||
"type": "number",
|
||||
"example": 0.35
|
||||
|
||||
@@ -393,6 +393,125 @@ definitions:
|
||||
example: easyai-ai-gateway
|
||||
type: string
|
||||
type: object
|
||||
httpapi.KelingCompatibleEnvelope:
|
||||
properties:
|
||||
code:
|
||||
example: 0
|
||||
type: integer
|
||||
data: {}
|
||||
message:
|
||||
example: SUCCEED
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
type: object
|
||||
httpapi.KelingOmniElementInput:
|
||||
properties:
|
||||
element_id: {}
|
||||
type: object
|
||||
httpapi.KelingOmniImageInput:
|
||||
properties:
|
||||
image_url:
|
||||
type: string
|
||||
type:
|
||||
enum:
|
||||
- first_frame
|
||||
- end_frame
|
||||
type: string
|
||||
type: object
|
||||
httpapi.KelingOmniMultiPrompt:
|
||||
properties:
|
||||
duration:
|
||||
example: "3"
|
||||
type: string
|
||||
index:
|
||||
example: 1
|
||||
type: integer
|
||||
prompt:
|
||||
example: A wide establishing shot
|
||||
type: string
|
||||
type: object
|
||||
httpapi.KelingOmniVideoInput:
|
||||
properties:
|
||||
keep_original_sound:
|
||||
enum:
|
||||
- "yes"
|
||||
- "no"
|
||||
type: string
|
||||
refer_type:
|
||||
enum:
|
||||
- base
|
||||
- feature
|
||||
type: string
|
||||
video_url:
|
||||
type: string
|
||||
type: object
|
||||
httpapi.KelingOmniVideoRequest:
|
||||
properties:
|
||||
aspect_ratio:
|
||||
enum:
|
||||
- "16:9"
|
||||
- "9:16"
|
||||
- "1:1"
|
||||
example: "9:16"
|
||||
type: string
|
||||
callback_url:
|
||||
type: string
|
||||
duration:
|
||||
example: "5"
|
||||
type: string
|
||||
element_list:
|
||||
items:
|
||||
$ref: '#/definitions/httpapi.KelingOmniElementInput'
|
||||
type: array
|
||||
external_task_id:
|
||||
type: string
|
||||
image_list:
|
||||
items:
|
||||
$ref: '#/definitions/httpapi.KelingOmniImageInput'
|
||||
type: array
|
||||
mode:
|
||||
enum:
|
||||
- std
|
||||
- pro
|
||||
- 4k
|
||||
example: pro
|
||||
type: string
|
||||
model_name:
|
||||
example: kling-v3-omni
|
||||
type: string
|
||||
multi_prompt:
|
||||
items:
|
||||
$ref: '#/definitions/httpapi.KelingOmniMultiPrompt'
|
||||
type: array
|
||||
multi_shot:
|
||||
example: false
|
||||
type: boolean
|
||||
prompt:
|
||||
example: A quiet street in the rain with natural ambient sound
|
||||
type: string
|
||||
shot_type:
|
||||
example: customize
|
||||
type: string
|
||||
sound:
|
||||
enum:
|
||||
- "on"
|
||||
- "off"
|
||||
example: "on"
|
||||
type: string
|
||||
video_list:
|
||||
items:
|
||||
$ref: '#/definitions/httpapi.KelingOmniVideoInput'
|
||||
type: array
|
||||
watermark_info:
|
||||
$ref: '#/definitions/httpapi.KelingOmniWatermarkInfo'
|
||||
type: object
|
||||
httpapi.KelingOmniWatermarkInfo:
|
||||
properties:
|
||||
enabled:
|
||||
example: false
|
||||
type: boolean
|
||||
type: object
|
||||
httpapi.ModelCatalogFilterOption:
|
||||
properties:
|
||||
count:
|
||||
@@ -909,6 +1028,12 @@ definitions:
|
||||
type: object
|
||||
httpapi.TaskRequest:
|
||||
properties:
|
||||
aspect_ratio:
|
||||
example: "16:9"
|
||||
type: string
|
||||
audio:
|
||||
example: false
|
||||
type: boolean
|
||||
audioWeight:
|
||||
example: 0.65
|
||||
type: number
|
||||
@@ -1005,6 +1130,9 @@ definitions:
|
||||
vol:
|
||||
example: 1
|
||||
type: number
|
||||
watermark:
|
||||
example: false
|
||||
type: boolean
|
||||
weirdnessConstraint:
|
||||
example: 0.35
|
||||
type: number
|
||||
@@ -6408,6 +6536,33 @@ paths:
|
||||
summary: 批量写入 API Key 访问规则
|
||||
tags:
|
||||
- api-keys
|
||||
/api/v1/api-keys/assignable-models:
|
||||
get:
|
||||
description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.PlatformModelListResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 列出 API Key 可分配模型
|
||||
tags:
|
||||
- api-keys
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
@@ -9904,6 +10059,92 @@ paths:
|
||||
summary: 取消异步任务
|
||||
tags:
|
||||
- tasks
|
||||
/v1/videos/omni-video:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API
|
||||
Key。任务固定异步执行,返回的 task_id 为网关任务 ID。
|
||||
parameters:
|
||||
- description: Kling Omni 官方兼容请求
|
||||
in: body
|
||||
name: input
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingOmniVideoRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"429":
|
||||
description: Too Many Requests
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 创建 Kling Omni 视频任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
/v1/videos/omni-video/{taskID}:
|
||||
get:
|
||||
description: 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。
|
||||
parameters:
|
||||
- description: 网关任务 ID
|
||||
in: path
|
||||
name: taskID
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.KelingCompatibleEnvelope'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 查询 Kling Omni 视频任务
|
||||
tags:
|
||||
- kling-compatible
|
||||
/v1/voice_clone:
|
||||
post:
|
||||
consumes:
|
||||
|
||||
@@ -1934,6 +1934,77 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoRetriesTransientPollAndKeepsOfficialResult(t *testing.T) {
|
||||
polls := 0
|
||||
persisted := make([]string, 0)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /contents/generations/tasks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-retry"})
|
||||
case "GET /contents/generations/tasks/cgt-retry":
|
||||
polls++
|
||||
if polls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"try later"}}`))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "cgt-retry", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
|
||||
"created_at": 123, "updated_at": 124, "content": map[string]any{"video_url": "https://example.com/retry.mp4"},
|
||||
"usage": map[string]any{"total_tokens": 8}, "seed": 7,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations", Model: "seedance", Body: map[string]any{"model": "seedance", "prompt": "retry"},
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "doubao-seedance-2-0-mini-260615", Credentials: map[string]any{"apiKey": "key"}, PlatformConfig: map[string]any{"volcesPollIntervalMs": 100, "volcesPollRetryMaxMs": 100, "volcesPollTimeoutSeconds": 2}},
|
||||
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
|
||||
persisted = append(persisted, remoteTaskID+":"+stringFromAny(payload["status"]))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run retrying Volces video: %v", err)
|
||||
}
|
||||
if polls != 2 || len(persisted) != 1 || persisted[0] != "cgt-retry:succeeded" {
|
||||
t.Fatalf("unexpected poll state polls=%d persisted=%+v", polls, persisted)
|
||||
}
|
||||
if response.Result["updated_at"] != float64(124) || response.Result["seed"] != float64(7) || response.Result["raw"] == nil {
|
||||
t.Fatalf("official result fields lost: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientDeletesOfficialVideoTask(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/contents/generations/tasks/cgt-delete" {
|
||||
t.Fatalf("unexpected delete request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer delete-key" {
|
||||
t.Fatalf("unexpected delete authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-delete", "status": "cancelled"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, _, err := (VolcesClient{HTTPClient: server.Client()}).DeleteVideoTask(context.Background(), Request{
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, Credentials: map[string]any{"apiKey": "delete-key"}},
|
||||
RemoteTaskID: "cgt-delete",
|
||||
})
|
||||
if err != nil || result["status"] != "cancelled" {
|
||||
t.Fatalf("unexpected delete response result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesCancelledTaskUsesDedicatedCancellationCode(t *testing.T) {
|
||||
if got := volcesTaskErrorCode(map[string]any{"status": "cancelled"}); got != "volces_task_cancelled" {
|
||||
t.Fatalf("cancelled task error code = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoRejectsDuplicateFirstFrameBeforeSubmit(t *testing.T) {
|
||||
var submitted bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -338,8 +338,11 @@ func kelingVideoPayload(ctx context.Context, request Request) (map[string]any, s
|
||||
if value, ok := body["cfg_scale"]; ok && numericValue(value, 0) > 0 {
|
||||
payload["cfg_scale"] = value
|
||||
}
|
||||
if boolValue(body, "audio") || boolValue(body, "output_audio") {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
payload["sound"] = sound
|
||||
}
|
||||
if mode := kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")); mode != "" {
|
||||
payload["mode"] = mode
|
||||
@@ -432,7 +435,7 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
watermarkEnabled = boolValue(watermarkInfo, "enabled")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model_name": upstreamModelName(request.Candidate),
|
||||
"model_name": kelingOmniUpstreamModelName(request.Candidate),
|
||||
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||
@@ -458,8 +461,13 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
if voices := mapListFromAny(body["voice_list"]); len(voices) > 0 {
|
||||
payload["voice_list"] = voices
|
||||
}
|
||||
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, nil, &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !hasVideo {
|
||||
payload["sound"] = sound
|
||||
}
|
||||
}
|
||||
if multiShot {
|
||||
payload["multi_shot"] = true
|
||||
@@ -728,6 +736,18 @@ func kelingIsOmniRequest(request Request) bool {
|
||||
request.Candidate.Capabilities["omni"] != nil
|
||||
}
|
||||
|
||||
func kelingOmniUpstreamModelName(candidate store.RuntimeModelCandidate) string {
|
||||
model := strings.TrimSpace(upstreamModelName(candidate))
|
||||
switch strings.ToLower(model) {
|
||||
case "kling-o1":
|
||||
return "kling-video-o1"
|
||||
case "kling-3.0-omni":
|
||||
return "kling-v3-omni"
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func kelingIs30TurboRequest(request Request) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
|
||||
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
|
||||
@@ -1073,6 +1093,54 @@ func kelingModeByResolution(resolution string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func kelingSoundSetting(body map[string]any) (string, bool) {
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromAny(body["sound"]))); sound == "on" || sound == "off" {
|
||||
return sound, true
|
||||
}
|
||||
for _, key := range []string{"audio", "output_audio", "generate_audio"} {
|
||||
if enabled, ok := kelingBoolFieldValue(body, key); ok {
|
||||
if enabled {
|
||||
return "on", true
|
||||
}
|
||||
return "off", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func kelingSupportsGeneratedSound(candidate store.RuntimeModelCandidate) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(candidate))) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func kelingWatermarkEnabled(body map[string]any) bool {
|
||||
if enabled, ok := kelingBoolFieldValue(body, "watermark"); ok {
|
||||
return enabled
|
||||
}
|
||||
info := mapFromAny(body["watermark_info"])
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
enabled, _ := kelingBoolFieldValue(info, "enabled")
|
||||
return enabled
|
||||
}
|
||||
|
||||
func kelingBoolFieldValue(body map[string]any, key string) (bool, bool) {
|
||||
if body == nil {
|
||||
return false, false
|
||||
}
|
||||
value, ok := body[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
typed, ok := value.(bool)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
func kelingCameraControl(body map[string]any) map[string]any {
|
||||
cameraControl := strings.TrimSpace(stringFromAny(body["camera_control"]))
|
||||
if cameraControl == "" {
|
||||
@@ -1182,7 +1250,7 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
|
||||
item["id"] = id
|
||||
}
|
||||
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||
if duration := firstPresent(video["duration"]); duration != nil && strings.TrimSpace(stringFromAny(duration)) != "" {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
|
||||
@@ -1194,11 +1262,15 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
modelName := upstreamModelName(request.Candidate)
|
||||
if kelingIsOmniRequest(request) {
|
||||
modelName = kelingOmniUpstreamModelName(request.Candidate)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"model": modelName,
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": items,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniPayloadPreservesCompatibleSettings(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "A product reveal",
|
||||
"duration": 3,
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"audio": false,
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"external_task_id": "external-1",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build compatible Omni payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 ||
|
||||
payload["model_name"] != "kling-video-o1" ||
|
||||
payload["mode"] != "std" ||
|
||||
payload["sound"] != "off" ||
|
||||
payload["duration"] != "3" ||
|
||||
payload["aspect_ratio"] != "16:9" ||
|
||||
payload["external_task_id"] != "external-1" {
|
||||
t.Fatalf("unexpected compatible Omni payload: %+v", payload)
|
||||
}
|
||||
watermark, _ := payload["watermark_info"].(map[string]any)
|
||||
if watermark["enabled"] != true {
|
||||
t.Fatalf("watermark setting was not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniUpstreamModelNameSeparatesGatewayAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"kling-o1": "kling-video-o1",
|
||||
"kling-video-o1": "kling-video-o1",
|
||||
"kling-3.0-omni": "kling-v3-omni",
|
||||
"kling-v3-omni": "kling-v3-omni",
|
||||
}
|
||||
for configured, want := range tests {
|
||||
got := kelingOmniUpstreamModelName(store.RuntimeModelCandidate{ProviderModelName: configured})
|
||||
if got != want {
|
||||
t.Fatalf("configured=%s got=%s want=%s", configured, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniRejectsGeneratedAudioForO1(t *testing.T) {
|
||||
_, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Body: map[string]any{
|
||||
"prompt": "A beach",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"},
|
||||
}, "token")
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
||||
t.Fatalf("expected generated-audio rejection for O1, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniResumeReturnsUpstreamFailureCodeAndModel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/videos/omni-video/remote-failed" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-key" {
|
||||
t.Fatalf("unexpected Authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "failure-request",
|
||||
"data": map[string]any{
|
||||
"task_id": "remote-failed",
|
||||
"task_status": "failed",
|
||||
"task_status_msg": "content policy rejection",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
RemoteTaskID: "remote-failed",
|
||||
RemoteTaskPayload: map[string]any{"endpoint": "/videos/omni-video"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Credentials: map[string]any{"apiKey": "upstream-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 10,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || ErrorCode(err) != "keling_task_failed" || !strings.Contains(err.Error(), "content policy rejection") {
|
||||
t.Fatalf("expected preserved Keling task failure, got code=%q err=%v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesIntelligentMultiShot(t *testing.T) {
|
||||
payload, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "Create three coherent shots",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"multi_shot": true,
|
||||
"shot_type": "intelligence",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build intelligent multi-shot payload: %v", err)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "intelligence" || payload["prompt"] != "Create three coherent shots" {
|
||||
t.Fatalf("unexpected intelligent multi-shot payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingVideoSuccessResultPreservesOfficialVideoMetadata(t *testing.T) {
|
||||
result := kelingVideoSuccessResult(Request{Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"}}, "remote-1", map[string]any{
|
||||
"data": map[string]any{
|
||||
"task_result": map[string]any{
|
||||
"videos": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "3",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
data, _ := result["data"].([]any)
|
||||
video, _ := data[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "3" {
|
||||
t.Fatalf("official video metadata was lost: %+v", video)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Request struct {
|
||||
RemoteTaskID string
|
||||
RemoteTaskPayload map[string]any
|
||||
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
|
||||
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
|
||||
Stream bool
|
||||
StreamDelta StreamDelta
|
||||
UpstreamProtocol string
|
||||
|
||||
@@ -100,66 +100,105 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
||||
timeout := volcesPollTimeout(request)
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
nextPoll := time.NewTimer(0)
|
||||
defer nextPoll.Stop()
|
||||
|
||||
var lastResult map[string]any
|
||||
lastRequestID := firstNonEmpty(submitRequestID, upstreamTaskID)
|
||||
transientFailures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true}
|
||||
default:
|
||||
}
|
||||
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
}
|
||||
lastResult = pollResult
|
||||
|
||||
switch volcesTaskStatus(pollResult) {
|
||||
case "succeeded":
|
||||
result := volcesVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
Usage: volcesVideoUsage(pollResult),
|
||||
Progress: volcesVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed", "cancelled":
|
||||
return Response{}, &ClientError{
|
||||
Code: volcesTaskErrorCode(pollResult),
|
||||
Message: volcesTaskErrorMessage(pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: lastRequestID, Retryable: true}
|
||||
case <-deadline.C:
|
||||
return Response{}, &ClientError{
|
||||
Code: "timeout",
|
||||
Message: fmt.Sprintf("volces video task %s did not finish before timeout; last status: %s", upstreamTaskID, volcesTaskStatus(lastResult)),
|
||||
RequestID: requestID,
|
||||
RequestID: lastRequestID,
|
||||
Retryable: true,
|
||||
}
|
||||
case <-ticker.C:
|
||||
case <-nextPoll.C:
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
lastRequestID = requestID
|
||||
if err != nil {
|
||||
err = annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
if !IsRetryable(err) {
|
||||
return Response{}, err
|
||||
}
|
||||
transientFailures++
|
||||
resetVolcesPollTimer(nextPoll, volcesRetryPollInterval(request, interval, transientFailures))
|
||||
continue
|
||||
}
|
||||
transientFailures = 0
|
||||
lastResult = pollResult
|
||||
if request.OnRemoteTaskPolled != nil {
|
||||
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
|
||||
switch volcesTaskStatus(pollResult) {
|
||||
case "succeeded":
|
||||
result := volcesVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
Usage: volcesVideoUsage(pollResult),
|
||||
Progress: volcesVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed", "cancelled":
|
||||
return Response{}, &ClientError{
|
||||
Code: volcesTaskErrorCode(pollResult),
|
||||
Message: volcesTaskErrorMessage(pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
resetVolcesPollTimer(nextPoll, interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteVideoTask calls the official contents-generations cancellation endpoint.
|
||||
// It is intentionally separate from Run so task cancellation can use the same
|
||||
// provider credentials that submitted the remote task.
|
||||
func (c VolcesClient) DeleteVideoTask(ctx context.Context, request Request) (map[string]any, string, error) {
|
||||
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||
if apiKey == "" {
|
||||
return nil, "", &ClientError{Code: "missing_credentials", Message: "volces api key is required", Retryable: false}
|
||||
}
|
||||
remoteTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
if remoteTaskID == "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "volces remote task id is required", Retryable: false}
|
||||
}
|
||||
taskPath := volcesVideoTaskPath(request) + "/" + remoteTaskID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, joinURL(request.Candidate.BaseURL, taskPath), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
response, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(response)
|
||||
result, err := decodeHTTPResponse(response)
|
||||
if err != nil {
|
||||
return result, requestID, annotateResponseError(err, requestID, time.Now(), time.Now())
|
||||
}
|
||||
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
|
||||
return result, firstNonEmpty(requestID, envelopeRequestID), err
|
||||
}
|
||||
|
||||
func volcesVideoTaskPath(request Request) string {
|
||||
path := firstNonEmptyStringValue(
|
||||
request.Candidate.PlatformConfig,
|
||||
@@ -997,6 +1036,9 @@ func volcesTaskErrorCode(result map[string]any) string {
|
||||
return code
|
||||
}
|
||||
status := volcesTaskStatus(result)
|
||||
if status == "cancelled" {
|
||||
return "volces_task_cancelled"
|
||||
}
|
||||
if status != "" {
|
||||
return status
|
||||
}
|
||||
@@ -1015,6 +1057,10 @@ func volcesTaskErrorMessage(result map[string]any) string {
|
||||
}
|
||||
|
||||
func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[string]any) map[string]any {
|
||||
result := cloneMapAny(raw)
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
content, _ := raw["content"].(map[string]any)
|
||||
videoURL := strings.TrimSpace(stringFromAny(content["video_url"]))
|
||||
created := intFromAny(raw["created_at"])
|
||||
@@ -1025,16 +1071,17 @@ func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if videoURL != "" {
|
||||
data = append(data, map[string]any{"url": videoURL, "type": "video"})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": data,
|
||||
"raw": raw,
|
||||
result["id"] = firstNonEmpty(stringFromAny(raw["id"]), upstreamTaskID)
|
||||
if strings.TrimSpace(stringFromAny(result["model"])) == "" {
|
||||
result["model"] = upstreamModelName(request.Candidate)
|
||||
}
|
||||
result["status"] = "succeeded"
|
||||
result["object"] = "video.generation"
|
||||
result["created"] = created
|
||||
result["upstream_task_id"] = upstreamTaskID
|
||||
result["data"] = data
|
||||
result["raw"] = cloneMapAny(raw)
|
||||
return result
|
||||
}
|
||||
|
||||
func volcesVideoUsage(raw map[string]any) Usage {
|
||||
@@ -1074,6 +1121,37 @@ func volcesPollTimeout(request Request) time.Duration {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func volcesRetryPollInterval(request Request, normal time.Duration, failures int) time.Duration {
|
||||
if failures < 1 {
|
||||
return normal
|
||||
}
|
||||
max := time.Duration(numericValue(firstPresent(request.Candidate.PlatformConfig["volcesPollRetryMaxMs"], request.Body["pollRetryMaxMs"], request.Body["poll_retry_max_ms"]), 30000)) * time.Millisecond
|
||||
if max < normal {
|
||||
max = normal
|
||||
}
|
||||
delay := normal
|
||||
for attempt := 1; attempt < failures && delay < max; attempt++ {
|
||||
delay *= 2
|
||||
}
|
||||
if delay > max {
|
||||
return max
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func resetVolcesPollTimer(timer *time.Timer, delay time.Duration) {
|
||||
if delay < 100*time.Millisecond {
|
||||
delay = 100 * time.Millisecond
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(delay)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
volcesAssetDefaultEndpoint = "https://ark.cn-beijing.volcengineapi.com"
|
||||
volcesAssetRegion = "cn-beijing"
|
||||
volcesAssetService = "ark"
|
||||
volcesAssetVersion = "2024-01-01"
|
||||
)
|
||||
|
||||
type VolcesAssetClient struct {
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type VolcesAssetCredentials struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type VolcesAssetResult struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
URL string `json:"URL,omitempty"`
|
||||
AssetType string `json:"AssetType,omitempty"`
|
||||
GroupID string `json:"GroupId,omitempty"`
|
||||
Status string `json:"Status,omitempty"`
|
||||
Error map[string]any `json:"Error,omitempty"`
|
||||
ProjectName string `json:"ProjectName,omitempty"`
|
||||
CreateTime string `json:"CreateTime,omitempty"`
|
||||
UpdateTime string `json:"UpdateTime,omitempty"`
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) CreateAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
requestID, err := c.call(ctx, credentials, "CreateAsset", body, &result)
|
||||
return VolcesAssetResult{ID: result.ID}, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) GetAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result VolcesAssetResult
|
||||
requestID, err := c.call(ctx, credentials, "GetAsset", body, &result)
|
||||
return result, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) call(ctx context.Context, credentials VolcesAssetCredentials, action string, body map[string]any, target any) (string, error) {
|
||||
accessKey := strings.TrimSpace(credentials.AccessKey)
|
||||
secretKey := strings.TrimSpace(credentials.SecretKey)
|
||||
if accessKey == "" || secretKey == "" {
|
||||
return "", &ClientError{Code: "missing_credentials", Message: "volces portrait asset accessKey and secretKey are required", Retryable: false}
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(credentials.Endpoint), "/")
|
||||
if endpoint == "" {
|
||||
endpoint = volcesAssetDefaultEndpoint
|
||||
}
|
||||
baseURL, err := url.Parse(endpoint)
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return "", &ClientError{Code: "invalid_configuration", Message: "invalid volces portrait asset endpoint", Retryable: false}
|
||||
}
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal volces asset request: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if c.Now != nil {
|
||||
now = c.Now().UTC()
|
||||
}
|
||||
xDate := now.Format("20060102T150405Z")
|
||||
contentSHA := sha256HexBytes(bodyJSON)
|
||||
requestURL := *baseURL
|
||||
requestURL.Path = "/"
|
||||
requestURL.RawPath = ""
|
||||
requestURL.RawQuery = canonicalVolcesAssetQuery(map[string]string{"Action": action, "Version": volcesAssetVersion})
|
||||
headers := map[string]string{
|
||||
"content-type": "application/json",
|
||||
"host": baseURL.Host,
|
||||
"x-content-sha256": contentSHA,
|
||||
"x-date": xDate,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Host = baseURL.Host
|
||||
req.Header.Set("Content-Type", headers["content-type"])
|
||||
req.Header.Set("X-Content-Sha256", headers["x-content-sha256"])
|
||||
req.Header.Set("X-Date", headers["x-date"])
|
||||
req.Header.Set("Authorization", volcesAssetAuthorization(accessKey, secretKey, http.MethodPost, "/", requestURL.RawQuery, headers, contentSHA, xDate))
|
||||
|
||||
response, err := httpClient(nil, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var envelope struct {
|
||||
ResponseMetadata struct {
|
||||
RequestID string `json:"RequestId"`
|
||||
Error struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
} `json:"ResponseMetadata"`
|
||||
Result json.RawMessage `json:"Result"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
|
||||
return requestIDFromHTTPResponse(response), &ClientError{Code: "invalid_response", Message: "decode volces portrait asset response: " + err.Error(), Retryable: HTTPRetryable(response.StatusCode), StatusCode: response.StatusCode}
|
||||
}
|
||||
requestID := firstNonEmpty(requestIDFromHTTPResponse(response), envelope.ResponseMetadata.RequestID)
|
||||
if envelope.ResponseMetadata.Error.Code != "" || response.StatusCode >= http.StatusBadRequest {
|
||||
message := strings.TrimSpace(envelope.ResponseMetadata.Error.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(envelope.ResponseMetadata.Error.Code)
|
||||
}
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("volces %s failed with status %d", action, response.StatusCode)
|
||||
}
|
||||
return requestID, &ClientError{Code: firstNonEmpty(envelope.ResponseMetadata.Error.Code, "volces_asset_error"), Message: message, RequestID: requestID, StatusCode: response.StatusCode, Retryable: HTTPRetryable(response.StatusCode)}
|
||||
}
|
||||
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "volces " + action + " returned empty result", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Result, target); err != nil {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "decode volces " + action + " result: " + err.Error(), RequestID: requestID, Retryable: false}
|
||||
}
|
||||
return requestID, nil
|
||||
}
|
||||
|
||||
func volcesAssetAuthorization(accessKey string, secretKey string, method string, path string, canonicalQuery string, headers map[string]string, bodySHA string, xDate string) string {
|
||||
signedHeaders := []string{"content-type", "host", "x-content-sha256", "x-date"}
|
||||
canonicalHeaderLines := make([]string, 0, len(signedHeaders))
|
||||
for _, key := range signedHeaders {
|
||||
canonicalHeaderLines = append(canonicalHeaderLines, key+":"+strings.TrimSpace(headers[key]))
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
strings.ToUpper(method), path, canonicalQuery,
|
||||
strings.Join(canonicalHeaderLines, "\n") + "\n",
|
||||
strings.Join(signedHeaders, ";"), bodySHA,
|
||||
}, "\n")
|
||||
date := xDate
|
||||
if len(date) >= 8 {
|
||||
date = date[:8]
|
||||
}
|
||||
scope := strings.Join([]string{date, volcesAssetRegion, volcesAssetService, "request"}, "/")
|
||||
stringToSign := strings.Join([]string{"HMAC-SHA256", xDate, scope, sha256HexString(canonicalRequest)}, "\n")
|
||||
kDate := hmacSHA256([]byte(secretKey), date)
|
||||
kRegion := hmacSHA256(kDate, volcesAssetRegion)
|
||||
kService := hmacSHA256(kRegion, volcesAssetService)
|
||||
kSigning := hmacSHA256(kService, "request")
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
return "HMAC-SHA256 Credential=" + accessKey + "/" + scope + ", SignedHeaders=" + strings.Join(signedHeaders, ";") + ", Signature=" + signature
|
||||
}
|
||||
|
||||
func canonicalVolcesAssetQuery(values map[string]string) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(values[key]))
|
||||
}
|
||||
return strings.ReplaceAll(strings.Join(parts, "&"), "+", "%20")
|
||||
}
|
||||
|
||||
func sha256HexBytes(value []byte) string {
|
||||
digest := sha256.Sum256(value)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func sha256HexString(value string) string { return sha256HexBytes([]byte(value)) }
|
||||
|
||||
func hmacSHA256(key []byte, value string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVolcesAssetClientSignsCreateAndReadsAsset(t *testing.T) {
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.URL.Query().Get("Action"))
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/" || r.URL.Query().Get("Version") != "2024-01-01" {
|
||||
t.Fatalf("unexpected asset request %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if r.Header.Get("X-Date") != "20260718T010203Z" {
|
||||
t.Fatalf("unexpected x-date: %q", r.Header.Get("X-Date"))
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "HMAC-SHA256 Credential=asset-ak/") {
|
||||
t.Fatalf("missing Volces authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
digest := sha256.Sum256(raw)
|
||||
if got := r.Header.Get("X-Content-Sha256"); got != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("content hash mismatch got=%q", got)
|
||||
}
|
||||
switch r.URL.Query().Get("Action") {
|
||||
case "CreateAsset":
|
||||
if body["GroupId"] != "group-1" || body["AssetType"] != "Image" {
|
||||
t.Fatalf("unexpected CreateAsset body: %+v", body)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "create-rid"}, "Result": map[string]any{"Id": "asset-1"}})
|
||||
case "GetAsset":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "get-rid"}, "Result": map[string]any{"Id": "asset-1", "Status": "Active", "AssetType": "Image"}})
|
||||
default:
|
||||
t.Fatalf("unexpected Action: %q", r.URL.Query().Get("Action"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := VolcesAssetClient{HTTPClient: server.Client(), Now: func() time.Time {
|
||||
return time.Date(2026, 7, 18, 1, 2, 3, 0, time.UTC)
|
||||
}}
|
||||
credentials := VolcesAssetCredentials{AccessKey: "asset-ak", SecretKey: "asset-sk", Endpoint: server.URL}
|
||||
created, requestID, err := client.CreateAsset(context.Background(), credentials, map[string]any{"GroupId": "group-1", "URL": "https://example.com/person.png", "AssetType": "Image", "ProjectName": "default"})
|
||||
if err != nil || created.ID != "asset-1" || requestID != "create-rid" {
|
||||
t.Fatalf("unexpected CreateAsset result=%+v requestID=%s err=%v", created, requestID, err)
|
||||
}
|
||||
asset, requestID, err := client.GetAsset(context.Background(), credentials, map[string]any{"Id": "asset-1", "ProjectName": "default"})
|
||||
if err != nil || asset.Status != "Active" || requestID != "get-rid" {
|
||||
t.Fatalf("unexpected GetAsset result=%+v requestID=%s err=%v", asset, requestID, err)
|
||||
}
|
||||
if strings.Join(calls, ",") != "CreateAsset,GetAsset" {
|
||||
t.Fatalf("unexpected actions: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,32 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// listAPIKeyAssignableModels godoc
|
||||
// @Summary 列出 API Key 可分配模型
|
||||
// @Description 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} PlatformModelListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/assignable-models [get]
|
||||
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
s.logger.Error("list api key assignable models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list api key assignable models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
// createAccessRule godoc
|
||||
// @Summary 创建访问规则
|
||||
// @Description 管理端创建一条访问控制规则。
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestAPIKeyAssignableModelsIgnoreAPIKeyRules(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 API key assignable-model 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()
|
||||
|
||||
suffixText := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "api_key_assignable_" + suffixText
|
||||
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)
|
||||
|
||||
testPool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test pool: %v", err)
|
||||
}
|
||||
defer testPool.Close()
|
||||
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote test 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)
|
||||
|
||||
createAPIKey := func(name string) string {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
APIKey struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"apiKey"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
|
||||
"name": name,
|
||||
}, http.StatusCreated, &response)
|
||||
return response.APIKey.ID
|
||||
}
|
||||
firstAPIKeyID := createAPIKey("first assignable key")
|
||||
secondAPIKeyID := createAPIKey("second assignable key")
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "openai",
|
||||
"platformKey": "api-key-assignable-" + suffixText,
|
||||
"name": "API Key Assignable Test",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"authType": "bearer",
|
||||
"credentials": map[string]any{"mode": "simulation"},
|
||||
"config": map[string]any{"testMode": true},
|
||||
}, http.StatusCreated, &platform)
|
||||
|
||||
var model struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
modelName := "api-key-assignable-model-" + suffixText
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "openai:gpt-4o-mini",
|
||||
"modelName": modelName,
|
||||
"modelAlias": modelName,
|
||||
"modelType": []string{"text_generate"},
|
||||
"displayName": "API Key Assignable Model",
|
||||
}, http.StatusCreated, &model)
|
||||
|
||||
assertAssignable := func() {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
ModelName string `json:"modelName"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/assignable-models", loginResponse.AccessToken, nil, http.StatusOK, &response)
|
||||
if !modelListContains(response.Items, model.ID) {
|
||||
t.Fatalf("user-owned model should remain assignable regardless of API key rules: %+v", response.Items)
|
||||
}
|
||||
}
|
||||
assignModel := func(apiKeyID string) {
|
||||
t.Helper()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", loginResponse.AccessToken, map[string]any{
|
||||
"subjectType": "api_key",
|
||||
"subjectId": apiKeyID,
|
||||
"effect": "allow",
|
||||
"upsertResources": []map[string]any{{
|
||||
"resourceType": "platform_model",
|
||||
"resourceId": model.ID,
|
||||
"priority": 100,
|
||||
"minPermissionLevel": 0,
|
||||
"status": "active",
|
||||
}},
|
||||
"deleteResources": []map[string]any{},
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
assertAssignable()
|
||||
assignModel(firstAPIKeyID)
|
||||
assertAssignable()
|
||||
assignModel(secondAPIKeyID)
|
||||
assertAssignable()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type gatewayTaskCreationStage string
|
||||
|
||||
const (
|
||||
gatewayTaskCreationPrepare gatewayTaskCreationStage = "prepare"
|
||||
gatewayTaskCreationStore gatewayTaskCreationStage = "store"
|
||||
)
|
||||
|
||||
type gatewayTaskCreationError struct {
|
||||
Stage gatewayTaskCreationStage
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *gatewayTaskCreationError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "gateway task creation failed"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *gatewayTaskCreationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (s *Server) prepareAndCreateGatewayTask(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
user *auth.User,
|
||||
kind string,
|
||||
model string,
|
||||
body map[string]any,
|
||||
async bool,
|
||||
) (store.GatewayTask, error) {
|
||||
prepared, err := s.prepareTaskRequest(ctx, r, user, body)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, &gatewayTaskCreationError{Stage: gatewayTaskCreationPrepare, Err: err}
|
||||
}
|
||||
task, err := s.store.CreateTask(ctx, store.CreateTaskInput{
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: async,
|
||||
Request: prepared.Body,
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}, user)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, &gatewayTaskCreationError{
|
||||
Stage: gatewayTaskCreationStore,
|
||||
Err: fmt.Errorf("create task: %w", err),
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
@@ -40,9 +40,9 @@ type geminiUploadSession struct {
|
||||
}
|
||||
|
||||
var geminiGenerateContentRoutePrefixes = []string{
|
||||
"/api/v1/models/",
|
||||
"/v1beta/models/",
|
||||
"/v1/models/",
|
||||
"/models/",
|
||||
}
|
||||
|
||||
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -25,9 +33,9 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "bare model path",
|
||||
prefix: "/models/",
|
||||
requestPath: "/models/gemini-image:generateContent",
|
||||
name: "gateway api v1 model",
|
||||
prefix: "/api/v1/models/",
|
||||
requestPath: "/api/v1/models/gemini-image:generateContent",
|
||||
wantModel: "gemini-image",
|
||||
wantOK: true,
|
||||
},
|
||||
@@ -61,6 +69,39 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
|
||||
server := &Server{
|
||||
auth: auth.New("test-secret", "", ""),
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/models", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}{
|
||||
{method: http.MethodGet, path: "/api/v1/models", status: http.StatusNoContent},
|
||||
{method: http.MethodPost, path: "/api/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/v1beta/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/models/gemini-image:generateContent", status: http.StatusNotFound},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
if response.Code != tt.status {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, tt.status, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) {
|
||||
mapping, err := geminiImageTaskBody("gemini-image", map[string]any{
|
||||
"contents": []any{
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const kelingOmniCompatibilityMarker = "keling_omni_v1"
|
||||
|
||||
type kelingCompatRequestIDKey struct{}
|
||||
|
||||
type KelingOmniVideoRequest struct {
|
||||
ModelName string `json:"model_name" example:"kling-v3-omni"`
|
||||
Prompt string `json:"prompt" example:"A quiet street in the rain with natural ambient sound"`
|
||||
MultiShot bool `json:"multi_shot" example:"false"`
|
||||
ShotType string `json:"shot_type,omitempty" example:"customize"`
|
||||
MultiPrompt []KelingOmniMultiPrompt `json:"multi_prompt,omitempty"`
|
||||
ImageList []KelingOmniImageInput `json:"image_list,omitempty"`
|
||||
ElementList []KelingOmniElementInput `json:"element_list,omitempty"`
|
||||
VideoList []KelingOmniVideoInput `json:"video_list,omitempty"`
|
||||
Sound string `json:"sound" enums:"on,off" example:"on"`
|
||||
Mode string `json:"mode" enums:"std,pro,4k" example:"pro"`
|
||||
AspectRatio string `json:"aspect_ratio" enums:"16:9,9:16,1:1" example:"9:16"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"5"`
|
||||
WatermarkInfo KelingOmniWatermarkInfo `json:"watermark_info,omitempty"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
ExternalTask string `json:"external_task_id,omitempty"`
|
||||
}
|
||||
|
||||
type KelingOmniMultiPrompt struct {
|
||||
Index int `json:"index" example:"1"`
|
||||
Prompt string `json:"prompt" example:"A wide establishing shot"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"3"`
|
||||
}
|
||||
|
||||
type KelingOmniImageInput struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Type string `json:"type,omitempty" enums:"first_frame,end_frame"`
|
||||
}
|
||||
|
||||
type KelingOmniElementInput struct {
|
||||
ElementID any `json:"element_id"`
|
||||
}
|
||||
|
||||
type KelingOmniVideoInput struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
ReferType string `json:"refer_type,omitempty" enums:"base,feature"`
|
||||
KeepOriginalSound string `json:"keep_original_sound,omitempty" enums:"yes,no"`
|
||||
}
|
||||
|
||||
type KelingOmniWatermarkInfo struct {
|
||||
Enabled bool `json:"enabled" example:"false"`
|
||||
}
|
||||
|
||||
type KelingCompatibleEnvelope struct {
|
||||
Code int `json:"code" example:"0"`
|
||||
Message string `json:"message" example:"SUCCEED"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type kelingCompatError struct {
|
||||
HTTPStatus int
|
||||
Code int
|
||||
Message string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e *kelingCompatError) Error() string {
|
||||
if e == nil {
|
||||
return "keling compatibility error"
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func newKelingCompatError(status int, code int, message string) *kelingCompatError {
|
||||
return &kelingCompatError{HTTPStatus: status, Code: code, Message: message}
|
||||
}
|
||||
|
||||
func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := newKelingCompatRequestID(r)
|
||||
r = r.WithContext(context.WithValue(r.Context(), kelingCompatRequestIDKey{}, requestID))
|
||||
user, err := s.auth.Authenticate(r)
|
||||
if err != nil {
|
||||
code := 1002
|
||||
message := "Authorization is invalid"
|
||||
if strings.TrimSpace(r.Header.Get("Authorization")) == "" {
|
||||
code = 1001
|
||||
message = "Authorization is required"
|
||||
}
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, code, message))
|
||||
return
|
||||
}
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "a Gateway API Key is required"))
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusForbidden, 1103, "API Key scope does not allow video generation"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
// createKelingOmniVideo godoc
|
||||
// @Summary 创建 Kling Omni 视频任务
|
||||
// @Description 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body KelingOmniVideoRequest true "Kling Omni 官方兼容请求"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 400 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video [post]
|
||||
func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, err.Error()))
|
||||
return
|
||||
}
|
||||
normalized, compatErr := normalizeKelingOmniRequest(body)
|
||||
if compatErr != nil {
|
||||
writeKelingCompatError(w, requestID, compatErr)
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(stringFromKelingCompat(normalized["model"]))
|
||||
if normalized["resolution"] == "2160p" {
|
||||
candidates, candidateErr := s.store.ListModelCandidates(r.Context(), model, "omni_video", user)
|
||||
if candidateErr != nil {
|
||||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(candidateErr))
|
||||
return
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, "mode=4k is not enabled by the selected model capabilities"))
|
||||
return
|
||||
}
|
||||
}
|
||||
task, createErr := s.prepareAndCreateGatewayTask(
|
||||
r.Context(),
|
||||
r,
|
||||
user,
|
||||
"videos.generations",
|
||||
model,
|
||||
normalized,
|
||||
true,
|
||||
)
|
||||
if createErr != nil {
|
||||
var staged *gatewayTaskCreationError
|
||||
if errors.As(createErr, &staged) && staged.Stage == gatewayTaskCreationPrepare {
|
||||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(staged.Err))
|
||||
return
|
||||
}
|
||||
s.logger.Error("create Kling-compatible task failed", "error", createErr)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed"))
|
||||
return
|
||||
}
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
s.logger.Error("enqueue Kling-compatible task failed", "taskId", task.ID, "error", err)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusServiceUnavailable, 5001, "video task queue is unavailable"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||||
Code: 0,
|
||||
Message: "SUCCEED",
|
||||
RequestID: requestID,
|
||||
Data: kelingCompatTaskData(task),
|
||||
})
|
||||
}
|
||||
|
||||
// getKelingOmniVideo godoc
|
||||
// @Summary 查询 Kling Omni 视频任务
|
||||
// @Description 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "网关任务 ID"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 404 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||||
return
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||||
return
|
||||
}
|
||||
s.logger.Error("get Kling-compatible task failed", "error", err)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "query task failed"))
|
||||
return
|
||||
}
|
||||
if !kelingCompatTaskOwnedBy(task, user) || !isKelingCompatTask(task) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||||
Code: 0,
|
||||
Message: "SUCCEED",
|
||||
RequestID: requestID,
|
||||
Data: kelingCompatTaskData(task),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCompatError) {
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(stringFromKelingCompat(input["callback_url"])); callbackURL != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "callback_url is not supported by this Gateway endpoint")
|
||||
}
|
||||
requestedModel := strings.TrimSpace(stringFromKelingCompat(input["model_name"]))
|
||||
if requestedModel == "" {
|
||||
requestedModel = "kling-video-o1"
|
||||
}
|
||||
model, maxDuration, ok := kelingCompatModel(requestedModel)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusNotFound, 1203, "unsupported model_name: "+requestedModel)
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["mode"])))
|
||||
if mode == "" {
|
||||
mode = "pro"
|
||||
}
|
||||
resolutionByMode := map[string]string{"std": "720p", "pro": "1080p", "4k": "2160p"}
|
||||
resolution := resolutionByMode[mode]
|
||||
if resolution == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "mode must be std, pro, or 4k")
|
||||
}
|
||||
|
||||
sound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["sound"])))
|
||||
if sound == "" {
|
||||
sound = "off"
|
||||
}
|
||||
if sound != "on" && sound != "off" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off")
|
||||
}
|
||||
if model == "kling-o1" && sound == "on" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off")
|
||||
}
|
||||
|
||||
content := make([]any, 0)
|
||||
prompt := strings.TrimSpace(stringFromKelingCompat(input["prompt"]))
|
||||
images, hasFirstFrame, imageErr := normalizeKelingImageList(input["image_list"])
|
||||
if imageErr != nil {
|
||||
return nil, imageErr
|
||||
}
|
||||
content = append(content, images...)
|
||||
elements, elementErr := normalizeKelingElementList(input["element_list"])
|
||||
if elementErr != nil {
|
||||
return nil, elementErr
|
||||
}
|
||||
content = append(content, elements...)
|
||||
videos, hasBaseVideo, hasVideo, videoErr := normalizeKelingVideoList(input["video_list"])
|
||||
if videoErr != nil {
|
||||
return nil, videoErr
|
||||
}
|
||||
content = append(content, videos...)
|
||||
if hasVideo && sound == "on" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be off when video_list is provided")
|
||||
}
|
||||
|
||||
multiShot, multiShotPresent, boolErr := kelingCompatOptionalBool(input, "multi_shot")
|
||||
if boolErr != nil {
|
||||
return nil, boolErr
|
||||
}
|
||||
shotType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["shot_type"])))
|
||||
multiPrompts, shotDuration, multiPromptErr := normalizeKelingMultiPrompts(input["multi_prompt"])
|
||||
if multiPromptErr != nil {
|
||||
return nil, multiPromptErr
|
||||
}
|
||||
if !multiShotPresent {
|
||||
multiShot = false
|
||||
}
|
||||
if multiShot {
|
||||
if shotType != "customize" && shotType != "intelligence" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type must be customize or intelligence when multi_shot is true")
|
||||
}
|
||||
if shotType == "customize" && len(multiPrompts) == 0 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is required for customized multi-shot generation")
|
||||
}
|
||||
if shotType == "intelligence" && len(multiPrompts) > 0 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is only supported when shot_type is customize")
|
||||
}
|
||||
} else if len(multiPrompts) > 0 || shotType != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type and multi_prompt require multi_shot=true")
|
||||
}
|
||||
if (len(multiPrompts) == 0 || shotType == "intelligence") && prompt == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "prompt is required")
|
||||
}
|
||||
if prompt != "" {
|
||||
content = append([]any{map[string]any{"type": "text", "text": prompt}}, content...)
|
||||
}
|
||||
content = append(content, multiPrompts...)
|
||||
|
||||
duration, durationProvided, durationErr := kelingCompatOptionalInt(input, "duration")
|
||||
if durationErr != nil {
|
||||
return nil, durationErr
|
||||
}
|
||||
if hasBaseVideo {
|
||||
if durationProvided {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration is not supported for base video editing")
|
||||
}
|
||||
} else {
|
||||
if len(multiPrompts) > 0 {
|
||||
if durationProvided && duration != shotDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration must equal the sum of multi_prompt durations")
|
||||
}
|
||||
duration = shotDuration
|
||||
} else if !durationProvided {
|
||||
duration = 5
|
||||
}
|
||||
if duration < 3 || duration > maxDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration))
|
||||
}
|
||||
if model == "kling-o1" && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
aspectRatio := strings.TrimSpace(stringFromKelingCompat(input["aspect_ratio"]))
|
||||
if aspectRatio != "" && aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio must be 16:9, 9:16, or 1:1")
|
||||
}
|
||||
if hasFirstFrame || hasBaseVideo {
|
||||
if aspectRatio != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is not supported with a first frame or base video")
|
||||
}
|
||||
} else if aspectRatio == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is required when no first frame or base video is provided")
|
||||
}
|
||||
|
||||
watermarkEnabled, watermarkErr := kelingCompatWatermarkEnabled(input["watermark_info"])
|
||||
if watermarkErr != nil {
|
||||
return nil, watermarkErr
|
||||
}
|
||||
externalTaskID := strings.TrimSpace(stringFromKelingCompat(input["external_task_id"]))
|
||||
normalized := map[string]any{
|
||||
"model": model,
|
||||
"model_name": requestedModel,
|
||||
"modelType": "omni_video",
|
||||
"runMode": "real",
|
||||
"content": content,
|
||||
"resolution": resolution,
|
||||
"mode": mode,
|
||||
"sound": sound,
|
||||
"audio": sound == "on",
|
||||
"multi_shot": multiShot,
|
||||
"watermark": watermarkEnabled,
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"external_task_id": externalTaskID,
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
}
|
||||
if prompt != "" {
|
||||
normalized["prompt"] = prompt
|
||||
}
|
||||
if shotType != "" {
|
||||
normalized["shot_type"] = shotType
|
||||
}
|
||||
if !hasBaseVideo {
|
||||
normalized["duration"] = duration
|
||||
}
|
||||
if aspectRatio != "" {
|
||||
normalized["aspect_ratio"] = aspectRatio
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeKelingImageList(value any) ([]any, bool, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "image_list")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
hasFirstFrame := false
|
||||
hasEndFrame := false
|
||||
for index, item := range items {
|
||||
url := strings.TrimSpace(stringFromKelingCompat(item["image_url"]))
|
||||
if url == "" {
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].image_url is required", index))
|
||||
}
|
||||
frameType := strings.TrimSpace(stringFromKelingCompat(item["type"]))
|
||||
role := "reference_image"
|
||||
switch frameType {
|
||||
case "":
|
||||
case "first_frame":
|
||||
role = "first_frame"
|
||||
hasFirstFrame = true
|
||||
case "end_frame":
|
||||
role = "last_frame"
|
||||
hasEndFrame = true
|
||||
default:
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].type must be first_frame or end_frame", index))
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"type": "image_url",
|
||||
"role": role,
|
||||
"image_url": map[string]any{"url": url},
|
||||
})
|
||||
}
|
||||
if hasEndFrame && !hasFirstFrame {
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, "end_frame requires first_frame")
|
||||
}
|
||||
return out, hasFirstFrame, nil
|
||||
}
|
||||
|
||||
func normalizeKelingElementList(value any) ([]any, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "element_list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
id := item["element_id"]
|
||||
if strings.TrimSpace(stringFromKelingCompat(id)) == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("element_list[%d].element_id is required", index))
|
||||
}
|
||||
out = append(out, map[string]any{"type": "element", "element": map[string]any{"element_id": id}})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeKelingVideoList(value any) ([]any, bool, bool, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "video_list")
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
if len(items) > 1 {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, "video_list supports at most one video")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
hasBase := false
|
||||
for index, item := range items {
|
||||
url := strings.TrimSpace(stringFromKelingCompat(item["video_url"]))
|
||||
if url == "" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].video_url is required", index))
|
||||
}
|
||||
referType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["refer_type"])))
|
||||
if referType == "" {
|
||||
referType = "base"
|
||||
}
|
||||
if referType != "base" && referType != "feature" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].refer_type must be base or feature", index))
|
||||
}
|
||||
keepSound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["keep_original_sound"])))
|
||||
if keepSound != "" && keepSound != "yes" && keepSound != "no" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].keep_original_sound must be yes or no", index))
|
||||
}
|
||||
nested := map[string]any{"url": url, "refer_type": referType}
|
||||
if keepSound != "" {
|
||||
nested["keep_original_sound"] = keepSound
|
||||
}
|
||||
role := "video_feature"
|
||||
if referType == "base" {
|
||||
role = "video_base"
|
||||
hasBase = true
|
||||
}
|
||||
out = append(out, map[string]any{"type": "video_url", "role": role, "video_url": nested})
|
||||
}
|
||||
return out, hasBase, len(items) > 0, nil
|
||||
}
|
||||
|
||||
func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "multi_prompt")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(items) > 6 {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt supports at most six shots")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
seen := map[int]bool{}
|
||||
total := 0
|
||||
for index, item := range items {
|
||||
shotIndex, ok := kelingCompatInt(item["index"])
|
||||
if !ok || shotIndex < 1 || shotIndex > 6 || seen[shotIndex] {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].index must be a unique integer from 1 to 6", index))
|
||||
}
|
||||
seen[shotIndex] = true
|
||||
prompt := strings.TrimSpace(stringFromKelingCompat(item["prompt"]))
|
||||
if prompt == "" {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].prompt is required", index))
|
||||
}
|
||||
duration, ok := kelingCompatInt(item["duration"])
|
||||
if !ok || duration < 1 {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].duration must be an integer of at least 1 second", index))
|
||||
}
|
||||
total += duration
|
||||
out = append(out, map[string]any{
|
||||
"type": "text",
|
||||
"role": "shot_prompt",
|
||||
"shot_index": shotIndex,
|
||||
"text": prompt,
|
||||
"duration": duration,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func kelingCompatModel(value string) (string, int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "kling-video-o1", "kling-o1":
|
||||
return "kling-o1", 10, true
|
||||
case "kling-v3-omni", "kling-3.0-omni":
|
||||
return "kling-3.0-omni", 15, true
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, field+" must be an array")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("%s[%d] must be an object", field, index))
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func kelingCompatOptionalInt(body map[string]any, key string) (int, bool, *kelingCompatError) {
|
||||
value, present := body[key]
|
||||
if !present || value == nil || strings.TrimSpace(stringFromKelingCompat(value)) == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, ok := kelingCompatInt(value)
|
||||
if !ok {
|
||||
return 0, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be an integer")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func kelingCompatOptionalBool(body map[string]any, key string) (bool, bool, *kelingCompatError) {
|
||||
value, present := body[key]
|
||||
if !present || value == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
parsed, ok := value.(bool)
|
||||
if !ok {
|
||||
return false, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be a boolean")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func kelingCompatWatermarkEnabled(value any) (bool, *kelingCompatError) {
|
||||
if value == nil {
|
||||
return false, nil
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info must be an object")
|
||||
}
|
||||
enabled, present := object["enabled"]
|
||||
if !present {
|
||||
return false, nil
|
||||
}
|
||||
result, ok := enabled.(bool)
|
||||
if !ok {
|
||||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info.enabled must be a boolean")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func kelingCompatInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
if math.Abs(typed-math.Round(typed)) > 1e-9 {
|
||||
return 0, false
|
||||
}
|
||||
return int(math.Round(typed)), true
|
||||
case json.Number:
|
||||
parsed, err := strconv.Atoi(typed.String())
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromKelingCompat(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
if math.Abs(typed-math.Round(typed)) < 1e-9 {
|
||||
return strconv.FormatInt(int64(math.Round(typed)), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatTaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"task_id": task.ID,
|
||||
"task_status": kelingCompatTaskStatus(task.Status),
|
||||
"task_info": map[string]any{
|
||||
"external_task_id": strings.TrimSpace(stringFromKelingCompat(task.Request["external_task_id"])),
|
||||
},
|
||||
"created_at": task.CreatedAt.UnixMilli(),
|
||||
"updated_at": task.UpdatedAt.UnixMilli(),
|
||||
"watermark_info": map[string]any{
|
||||
"enabled": kelingCompatTaskWatermark(task.Request),
|
||||
},
|
||||
}
|
||||
if message := kelingCompatTaskMessage(task); message != "" {
|
||||
data["task_status_msg"] = message
|
||||
}
|
||||
if kelingCompatTaskStatus(task.Status) == "failed" {
|
||||
data["task_status_code"] = kelingCompatBusinessCode(task.ErrorCode, kelingCompatTaskMessage(task))
|
||||
}
|
||||
if videos := kelingCompatTaskVideos(task.Result); len(videos) > 0 {
|
||||
data["task_result"] = map[string]any{"videos": videos}
|
||||
}
|
||||
if task.FinalChargeAmount > 0 {
|
||||
data["final_unit_deduction"] = strconv.FormatFloat(task.FinalChargeAmount, 'f', -1, 64)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func kelingCompatTaskStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "succeed"
|
||||
case "failed", "cancelled", "canceled":
|
||||
return "failed"
|
||||
case "running", "processing":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatTaskVideos(result map[string]any) []any {
|
||||
raw, _ := result["data"].([]any)
|
||||
out := make([]any, 0, len(raw))
|
||||
for _, itemValue := range raw {
|
||||
item, _ := itemValue.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
url := strings.TrimSpace(stringFromKelingCompat(firstKelingCompatValue(item["url"], item["video_url"])))
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
video := map[string]any{"url": url}
|
||||
if id := strings.TrimSpace(stringFromKelingCompat(item["id"])); id != "" {
|
||||
video["id"] = id
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromKelingCompat(item["watermark_url"])); watermarkURL != "" {
|
||||
video["watermark_url"] = watermarkURL
|
||||
}
|
||||
if duration := strings.TrimSpace(stringFromKelingCompat(item["duration"])); duration != "" {
|
||||
video["duration"] = duration
|
||||
}
|
||||
out = append(out, video)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstKelingCompatValue(values ...any) any {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(stringFromKelingCompat(value)) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kelingCompatTaskMessage(task store.GatewayTask) string {
|
||||
return strings.TrimSpace(firstNonEmpty(task.ErrorMessage, task.Error, task.Message))
|
||||
}
|
||||
|
||||
func kelingCompatTaskWatermark(request map[string]any) bool {
|
||||
if value, ok := request["watermark"].(bool); ok {
|
||||
return value
|
||||
}
|
||||
if info, ok := request["watermark_info"].(map[string]any); ok {
|
||||
value, _ := info["enabled"].(bool)
|
||||
return value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kelingCompatCandidatesSupport4K(candidates []store.RuntimeModelCandidate) bool {
|
||||
for _, candidate := range candidates {
|
||||
if strings.ToLower(strings.TrimSpace(candidate.Provider)) != "keling" {
|
||||
continue
|
||||
}
|
||||
capability, _ := candidate.Capabilities["omni_video"].(map[string]any)
|
||||
if capability == nil {
|
||||
capability, _ = candidate.Capabilities["omni"].(map[string]any)
|
||||
}
|
||||
for _, resolution := range kelingCompatStringList(capability["output_resolutions"]) {
|
||||
switch strings.ToLower(strings.TrimSpace(resolution)) {
|
||||
case "2160p", "4k":
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kelingCompatStringList(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
result := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text := strings.TrimSpace(stringFromKelingCompat(item)); text != "" {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatGatewayError(err error) *kelingCompatError {
|
||||
if err == nil {
|
||||
return newKelingCompatError(http.StatusInternalServerError, 5000, "unknown gateway error")
|
||||
}
|
||||
codeText := clients.ErrorCode(err)
|
||||
businessCode := kelingCompatBusinessCode(codeText, err.Error())
|
||||
status := http.StatusInternalServerError
|
||||
switch businessCode {
|
||||
case 1101:
|
||||
status = http.StatusPaymentRequired
|
||||
case 1103:
|
||||
status = http.StatusForbidden
|
||||
case 1201:
|
||||
status = http.StatusBadRequest
|
||||
case 1203:
|
||||
status = http.StatusNotFound
|
||||
case 1302, 1303:
|
||||
status = http.StatusTooManyRequests
|
||||
case 5001:
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
return newKelingCompatError(status, businessCode, err.Error())
|
||||
}
|
||||
|
||||
func kelingCompatBusinessCode(errorCode string, message string) int {
|
||||
combined := strings.ToLower(strings.TrimSpace(errorCode + " " + message))
|
||||
containsAny := func(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(combined, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case containsAny("insufficient_balance", "insufficient balance", "balance_not_enough", "wallet balance", "余额不足", "欠费", "quota exceeded"):
|
||||
return 1101
|
||||
case containsAny("permission_denied", "permission denied", "forbidden", "access denied", "scope does not allow"):
|
||||
return 1103
|
||||
case containsAny("concurrent", "concurrency"):
|
||||
return 1303
|
||||
case containsAny("rate_limit", "rate limit", "too many requests", "rpm", "tpm"):
|
||||
return 1302
|
||||
case containsAny("no_model_candidate", "no model candidate", "model_not_found", "unsupported model", "resource not found"):
|
||||
return 1203
|
||||
case containsAny("invalid_parameter", "invalid parameter", "bad_request", "parameter_preprocessing", "duration", "aspect_ratio"):
|
||||
return 1201
|
||||
case containsAny("upload_", "request_asset_", "network", "timeout", "upstream", "service unavailable", "bad gateway"):
|
||||
return 5001
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
func isKelingCompatTask(task store.GatewayTask) bool {
|
||||
return task.Kind == "videos.generations" && strings.TrimSpace(stringFromKelingCompat(task.Request["_gateway_compatibility"])) == kelingOmniCompatibilityMarker
|
||||
}
|
||||
|
||||
func kelingCompatTaskOwnedBy(task store.GatewayTask, user *auth.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
taskOwner := strings.TrimSpace(firstNonEmpty(task.GatewayUserID, task.UserID))
|
||||
requestOwner := strings.TrimSpace(firstNonEmpty(user.GatewayUserID, user.ID))
|
||||
return taskOwner != "" && requestOwner != "" && taskOwner == requestOwner
|
||||
}
|
||||
|
||||
func newKelingCompatRequestID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if value := strings.TrimSpace(firstNonEmpty(r.Header.Get("X-Request-ID"), r.Header.Get("X-Request-Id"))); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err := rand.Read(random); err == nil {
|
||||
return hex.EncodeToString(random)
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
func kelingCompatRequestID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if value, ok := r.Context().Value(kelingCompatRequestIDKey{}).(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return newKelingCompatRequestID(r)
|
||||
}
|
||||
|
||||
func writeKelingCompatError(w http.ResponseWriter, requestID string, err *kelingCompatError) {
|
||||
if err == nil {
|
||||
err = newKelingCompatError(http.StatusInternalServerError, 5000, "internal error")
|
||||
}
|
||||
if err.RequestID != "" {
|
||||
requestID = err.RequestID
|
||||
}
|
||||
if requestID == "" {
|
||||
requestID = newKelingCompatRequestID(nil)
|
||||
}
|
||||
status := err.HTTPStatus
|
||||
if status == 0 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeJSON(w, status, KelingCompatibleEnvelope{
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A rainy street with natural ambience",
|
||||
"mode": "pro",
|
||||
"sound": "on",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"external_task_id": "external-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize Kling request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" ||
|
||||
normalized["modelType"] != "omni_video" ||
|
||||
normalized["resolution"] != "1080p" ||
|
||||
normalized["aspect_ratio"] != "9:16" ||
|
||||
normalized["duration"] != 5 ||
|
||||
normalized["audio"] != true ||
|
||||
normalized["sound"] != "on" ||
|
||||
normalized["watermark"] != true ||
|
||||
normalized["external_task_id"] != "external-1" ||
|
||||
normalized["_gateway_compatibility"] != kelingOmniCompatibilityMarker {
|
||||
t.Fatalf("unexpected normalized request: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("unexpected content: %+v", normalized["content"])
|
||||
}
|
||||
text, _ := content[0].(map[string]any)
|
||||
if text["type"] != "text" || text["text"] != "A rainy street with natural ambience" {
|
||||
t.Fatalf("unexpected text content: %+v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-3.0-omni",
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 5,
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": 1, "prompt": "First shot", "duration": "2"},
|
||||
map[string]any{"index": 2, "prompt": "Second shot", "duration": 3},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/reference.png"},
|
||||
},
|
||||
"element_list": []any{
|
||||
map[string]any{"element_id": float64(123)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize multi-shot request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" {
|
||||
t.Fatalf("unexpected multi-shot fields: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("expected image, element and two shot prompts, got %+v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestRejectsUnsupportedCombinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{
|
||||
name: "callback",
|
||||
body: map[string]any{"callback_url": "https://example.com/callback"},
|
||||
},
|
||||
{
|
||||
name: "unknown model",
|
||||
body: map[string]any{"model_name": "kling-unknown", "prompt": "x", "aspect_ratio": "16:9"},
|
||||
},
|
||||
{
|
||||
name: "o1 duration",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 11},
|
||||
},
|
||||
{
|
||||
name: "o1 text to video three seconds",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 3},
|
||||
},
|
||||
{
|
||||
name: "o1 generated audio",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "sound": "on", "aspect_ratio": "16:9", "duration": 5},
|
||||
},
|
||||
{
|
||||
name: "video sound",
|
||||
body: map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "edit",
|
||||
"sound": "on",
|
||||
"video_list": []any{map[string]any{"video_url": "https://example.com/base.mp4", "refer_type": "base"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "first frame ratio",
|
||||
body: map[string]any{
|
||||
"prompt": "animate",
|
||||
"aspect_ratio": "16:9",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
_, err := normalizeKelingOmniRequest(item.body)
|
||||
if err == nil || err.Code != 1201 && err.Code != 1203 {
|
||||
t.Fatalf("expected official compatibility error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingO1AllowsThreeSecondsWithReferenceImage(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "Use the landscape as a visual reference",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://placehold.co/1024x1024/png"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reference-image O1 request should allow three seconds: %v", err)
|
||||
}
|
||||
if normalized["duration"] != 3 {
|
||||
t.Fatalf("unexpected duration: %+v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatTaskDataAndOwnership(t *testing.T) {
|
||||
createdAt := time.Unix(100, 0)
|
||||
task := store.GatewayTask{
|
||||
ID: "task-1",
|
||||
Kind: "videos.generations",
|
||||
GatewayUserID: "user-1",
|
||||
Status: "succeeded",
|
||||
FinalChargeAmount: 2.5,
|
||||
Request: map[string]any{
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
"external_task_id": "external-1",
|
||||
"watermark": true,
|
||||
},
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "5",
|
||||
}}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Second),
|
||||
}
|
||||
if !isKelingCompatTask(task) || !kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-1"}) {
|
||||
t.Fatalf("expected task ownership and compatibility marker")
|
||||
}
|
||||
if kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-2"}) {
|
||||
t.Fatalf("cross-user task access must be rejected")
|
||||
}
|
||||
data := kelingCompatTaskData(task)
|
||||
if data["task_status"] != "succeed" || data["final_unit_deduction"] != "2.5" {
|
||||
t.Fatalf("unexpected task data: %+v", data)
|
||||
}
|
||||
result, _ := data["task_result"].(map[string]any)
|
||||
videos, _ := result["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "5" {
|
||||
t.Fatalf("unexpected compatible video: %+v", video)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireKelingAPIKeyWritesOfficialAuthEnvelope(t *testing.T) {
|
||||
server := &Server{auth: auth.New("secret", "", "")}
|
||||
server.auth.LocalAPIKeyVerifier = func(_ context.Context, key string) (*auth.User, error) {
|
||||
if key != "sk-gw-valid" {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
return &auth.User{ID: "user-1", GatewayUserID: "user-1", APIKeyID: "key-1", APIKeyScopes: []string{"video"}}, nil
|
||||
}
|
||||
handler := server.requireKelingAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.UserFromContext(r.Context()); !ok {
|
||||
t.Fatal("authenticated user is missing")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil))
|
||||
if missing.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing auth status=%d body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
var missingBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(missing.Body.Bytes(), &missingBody); err != nil || missingBody.Code != 1001 || missingBody.RequestID == "" {
|
||||
t.Fatalf("unexpected missing auth envelope: %+v err=%v", missingBody, err)
|
||||
}
|
||||
|
||||
invalid := httptest.NewRecorder()
|
||||
invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
invalidRequest.Header.Set("Authorization", "Bearer sk-gw-invalid")
|
||||
handler.ServeHTTP(invalid, invalidRequest)
|
||||
var invalidBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(invalid.Body.Bytes(), &invalidBody); err != nil || invalid.Code != http.StatusUnauthorized || invalidBody.Code != 1002 {
|
||||
t.Fatalf("unexpected invalid auth envelope: status=%d body=%+v err=%v", invalid.Code, invalidBody, err)
|
||||
}
|
||||
|
||||
valid := httptest.NewRecorder()
|
||||
validRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
validRequest.Header.Set("Authorization", "Bearer sk-gw-valid")
|
||||
handler.ServeHTTP(valid, validRequest)
|
||||
if valid.Code != http.StatusNoContent {
|
||||
t.Fatalf("valid API Key status=%d body=%s", valid.Code, valid.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatErrorImplementsError(t *testing.T) {
|
||||
err := newKelingCompatError(http.StatusBadRequest, 1201, "invalid")
|
||||
if !errors.Is(err, err) || err.Error() != "invalid" {
|
||||
t.Fatalf("unexpected error behavior: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatBusinessCodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
message string
|
||||
want int
|
||||
}{
|
||||
{code: "insufficient_balance", want: 1101},
|
||||
{code: "permission_denied", want: 1103},
|
||||
{code: "invalid_parameter", want: 1201},
|
||||
{code: "no_model_candidate", want: 1203},
|
||||
{code: "rate_limit_exceeded", want: 1302},
|
||||
{code: "concurrency_limit", want: 1303},
|
||||
{code: "network", want: 5001},
|
||||
{code: "unknown", want: 5000},
|
||||
}
|
||||
for _, item := range tests {
|
||||
if got := kelingCompatBusinessCode(item.code, item.message); got != item.want {
|
||||
t.Fatalf("code=%q message=%q got=%d want=%d", item.code, item.message, got, item.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatCandidatesSupport4K(t *testing.T) {
|
||||
candidates := []store.RuntimeModelCandidate{
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"720p", "1080p"}}}},
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"2160p"}}}},
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
t.Fatal("expected 2160p Keling capability to enable mode=4k")
|
||||
}
|
||||
if kelingCompatCandidatesSupport4K(candidates[:1]) {
|
||||
t.Fatal("mode=4k must stay disabled without an explicit capability")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling-compatible HTTP integration flow")
|
||||
}
|
||||
|
||||
var upstreamTaskSequence atomic.Int64
|
||||
var upstreamPayloadMu sync.Mutex
|
||||
var upstreamPayloads []map[string]any
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-keling-key" {
|
||||
t.Fatalf("unexpected upstream Authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/videos/omni-video":
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode upstream request: %v", err)
|
||||
}
|
||||
upstreamPayloadMu.Lock()
|
||||
upstreamPayloads = append(upstreamPayloads, payload)
|
||||
upstreamPayloadMu.Unlock()
|
||||
id := "upstream-" + strconv.FormatInt(upstreamTaskSequence.Add(1), 10)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "submit-" + id,
|
||||
"data": map[string]any{"task_id": id, "task_status": "submitted"},
|
||||
})
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/videos/omni-video/upstream-"):
|
||||
id := strings.TrimPrefix(r.URL.Path, "/videos/omni-video/")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "poll-" + id,
|
||||
"data": map[string]any{
|
||||
"task_id": id,
|
||||
"task_status": "succeed",
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
"task_result": map[string]any{"videos": []any{map[string]any{
|
||||
"id": "video-" + id,
|
||||
"url": "https://example.com/" + id + ".mp4",
|
||||
"watermark_url": "https://example.com/" + id + "-watermark.mp4",
|
||||
"duration": "3",
|
||||
}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
|
||||
Provider: "keling",
|
||||
PlatformKey: "keling-compatible-test-" + suffix,
|
||||
Name: "Kling Compatible Test",
|
||||
BaseURL: upstream.URL,
|
||||
AuthType: "APIKey",
|
||||
Credentials: map[string]any{"apiKey": "upstream-keling-key"},
|
||||
Config: map[string]any{
|
||||
"kelingPollIntervalMs": 100,
|
||||
"kelingPollTimeoutSeconds": 5,
|
||||
},
|
||||
Priority: 1,
|
||||
Status: "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform: %v", err)
|
||||
}
|
||||
_, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{
|
||||
PlatformID: platform.ID,
|
||||
CanonicalModelKey: "keling:kling-video-o1",
|
||||
ModelName: "kling-o1",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelAlias: "kling-o1",
|
||||
ModelType: store.StringList{"omni_video", "video_generate"},
|
||||
DisplayName: "Kling O1 Compatible Test",
|
||||
Capabilities: map[string]any{
|
||||
"omni_video": map[string]any{
|
||||
"supported_modes": []any{"text_to_video", "image_reference"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": false,
|
||||
"max_images": 7,
|
||||
},
|
||||
"video_generate": map[string]any{
|
||||
"supported_modes": []any{"text_to_video"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform model: %v", err)
|
||||
}
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
gateway := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer gateway.Close()
|
||||
|
||||
firstUserToken, firstAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "first", suffix, true)
|
||||
_ = firstUserToken
|
||||
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
|
||||
|
||||
var created KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "A clean product reveal",
|
||||
"mode": "std",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": "3",
|
||||
"sound": "off",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/reference.png"}},
|
||||
"external_task_id": "compat-http-1",
|
||||
}, http.StatusOK, &created)
|
||||
createdData, _ := created.Data.(map[string]any)
|
||||
if created.Code != 0 || created.RequestID == "" || strings.TrimSpace(stringFromKelingCompat(createdData["task_id"])) == "" || createdData["task_status"] != "submitted" {
|
||||
t.Fatalf("unexpected compatible create response: %+v", created)
|
||||
}
|
||||
taskID := stringFromKelingCompat(createdData["task_id"])
|
||||
|
||||
var hidden KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
if hidden.Code != 1203 {
|
||||
t.Fatalf("cross-user task must be hidden: %+v", hidden)
|
||||
}
|
||||
|
||||
completed := waitForKelingCompatTask(t, gateway.URL, firstAPIKey, taskID, 5*time.Second)
|
||||
if completed.Code != 0 {
|
||||
t.Fatalf("compatible task failed: %+v", completed)
|
||||
}
|
||||
completedData, _ := completed.Data.(map[string]any)
|
||||
if completedData["task_status"] != "succeed" {
|
||||
t.Fatalf("compatible task did not succeed: %+v", completedData)
|
||||
}
|
||||
taskResult, _ := completedData["task_result"].(map[string]any)
|
||||
videos, _ := taskResult["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] == "" || video["watermark_url"] == "" || video["duration"] != "3" {
|
||||
t.Fatalf("compatible result lost video metadata: %+v", video)
|
||||
}
|
||||
|
||||
var standard struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A second product reveal",
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &standard)
|
||||
if standard.TaskID == "" {
|
||||
t.Fatal("standard video generation did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, standard.TaskID, []string{"succeeded"}, 5*time.Second)
|
||||
|
||||
var unsupportedAudio struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "An O1 request that must not silently ignore audio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &unsupportedAudio)
|
||||
if unsupportedAudio.TaskID == "" {
|
||||
t.Fatal("unsupported O1 audio request did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, unsupportedAudio.TaskID, []string{"failed"}, 5*time.Second)
|
||||
var failedAudioTask store.GatewayTask
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/tasks/"+unsupportedAudio.TaskID, firstAPIKey, nil, http.StatusOK, &failedAudioTask)
|
||||
if failedAudioTask.ErrorCode != "invalid_parameter" || !strings.Contains(failedAudioTask.ErrorMessage, "does not support generated audio") {
|
||||
t.Fatalf("O1 audio request must fail visibly before upstream submission: %+v", failedAudioTask)
|
||||
}
|
||||
|
||||
upstreamPayloadMu.Lock()
|
||||
defer upstreamPayloadMu.Unlock()
|
||||
if len(upstreamPayloads) != 2 {
|
||||
t.Fatalf("expected two upstream submissions, got %d", len(upstreamPayloads))
|
||||
}
|
||||
compatiblePayload := upstreamPayloads[0]
|
||||
if compatiblePayload["model_name"] != "kling-video-o1" || compatiblePayload["mode"] != "std" || compatiblePayload["sound"] != "off" || compatiblePayload["duration"] != "3" || compatiblePayload["aspect_ratio"] != "16:9" || compatiblePayload["external_task_id"] != "compat-http-1" {
|
||||
t.Fatalf("unexpected compatible upstream payload: %+v", compatiblePayload)
|
||||
}
|
||||
}
|
||||
|
||||
func createKelingCompatIntegrationUser(t *testing.T, ctx context.Context, db *store.Store, baseURL string, prefix string, suffix string, fund bool) (string, string) {
|
||||
t.Helper()
|
||||
username := fmt.Sprintf("kling_compat_%s_%s", prefix, suffix)
|
||||
password := "password123"
|
||||
var registered struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®istered)
|
||||
var apiKey struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/api-keys", registered.AccessToken, map[string]any{
|
||||
"name": "Kling compatible integration key",
|
||||
"scopes": []string{"video"},
|
||||
}, http.StatusCreated, &apiKey)
|
||||
if fund {
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote integration user: %v", err)
|
||||
}
|
||||
var loggedIn struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loggedIn)
|
||||
var gatewayUserID string
|
||||
if err := db.Pool().QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&gatewayUserID); err != nil {
|
||||
t.Fatalf("read integration user id: %v", err)
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loggedIn.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"balance": 1000,
|
||||
"reason": "seed Kling compatible integration wallet",
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
return registered.AccessToken, apiKey.Secret
|
||||
}
|
||||
|
||||
func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID string, timeout time.Duration) KelingCompatibleEnvelope {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var response KelingCompatibleEnvelope
|
||||
doJSON(t, baseURL, http.MethodGet, "/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response.Data.(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return response
|
||||
case "failed":
|
||||
t.Fatalf("Kling-compatible task failed: %+v", response)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for Kling-compatible task %s", taskID)
|
||||
return KelingCompatibleEnvelope{}
|
||||
}
|
||||
@@ -7,46 +7,57 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
return s.platformModelResponseWithRuleSets(model, s.responsePricingRuleSetConfigs(ctx, []store.PlatformModel{model}))
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponseWithRuleSets(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel {
|
||||
model.Capabilities = store.EffectivePlatformModelCapabilities(model.BaseCapabilities, model.Capabilities)
|
||||
model.Capabilities = enrichResponseCapabilities(model)
|
||||
model = s.withEffectiveResponseBillingConfig(ctx, model)
|
||||
model = withEffectiveResponseBillingConfig(model, ruleSetConfigs)
|
||||
return store.FilterPlatformModelBillingConfig(model)
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponses(ctx context.Context, models []store.PlatformModel) []store.PlatformModel {
|
||||
ruleSetConfigs := s.responsePricingRuleSetConfigs(ctx, models)
|
||||
items := make([]store.PlatformModel, len(models))
|
||||
for i, model := range models {
|
||||
items[i] = s.platformModelResponse(ctx, model)
|
||||
items[i] = s.platformModelResponseWithRuleSets(model, ruleSetConfigs)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Server) withEffectiveResponseBillingConfig(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
config := model.BillingConfig
|
||||
if model.PricingRuleSetID != "" {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, model.PricingRuleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
config = ruleSetConfig
|
||||
func (s *Server) responsePricingRuleSetConfigs(ctx context.Context, models []store.PlatformModel) map[string]map[string]any {
|
||||
configs := map[string]map[string]any{}
|
||||
if s.store == nil {
|
||||
return configs
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, model := range models {
|
||||
for _, id := range []string{firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID), model.PricingRuleSetID} {
|
||||
if id != "" {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(model.BillingConfigOverride) > 0 {
|
||||
config = mergeResponseBillingConfig(config, model.BillingConfigOverride)
|
||||
for id := range ids {
|
||||
if config, err := s.store.PricingRuleSetBillingConfig(ctx, id); err == nil && len(config) > 0 {
|
||||
configs[id] = config
|
||||
}
|
||||
}
|
||||
model.BillingConfig = config
|
||||
return model
|
||||
return configs
|
||||
}
|
||||
|
||||
func mergeResponseBillingConfig(base map[string]any, override map[string]any) map[string]any {
|
||||
if len(base) == 0 && len(override) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(base)+len(override))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range override {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
func withEffectiveResponseBillingConfig(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel {
|
||||
inheritedRuleSetConfig := ruleSetConfigs[firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID)]
|
||||
modelRuleSetConfig := ruleSetConfigs[model.PricingRuleSetID]
|
||||
model.BillingConfig = store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{
|
||||
BaseConfig: model.BaseBillingConfig,
|
||||
LegacyPlatformModelConfig: model.BillingConfig,
|
||||
InheritedRuleSetConfig: inheritedRuleSetConfig,
|
||||
ModelRuleSetConfig: modelRuleSetConfig,
|
||||
Override: model.BillingConfigOverride,
|
||||
})
|
||||
return model
|
||||
}
|
||||
|
||||
func enrichResponseCapabilities(model store.PlatformModel) map[string]any {
|
||||
|
||||
@@ -173,6 +173,41 @@ func TestPlatformModelResponsePreservesTextGenerateFieldsOverFallbacks(t *testin
|
||||
assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"minimal", "low", "medium"})
|
||||
}
|
||||
|
||||
func TestPlatformModelResponseUsesBaseBillingConfigWithoutMaterializedSnapshot(t *testing.T) {
|
||||
model := store.PlatformModel{
|
||||
ModelName: "base-priced-model",
|
||||
ModelType: store.StringList{"video_generate"},
|
||||
BaseBillingConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
}
|
||||
|
||||
response := (&Server{}).platformModelResponse(context.Background(), model)
|
||||
video, ok := response.BillingConfig["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(416) {
|
||||
t.Fatalf("expected base billing price 416, got %#v", response.BillingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveResponseBillingConfigPrefersBaseRuleOverLegacySnapshot(t *testing.T) {
|
||||
model := store.PlatformModel{
|
||||
BasePricingRuleSetID: "seedance-pricing",
|
||||
BillingConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(100)},
|
||||
},
|
||||
}
|
||||
response := withEffectiveResponseBillingConfig(model, map[string]map[string]any{
|
||||
"seedance-pricing": {
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
})
|
||||
|
||||
video, ok := response.BillingConfig["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(416) {
|
||||
t.Fatalf("expected base rule price 416, got %#v", response.BillingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func textGenerateCapabilities(t *testing.T, model store.PlatformModel) map[string]any {
|
||||
t.Helper()
|
||||
capabilities, ok := model.Capabilities["text_generate"].(map[string]any)
|
||||
|
||||
@@ -229,6 +229,9 @@ type TaskRequest struct {
|
||||
Size string `json:"size,omitempty" example:"1024x1024"`
|
||||
Duration int `json:"duration,omitempty" example:"5"`
|
||||
Resolution string `json:"resolution,omitempty" example:"720p"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
|
||||
Audio *bool `json:"audio,omitempty" example:"false"`
|
||||
Watermark *bool `json:"watermark,omitempty" example:"false"`
|
||||
MakeInstrumental bool `json:"makeInstrumental,omitempty" example:"false"`
|
||||
CustomMode bool `json:"customMode,omitempty" example:"false"`
|
||||
Style string `json:"style,omitempty" example:"city pop, bright synth"`
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const seedancePortraitAssetCategory = "seedance_portrait_asset"
|
||||
|
||||
// getSeedancePortraitAssetCapability godoc
|
||||
// @Summary 查询 Seedance 真人资产能力
|
||||
// @Description 返回当前网关是否已配置可创建、同步和引用的火山 Seedance 真人资产平台。
|
||||
// @Tags portrait-assets
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} runner.PortraitAssetCapability
|
||||
// @Router /api/v1/resource/material/seedance-portrait-assets/capability [get]
|
||||
func (s *Server) getSeedancePortraitAssetCapability(w http.ResponseWriter, r *http.Request) {
|
||||
capability, err := s.runner.PortraitAssetCapability(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("get portrait asset capability failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get portrait asset capability failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, capability)
|
||||
}
|
||||
|
||||
// listSeedancePortraitAssets godoc
|
||||
// @Summary 列出 Seedance 真人资产
|
||||
// @Description 返回当前用户的真人资产;兼容 server-main material 列表响应字段。
|
||||
// @Tags portrait-assets
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/resource/material/user/materials [get]
|
||||
func (s *Server) listSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if category := strings.TrimSpace(r.URL.Query().Get("category")); category != seedancePortraitAssetCategory {
|
||||
writeError(w, http.StatusNotFound, "material category not found")
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListPortraitAssets(r.Context(), user, store.PortraitAssetListFilter{
|
||||
Keyword: r.URL.Query().Get("keyword"),
|
||||
SourceType: firstNonEmptyQuery(r, "fileType", "sourceType"),
|
||||
Page: portraitAssetQueryInt(r, "pageNumber", "page"),
|
||||
PageSize: portraitAssetQueryInt(r, "pageSize"),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list portrait assets failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list portrait assets failed")
|
||||
return
|
||||
}
|
||||
responseItems := make([]any, 0, len(items.Items))
|
||||
for _, item := range items.Items {
|
||||
responseItems = append(responseItems, s.portraitAssetResponse(r, item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": responseItems,
|
||||
"total": items.Total,
|
||||
"page": items.Page,
|
||||
"pageSize": items.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// createSeedancePortraitAsset godoc
|
||||
// @Summary 上传并创建 Seedance 真人资产
|
||||
// @Description 文件先写入网关文件存储;仅在 private_avatar_eligible=true 时登记到火山 Assets。创建后会立即触发一次状态同步。
|
||||
// @Tags portrait-assets
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param file formData file true "真人资产源文件(图片、视频或音频)"
|
||||
// @Param data formData string true "material JSON,category 必须是 seedance_portrait_asset"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/resource/material [post]
|
||||
func (s *Server) createSeedancePortraitAsset(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||
if err := r.ParseMultipartForm(multipartTaskMemoryBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form-data body")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(r.FormValue("data"))), &data); err != nil || data == nil {
|
||||
writeError(w, http.StatusBadRequest, "data must be a JSON object")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(portraitAssetString(data["category"])) != seedancePortraitAssetCategory {
|
||||
writeError(w, http.StatusBadRequest, "category must be seedance_portrait_asset")
|
||||
return
|
||||
}
|
||||
privateEligible, _ := data["private_avatar_eligible"].(bool)
|
||||
if !privateEligible {
|
||||
writeError(w, http.StatusBadRequest, "private_avatar_eligible must be true after the user confirms authorization", "portrait_asset_authorization_required")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
payload, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "read portrait asset file failed")
|
||||
return
|
||||
}
|
||||
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||
if contentType == "" && len(payload) > 0 {
|
||||
contentType = http.DetectContentType(payload)
|
||||
}
|
||||
sourceType := strings.ToLower(strings.TrimSpace(firstNonEmpty(portraitAssetString(data["fileType"]), portraitAssetString(data["sourceType"]))))
|
||||
if !portraitAssetSourceMatchesContentType(sourceType, contentType) {
|
||||
writeError(w, http.StatusBadRequest, "fileType must be image, video, or audio and match the uploaded file", "portrait_asset_unsupported_type")
|
||||
return
|
||||
}
|
||||
upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{
|
||||
Bytes: payload, ContentType: contentType, FileName: header.Filename, Source: "seedance-portrait-asset", Scene: store.FileStorageSceneUpload,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("upload portrait asset failed", "error", err)
|
||||
writeError(w, http.StatusBadGateway, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(portraitAssetString(upload["url"]))
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadGateway, "portrait asset upload returned no URL", "portrait_asset_source_url_required")
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
asset, reused, err := s.runner.CreatePortraitAsset(r.Context(), user, runner.PortraitAssetCreateInput{
|
||||
Name: strings.TrimSpace(portraitAssetString(data["name"])),
|
||||
Description: strings.TrimSpace(portraitAssetString(data["description"])),
|
||||
SourceType: sourceType,
|
||||
URL: url,
|
||||
Preview: firstNonEmpty(portraitAssetString(data["preview"]), url),
|
||||
MimeType: contentType,
|
||||
ByteSize: int64(len(payload)),
|
||||
SourceSHA256: hex.EncodeToString(digest[:]),
|
||||
PrivateAvatarEligible: privateEligible,
|
||||
Metadata: map[string]any{
|
||||
"tags": data["tags"],
|
||||
"materialGroupId": data["material_group_id"],
|
||||
"uploadedFileName": header.Filename,
|
||||
"uploadAssetStorage": upload["assetStorage"],
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writePortraitAssetError(w, err)
|
||||
return
|
||||
}
|
||||
_, _ = s.runner.SyncPortraitAssets(r.Context(), user, []string{asset.ID})
|
||||
asset, _, err = s.refreshPortraitAssetForResponse(r, user, asset.ID, asset)
|
||||
if err != nil {
|
||||
s.logger.Error("refresh portrait asset after create failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "refresh portrait asset failed")
|
||||
return
|
||||
}
|
||||
response := map[string]any{"asset": s.portraitAssetResponse(r, asset)}
|
||||
if reused {
|
||||
response["dedupe"] = map[string]any{"reused": true, "code": "PORTRAIT_ASSET_REUSED", "reason": "same_source", "message": "已复用相同源文件的真人资产,并触发状态刷新。"}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// syncSeedancePortraitAssets godoc
|
||||
// @Summary 同步 Seedance 真人资产状态
|
||||
// @Description 调用火山 CreateAsset/GetAsset;多次调用可把 Processing 状态刷新为 Active 或 Failed。
|
||||
// @Tags portrait-assets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} runner.PortraitAssetSyncResponse
|
||||
// @Router /api/v1/resource/material/seedance-portrait-assets/sync [post]
|
||||
func (s *Server) syncSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if len(request.IDs) == 0 {
|
||||
writeJSON(w, http.StatusOK, runner.PortraitAssetSyncResponse{SyncedIDs: []string{}, Skipped: []runner.PortraitAssetIssue{}, Failed: []runner.PortraitAssetIssue{}, Assets: []store.PortraitAsset{}})
|
||||
return
|
||||
}
|
||||
response, err := s.runner.SyncPortraitAssets(r.Context(), user, request.IDs)
|
||||
if err != nil {
|
||||
s.logger.Error("sync portrait assets failed", "error", err)
|
||||
writePortraitAssetError(w, err)
|
||||
return
|
||||
}
|
||||
assets := make([]any, 0, len(response.Assets))
|
||||
for _, asset := range response.Assets {
|
||||
assets = append(assets, s.portraitAssetResponse(r, asset))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"requested": response.Requested, "accepted": response.Accepted, "syncedIds": response.SyncedIDs,
|
||||
"skipped": response.Skipped, "failed": response.Failed, "assets": assets,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) refreshPortraitAssetForResponse(r *http.Request, user *auth.User, assetID string, fallback store.PortraitAsset) (store.PortraitAsset, bool, error) {
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(r.Context(), user, assetID)
|
||||
if err != nil || !found {
|
||||
return fallback, found, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) portraitAssetResponse(r *http.Request, asset store.PortraitAsset) map[string]any {
|
||||
active, total, lastError, updatedAt, err := s.store.PortraitAssetBindingSummary(r.Context(), asset.ID)
|
||||
if err != nil {
|
||||
active, total, lastError, updatedAt = 0, 0, asset.LastError, asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
summaryStatus := asset.Status
|
||||
if summaryStatus == "not_synced" && total == 0 {
|
||||
summaryStatus = "not_synced"
|
||||
}
|
||||
response := map[string]any{
|
||||
"id": asset.ID, "name": asset.Name, "description": asset.Description, "url": asset.URL, "preview": firstNonEmpty(asset.Preview, asset.URL),
|
||||
"type": "personal", "fileType": asset.SourceType, "sourceType": asset.SourceType, "size": asset.ByteSize,
|
||||
"privateAvatarEligible": asset.PrivateAvatarEligible,
|
||||
"createdAt": asset.CreatedAt.UTC().Format(time.RFC3339Nano), "updatedAt": asset.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"seedanceAssetSummary": map[string]any{
|
||||
"eligible": asset.PrivateAvatarEligible, "status": summaryStatus, "provider": "volces", "activePlatformCount": active,
|
||||
"totalPlatformCount": total, "sourceType": asset.SourceType, "updatedAt": updatedAt,
|
||||
},
|
||||
}
|
||||
if lastError != "" {
|
||||
response["seedanceAssetSummary"].(map[string]any)["lastError"] = lastError
|
||||
}
|
||||
if asset.SourceType == "image" {
|
||||
response["thumbnail"] = firstNonEmpty(asset.Preview, asset.URL)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func writePortraitAssetError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if clientErr := clients.ErrorCode(err); clientErr != "client_error" {
|
||||
switch clientErr {
|
||||
case "portrait_asset_not_found":
|
||||
status = http.StatusNotFound
|
||||
case "portrait_asset_processing":
|
||||
status = http.StatusServiceUnavailable
|
||||
case "portrait_asset_authorization_required", "portrait_asset_unsupported_type", "portrait_asset_source_url_required", "portrait_asset_id_required", "portrait_asset_unsupported_model", "portrait_asset_audio_only":
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error(), clientErr)
|
||||
return
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
}
|
||||
|
||||
func portraitAssetSourceMatchesContentType(sourceType string, contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
switch sourceType {
|
||||
case "image":
|
||||
return strings.HasPrefix(contentType, "image/")
|
||||
case "video":
|
||||
return strings.HasPrefix(contentType, "video/")
|
||||
case "audio":
|
||||
return strings.HasPrefix(contentType, "audio/")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetQueryInt(r *http.Request, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscan(value, &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmptyQuery(r *http.Request, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func portraitAssetString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
|
||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
||||
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
|
||||
mux.Handle("GET /api/v1/api-keys/assignable-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAssignableModels)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
@@ -257,6 +258,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
|
||||
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /api/v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /api/v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
@@ -264,6 +267,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("GET /api/v1/voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
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)))
|
||||
mux.Handle("GET /api/v1/resource/material/seedance-portrait-assets/capability", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getSeedancePortraitAssetCapability)))
|
||||
mux.Handle("GET /api/v1/resource/material/user/materials", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listSeedancePortraitAssets)))
|
||||
mux.Handle("POST /api/v1/resource/material", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createSeedancePortraitAsset)))
|
||||
mux.Handle("POST /api/v1/resource/material/seedance-portrait-assets/sync", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.syncSeedancePortraitAssets)))
|
||||
mux.Handle("POST /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
server.registerKlingCompatibilityRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
@@ -290,6 +301,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
|
||||
mux.Handle("GET /v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
|
||||
|
||||
// createVolcesContentsGenerationTask godoc
|
||||
// @Summary 创建火山内容生成任务
|
||||
// @Description 兼容火山方舟 POST /api/v3/contents/generations/tasks。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [post]
|
||||
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.createVolcesCompatibleTask(r, user, body)
|
||||
if err != nil {
|
||||
writeVolcesCompatibleTaskError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
|
||||
}
|
||||
|
||||
// getVolcesContentsGenerationTask godoc
|
||||
// @Summary 查询火山内容生成任务
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [get]
|
||||
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
|
||||
}
|
||||
|
||||
// listVolcesContentsGenerationTasks godoc
|
||||
// @Summary 列出火山内容生成任务
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [get]
|
||||
func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
page := portraitAssetQueryInt(r, "page_num", "pageNumber", "page")
|
||||
pageSize := portraitAssetQueryInt(r, "page_size", "pageSize")
|
||||
tasks, err := s.store.ListVolcesCompatibleTasks(r.Context(), user, store.VolcesCompatibleTaskListFilter{
|
||||
CompatibilityMarker: volcesContentsCompatibilityMarker,
|
||||
Status: r.URL.Query().Get("filter.status"),
|
||||
Model: r.URL.Query().Get("filter.model"),
|
||||
TaskIDs: r.URL.Query()["filter.task_ids"],
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list Volces-compatible tasks failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list tasks failed")
|
||||
return
|
||||
}
|
||||
items := make([]any, 0)
|
||||
for _, task := range tasks.Items {
|
||||
items = append(items, volcesCompatibleTask(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "total": tasks.Total,
|
||||
"page_num": tasks.Page, "page_size": tasks.PageSize,
|
||||
// data/page are retained as additive gateway fields for existing callers.
|
||||
"data": items, "page": tasks.Page,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteVolcesContentsGenerationTask godoc
|
||||
// @Summary 取消火山内容生成任务
|
||||
// @Description 取消网关任务;对于已提交且保存了上游任务标识的 Volces 视频任务,同时调用火山 DELETE 接口并持久化取消状态。
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [delete]
|
||||
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
result, err := s.runner.CancelVolcesVideoTask(r.Context(), task, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, runner.ErrTaskAccessDenied) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("cancel Volces-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "cancel task failed")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.GetTask(r.Context(), task.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "get cancelled task failed")
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(updated)
|
||||
response["cancelled"] = result.Cancelled
|
||||
response["cancellable"] = result.Cancellable
|
||||
response["message"] = result.Message
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// createLegacyVolcesVideoGeneration godoc
|
||||
// @Summary 创建 server-main 兼容视频任务
|
||||
// @Description 兼容 server-main 的 /api/v1/video/generations,返回 submitted 和 task_id;额外保留火山任务字段。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/video/generations [post]
|
||||
func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.createVolcesCompatibleTask(r, user, body)
|
||||
if err != nil {
|
||||
writeVolcesCompatibleTaskError(w, err)
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(task)
|
||||
response["status"] = "submitted"
|
||||
response["task_id"] = task.ID
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// getLegacyVolcesVideoResult godoc
|
||||
// @Summary 查询 server-main 兼容视频结果
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/ai/result/{taskID} [get]
|
||||
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
compat := volcesCompatibleTask(task)
|
||||
legacyStatus := "process"
|
||||
switch compat["status"] {
|
||||
case "succeeded":
|
||||
legacyStatus = "success"
|
||||
case "failed", "cancelled":
|
||||
legacyStatus = "failed"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": legacyStatus, "task_id": task.ID, "data": compat["content"], "result": compat,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, body map[string]any) (store.GatewayTask, error) {
|
||||
model := strings.TrimSpace(volcesCompatString(body["model"]))
|
||||
if model == "" {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "invalid_parameter", Message: "model is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "forbidden", Message: "api key scope does not allow video generation", StatusCode: http.StatusForbidden, Retryable: false}
|
||||
}
|
||||
body["_gateway_compatibility"] = volcesContentsCompatibilityMarker
|
||||
task, err := s.prepareAndCreateGatewayTask(r.Context(), r, user, "videos.generations", model, body, true)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, err
|
||||
}
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "enqueue_failed", Message: err.Error(), StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Server) volcesCompatibleTaskForUser(w http.ResponseWriter, r *http.Request) (store.GatewayTask, bool) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
s.logger.Error("get Volces-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get task failed")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
if !isVolcesCompatibleTask(task) || !kelingCompatTaskOwnedBy(task, user) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func isVolcesCompatibleTask(task store.GatewayTask) bool {
|
||||
return task.Kind == "videos.generations" && strings.TrimSpace(volcesCompatString(task.Request["_gateway_compatibility"])) == volcesContentsCompatibilityMarker
|
||||
}
|
||||
|
||||
func volcesCompatibleTask(task store.GatewayTask) map[string]any {
|
||||
response := cloneVolcesCompatibleMap(task.Result)
|
||||
if len(response) == 0 {
|
||||
response = cloneVolcesCompatibleMap(task.RemoteTaskPayload)
|
||||
}
|
||||
if response == nil {
|
||||
response = map[string]any{}
|
||||
}
|
||||
response["id"] = task.ID
|
||||
response["model"] = firstNonEmpty(volcesCompatString(response["model"]), task.Model)
|
||||
response["status"] = volcesCompatibleTaskStatus(task.Status)
|
||||
response["created_at"] = task.CreatedAt.Unix()
|
||||
response["updated_at"] = task.UpdatedAt.Unix()
|
||||
if task.RemoteTaskID != "" {
|
||||
response["upstream_task_id"] = task.RemoteTaskID
|
||||
}
|
||||
for _, key := range []string{"content", "seed", "resolution", "ratio", "duration", "frames", "framespersecond"} {
|
||||
if response[key] == nil && task.Request[key] != nil {
|
||||
response[key] = task.Request[key]
|
||||
}
|
||||
}
|
||||
if len(task.Usage) > 0 && response["usage"] == nil {
|
||||
response["usage"] = task.Usage
|
||||
}
|
||||
if task.Status == "failed" || task.Status == "cancelled" {
|
||||
response["error"] = map[string]any{"code": firstNonEmpty(task.ErrorCode, strings.ToUpper(task.Status)), "message": firstNonEmpty(task.ErrorMessage, task.Error, task.Message)}
|
||||
}
|
||||
response["gateway_task_id"] = task.ID
|
||||
response["gateway_status"] = task.Status
|
||||
response["billings"] = task.Billings
|
||||
response["billing_summary"] = task.BillingSummary
|
||||
response["final_charge_amount"] = task.FinalChargeAmount
|
||||
return response
|
||||
}
|
||||
|
||||
func volcesCompatibleTaskStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "succeeded"
|
||||
case "failed":
|
||||
return "failed"
|
||||
case "cancelled", "canceled":
|
||||
return "cancelled"
|
||||
case "running", "processing":
|
||||
return "running"
|
||||
default:
|
||||
return "queued"
|
||||
}
|
||||
}
|
||||
|
||||
func cloneVolcesCompatibleMap(source map[string]any) map[string]any {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeVolcesCompatibleTaskError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
var staged *gatewayTaskCreationError
|
||||
if errors.As(err, &staged) {
|
||||
err = staged.Err
|
||||
}
|
||||
var clientErr *clients.ClientError
|
||||
if errors.As(err, &clientErr) && clientErr.StatusCode > 0 {
|
||||
status = clientErr.StatusCode
|
||||
} else if errors.As(err, &clientErr) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
}
|
||||
|
||||
func volcesCompatString(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)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestVolcesCompatibleTaskPreservesOfficialFieldsAndGatewayBilling(t *testing.T) {
|
||||
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
|
||||
task := store.GatewayTask{
|
||||
ID: "gateway-task-1", Kind: "videos.generations", Status: "succeeded", Model: "doubao-seedance-2-0-mini-260615",
|
||||
RemoteTaskID: "cgt-upstream-1", CreatedAt: now, UpdatedAt: now.Add(time.Second),
|
||||
Result: map[string]any{
|
||||
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
|
||||
"content": map[string]any{"video_url": "https://example.com/out.mp4"}, "usage": map[string]any{"total_tokens": 9},
|
||||
},
|
||||
Billings: []any{map[string]any{"amount": 3}}, BillingSummary: map[string]any{"currency": "resource"}, FinalChargeAmount: 3,
|
||||
}
|
||||
got := volcesCompatibleTask(task)
|
||||
if got["id"] != task.ID || got["upstream_task_id"] != task.RemoteTaskID || got["status"] != "succeeded" {
|
||||
t.Fatalf("unexpected compatibility identity/status: %+v", got)
|
||||
}
|
||||
content, _ := got["content"].(map[string]any)
|
||||
if content["video_url"] != "https://example.com/out.mp4" || got["usage"] == nil || got["billings"] == nil {
|
||||
t.Fatalf("official or billing fields were lost: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingO1GeneratedAudioIsRejectedInsteadOfSilentlyRemoved(t *testing.T) {
|
||||
result := preprocessRequestWithLog("videos.generations", map[string]any{
|
||||
"model": "kling-o1",
|
||||
"audio": true,
|
||||
}, store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelType: "video_generate",
|
||||
Capabilities: map[string]any{
|
||||
"video_generate": map[string]any{"output_audio": false},
|
||||
},
|
||||
})
|
||||
if result.Err == nil {
|
||||
t.Fatal("Keling O1 audio=true must be rejected")
|
||||
}
|
||||
if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].Action != "reject" {
|
||||
t.Fatalf("expected an auditable reject change, got %+v", result.Log.Changes)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type resolutionNormalizeProcessor struct{}
|
||||
@@ -691,6 +693,16 @@ func (audioProcessor) ShouldProcess(params map[string]any, modelType string, con
|
||||
}
|
||||
|
||||
func (audioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||
if context != nil && kelingO1GeneratedAudioRequested(params, context.candidate) {
|
||||
return context.reject(
|
||||
"AudioProcessor",
|
||||
"audio",
|
||||
params["audio"],
|
||||
"kling-video-o1 does not support generated audio",
|
||||
capabilityPath(modelType, "output_audio"),
|
||||
capabilityValue(context.modelCapability, modelType, "output_audio"),
|
||||
)
|
||||
}
|
||||
capability := capabilityForType(context.modelCapability, modelType)
|
||||
if capability == nil || !boolFromAny(capability["output_audio"]) {
|
||||
for _, key := range []string{"audio", "output_audio"} {
|
||||
@@ -712,6 +724,17 @@ func (audioProcessor) Process(params map[string]any, modelType string, context *
|
||||
return true
|
||||
}
|
||||
|
||||
func kelingO1GeneratedAudioRequested(params map[string]any, candidate store.RuntimeModelCandidate) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(candidate.Provider), "keling") {
|
||||
return false
|
||||
}
|
||||
model := strings.ToLower(strings.TrimSpace(candidate.ProviderModelName))
|
||||
if model != "kling-o1" && model != "kling-video-o1" {
|
||||
return false
|
||||
}
|
||||
return boolFromAny(params["audio"]) || boolFromAny(params["output_audio"])
|
||||
}
|
||||
|
||||
type imageCountProcessor struct{}
|
||||
|
||||
func (imageCountProcessor) Name() string { return "ImageCountProcessor" }
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var portraitAssetPlaceholderPattern = regexp.MustCompile(`(?i)<<<[[:space:]]*portrait[_-]?asset_([0-9]+)[[:space:]]*>>>|@portrait_asset([0-9]+)|@人像资产([0-9]+)`)
|
||||
|
||||
type PortraitAssetCapability struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CanUse bool `json:"canUse"`
|
||||
CanCreate bool `json:"canCreate"`
|
||||
CanUseAsPortraitAsset bool `json:"canUseAsPortraitAsset"`
|
||||
CanUseAsPlainMaterial bool `json:"canUseAsPlainMaterial"`
|
||||
AvailablePlatformIDs []string `json:"availablePlatformIds"`
|
||||
CreationPlatformIDs []string `json:"creationPlatformIds"`
|
||||
CanReferenceTencentAsset bool `json:"canReferenceTencentAssetUri"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type PortraitAssetCreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
SourceType string
|
||||
URL string
|
||||
Preview string
|
||||
MimeType string
|
||||
ByteSize int64
|
||||
SourceSHA256 string
|
||||
PrivateAvatarEligible bool
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type PortraitAssetSyncResponse struct {
|
||||
Requested int `json:"requested"`
|
||||
Accepted int `json:"accepted"`
|
||||
SyncedIDs []string `json:"syncedIds"`
|
||||
Skipped []PortraitAssetIssue `json:"skipped"`
|
||||
Failed []PortraitAssetIssue `json:"failed"`
|
||||
Assets []store.PortraitAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type PortraitAssetIssue struct {
|
||||
ID string `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type portraitAssetPlatformSettings struct {
|
||||
ProjectName string
|
||||
AssetGroupID string
|
||||
Credentials clients.VolcesAssetCredentials
|
||||
}
|
||||
|
||||
func (s *Service) PortraitAssetCapability(ctx context.Context) (PortraitAssetCapability, error) {
|
||||
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
|
||||
if err != nil {
|
||||
return PortraitAssetCapability{}, err
|
||||
}
|
||||
ids := make([]string, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
if _, ok := portraitAssetSettings(platform); ok {
|
||||
ids = append(ids, platform.PlatformID)
|
||||
}
|
||||
}
|
||||
capability := PortraitAssetCapability{
|
||||
Enabled: len(ids) > 0,
|
||||
CanUse: len(ids) > 0,
|
||||
CanCreate: len(ids) > 0,
|
||||
CanUseAsPortraitAsset: len(ids) > 0,
|
||||
CanUseAsPlainMaterial: true,
|
||||
AvailablePlatformIDs: ids,
|
||||
CreationPlatformIDs: ids,
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
capability.Reason = "未配置可用的火山 Seedance 人像资产平台;请在 Volces 平台 config.seedancePrivateAsset 中配置 enabled、accessKey、secretKey、projectName、assetGroupId。"
|
||||
}
|
||||
return capability, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePortraitAsset(ctx context.Context, user *auth.User, input PortraitAssetCreateInput) (store.PortraitAsset, bool, error) {
|
||||
if s.store == nil {
|
||||
return store.PortraitAsset{}, false, fmt.Errorf("portrait asset store is unavailable")
|
||||
}
|
||||
if !validPortraitAssetSourceType(input.SourceType) {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_unsupported_type", Message: "source type must be image, video, or audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if strings.TrimSpace(input.URL) == "" {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_source_url_required", Message: "portrait asset source URL is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !input.PrivateAvatarEligible {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "private_avatar_eligible must be true after the user confirms authorization", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if existing, found, err := s.store.FindPortraitAssetBySourceHash(ctx, user, input.SourceSHA256); err != nil {
|
||||
return store.PortraitAsset{}, false, err
|
||||
} else if found {
|
||||
return existing, true, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
if user == nil || userID == "" {
|
||||
return store.PortraitAsset{}, false, store.ErrLocalUserRequired
|
||||
}
|
||||
asset, err := s.store.CreatePortraitAsset(ctx, store.PortraitAssetInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
UserID: userID,
|
||||
GatewayTenantID: strings.TrimSpace(user.GatewayTenantID),
|
||||
TenantID: strings.TrimSpace(user.TenantID),
|
||||
TenantKey: strings.TrimSpace(user.TenantKey),
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
SourceType: strings.ToLower(strings.TrimSpace(input.SourceType)),
|
||||
URL: strings.TrimSpace(input.URL),
|
||||
Preview: firstNonEmptyString(strings.TrimSpace(input.Preview), strings.TrimSpace(input.URL)),
|
||||
MimeType: strings.TrimSpace(input.MimeType),
|
||||
ByteSize: input.ByteSize,
|
||||
SourceSHA256: strings.TrimSpace(input.SourceSHA256),
|
||||
PrivateAvatarEligible: input.PrivateAvatarEligible,
|
||||
Metadata: input.Metadata,
|
||||
})
|
||||
return asset, false, err
|
||||
}
|
||||
|
||||
func (s *Service) SyncPortraitAssets(ctx context.Context, user *auth.User, ids []string) (PortraitAssetSyncResponse, error) {
|
||||
response := PortraitAssetSyncResponse{
|
||||
Requested: len(ids), SyncedIDs: make([]string, 0), Skipped: make([]PortraitAssetIssue, 0), Failed: make([]PortraitAssetIssue, 0), Assets: make([]store.PortraitAsset, 0),
|
||||
}
|
||||
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
configured := make([]store.PortraitAssetPlatform, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
if _, ok := portraitAssetSettings(platform); ok {
|
||||
configured = append(configured, platform)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range ids {
|
||||
assetID := strings.TrimSpace(value)
|
||||
if assetID == "" || seen[assetID] {
|
||||
continue
|
||||
}
|
||||
seen[assetID] = true
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if !found {
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: assetID, Reason: "portrait asset not found"})
|
||||
continue
|
||||
}
|
||||
if !asset.PrivateAvatarEligible {
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: "portrait asset authorization is required"})
|
||||
continue
|
||||
}
|
||||
if len(configured) == 0 {
|
||||
_ = s.store.UpdatePortraitAssetStatus(ctx, asset.ID, "not_configured", "no configured Volces portrait asset platform")
|
||||
asset.Status = "not_configured"
|
||||
asset.LastError = "no configured Volces portrait asset platform"
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: asset.LastError})
|
||||
response.Assets = append(response.Assets, asset)
|
||||
continue
|
||||
}
|
||||
|
||||
response.Accepted++
|
||||
assetFailed := false
|
||||
for _, platform := range configured {
|
||||
if err := s.syncPortraitAssetToPlatform(ctx, asset, platform); err != nil {
|
||||
assetFailed = true
|
||||
response.Failed = append(response.Failed, PortraitAssetIssue{ID: asset.ID, Reason: platform.PlatformID + ": " + err.Error()})
|
||||
}
|
||||
}
|
||||
updated, _, err := s.refreshPortraitAssetStatus(ctx, user, asset.ID)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Assets = append(response.Assets, updated)
|
||||
if !assetFailed {
|
||||
response.SyncedIDs = append(response.SyncedIDs, updated.ID)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncPortraitAssetToPlatform(ctx context.Context, asset store.PortraitAsset, platform store.PortraitAssetPlatform) error {
|
||||
settings, ok := portraitAssetSettings(platform)
|
||||
if !ok {
|
||||
return &clients.ClientError{Code: "portrait_asset_not_configured", Message: "platform portrait asset configuration is incomplete", Retryable: false}
|
||||
}
|
||||
binding, found, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, platform.PlatformID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
binding = store.PortraitAssetBinding{AssetID: asset.ID, PlatformID: platform.PlatformID, ProjectName: settings.ProjectName, AssetGroupID: settings.AssetGroupID, Status: "pending"}
|
||||
}
|
||||
if !portraitAssetHasPublicURL(asset.URL) {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{
|
||||
Code: "portrait_asset_public_url_required",
|
||||
Message: "portrait asset URL must be an absolute http(s) URL reachable by Volces",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
})
|
||||
}
|
||||
client := clients.VolcesAssetClient{HTTPClient: s.portraitAssetHTTPClient()}
|
||||
remoteID := strings.TrimSpace(binding.RemoteAssetID)
|
||||
if remoteID == "" {
|
||||
created, _, createErr := client.CreateAsset(ctx, settings.Credentials, map[string]any{
|
||||
"GroupId": settings.AssetGroupID, "URL": asset.URL, "Name": asset.Name,
|
||||
"AssetType": volcesPortraitAssetType(asset.SourceType), "ProjectName": settings.ProjectName,
|
||||
})
|
||||
if createErr != nil {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, createErr)
|
||||
}
|
||||
remoteID = strings.TrimSpace(created.ID)
|
||||
if remoteID == "" {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{Code: "invalid_response", Message: "volces CreateAsset returned no asset id", Retryable: false})
|
||||
}
|
||||
binding.RemoteAssetID = remoteID
|
||||
}
|
||||
remote, _, getErr := client.GetAsset(ctx, settings.Credentials, map[string]any{"Id": remoteID, "ProjectName": settings.ProjectName})
|
||||
if getErr != nil {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, getErr)
|
||||
}
|
||||
binding.ProjectName = settings.ProjectName
|
||||
binding.AssetGroupID = settings.AssetGroupID
|
||||
binding.RemoteAssetID = firstNonEmptyString(remote.ID, remoteID)
|
||||
binding.RemoteAssetURI = "asset://" + binding.RemoteAssetID
|
||||
binding.Status = portraitAssetBindingStatus(remote.Status)
|
||||
binding.LastErrorCode = strings.TrimSpace(stringFromMap(remote.Error, "Code"))
|
||||
binding.LastErrorMessage = strings.TrimSpace(stringFromMap(remote.Error, "Message"))
|
||||
if binding.Status == "failed" && binding.LastErrorMessage == "" {
|
||||
binding.LastErrorMessage = "volces portrait asset processing failed"
|
||||
}
|
||||
_, err = s.store.UpsertPortraitAssetBinding(ctx, binding)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) recordPortraitAssetBindingFailure(ctx context.Context, binding store.PortraitAssetBinding, settings portraitAssetPlatformSettings, cause error) error {
|
||||
binding.ProjectName = settings.ProjectName
|
||||
binding.AssetGroupID = settings.AssetGroupID
|
||||
binding.Status = "failed"
|
||||
binding.LastErrorCode = clients.ErrorCode(cause)
|
||||
binding.LastErrorMessage = cause.Error()
|
||||
_, err := s.store.UpsertPortraitAssetBinding(ctx, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (s *Service) refreshPortraitAssetStatus(ctx context.Context, user *auth.User, assetID string) (store.PortraitAsset, bool, error) {
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil || !found {
|
||||
return asset, found, err
|
||||
}
|
||||
active, total, lastError, _, err := s.store.PortraitAssetBindingSummary(ctx, asset.ID)
|
||||
if err != nil {
|
||||
return asset, true, err
|
||||
}
|
||||
status := "not_synced"
|
||||
if total == 0 {
|
||||
status = "not_synced"
|
||||
} else if active > 0 {
|
||||
status = "active"
|
||||
if active < total {
|
||||
status = "partial"
|
||||
}
|
||||
} else if lastError != "" {
|
||||
status = "failed"
|
||||
} else {
|
||||
status = "pending"
|
||||
}
|
||||
if err := s.store.UpdatePortraitAssetStatus(ctx, asset.ID, status, lastError); err != nil {
|
||||
return asset, true, err
|
||||
}
|
||||
asset.Status = status
|
||||
asset.LastError = lastError
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) compilePortraitAssetReferences(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (map[string]any, error) {
|
||||
entries := portraitAssetList(body["portrait_asset_list"])
|
||||
if len(entries) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
if kind != "videos.generations" || !isVolcesPortraitAssetCandidate(candidate) {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "portrait assets require a configured Volces Seedance omni video model", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !candidateSupportsPortraitAssets(candidate) {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "selected model does not enable supports_portrait_asset_reference", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
|
||||
out := cloneMap(body)
|
||||
content := contentItems(out["content"])
|
||||
labels := make([]string, len(entries))
|
||||
nonAudioAssets := 0
|
||||
for index, entry := range entries {
|
||||
assetID := firstNonEmptyString(stringFromMap(entry, "id"), stringFromMap(entry, "easyai_portrait_asset_id"))
|
||||
if assetID == "" {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_id_required", Message: fmt.Sprintf("portrait_asset_list[%d].id is required", index), StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_not_found", Message: "portrait asset not found", StatusCode: http.StatusNotFound, Retryable: false}
|
||||
}
|
||||
if !asset.PrivateAvatarEligible {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "portrait asset authorization is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
binding, bound, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, candidate.PlatformID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bound || binding.Status != "active" || strings.TrimSpace(binding.RemoteAssetURI) == "" {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_processing", Message: "portrait asset is not active for the selected Volces platform; sync it and retry", StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
labels[index] = firstNonEmptyString(strings.TrimSpace(stringFromMap(entry, "name")), asset.Name, "portrait asset "+fmt.Sprint(index+1))
|
||||
if asset.SourceType != "audio" {
|
||||
nonAudioAssets++
|
||||
}
|
||||
content = append(content, portraitAssetContent(asset.SourceType, binding.RemoteAssetURI))
|
||||
}
|
||||
if nonAudioAssets == 0 {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_audio_only", Message: "portrait_asset_list cannot contain audio-only assets", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
for index := range content {
|
||||
if strings.ToLower(strings.TrimSpace(stringFromAny(content[index]["type"]))) != "text" {
|
||||
continue
|
||||
}
|
||||
content[index]["text"] = replacePortraitAssetPlaceholders(stringFromAny(content[index]["text"]), labels)
|
||||
}
|
||||
out["content"] = mapsToAnySlice(content)
|
||||
delete(out, "portrait_asset_list")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) portraitAssetHTTPClient() *http.Client {
|
||||
if s.httpClients != nil && s.httpClients.none != nil {
|
||||
return s.httpClients.none
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func portraitAssetSettings(platform store.PortraitAssetPlatform) (portraitAssetPlatformSettings, bool) {
|
||||
config := portraitAssetNestedConfig(platform.Config)
|
||||
accessKey := firstNonEmptyString(portraitAssetValue(config, "accessKey", "access_key"), portraitAssetValue(platform.Credentials, "accessKey", "access_key"))
|
||||
secretKey := firstNonEmptyString(portraitAssetValue(config, "secretKey", "secret_key"), portraitAssetValue(platform.Credentials, "secretKey", "secret_key"))
|
||||
projectName := firstNonEmptyString(portraitAssetValue(config, "projectName", "project_name"), "default")
|
||||
assetGroupID := portraitAssetValue(config, "assetGroupId", "asset_group_id")
|
||||
endpoint := firstNonEmptyString(portraitAssetValue(config, "assetEndpoint", "asset_endpoint", "volcesAssetEndpoint", "volces_asset_endpoint"), clientsVolcesAssetDefaultEndpoint())
|
||||
if accessKey == "" || secretKey == "" || projectName == "" || assetGroupID == "" {
|
||||
return portraitAssetPlatformSettings{}, false
|
||||
}
|
||||
if enabled, present := portraitAssetBool(config, "enabled"); present && !enabled {
|
||||
return portraitAssetPlatformSettings{}, false
|
||||
}
|
||||
return portraitAssetPlatformSettings{ProjectName: projectName, AssetGroupID: assetGroupID, Credentials: clients.VolcesAssetCredentials{AccessKey: accessKey, SecretKey: secretKey, Endpoint: endpoint}}, true
|
||||
}
|
||||
|
||||
func portraitAssetNestedConfig(config map[string]any) map[string]any {
|
||||
for _, key := range []string{"seedancePrivateAsset", "seedance_private_asset", "portraitAsset", "portrait_asset"} {
|
||||
if nested, ok := config[key].(map[string]any); ok {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func portraitAssetValue(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(stringFromAny(values[key])); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func portraitAssetBool(values map[string]any, key string) (bool, bool) {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(typed), "true"), true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func validPortraitAssetSourceType(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "image", "video", "audio":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetHasPublicURL(value string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")
|
||||
}
|
||||
|
||||
func volcesPortraitAssetType(sourceType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "video":
|
||||
return "Video"
|
||||
case "audio":
|
||||
return "Audio"
|
||||
default:
|
||||
return "Image"
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetBindingStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "active", "succeeded", "success":
|
||||
return "active"
|
||||
case "failed", "error":
|
||||
return "failed"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetList(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
return typed
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetContent(sourceType string, assetURI string) map[string]any {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "video":
|
||||
return map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": assetURI}}
|
||||
case "audio":
|
||||
return map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": assetURI}}
|
||||
default:
|
||||
return map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": assetURI}}
|
||||
}
|
||||
}
|
||||
|
||||
func replacePortraitAssetPlaceholders(value string, labels []string) string {
|
||||
return portraitAssetPlaceholderPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := portraitAssetPlaceholderPattern.FindStringSubmatch(match)
|
||||
for index := 1; index < len(parts); index++ {
|
||||
if parts[index] == "" {
|
||||
continue
|
||||
}
|
||||
position := int(parts[index][0] - '0')
|
||||
if len(parts[index]) > 1 {
|
||||
position = 0
|
||||
for _, r := range parts[index] {
|
||||
position = position*10 + int(r-'0')
|
||||
}
|
||||
}
|
||||
if position > 0 && position <= len(labels) && strings.TrimSpace(labels[position-1]) != "" {
|
||||
return labels[position-1]
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func isVolcesPortraitAssetCandidate(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
|
||||
return provider == "volces" || provider == "volces-openai"
|
||||
}
|
||||
|
||||
func candidateSupportsPortraitAssets(candidate store.RuntimeModelCandidate) bool {
|
||||
capabilities := effectiveModelCapability(candidate)
|
||||
for _, key := range []string{candidate.ModelType, "omni_video", "omni", "video_generate"} {
|
||||
if capability, ok := capabilities[key].(map[string]any); ok {
|
||||
if enabled, present := portraitAssetBool(capability, "supports_portrait_asset_reference"); present {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func portraitAssetUserKeys(user *auth.User) (string, string) {
|
||||
if user == nil {
|
||||
return "", ""
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
|
||||
if gatewayUserID == "" && user.Source == "gateway" {
|
||||
gatewayUserID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
return gatewayUserID, strings.TrimSpace(user.ID)
|
||||
}
|
||||
|
||||
func portraitAssetSHA256(payload []byte) string {
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func clientsVolcesAssetDefaultEndpoint() string { return "https://ark.cn-beijing.volcengineapi.com" }
|
||||
@@ -0,0 +1,50 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestReplacePortraitAssetPlaceholders(t *testing.T) {
|
||||
got := replacePortraitAssetPlaceholders("让 <<<portrait_asset_1>>> 和 @portrait_asset2、@人像资产3 出镜", []string{"Alice", "Bob", "Carol"})
|
||||
want := "让 Alice 和 Bob、Carol 出镜"
|
||||
if got != want {
|
||||
t.Fatalf("placeholder replacement = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitAssetContentUsesAssetURI(t *testing.T) {
|
||||
item := portraitAssetContent("video", "asset://volces-video-1")
|
||||
video, _ := item["video_url"].(map[string]any)
|
||||
if item["type"] != "video_url" || item["role"] != "reference_video" || video["url"] != "asset://volces-video-1" {
|
||||
t.Fatalf("unexpected portrait asset content: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitAssetSettingsRequireConfiguredVolcesAssetGroup(t *testing.T) {
|
||||
settings, ok := portraitAssetSettings(store.PortraitAssetPlatform{Config: map[string]any{
|
||||
"seedancePrivateAsset": map[string]any{
|
||||
"enabled": true, "accessKey": "ak", "secretKey": "sk", "projectName": "project", "assetGroupId": "group",
|
||||
},
|
||||
}})
|
||||
if !ok || settings.ProjectName != "project" || settings.AssetGroupID != "group" || settings.Credentials.AccessKey != "ak" {
|
||||
t.Fatalf("unexpected configured portrait asset settings: %+v ok=%v", settings, ok)
|
||||
}
|
||||
if _, ok := portraitAssetSettings(store.PortraitAssetPlatform{Config: map[string]any{"seedancePrivateAsset": map[string]any{"enabled": true, "accessKey": "ak"}}}); ok {
|
||||
t.Fatal("incomplete platform config must not enable portrait assets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitAssetHasPublicURL(t *testing.T) {
|
||||
for _, value := range []string{"https://assets.example.com/portrait.png", "http://assets.example.com/portrait.mp4"} {
|
||||
if !portraitAssetHasPublicURL(value) {
|
||||
t.Fatalf("expected public URL: %q", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"/uploads/portrait.png", "file:///tmp/portrait.png", "asset://portrait-id"} {
|
||||
if portraitAssetHasPublicURL(value) {
|
||||
t.Fatalf("expected non-public URL: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,24 +194,25 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
}
|
||||
|
||||
func (s *Service) effectiveBillingConfig(ctx context.Context, candidate store.RuntimeModelCandidate) map[string]any {
|
||||
base := candidate.BaseBillingConfig
|
||||
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" {
|
||||
var inheritedRuleSetConfig map[string]any
|
||||
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, ruleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
base = ruleSetConfig
|
||||
inheritedRuleSetConfig = ruleSetConfig
|
||||
}
|
||||
}
|
||||
if len(candidate.BillingConfig) > 0 {
|
||||
base = candidate.BillingConfig
|
||||
}
|
||||
if candidate.ModelPricingRuleSetID != "" {
|
||||
var modelRuleSetConfig map[string]any
|
||||
if candidate.ModelPricingRuleSetID != "" && s.store != nil {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, candidate.ModelPricingRuleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
base = ruleSetConfig
|
||||
modelRuleSetConfig = ruleSetConfig
|
||||
}
|
||||
}
|
||||
if len(candidate.BillingConfigOverride) > 0 {
|
||||
base = mergeMap(base, candidate.BillingConfigOverride)
|
||||
}
|
||||
return base
|
||||
return store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{
|
||||
BaseConfig: candidate.BaseBillingConfig,
|
||||
LegacyPlatformModelConfig: candidate.BillingConfig,
|
||||
InheritedRuleSetConfig: inheritedRuleSetConfig,
|
||||
ModelRuleSetConfig: modelRuleSetConfig,
|
||||
Override: candidate.BillingConfigOverride,
|
||||
})
|
||||
}
|
||||
|
||||
func effectiveDiscount(ctx context.Context, db *store.Store, user *auth.User, candidate store.RuntimeModelCandidate) float64 {
|
||||
|
||||
@@ -526,6 +526,20 @@ candidatesLoop:
|
||||
candidateBody := preprocessing.Body
|
||||
candidatePricing := pricingByCandidate[pricingCandidateKey(candidate)]
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, candidatePricing, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
|
||||
if err != nil && isVolcesRemoteTaskCancellation(candidate, err) {
|
||||
cancelled, changed, cancelErr := s.store.CancelSubmittedTask(ctx, task.ID, task.ExecutionToken, "任务已由火山引擎取消")
|
||||
if cancelErr != nil {
|
||||
return Result{}, cancelErr
|
||||
}
|
||||
if changed {
|
||||
// CancelSubmittedTask atomically transfers any reservation to the release Outbox.
|
||||
walletReservationFinalized = true
|
||||
if emitErr := s.emit(ctx, task.ID, "task.cancelled", "cancelled", "cancelled", 1, "任务已由火山引擎取消", map[string]any{"taskId": task.ID, "reason": "upstream_cancelled"}, isSimulation(task, candidate)); emitErr != nil {
|
||||
return Result{}, emitErr
|
||||
}
|
||||
return Result{Task: cancelled, Output: cancelled.Result}, nil
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
attemptNo = nextAttemptNo
|
||||
var billings []any
|
||||
@@ -592,6 +606,13 @@ candidatesLoop:
|
||||
ResponseDurationMS: record.ResponseDurationMS,
|
||||
})
|
||||
if finishErr != nil {
|
||||
if errors.Is(finishErr, store.ErrTaskExecutionLeaseLost) {
|
||||
latest, latestErr := s.store.GetTask(ctx, task.ID)
|
||||
if latestErr == nil && latest.Status == "cancelled" {
|
||||
walletReservationFinalized = true
|
||||
return Result{Task: latest, Output: latest.Result}, nil
|
||||
}
|
||||
}
|
||||
return Result{}, finishErr
|
||||
}
|
||||
walletReservationFinalized = true
|
||||
@@ -916,7 +937,19 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
return clients.Response{}, fmt.Errorf("prepare http client: %w", err)
|
||||
}
|
||||
client := s.clientFor(candidate, simulated)
|
||||
providerBody, err := s.hydrateProviderRequestAssets(ctx, body, candidate)
|
||||
providerBody, err := s.compilePortraitAssetReferences(ctx, user, task.Kind, body, candidate)
|
||||
if err != nil {
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
Retryable: false,
|
||||
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(err, false)}}),
|
||||
ErrorCode: clients.ErrorCode(err),
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return clients.Response{}, err
|
||||
}
|
||||
providerBody, err = s.hydrateProviderRequestAssets(ctx, providerBody, candidate)
|
||||
if err != nil {
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
@@ -953,6 +986,12 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
}
|
||||
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
|
||||
},
|
||||
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
|
||||
if strings.TrimSpace(remoteTaskID) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
|
||||
},
|
||||
Stream: boolFromMap(providerBody, "stream"),
|
||||
StreamDelta: onDelta,
|
||||
UpstreamProtocol: candidate.ResponseProtocol,
|
||||
@@ -1187,12 +1226,19 @@ func (s *Service) failTask(ctx context.Context, taskID string, executionToken st
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, err
|
||||
}
|
||||
if failed.Status == "cancelled" {
|
||||
return failed, nil
|
||||
}
|
||||
if eventErr := s.emit(ctx, taskID, "task.failed", "failed", "failed", 1, message, map[string]any{"code": code, "requestId": requestID, "metrics": metrics}, simulated); eventErr != nil {
|
||||
return store.GatewayTask{}, eventErr
|
||||
}
|
||||
return failed, nil
|
||||
}
|
||||
|
||||
func isVolcesRemoteTaskCancellation(candidate store.RuntimeModelCandidate, err error) bool {
|
||||
return isVolcesCancellationCandidate(candidate) && strings.EqualFold(clients.ErrorCode(err), "volces_task_cancelled")
|
||||
}
|
||||
|
||||
type failedAttemptRecord struct {
|
||||
Task store.GatewayTask
|
||||
Body map[string]any
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/riverqueue/river/rivertype"
|
||||
)
|
||||
@@ -104,6 +105,61 @@ func (s *Service) CancelTask(ctx context.Context, taskID string, user *auth.User
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelVolcesVideoTask extends local queue cancellation with the official
|
||||
// Volces DELETE call once a video task has a persisted remote task id.
|
||||
func (s *Service) CancelVolcesVideoTask(ctx context.Context, task store.GatewayTask, user *auth.User) (TaskCancelResult, error) {
|
||||
local, err := s.CancelTask(ctx, task.ID, user)
|
||||
if err != nil || local.Cancelled || strings.TrimSpace(task.RemoteTaskID) == "" {
|
||||
return local, err
|
||||
}
|
||||
if taskCancelTerminalStatus(task.Status) {
|
||||
return local, nil
|
||||
}
|
||||
var latest store.TaskAttempt
|
||||
for _, attempt := range task.Attempts {
|
||||
if attempt.PlatformModelID != "" && (latest.AttemptNo == 0 || attempt.AttemptNo >= latest.AttemptNo) {
|
||||
latest = attempt
|
||||
}
|
||||
}
|
||||
candidate, found, err := s.store.GetRuntimeModelCandidateForRemoteTask(ctx, latest.PlatformModelID, latest.PlatformID)
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
if !found || !isVolcesCancellationCandidate(candidate) {
|
||||
return local, nil
|
||||
}
|
||||
httpClient, err := s.httpClientForCandidate(candidate, false)
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
_, _, err = (clients.VolcesClient{HTTPClient: httpClient}).DeleteVideoTask(ctx, clients.Request{
|
||||
Kind: "videos.generations", Candidate: candidate, HTTPClient: httpClient, RemoteTaskID: task.RemoteTaskID,
|
||||
})
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
cancelledTask, cancelled, err := s.store.CancelSubmittedTask(ctx, task.ID, task.ExecutionToken, "任务已由火山引擎取消")
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
if !cancelled {
|
||||
latestTask, latestErr := s.store.GetTask(ctx, task.ID)
|
||||
if latestErr == nil {
|
||||
return taskCancelUnavailable(latestTask, "任务状态已变化,未覆盖本地最终状态"), nil
|
||||
}
|
||||
return local, nil
|
||||
}
|
||||
if err := s.emit(ctx, cancelledTask.ID, "task.cancelled", "cancelled", "cancelled", 1, "任务已由火山引擎取消", map[string]any{"taskId": cancelledTask.ID, "reason": "upstream_cancel"}, cancelledTask.RunMode == "simulation"); err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
return TaskCancelResult{TaskID: cancelledTask.ID, Cancelled: true, Cancellable: true, Submitted: true, Message: "任务已由火山引擎取消"}, nil
|
||||
}
|
||||
|
||||
func isVolcesCancellationCandidate(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
|
||||
return provider == "volces" || provider == "volces-openai"
|
||||
}
|
||||
|
||||
func taskCancelUnavailable(task store.GatewayTask, message string) TaskCancelResult {
|
||||
return TaskCancelResult{
|
||||
TaskID: task.ID,
|
||||
|
||||
@@ -283,6 +283,21 @@ func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.Us
|
||||
}
|
||||
|
||||
func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
|
||||
return s.listPlatformModelsForAccessRules(ctx, user, nil)
|
||||
}
|
||||
|
||||
// ListAPIKeyAssignablePlatformModels returns the enabled models that the
|
||||
// current user may delegate to their API keys. API-key rules are deliberately
|
||||
// excluded here: they restrict individual credentials and must not shrink the
|
||||
// resource pool that the owning user can manage.
|
||||
func (s *Store) ListAPIKeyAssignablePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
|
||||
if localGatewayUserID(user) == "" {
|
||||
return nil, ErrLocalUserRequired
|
||||
}
|
||||
return s.listPlatformModelsForAccessRules(ctx, user, map[string]bool{"api_key": true})
|
||||
}
|
||||
|
||||
func (s *Store) listPlatformModelsForAccessRules(ctx context.Context, user *auth.User, excludedSubjectTypes map[string]bool) ([]PlatformModel, error) {
|
||||
accessUser, err := s.resolveCurrentAccessUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -307,7 +322,7 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use
|
||||
enabled = append(enabled, model)
|
||||
}
|
||||
}
|
||||
return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled)
|
||||
return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled, excludedSubjectTypes)
|
||||
}
|
||||
|
||||
func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error {
|
||||
@@ -328,7 +343,7 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user
|
||||
}
|
||||
|
||||
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) {
|
||||
models, err := s.ListAccessiblePlatformModels(ctx, user)
|
||||
models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,7 +398,12 @@ WHERE id = $1::uuid
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
func (s *Store) filterPlatformModelsByAccessRules(
|
||||
ctx context.Context,
|
||||
user *auth.User,
|
||||
models []PlatformModel,
|
||||
excludedSubjectTypes map[string]bool,
|
||||
) ([]PlatformModel, error) {
|
||||
if len(models) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
@@ -398,6 +418,12 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
if len(excludedSubjectTypes) > 0 {
|
||||
rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes)
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
@@ -412,6 +438,17 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func filterAccessRulesBySubjectType(rules []AccessRule, excludedSubjectTypes map[string]bool) []AccessRule {
|
||||
filtered := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if excludedSubjectTypes[rule.SubjectType] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) {
|
||||
values := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterAccessRulesBySubjectTypeExcludesAPIKeyRulesOnly(t *testing.T) {
|
||||
rules := []AccessRule{
|
||||
{ID: "api-key-allow", SubjectType: "api_key", Effect: "allow"},
|
||||
{ID: "api-key-deny", SubjectType: "api_key", Effect: "deny"},
|
||||
{ID: "user-group-allow", SubjectType: "user_group", Effect: "allow"},
|
||||
{ID: "user-deny", SubjectType: "user", Effect: "deny"},
|
||||
{ID: "tenant-allow", SubjectType: "tenant", Effect: "allow"},
|
||||
}
|
||||
|
||||
filtered := filterAccessRulesBySubjectType(rules, map[string]bool{"api_key": true})
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filtered rule count = %d, want 3: %+v", len(filtered), filtered)
|
||||
}
|
||||
for _, rule := range filtered {
|
||||
if rule.SubjectType == "api_key" {
|
||||
t.Fatalf("api-key rule should not affect the owning user's assignable resources: %+v", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package store
|
||||
|
||||
// EffectiveBillingConfigInput describes the billing layers used by runtime and
|
||||
// catalog responses. LegacyPlatformModelConfig is retained only as a fallback
|
||||
// for models that do not have an effective pricing rule set.
|
||||
type EffectiveBillingConfigInput struct {
|
||||
BaseConfig map[string]any
|
||||
LegacyPlatformModelConfig map[string]any
|
||||
InheritedRuleSetConfig map[string]any
|
||||
ModelRuleSetConfig map[string]any
|
||||
Override map[string]any
|
||||
}
|
||||
|
||||
// ResolveEffectiveBillingConfig keeps inherited pricing rules authoritative over
|
||||
// the legacy materialized snapshot. Explicit model rules and overrides retain
|
||||
// their higher-priority exception semantics.
|
||||
func ResolveEffectiveBillingConfig(input EffectiveBillingConfigInput) map[string]any {
|
||||
config := mergeObjects(input.BaseConfig, nil)
|
||||
if len(input.InheritedRuleSetConfig) > 0 {
|
||||
// Rule sets are allowed to cover only a subset of resource types. Keep
|
||||
// base-model prices for resources that the inherited rule set does not
|
||||
// define, while letting the rule set remain authoritative for matching
|
||||
// top-level keys.
|
||||
config = mergeObjects(config, input.InheritedRuleSetConfig)
|
||||
} else if len(input.LegacyPlatformModelConfig) > 0 {
|
||||
config = mergeObjects(config, input.LegacyPlatformModelConfig)
|
||||
}
|
||||
if len(input.ModelRuleSetConfig) > 0 {
|
||||
config = mergeObjects(config, input.ModelRuleSetConfig)
|
||||
}
|
||||
return mergeObjects(config, input.Override)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEffectiveBillingConfigKeepsPricingRulesAuthoritative(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input EffectiveBillingConfigInput
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "inherited rule replaces stale platform snapshot",
|
||||
input: EffectiveBillingConfigInput{
|
||||
BaseConfig: videoBillingConfig(100),
|
||||
LegacyPlatformModelConfig: videoBillingConfig(100),
|
||||
InheritedRuleSetConfig: videoBillingConfig(416),
|
||||
},
|
||||
want: 416,
|
||||
},
|
||||
{
|
||||
name: "legacy snapshot remains a fallback without a rule",
|
||||
input: EffectiveBillingConfigInput{
|
||||
BaseConfig: videoBillingConfig(100),
|
||||
LegacyPlatformModelConfig: videoBillingConfig(125),
|
||||
},
|
||||
want: 125,
|
||||
},
|
||||
{
|
||||
name: "model rule remains an explicit pricing exception",
|
||||
input: EffectiveBillingConfigInput{
|
||||
BaseConfig: videoBillingConfig(100),
|
||||
LegacyPlatformModelConfig: videoBillingConfig(125),
|
||||
InheritedRuleSetConfig: videoBillingConfig(416),
|
||||
ModelRuleSetConfig: videoBillingConfig(500),
|
||||
},
|
||||
want: 500,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config := ResolveEffectiveBillingConfig(test.input)
|
||||
video, ok := config["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected video billing config, got %#v", config)
|
||||
}
|
||||
if got := video["basePrice"]; got != test.want {
|
||||
t.Fatalf("video base price = %#v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveBillingConfigAppliesOverrideLast(t *testing.T) {
|
||||
config := ResolveEffectiveBillingConfig(EffectiveBillingConfigInput{
|
||||
InheritedRuleSetConfig: videoBillingConfig(416),
|
||||
Override: videoBillingConfig(600),
|
||||
})
|
||||
video, ok := config["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(600) {
|
||||
t.Fatalf("expected override price 600, got %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveBillingConfigPreservesBaseResourcesMissingFromRuleSet(t *testing.T) {
|
||||
config := ResolveEffectiveBillingConfig(EffectiveBillingConfigInput{
|
||||
BaseConfig: map[string]any{
|
||||
"music": map[string]any{"basePrice": float64(20)},
|
||||
"audio": map[string]any{"basePrice": float64(1)},
|
||||
"video": map[string]any{"basePrice": float64(100)},
|
||||
},
|
||||
InheritedRuleSetConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
})
|
||||
|
||||
assertBillingBasePrice(t, config, "music", 20)
|
||||
assertBillingBasePrice(t, config, "audio", 1)
|
||||
assertBillingBasePrice(t, config, "video", 416)
|
||||
}
|
||||
|
||||
func assertBillingBasePrice(t *testing.T, config map[string]any, resource string, want float64) {
|
||||
t.Helper()
|
||||
resourceConfig, ok := config[resource].(map[string]any)
|
||||
if !ok || resourceConfig["basePrice"] != want {
|
||||
t.Fatalf("%s base price = %#v, want %v", resource, config[resource], want)
|
||||
}
|
||||
}
|
||||
|
||||
func videoBillingConfig(basePrice float64) map[string]any {
|
||||
return map[string]any{
|
||||
"video": map[string]any{"basePrice": basePrice},
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type modelCatalogSnapshot struct {
|
||||
DisplayName string
|
||||
Capabilities map[string]any
|
||||
BaseBillingConfig map[string]any
|
||||
PricingRuleSetID string
|
||||
DefaultRateLimitPolicy map[string]any
|
||||
RuntimePolicySetID string
|
||||
RuntimePolicyOverride map[string]any
|
||||
@@ -121,10 +122,10 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
|
||||
if err := validateEnabledVolcesTextModelCapabilities(ctx, q, input, capabilities); err != nil {
|
||||
return PlatformModel{}, err
|
||||
}
|
||||
// billing_config is a legacy, explicitly supplied compatibility field. Do
|
||||
// not materialize base-model pricing into it: copied prices become stale as
|
||||
// soon as the base pricing rule changes and can mask the authoritative rule.
|
||||
billingConfig := input.BillingConfig
|
||||
if len(billingConfig) == 0 {
|
||||
billingConfig = mergeObjects(base.BaseBillingConfig, input.BillingConfigOverride)
|
||||
}
|
||||
explicitRuntimePolicySetID := strings.TrimSpace(input.RuntimePolicySetID)
|
||||
rateLimitPolicy := input.RateLimitPolicy
|
||||
if len(rateLimitPolicy) == 0 && explicitRuntimePolicySetID == "" {
|
||||
@@ -260,6 +261,8 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
|
||||
model.ModelType = decodeStringArray(modelTypeBytes)
|
||||
model.BillingConfigOverride = decodeObject(billingOverrideBytes)
|
||||
model.BillingConfig = decodeObject(billingBytes)
|
||||
model.BaseBillingConfig = base.BaseBillingConfig
|
||||
model.BasePricingRuleSetID = base.PricingRuleSetID
|
||||
model.PermissionConfig = decodeObject(permissionBytes)
|
||||
model.RetryPolicy = decodeObject(retryPolicyBytes)
|
||||
model.RateLimitPolicy = decodeObject(rateLimitPolicyBytes)
|
||||
@@ -368,7 +371,7 @@ func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id
|
||||
var modelTypeBytes []byte
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
|
||||
capabilities, base_billing_config, default_rate_limit_policy,
|
||||
capabilities, base_billing_config, COALESCE(pricing_rule_set_id::text, ''), default_rate_limit_policy,
|
||||
COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override
|
||||
FROM base_model_catalog
|
||||
WHERE ($1 <> '' AND id = NULLIF($1, '')::uuid)
|
||||
@@ -384,6 +387,7 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp
|
||||
&item.DisplayName,
|
||||
&capabilities,
|
||||
&billingConfig,
|
||||
&item.PricingRuleSetID,
|
||||
&rateLimitPolicy,
|
||||
&item.RuntimePolicySetID,
|
||||
&runtimePolicyOverride,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListModelsLoadsEffectiveBillingSources(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 platform-model billing source integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
models, err := db.ListModels(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list models with effective billing sources: %v", err)
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.BaseModelID == "" {
|
||||
continue
|
||||
}
|
||||
if model.BaseBillingConfig == nil {
|
||||
t.Fatalf("platform model %s did not load base billing config", model.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Skip("database has no base-model-backed platform model")
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
type PortraitAsset struct {
|
||||
ID string `json:"id"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserID string `json:"userId"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SourceType string `json:"sourceType"`
|
||||
URL string `json:"url"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
ByteSize int64 `json:"size,omitempty"`
|
||||
SourceSHA256 string `json:"sourceSha256,omitempty"`
|
||||
PrivateAvatarEligible bool `json:"privateAvatarEligible"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PortraitAssetBinding struct {
|
||||
ID string `json:"id"`
|
||||
AssetID string `json:"assetId"`
|
||||
PlatformID string `json:"platformId"`
|
||||
ProjectName string `json:"projectName,omitempty"`
|
||||
AssetGroupID string `json:"assetGroupId,omitempty"`
|
||||
RemoteAssetID string `json:"remoteAssetId,omitempty"`
|
||||
RemoteAssetURI string `json:"remoteAssetUri,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty"`
|
||||
LastErrorMessage string `json:"lastErrorMessage,omitempty"`
|
||||
LastSyncedAt string `json:"lastSyncedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PortraitAssetInput struct {
|
||||
GatewayUserID string
|
||||
UserID string
|
||||
GatewayTenantID string
|
||||
TenantID string
|
||||
TenantKey string
|
||||
Name string
|
||||
Description string
|
||||
SourceType string
|
||||
URL string
|
||||
Preview string
|
||||
MimeType string
|
||||
ByteSize int64
|
||||
SourceSHA256 string
|
||||
PrivateAvatarEligible bool
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type PortraitAssetListFilter struct {
|
||||
Keyword string
|
||||
SourceType string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type PortraitAssetListResult struct {
|
||||
Items []PortraitAsset
|
||||
Total int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type PortraitAssetPlatform struct {
|
||||
PlatformID string
|
||||
PlatformKey string
|
||||
Provider string
|
||||
Credentials map[string]any
|
||||
Config map[string]any
|
||||
}
|
||||
|
||||
const portraitAssetColumns = `
|
||||
a.id::text, COALESCE(a.gateway_user_id::text, ''), a.user_id,
|
||||
COALESCE(a.gateway_tenant_id::text, ''), COALESCE(a.tenant_id, ''), COALESCE(a.tenant_key, ''),
|
||||
a.name, a.description, a.source_type, a.url, a.preview, a.mime_type, a.byte_size,
|
||||
a.source_sha256, a.private_avatar_eligible, a.status, a.last_error, a.metadata, a.created_at, a.updated_at`
|
||||
|
||||
func (s *Store) CreatePortraitAsset(ctx context.Context, input PortraitAssetInput) (PortraitAsset, error) {
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanPortraitAsset(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_portrait_assets (
|
||||
gateway_user_id, user_id, gateway_tenant_id, tenant_id, tenant_key,
|
||||
name, description, source_type, url, preview, mime_type, byte_size, source_sha256,
|
||||
private_avatar_eligible, status, metadata
|
||||
)
|
||||
VALUES (
|
||||
NULLIF($1, '')::uuid, $2, NULLIF($3, '')::uuid, NULLIF($4, ''), NULLIF($5, ''),
|
||||
$6, $7, $8, $9, $10, $11, $12, $13, $14, 'not_synced', $15::jsonb
|
||||
)
|
||||
RETURNING `+portraitAssetColumns,
|
||||
input.GatewayUserID, input.UserID, input.GatewayTenantID, input.TenantID, input.TenantKey,
|
||||
strings.TrimSpace(input.Name), strings.TrimSpace(input.Description), strings.TrimSpace(input.SourceType),
|
||||
strings.TrimSpace(input.URL), strings.TrimSpace(input.Preview), strings.TrimSpace(input.MimeType), input.ByteSize,
|
||||
strings.TrimSpace(input.SourceSHA256), input.PrivateAvatarEligible, string(metadata),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) FindPortraitAssetBySourceHash(ctx context.Context, user *auth.User, sourceSHA256 string) (PortraitAsset, bool, error) {
|
||||
sourceSHA256 = strings.TrimSpace(sourceSHA256)
|
||||
if sourceSHA256 == "" {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
asset, err := scanPortraitAsset(s.pool.QueryRow(ctx, `
|
||||
SELECT `+portraitAssetColumns+`
|
||||
FROM gateway_portrait_assets a
|
||||
WHERE ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))
|
||||
AND a.source_sha256 = $3
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 1`, gatewayUserID, userID, sourceSHA256))
|
||||
if IsNotFound(err) {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
return asset, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) FindPortraitAssetForUser(ctx context.Context, user *auth.User, assetID string) (PortraitAsset, bool, error) {
|
||||
assetID = strings.TrimSpace(assetID)
|
||||
if assetID == "" {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
asset, err := scanPortraitAsset(s.pool.QueryRow(ctx, `
|
||||
SELECT `+portraitAssetColumns+`
|
||||
FROM gateway_portrait_assets a
|
||||
WHERE a.id = NULLIF($3, '')::uuid
|
||||
AND ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))`, gatewayUserID, userID, assetID))
|
||||
if IsNotFound(err) {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
return asset, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPortraitAssets(ctx context.Context, user *auth.User, filter PortraitAssetListFilter) (PortraitAssetListResult, error) {
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
keyword := strings.TrimSpace(filter.Keyword)
|
||||
if keyword != "" {
|
||||
keyword = "%" + keyword + "%"
|
||||
}
|
||||
where := `
|
||||
WHERE ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))
|
||||
AND (NULLIF($3, '') IS NULL OR a.source_type = $3)
|
||||
AND (NULLIF($4, '') IS NULL OR a.name ILIKE $4 OR a.description ILIKE $4)`
|
||||
args := []any{gatewayUserID, userID, strings.TrimSpace(filter.SourceType), keyword}
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_portrait_assets a `+where, args...).Scan(&total); err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
args = append(args, pageSize, (page-1)*pageSize)
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+portraitAssetColumns+`
|
||||
FROM gateway_portrait_assets a `+where+`
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT $5 OFFSET $6`, args...)
|
||||
if err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]PortraitAsset, 0)
|
||||
for rows.Next() {
|
||||
asset, err := scanPortraitAsset(rows)
|
||||
if err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
items = append(items, asset)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
return PortraitAssetListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetPortraitAssetBinding(ctx context.Context, assetID string, platformID string) (PortraitAssetBinding, bool, error) {
|
||||
binding, err := scanPortraitAssetBinding(s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, asset_id::text, platform_id::text, project_name, asset_group_id,
|
||||
remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message,
|
||||
COALESCE(last_synced_at::text, ''), created_at, updated_at
|
||||
FROM gateway_portrait_asset_bindings
|
||||
WHERE asset_id = $1::uuid AND platform_id = $2::uuid`, assetID, platformID))
|
||||
if IsNotFound(err) {
|
||||
return PortraitAssetBinding{}, false, nil
|
||||
}
|
||||
return binding, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPortraitAssetBinding(ctx context.Context, binding PortraitAssetBinding) (PortraitAssetBinding, error) {
|
||||
return scanPortraitAssetBinding(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_portrait_asset_bindings (
|
||||
asset_id, platform_id, project_name, asset_group_id, remote_asset_id, remote_asset_uri,
|
||||
status, last_error_code, last_error_message, last_synced_at
|
||||
)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9, now())
|
||||
ON CONFLICT (asset_id, platform_id) DO UPDATE SET
|
||||
project_name = EXCLUDED.project_name,
|
||||
asset_group_id = EXCLUDED.asset_group_id,
|
||||
remote_asset_id = CASE WHEN EXCLUDED.remote_asset_id <> '' THEN EXCLUDED.remote_asset_id ELSE gateway_portrait_asset_bindings.remote_asset_id END,
|
||||
remote_asset_uri = CASE WHEN EXCLUDED.remote_asset_uri <> '' THEN EXCLUDED.remote_asset_uri ELSE gateway_portrait_asset_bindings.remote_asset_uri END,
|
||||
status = EXCLUDED.status,
|
||||
last_error_code = EXCLUDED.last_error_code,
|
||||
last_error_message = EXCLUDED.last_error_message,
|
||||
last_synced_at = now(),
|
||||
updated_at = now()
|
||||
RETURNING id::text, asset_id::text, platform_id::text, project_name, asset_group_id,
|
||||
remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message,
|
||||
COALESCE(last_synced_at::text, ''), created_at, updated_at`,
|
||||
binding.AssetID, binding.PlatformID, strings.TrimSpace(binding.ProjectName), strings.TrimSpace(binding.AssetGroupID),
|
||||
strings.TrimSpace(binding.RemoteAssetID), strings.TrimSpace(binding.RemoteAssetURI), strings.TrimSpace(binding.Status),
|
||||
strings.TrimSpace(binding.LastErrorCode), strings.TrimSpace(binding.LastErrorMessage),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePortraitAssetStatus(ctx context.Context, assetID string, status string, lastError string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_portrait_assets
|
||||
SET status = $2, last_error = $3, updated_at = now()
|
||||
WHERE id = $1::uuid`, assetID, strings.TrimSpace(status), strings.TrimSpace(lastError))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PortraitAssetBindingSummary(ctx context.Context, assetID string) (active int, total int, latestError string, updatedAt string, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE status = 'active'), COUNT(*),
|
||||
COALESCE((ARRAY_AGG(NULLIF(last_error_message, '') ORDER BY updated_at DESC) FILTER (WHERE NULLIF(last_error_message, '') IS NOT NULL))[1], ''),
|
||||
COALESCE(MAX(updated_at)::text, '')
|
||||
FROM gateway_portrait_asset_bindings
|
||||
WHERE asset_id = $1::uuid`, assetID).Scan(&active, &total, &latestError, &updatedAt)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) ListPortraitAssetPlatforms(ctx context.Context) ([]PortraitAssetPlatform, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT p.id::text, p.platform_key, p.provider, p.credentials, p.config
|
||||
FROM integration_platforms p
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND p.status = 'enabled'
|
||||
AND LOWER(p.provider) IN ('volces', 'volces-openai')
|
||||
ORDER BY COALESCE(p.dynamic_priority, p.priority), p.created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]PortraitAssetPlatform, 0)
|
||||
for rows.Next() {
|
||||
var item PortraitAssetPlatform
|
||||
var credentials, config []byte
|
||||
if err := rows.Scan(&item.PlatformID, &item.PlatformKey, &item.Provider, &credentials, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Credentials = decodeObject(credentials)
|
||||
item.Config = decodeObject(config)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func portraitAssetUserKeys(user *auth.User) (string, string) {
|
||||
if user == nil {
|
||||
return "", ""
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
|
||||
if gatewayUserID == "" && user.Source == "gateway" {
|
||||
gatewayUserID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
return gatewayUserID, strings.TrimSpace(user.ID)
|
||||
}
|
||||
|
||||
type portraitAssetScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanPortraitAsset(scanner portraitAssetScanner) (PortraitAsset, error) {
|
||||
var asset PortraitAsset
|
||||
var metadata []byte
|
||||
err := scanner.Scan(
|
||||
&asset.ID, &asset.GatewayUserID, &asset.UserID, &asset.GatewayTenantID, &asset.TenantID, &asset.TenantKey,
|
||||
&asset.Name, &asset.Description, &asset.SourceType, &asset.URL, &asset.Preview, &asset.MimeType, &asset.ByteSize,
|
||||
&asset.SourceSHA256, &asset.PrivateAvatarEligible, &asset.Status, &asset.LastError, &metadata, &asset.CreatedAt, &asset.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PortraitAsset{}, err
|
||||
}
|
||||
asset.Metadata = decodeObject(metadata)
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func scanPortraitAssetBinding(scanner portraitAssetScanner) (PortraitAssetBinding, error) {
|
||||
var binding PortraitAssetBinding
|
||||
if err := scanner.Scan(
|
||||
&binding.ID, &binding.AssetID, &binding.PlatformID, &binding.ProjectName, &binding.AssetGroupID,
|
||||
&binding.RemoteAssetID, &binding.RemoteAssetURI, &binding.Status, &binding.LastErrorCode, &binding.LastErrorMessage,
|
||||
&binding.LastSyncedAt, &binding.CreatedAt, &binding.UpdatedAt,
|
||||
); err != nil {
|
||||
return PortraitAssetBinding{}, err
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
@@ -217,33 +217,36 @@ type CreatedAPIKey struct {
|
||||
}
|
||||
|
||||
type PlatformModel struct {
|
||||
ID string `json:"id"`
|
||||
PlatformID string `json:"platformId"`
|
||||
BaseModelID string `json:"baseModelId,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
ModelName string `json:"modelName"`
|
||||
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||
ModelAlias string `json:"modelAlias,omitempty"`
|
||||
ModelType StringList `json:"modelType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
BaseCapabilities map[string]any `json:"-"`
|
||||
PricingMode string `json:"pricingMode"`
|
||||
DiscountFactor float64 `json:"discountFactor,omitempty"`
|
||||
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
|
||||
BillingConfig map[string]any `json:"billingConfig,omitempty"`
|
||||
PermissionConfig map[string]any `json:"permissionConfig,omitempty"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||
RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"`
|
||||
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"`
|
||||
CooldownUntil string `json:"cooldownUntil,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
PlatformID string `json:"platformId"`
|
||||
BaseModelID string `json:"baseModelId,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
ModelName string `json:"modelName"`
|
||||
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||
ModelAlias string `json:"modelAlias,omitempty"`
|
||||
ModelType StringList `json:"modelType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
BaseCapabilities map[string]any `json:"-"`
|
||||
BaseBillingConfig map[string]any `json:"-"`
|
||||
BasePricingRuleSetID string `json:"-"`
|
||||
PlatformPricingRuleSetID string `json:"-"`
|
||||
PricingMode string `json:"pricingMode"`
|
||||
DiscountFactor float64 `json:"discountFactor,omitempty"`
|
||||
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
|
||||
BillingConfig map[string]any `json:"billingConfig,omitempty"`
|
||||
PermissionConfig map[string]any `json:"permissionConfig,omitempty"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||
RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"`
|
||||
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"`
|
||||
CooldownUntil string `json:"cooldownUntil,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AccessRule struct {
|
||||
@@ -927,7 +930,9 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
|
||||
m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name,
|
||||
m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
|
||||
m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb),
|
||||
COALESCE(b.base_billing_config, '{}'::jsonb), COALESCE(b.pricing_rule_set_id::text, ''),
|
||||
COALESCE(p.pricing_rule_set_id::text, ''), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
|
||||
COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config,
|
||||
m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override,
|
||||
COALESCE(to_char(m.cooldown_until AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), ''),
|
||||
@@ -935,7 +940,7 @@ SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.p
|
||||
FROM platform_models m
|
||||
JOIN integration_platforms p ON p.id = m.platform_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT catalog.capabilities
|
||||
SELECT catalog.capabilities, catalog.base_billing_config, catalog.pricing_rule_set_id
|
||||
FROM base_model_catalog catalog
|
||||
WHERE (m.base_model_id IS NOT NULL AND catalog.id = m.base_model_id)
|
||||
OR (
|
||||
@@ -962,6 +967,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||
var capabilityOverride []byte
|
||||
var capabilities []byte
|
||||
var baseCapabilities []byte
|
||||
var baseBillingConfig []byte
|
||||
var billingConfigOverride []byte
|
||||
var billingConfig []byte
|
||||
var permissionConfig []byte
|
||||
@@ -983,6 +989,9 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||
&capabilityOverride,
|
||||
&capabilities,
|
||||
&baseCapabilities,
|
||||
&baseBillingConfig,
|
||||
&model.BasePricingRuleSetID,
|
||||
&model.PlatformPricingRuleSetID,
|
||||
&model.PricingMode,
|
||||
&model.DiscountFactor,
|
||||
&model.PricingRuleSetID,
|
||||
@@ -1003,6 +1012,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||
model.CapabilityOverride = decodeObject(capabilityOverride)
|
||||
model.Capabilities = decodeObject(capabilities)
|
||||
model.BaseCapabilities = decodeObject(baseCapabilities)
|
||||
model.BaseBillingConfig = decodeObject(baseBillingConfig)
|
||||
model.ModelType = decodeStringArray(modelTypeBytes)
|
||||
model.BillingConfigOverride = decodeObject(billingConfigOverride)
|
||||
model.BillingConfig = decodeObject(billingConfig)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetRuntimeModelCandidateForRemoteTask restores the exact platform model used
|
||||
// to submit an asynchronous provider task. It deliberately ignores enabled
|
||||
// state so a task can still be cancelled after its platform is disabled.
|
||||
func (s *Store) GetRuntimeModelCandidateForRemoteTask(ctx context.Context, platformModelID string, platformID string) (RuntimeModelCandidate, bool, error) {
|
||||
platformModelID = strings.TrimSpace(platformModelID)
|
||||
platformID = strings.TrimSpace(platformID)
|
||||
if platformModelID == "" || platformID == "" {
|
||||
return RuntimeModelCandidate{}, false, nil
|
||||
}
|
||||
var candidate RuntimeModelCandidate
|
||||
var credentials, config []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT p.id::text, p.platform_key, p.name, p.provider,
|
||||
COALESCE(NULLIF(p.config->>'specType', ''), p.provider), COALESCE(p.base_url, ''), p.auth_type,
|
||||
p.credentials, p.config, m.id::text, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name),
|
||||
m.model_name, COALESCE(m.model_alias, ''),
|
||||
COALESCE((m.model_type->>0), 'video_generate')
|
||||
FROM platform_models m
|
||||
JOIN integration_platforms p ON p.id = m.platform_id
|
||||
WHERE m.id = $1::uuid AND p.id = $2::uuid AND p.deleted_at IS NULL`, platformModelID, platformID).Scan(
|
||||
&candidate.PlatformID, &candidate.PlatformKey, &candidate.PlatformName, &candidate.Provider,
|
||||
&candidate.SpecType, &candidate.BaseURL, &candidate.AuthType, &credentials, &config,
|
||||
&candidate.PlatformModelID, &candidate.ProviderModelName, &candidate.ModelName, &candidate.ModelAlias, &candidate.ModelType,
|
||||
)
|
||||
if IsNotFound(err) {
|
||||
return RuntimeModelCandidate{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RuntimeModelCandidate{}, false, err
|
||||
}
|
||||
candidate.Credentials = decodeObject(credentials)
|
||||
candidate.PlatformConfig = decodeObject(config)
|
||||
candidate.ClientID = candidate.PlatformKey + ":" + candidate.ModelType + ":" + firstNonEmpty(candidate.ProviderModelName, candidate.ModelName)
|
||||
candidate.QueueKey = candidate.ClientID
|
||||
return candidate, true, nil
|
||||
}
|
||||
@@ -530,7 +530,8 @@ WHERE id = $1::uuid
|
||||
UPDATE gateway_task_attempts
|
||||
SET remote_task_id = NULLIF($2::text, ''),
|
||||
response_snapshot = COALESCE(response_snapshot, '{}'::jsonb) || jsonb_build_object('remote_task_payload', $3::jsonb)
|
||||
WHERE id = $1::uuid`,
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'`,
|
||||
attemptID,
|
||||
remoteTaskID,
|
||||
string(payloadJSON),
|
||||
@@ -585,6 +586,72 @@ WHERE id = $1::uuid
|
||||
return task, true, nil
|
||||
}
|
||||
|
||||
// CancelSubmittedTask records a confirmed upstream cancellation. Callers must
|
||||
// first complete the provider-side DELETE so local status never claims a remote
|
||||
// task was cancelled when the upstream request was not accepted.
|
||||
func (s *Store) CancelSubmittedTask(ctx context.Context, taskID string, executionToken string, message string) (GatewayTask, bool, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
message = "任务已由上游取消"
|
||||
}
|
||||
var task GatewayTask
|
||||
changed := false
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var err error
|
||||
task, err = scanGatewayTask(tx.QueryRow(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'cancelled',
|
||||
error = NULLIF($2, ''),
|
||||
error_code = 'task_cancelled',
|
||||
error_message = NULLIF($2, ''),
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN reservation_amount > 0 THEN 'pending'
|
||||
ELSE 'released'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND NULLIF(remote_task_id, '') IS NOT NULL
|
||||
AND (
|
||||
(status = 'running' AND execution_token = NULLIF($3, '')::uuid)
|
||||
OR status = 'queued'
|
||||
)
|
||||
RETURNING `+gatewayTaskColumns, taskID, message, strings.TrimSpace(executionToken)))
|
||||
if IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
payloadJSON, _ := json.Marshal(map[string]any{"taskId": taskID, "reason": "upstream_cancelled"})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload, status, next_attempt_at
|
||||
)
|
||||
SELECT id, 'task.billing.release', 'release', reservation_amount, billing_currency,
|
||||
pricing_snapshot, $2::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND gateway_user_id IS NOT NULL
|
||||
AND reservation_amount > 0
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, taskID, string(payloadJSON))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return GatewayTask{}, false, err
|
||||
}
|
||||
return task, changed, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRecoverableAsyncTasks(ctx context.Context, limit int) ([]AsyncTaskQueueItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
// VolcesCompatibleTaskListFilter mirrors the supported filters of Ark's
|
||||
// ListContentsGenerationsTasks API. Task IDs are the gateway's public task
|
||||
// IDs, which are the IDs returned by the compatibility create endpoint.
|
||||
type VolcesCompatibleTaskListFilter struct {
|
||||
CompatibilityMarker string
|
||||
Status string
|
||||
Model string
|
||||
TaskIDs []string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListVolcesCompatibleTasks returns only video tasks created through a named
|
||||
// compatibility surface. Keeping this query separate from ListTasks avoids
|
||||
// broadening the ordinary task-list API's filtering semantics.
|
||||
func (s *Store) ListVolcesCompatibleTasks(ctx context.Context, user *auth.User, filter VolcesCompatibleTaskListFilter) (TaskListResult, error) {
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > 500 {
|
||||
page = 500
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 500 {
|
||||
pageSize = 500
|
||||
}
|
||||
gatewayUserID := localGatewayUserID(user)
|
||||
userID, apiKeyID := "", ""
|
||||
if user != nil {
|
||||
userID = strings.TrimSpace(user.ID)
|
||||
apiKeyID = strings.TrimSpace(user.APIKeyID)
|
||||
}
|
||||
if gatewayUserID == "" && userID == "" {
|
||||
return TaskListResult{}, ErrLocalUserRequired
|
||||
}
|
||||
taskIDs := make([]string, 0, len(filter.TaskIDs))
|
||||
seen := make(map[string]bool, len(filter.TaskIDs))
|
||||
for _, taskID := range filter.TaskIDs {
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
if taskID != "" && !seen[taskID] {
|
||||
seen[taskID] = true
|
||||
taskIDs = append(taskIDs, taskID)
|
||||
}
|
||||
}
|
||||
args := []any{
|
||||
gatewayUserID,
|
||||
userID,
|
||||
apiKeyID,
|
||||
strings.TrimSpace(filter.CompatibilityMarker),
|
||||
strings.ToLower(strings.TrimSpace(filter.Status)),
|
||||
strings.TrimSpace(filter.Model),
|
||||
taskIDs,
|
||||
}
|
||||
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 kind = 'videos.generations'
|
||||
AND request->>'_gateway_compatibility' = $4
|
||||
AND (NULLIF($5, '') IS NULL OR LOWER(status) = $5)
|
||||
AND (NULLIF($6, '') IS NULL OR model = $6 OR resolved_model = $6)
|
||||
AND (COALESCE(array_length($7::text[], 1), 0) = 0 OR id::text = ANY($7::text[]))`
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+gatewayTaskColumns+`
|
||||
FROM gateway_tasks
|
||||
`+whereSQL+`
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $8 OFFSET $9`, append(args, pageSize, (page-1)*pageSize)...)
|
||||
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
|
||||
}
|
||||
return TaskListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
-- GLM-5.2 is a text-only foundation model. The official model page lists text
|
||||
-- as both its input and output modality; vision belongs to the separate GLM-5V
|
||||
-- family. Keep the base catalog, snapshots, and already-created platform rows
|
||||
-- authoritative so stale/customized image_analysis metadata cannot leak back
|
||||
-- into model discovery.
|
||||
-- Source: https://docs.bigmodel.cn/cn/guide/models/text/glm-5.2
|
||||
|
||||
WITH glm52_contract AS (
|
||||
SELECT
|
||||
'["text_generate","tools_call"]'::jsonb AS model_type,
|
||||
'{
|
||||
"text_generate": {
|
||||
"supportedApiProtocols": ["openai_chat_completions"],
|
||||
"max_context_tokens": 1000000,
|
||||
"max_output_tokens": 131072,
|
||||
"supportTool": true,
|
||||
"supportThinking": true,
|
||||
"supportThinkingModeSwitch": true,
|
||||
"thinkingEffortLevels": ["none", "high", "max"],
|
||||
"supportStructuredOutput": true
|
||||
},
|
||||
"tools_call": {
|
||||
"supportedApiProtocols": ["openai_chat_completions"],
|
||||
"max_context_tokens": 1000000,
|
||||
"max_output_tokens": 131072,
|
||||
"supportTool": true,
|
||||
"supportThinking": true,
|
||||
"supportThinkingModeSwitch": true,
|
||||
"thinkingEffortLevels": ["none", "high", "max"],
|
||||
"supportStructuredOutput": true
|
||||
},
|
||||
"originalTypes": ["text_generate", "tools_call"]
|
||||
}'::jsonb AS capabilities,
|
||||
'旗舰 Coding 文本模型(不支持图像/视频理解),1M 上下文,最大输出 128K;支持思考及推理强度、流式输出/工具调用、结构化输出与隐式上下文缓存。'::text AS description
|
||||
),
|
||||
updated_base_models AS (
|
||||
UPDATE base_model_catalog base_model
|
||||
SET model_type = contract.model_type,
|
||||
capabilities = contract.capabilities,
|
||||
metadata = COALESCE(base_model.metadata, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'originalTypes', contract.model_type,
|
||||
'description', contract.description,
|
||||
'rawModel', COALESCE(base_model.metadata->'rawModel', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'types', contract.model_type,
|
||||
'description', contract.description,
|
||||
'capabilities', contract.capabilities - 'originalTypes'
|
||||
)
|
||||
),
|
||||
default_snapshot = CASE
|
||||
WHEN COALESCE(base_model.default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN base_model.default_snapshot
|
||||
ELSE COALESCE(base_model.default_snapshot, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'modelType', contract.model_type,
|
||||
'capabilities', contract.capabilities,
|
||||
'metadata', COALESCE(base_model.default_snapshot->'metadata', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'originalTypes', contract.model_type,
|
||||
'description', contract.description,
|
||||
'rawModel', COALESCE(base_model.default_snapshot->'metadata'->'rawModel', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'types', contract.model_type,
|
||||
'description', contract.description,
|
||||
'capabilities', contract.capabilities - 'originalTypes'
|
||||
)
|
||||
)
|
||||
)
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM glm52_contract contract
|
||||
WHERE (
|
||||
base_model.canonical_model_key IN ('easyai:GLM-5.2', 'zhipu-openai:glm-5.2')
|
||||
OR (
|
||||
base_model.provider_key = 'easyai'
|
||||
AND lower(base_model.provider_model_name) = 'glm-5.2'
|
||||
)
|
||||
OR (
|
||||
base_model.provider_key = 'zhipu-openai'
|
||||
AND lower(base_model.provider_model_name) = 'glm-5.2'
|
||||
)
|
||||
)
|
||||
RETURNING base_model.id
|
||||
)
|
||||
UPDATE platform_models platform_model
|
||||
SET model_type = contract.model_type,
|
||||
capabilities = contract.capabilities,
|
||||
capability_override = (COALESCE(platform_model.capability_override, '{}'::jsonb) - 'image_analysis' - 'video_understanding' - 'originalTypes'),
|
||||
updated_at = now()
|
||||
FROM integration_platforms platform
|
||||
CROSS JOIN glm52_contract contract
|
||||
WHERE platform_model.platform_id = platform.id
|
||||
AND platform.deleted_at IS NULL
|
||||
AND (
|
||||
platform_model.base_model_id IN (
|
||||
SELECT id FROM updated_base_models
|
||||
)
|
||||
OR (
|
||||
platform.provider IN ('easyai', 'zhipu-openai')
|
||||
AND lower(COALESCE(NULLIF(platform_model.provider_model_name, ''), platform_model.model_name)) = 'glm-5.2'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_portrait_assets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
name text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
source_type text NOT NULL,
|
||||
url text NOT NULL,
|
||||
preview text NOT NULL DEFAULT '',
|
||||
mime_type text NOT NULL DEFAULT '',
|
||||
byte_size bigint NOT NULL DEFAULT 0,
|
||||
source_sha256 text NOT NULL DEFAULT '',
|
||||
private_avatar_eligible boolean NOT NULL DEFAULT false,
|
||||
status text NOT NULL DEFAULT 'not_synced',
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_portrait_asset_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_id uuid NOT NULL REFERENCES gateway_portrait_assets(id) ON DELETE CASCADE,
|
||||
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE,
|
||||
project_name text NOT NULL DEFAULT '',
|
||||
asset_group_id text NOT NULL DEFAULT '',
|
||||
remote_asset_id text NOT NULL DEFAULT '',
|
||||
remote_asset_uri text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
last_error_code text NOT NULL DEFAULT '',
|
||||
last_error_message text NOT NULL DEFAULT '',
|
||||
last_synced_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(asset_id, platform_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_assets_user_created
|
||||
ON gateway_portrait_assets(gateway_user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_assets_user_id_created
|
||||
ON gateway_portrait_assets(user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_assets_user_hash
|
||||
ON gateway_portrait_assets(gateway_user_id, source_sha256)
|
||||
WHERE source_sha256 <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_asset_bindings_asset_platform
|
||||
ON gateway_portrait_asset_bindings(asset_id, platform_id);
|
||||
|
||||
UPDATE base_model_catalog
|
||||
SET capabilities = jsonb_set(
|
||||
COALESCE(capabilities, '{}'::jsonb),
|
||||
'{omni_video,supports_portrait_asset_reference}',
|
||||
'true'::jsonb,
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE provider_key = 'volces'
|
||||
AND provider_model_name LIKE 'doubao-seedance-2-0%'
|
||||
AND model_type @> '["omni_video"]'::jsonb;
|
||||
|
||||
UPDATE platform_models m
|
||||
SET capabilities = jsonb_set(
|
||||
COALESCE(m.capabilities, '{}'::jsonb),
|
||||
'{omni_video,supports_portrait_asset_reference}',
|
||||
'true'::jsonb,
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
FROM integration_platforms p
|
||||
WHERE p.id = m.platform_id
|
||||
AND p.provider = 'volces'
|
||||
AND m.model_type @> '["omni_video"]'::jsonb
|
||||
AND COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) LIKE 'doubao-seedance-2-0%';
|
||||
+27
-19
@@ -69,6 +69,7 @@ import {
|
||||
listAccessRules,
|
||||
listAuditLogs,
|
||||
listApiKeyAccessRules,
|
||||
listApiKeyAssignableModels,
|
||||
listApiKeys,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
@@ -132,7 +133,7 @@ import {
|
||||
startOIDCLogin,
|
||||
startOIDCLogout,
|
||||
} from './lib/oidc';
|
||||
import { runTask } from './lib/run-task';
|
||||
import { runTask, type RunTaskOptions } from './lib/run-task';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
@@ -176,6 +177,7 @@ type DataKey =
|
||||
| 'publicCatalog'
|
||||
| 'playgroundApiKeys'
|
||||
| 'playgroundModels'
|
||||
| 'apiKeyPolicyModels'
|
||||
| 'modelCatalog'
|
||||
| 'networkProxyConfig'
|
||||
| 'clientCustomizationSettings'
|
||||
@@ -227,6 +229,7 @@ export function App() {
|
||||
summary: { modelCount: 0, sourceCount: 0 },
|
||||
});
|
||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||
const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState<PlatformModel[]>([]);
|
||||
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
|
||||
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
||||
@@ -530,6 +533,9 @@ export function App() {
|
||||
case 'playgroundModels':
|
||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||
return;
|
||||
case 'apiKeyPolicyModels':
|
||||
setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items);
|
||||
return;
|
||||
case 'playgroundApiKeys': {
|
||||
const response = await listPlayableApiKeys(nextToken);
|
||||
setApiKeys(response.items);
|
||||
@@ -687,7 +693,7 @@ export function App() {
|
||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.platformId
|
||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||
@@ -707,7 +713,7 @@ export function App() {
|
||||
const updated = await updatePlatform(token, platform.id, input);
|
||||
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
||||
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
||||
} catch (err) {
|
||||
@@ -739,7 +745,7 @@ export function App() {
|
||||
platformPriority: state.priority,
|
||||
}
|
||||
: status));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
|
||||
} catch (err) {
|
||||
@@ -783,7 +789,7 @@ export function App() {
|
||||
cooldownUntil: undefined,
|
||||
}
|
||||
: model));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('模型运行状态已恢复。');
|
||||
} catch (err) {
|
||||
@@ -800,7 +806,7 @@ export function App() {
|
||||
await deletePlatform(token, platformId);
|
||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台已删除。');
|
||||
} catch (err) {
|
||||
@@ -816,6 +822,7 @@ export function App() {
|
||||
try {
|
||||
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
||||
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
||||
} catch (err) {
|
||||
@@ -831,6 +838,7 @@ export function App() {
|
||||
try {
|
||||
await deleteTenant(token, tenantId);
|
||||
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('租户已删除。');
|
||||
} catch (err) {
|
||||
@@ -846,7 +854,7 @@ export function App() {
|
||||
try {
|
||||
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
||||
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
||||
} catch (err) {
|
||||
@@ -902,7 +910,7 @@ export function App() {
|
||||
try {
|
||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||
} catch (err) {
|
||||
@@ -920,8 +928,7 @@ export function App() {
|
||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户组已删除。');
|
||||
} catch (err) {
|
||||
@@ -975,7 +982,7 @@ export function App() {
|
||||
try {
|
||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||
} catch (err) {
|
||||
@@ -991,7 +998,7 @@ export function App() {
|
||||
try {
|
||||
await deleteAccessRule(token, ruleId);
|
||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限规则已删除。');
|
||||
} catch (err) {
|
||||
@@ -1007,7 +1014,7 @@ export function App() {
|
||||
try {
|
||||
const response = await batchAccessRules(token, input);
|
||||
setAccessRules(response.items);
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限已更新。');
|
||||
} catch (err) {
|
||||
@@ -1142,7 +1149,7 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTask(event: FormEvent<HTMLFormElement>) {
|
||||
async function submitTask(event: FormEvent<HTMLFormElement>, options: RunTaskOptions = {}) {
|
||||
event.preventDefault();
|
||||
const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : '';
|
||||
const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id;
|
||||
@@ -1153,11 +1160,12 @@ export function App() {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const response = await runTask(credential, taskForm);
|
||||
const response = await runTask(credential, taskForm, options);
|
||||
const completionMessage = response.submissionMode === 'simulation' ? '完成测试模式运行' : '完成真实提交';
|
||||
if (response.localOnly) {
|
||||
setTaskResult(response.task);
|
||||
setCoreState('ready');
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`);
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`);
|
||||
return;
|
||||
}
|
||||
const syncTask = (detail: GatewayTask) => {
|
||||
@@ -1169,7 +1177,7 @@ export function App() {
|
||||
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
||||
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`);
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`);
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '测试任务失败');
|
||||
@@ -1355,7 +1363,7 @@ export function App() {
|
||||
apiKeyForm={apiKeyForm}
|
||||
apiKeySecret={apiKeySecret}
|
||||
apiKeySecretsById={apiKeySecretsById}
|
||||
apiKeyPolicyModels={playgroundModels}
|
||||
apiKeyPolicyModels={apiKeyPolicyModels}
|
||||
data={data}
|
||||
message={coreMessage}
|
||||
section={workspaceSection}
|
||||
@@ -1622,7 +1630,7 @@ function dataKeysForRoute(
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'billing') return ['wallet'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'apiKeyPolicyModels'];
|
||||
if (workspaceSection === 'tasks') return ['tasks'];
|
||||
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
||||
return [];
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
getCurrentUser,
|
||||
getOpsManagementSkillMetadata,
|
||||
loginLocalAccount,
|
||||
listApiKeyAssignableModels,
|
||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||
startIdentityPairing,
|
||||
retireIdentityPairingSecurityEventConflict,
|
||||
validateIdentityRevision,
|
||||
} from './api';
|
||||
import { applyTaskSubmissionMode, runTask } from './lib/run-task';
|
||||
|
||||
describe('local login transport', () => {
|
||||
afterEach(() => {
|
||||
@@ -231,6 +233,26 @@ describe('OIDC browser session transport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key permission resources', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads the user-owned resource pool independently from playable models', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listApiKeyAssignableModels('user-token');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain('/api/v1/api-keys/assignable-models');
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public Agent resources', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -295,4 +317,46 @@ describe('API documentation runner transports', () => {
|
||||
expect(url).toContain('/api/v1/tasks/task-123');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('removes every simulation switch from a real submission while preserving edited parameters', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'chatcmpl-real' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await runTask(
|
||||
'sk-test',
|
||||
{ kind: 'chat.completions', model: 'gpt-fallback', prompt: 'fallback' },
|
||||
{
|
||||
submissionMode: 'production',
|
||||
requestBody: {
|
||||
model: 'gpt-real',
|
||||
messages: [{ role: 'user', content: 'edited body' }],
|
||||
temperature: 0.25,
|
||||
runMode: 'simulation',
|
||||
run_mode: 'simulation',
|
||||
simulation: true,
|
||||
testMode: true,
|
||||
test_mode: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
model: 'gpt-real',
|
||||
messages: [{ role: 'user', content: 'edited body' }],
|
||||
temperature: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses canonical simulation parameters without removing a model-specific mode field', () => {
|
||||
expect(applyTaskSubmissionMode({ model: 'video-model', mode: 'pro', testMode: false }, 'simulation')).toEqual({
|
||||
model: 'video-model',
|
||||
mode: 'pro',
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise<ListResponse
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
|
||||
}
|
||||
|
||||
export async function listApiKeyAssignableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/api-keys/assignable-models', { token });
|
||||
}
|
||||
|
||||
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
||||
return request<GatewayAccessRule>('/api/admin/access-rules', {
|
||||
body: input,
|
||||
|
||||
@@ -9,101 +9,97 @@ import {
|
||||
createVideoGenerationTask,
|
||||
getAPITask,
|
||||
} from '../api';
|
||||
import type { TaskForm } from '../types';
|
||||
import type { TaskForm, TaskSubmissionMode } from '../types';
|
||||
|
||||
export interface RunTaskResponse {
|
||||
localOnly?: boolean;
|
||||
next?: Record<string, string>;
|
||||
submissionMode: TaskSubmissionMode;
|
||||
task: GatewayTask;
|
||||
}
|
||||
|
||||
export async function runTask(token: string, task: TaskForm): Promise<RunTaskResponse> {
|
||||
export interface RunTaskOptions {
|
||||
requestBody?: Record<string, unknown>;
|
||||
submissionMode?: TaskSubmissionMode;
|
||||
}
|
||||
|
||||
const simulationParameterKeys = ['runMode', 'run_mode', 'simulation', 'testMode', 'test_mode'] as const;
|
||||
|
||||
export function applyTaskSubmissionMode(
|
||||
input: Record<string, unknown>,
|
||||
submissionMode: TaskSubmissionMode,
|
||||
): Record<string, unknown> {
|
||||
const body = { ...input };
|
||||
for (const key of simulationParameterKeys) delete body[key];
|
||||
if (submissionMode === 'simulation') {
|
||||
body.runMode = 'simulation';
|
||||
body.simulation = true;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function runTask(token: string, task: TaskForm, options: RunTaskOptions = {}): Promise<RunTaskResponse> {
|
||||
const submissionMode = options.submissionMode ?? 'simulation';
|
||||
const requestBody = task.kind === 'tasks.retrieve'
|
||||
? { taskId: task.taskId }
|
||||
: applyTaskSubmissionMode(options.requestBody ?? defaultRequestBody(task), submissionMode);
|
||||
|
||||
if (task.kind === 'chat.completions') {
|
||||
const result = await createCompatibleChatCompletion(token, {
|
||||
model: task.model,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: task.prompt }],
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createCompatibleChatCompletion(
|
||||
token,
|
||||
requestBody as Parameters<typeof createCompatibleChatCompletion>[1],
|
||||
);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'responses') {
|
||||
const result = await createResponse(token, {
|
||||
model: task.model,
|
||||
input: task.prompt,
|
||||
instructions: task.instructions,
|
||||
previous_response_id: task.previousResponseId,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
store: true,
|
||||
stream: false,
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createResponse(token, requestBody as Parameters<typeof createResponse>[1]);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'embeddings') {
|
||||
const result = await createEmbedding(token, {
|
||||
model: task.model,
|
||||
input: embeddingInput(task.prompt),
|
||||
dimensions: task.dimensions,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createEmbedding(token, requestBody as Parameters<typeof createEmbedding>[1]);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'reranks') {
|
||||
const result = await createRerank(token, {
|
||||
model: task.model,
|
||||
query: task.prompt,
|
||||
documents: rerankDocuments(task.documents),
|
||||
top_n: task.topN,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createRerank(token, requestBody as Parameters<typeof createRerank>[1]);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'images.generations') {
|
||||
return createImageGenerationTask(token, {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
quality: 'medium',
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
size: '1024x1024',
|
||||
});
|
||||
const response = await createImageGenerationTask(
|
||||
token,
|
||||
requestBody as Parameters<typeof createImageGenerationTask>[1],
|
||||
);
|
||||
return { ...response, submissionMode };
|
||||
}
|
||||
if (task.kind === 'images.edits') {
|
||||
return createImageEditTask(token, {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
image: task.image,
|
||||
mask: task.mask,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
const response = await createImageEditTask(token, requestBody as Parameters<typeof createImageEditTask>[1]);
|
||||
return { ...response, submissionMode };
|
||||
}
|
||||
if (task.kind === 'videos.generations') {
|
||||
return createVideoGenerationTask(token, {
|
||||
model: task.model,
|
||||
content: [{ type: 'text', text: task.prompt }],
|
||||
aspect_ratio: task.aspectRatio ?? '16:9',
|
||||
resolution: task.resolution ?? '720p',
|
||||
duration: task.duration ?? 5,
|
||||
audio: task.outputAudio ?? true,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
const response = await createVideoGenerationTask(
|
||||
token,
|
||||
requestBody as unknown as Parameters<typeof createVideoGenerationTask>[1],
|
||||
);
|
||||
return { ...response, submissionMode };
|
||||
}
|
||||
if (task.kind === 'tasks.retrieve') {
|
||||
const taskId = task.taskId?.trim();
|
||||
if (!taskId) throw new Error('请输入要取回的 Task ID');
|
||||
const result = await getAPITask(token, taskId);
|
||||
return { localOnly: true, task: compatibleTask(task, result as unknown as Record<string, unknown>) };
|
||||
return {
|
||||
localOnly: true,
|
||||
submissionMode: 'production',
|
||||
task: compatibleTask(task, result as unknown as Record<string, unknown>, requestBody, 'production'),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported task kind: ${task.kind}`);
|
||||
}
|
||||
|
||||
function compatibleTask(task: TaskForm, result: Record<string, unknown>): GatewayTask {
|
||||
function compatibleTask(
|
||||
task: TaskForm,
|
||||
result: Record<string, unknown>,
|
||||
requestBody: Record<string, unknown>,
|
||||
submissionMode: TaskSubmissionMode,
|
||||
): GatewayTask {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `docs-${task.kind}-${Date.now()}`,
|
||||
@@ -111,26 +107,31 @@ function compatibleTask(task: TaskForm, result: Record<string, unknown>): Gatewa
|
||||
createdAt: now,
|
||||
finishedAt: now,
|
||||
kind: task.kind,
|
||||
model: task.model,
|
||||
model: typeof requestBody.model === 'string' ? requestBody.model : task.model,
|
||||
modelType: modelTypeForKind(task.kind),
|
||||
request: requestSnapshot(task),
|
||||
request: requestBody,
|
||||
result,
|
||||
runMode: 'simulation',
|
||||
runMode: submissionMode,
|
||||
status: 'succeeded',
|
||||
updatedAt: now,
|
||||
userId: 'docs-runner',
|
||||
};
|
||||
}
|
||||
|
||||
function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
function defaultRequestBody(task: TaskForm): Record<string, unknown> {
|
||||
if (task.kind === 'chat.completions') {
|
||||
return {
|
||||
model: task.model,
|
||||
messages: [{ role: 'user', content: task.prompt }],
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'responses') {
|
||||
return {
|
||||
model: task.model,
|
||||
input: task.prompt,
|
||||
instructions: task.instructions,
|
||||
previous_response_id: task.previousResponseId,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
store: true,
|
||||
stream: false,
|
||||
};
|
||||
@@ -140,8 +141,6 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
model: task.model,
|
||||
input: embeddingInput(task.prompt),
|
||||
dimensions: task.dimensions,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'reranks') {
|
||||
@@ -150,8 +149,22 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
query: task.prompt,
|
||||
documents: rerankDocuments(task.documents),
|
||||
top_n: task.topN,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'images.generations') {
|
||||
return {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
quality: 'medium',
|
||||
size: '1024x1024',
|
||||
};
|
||||
}
|
||||
if (task.kind === 'images.edits') {
|
||||
return {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
image: task.image,
|
||||
mask: task.mask,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'videos.generations') {
|
||||
@@ -162,18 +175,10 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
resolution: task.resolution ?? '720p',
|
||||
duration: task.duration ?? 5,
|
||||
audio: task.outputAudio ?? true,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'tasks.retrieve') return { taskId: task.taskId };
|
||||
return {
|
||||
model: task.model,
|
||||
messages: [{ role: 'user', content: task.prompt }],
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
stream: false,
|
||||
};
|
||||
return { model: task.model };
|
||||
}
|
||||
|
||||
function embeddingInput(prompt: string) {
|
||||
|
||||
@@ -36,6 +36,17 @@ describe('ApiDocsPage extended task documentation', () => {
|
||||
expect(html).toContain('任务取回接口');
|
||||
});
|
||||
|
||||
it('defaults the online runner to test mode and offers an explicit real submission mode', () => {
|
||||
const html = renderDocs('imageEdit', { kind: 'images.edits', model: 'gpt-image-1', prompt: '移除背景' });
|
||||
|
||||
expect(html).toContain('运行模式');
|
||||
expect(html).toContain('测试模式');
|
||||
expect(html).toContain('真实提交');
|
||||
expect(html).toContain('aria-pressed="true"');
|
||||
expect(html).toContain('"runMode": "simulation"');
|
||||
expect(html).toContain('"simulation": true');
|
||||
});
|
||||
|
||||
it('documents async mode as a body-independent capability', () => {
|
||||
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import { Fragment, useEffect, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts';
|
||||
import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react';
|
||||
import { Badge, Button, Input, Select, Textarea } from '../components/ui';
|
||||
import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
|
||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
|
||||
import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
|
||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
|
||||
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
||||
|
||||
interface ApiDocItem {
|
||||
@@ -90,7 +91,7 @@ export function ApiDocsPage(props: {
|
||||
onCreateApiKey: () => void;
|
||||
onLogin: () => void;
|
||||
onDocSectionChange: (value: ApiDocSection) => void;
|
||||
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitTask: (event: FormEvent<HTMLFormElement>, options?: RunTaskOptions) => void;
|
||||
onTaskFormChange: (value: TaskForm) => void;
|
||||
}) {
|
||||
const activeGuide = guideItems.find((item) => item.key === props.activeDocSection);
|
||||
@@ -101,12 +102,12 @@ export function ApiDocsPage(props: {
|
||||
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
|
||||
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
|
||||
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
|
||||
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
|
||||
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||
const bodyExample = useMemo(
|
||||
() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'),
|
||||
[currentApiDoc?.key, props.taskForm],
|
||||
);
|
||||
const [submissionMode, setSubmissionMode] = useState<TaskSubmissionMode>('simulation');
|
||||
const [bodyDraft, setBodyDraft] = useState(() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||
const [bodyError, setBodyError] = useState('');
|
||||
const runnerPath = currentApiDoc?.path
|
||||
? isTaskRetrieveDoc
|
||||
? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}')
|
||||
@@ -119,6 +120,12 @@ export function ApiDocsPage(props: {
|
||||
}
|
||||
}, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSubmissionMode('simulation');
|
||||
setBodyDraft(requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||
setBodyError('');
|
||||
}, [currentApiDoc?.key, props.taskForm]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getOpsManagementSkillMetadata()
|
||||
@@ -134,18 +141,55 @@ export function ApiDocsPage(props: {
|
||||
}, []);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!runnerAvailable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!props.canRun) {
|
||||
event.preventDefault();
|
||||
props.onLogin();
|
||||
return;
|
||||
}
|
||||
if (runnerModeEnabled) {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError(parsed.error);
|
||||
return;
|
||||
}
|
||||
const requestBody = applyTaskSubmissionMode(parsed.body, submissionMode);
|
||||
setBodyDraft(JSON.stringify(requestBody, null, 2));
|
||||
setBodyError('');
|
||||
props.onSubmitTask(event, { requestBody, submissionMode });
|
||||
return;
|
||||
}
|
||||
props.onSubmitTask(event);
|
||||
}
|
||||
|
||||
function handleBodyChange(value: string) {
|
||||
setBodyDraft(value);
|
||||
setBodyError(parseEditableRequestBody(value).error);
|
||||
}
|
||||
|
||||
function handleBodyBlur() {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, submissionMode), null, 2));
|
||||
setBodyError('');
|
||||
}
|
||||
|
||||
function handleSubmissionModeChange(nextMode: TaskSubmissionMode) {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError('请先修正请求 Body 的 JSON 格式,再切换运行模式。');
|
||||
return;
|
||||
}
|
||||
setSubmissionMode(nextMode);
|
||||
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, nextMode), null, 2));
|
||||
setBodyError('');
|
||||
}
|
||||
|
||||
function handleDocClick(item: ApiDocItem) {
|
||||
if (item.kind) {
|
||||
props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult));
|
||||
@@ -257,7 +301,7 @@ export function ApiDocsPage(props: {
|
||||
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ? <form onSubmit={handleSubmit}>
|
||||
<header>
|
||||
<strong>在线运行</strong>
|
||||
<Button type="submit" size="sm" disabled={!runnerAvailable || (props.canRun && props.coreState === 'loading')}>
|
||||
<Button type="submit" size="sm" disabled={!runnerAvailable || Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
|
||||
<Send size={14} />
|
||||
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
|
||||
</Button>
|
||||
@@ -286,6 +330,34 @@ export function ApiDocsPage(props: {
|
||||
)}
|
||||
{runnerAvailable ? (
|
||||
<>
|
||||
{runnerModeEnabled && (
|
||||
<div className="runnerModeField">
|
||||
<span className="runnerModeLabel">运行模式</span>
|
||||
<div className="runnerModeToggle" role="group" aria-label="运行模式">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={submissionMode === 'simulation'}
|
||||
data-active={submissionMode === 'simulation'}
|
||||
onClick={() => handleSubmissionModeChange('simulation')}
|
||||
>
|
||||
测试模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={submissionMode === 'production'}
|
||||
data-active={submissionMode === 'production'}
|
||||
onClick={() => handleSubmissionModeChange('production')}
|
||||
>
|
||||
真实提交
|
||||
</button>
|
||||
</div>
|
||||
<p className="runnerModeHint" data-mode={submissionMode}>
|
||||
{submissionMode === 'simulation'
|
||||
? '不触达真实供应商,不消耗上游额度。'
|
||||
: '请求将真实提交给供应商,可能产生费用。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="shLabel">
|
||||
能力
|
||||
<Select value={props.taskForm.kind} onChange={(event) => handleKindChange(event.target.value as TaskKind)}>
|
||||
@@ -304,14 +376,29 @@ export function ApiDocsPage(props: {
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="shLabel">
|
||||
请求 Body
|
||||
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
|
||||
</label>
|
||||
<div className="runnerBodyField">
|
||||
<label className="shLabel">
|
||||
请求 Body
|
||||
<Textarea
|
||||
aria-describedby={bodyError ? 'docs-runner-body-error' : undefined}
|
||||
aria-invalid={Boolean(bodyError)}
|
||||
value={bodyDraft}
|
||||
onBlur={handleBodyBlur}
|
||||
onChange={(event) => handleBodyChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{bodyError && <span className="runnerBodyError" id="docs-runner-body-error" role="alert">{bodyError}</span>}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
|
||||
<Button type="submit" disabled={Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
|
||||
<Play size={15} />
|
||||
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
|
||||
{!props.canRun
|
||||
? '登录后运行'
|
||||
: props.coreState === 'loading'
|
||||
? '运行中'
|
||||
: submissionMode === 'simulation' || !runnerModeEnabled
|
||||
? '运行测试'
|
||||
: '真实提交'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -767,9 +854,9 @@ function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm
|
||||
return { ...current, kind, model: 'task' };
|
||||
}
|
||||
|
||||
function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
function requestBodyExample(task: TaskForm, section: ApiDocSection, submissionMode: TaskSubmissionMode) {
|
||||
const body = task.kind === 'chat.completions'
|
||||
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], runMode: 'simulation', simulation: true, stream: false }
|
||||
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], stream: false }
|
||||
: task.kind === 'responses'
|
||||
? {
|
||||
model: task.model,
|
||||
@@ -778,15 +865,13 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
previous_response_id: task.previousResponseId || undefined,
|
||||
store: true,
|
||||
stream: false,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
}
|
||||
: task.kind === 'embeddings'
|
||||
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4, runMode: 'simulation', simulation: true }
|
||||
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4 }
|
||||
: task.kind === 'reranks'
|
||||
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2, runMode: 'simulation', simulation: true }
|
||||
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2 }
|
||||
: task.kind === 'images.edits'
|
||||
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, runMode: 'simulation', simulation: true }
|
||||
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask }
|
||||
: task.kind === 'videos.generations'
|
||||
? {
|
||||
model: task.model,
|
||||
@@ -795,54 +880,23 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
resolution: task.resolution ?? '720p',
|
||||
aspect_ratio: task.aspectRatio ?? '16:9',
|
||||
audio: task.outputAudio ?? true,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
}
|
||||
: section === 'pricing'
|
||||
? { kind: 'chat.completions', model: 'gpt-4o-mini', messages: [{ role: 'user', content: '你好' }], max_tokens: 512 }
|
||||
: { model: task.model, prompt: task.prompt, quality: 'medium', runMode: 'simulation', simulation: true, size: '1024x1024' };
|
||||
return JSON.stringify(body, null, 2);
|
||||
: { model: task.model, prompt: task.prompt, quality: 'medium', size: '1024x1024' };
|
||||
return JSON.stringify(section === 'pricing' ? body : applyTaskSubmissionMode(body, submissionMode), null, 2);
|
||||
}
|
||||
|
||||
function parseBody(value: string, current: TaskForm): TaskForm {
|
||||
function parseEditableRequestBody(value: string): { body: Record<string, unknown> | null; error: string } {
|
||||
try {
|
||||
const body = JSON.parse(value) as {
|
||||
image?: string;
|
||||
aspect_ratio?: string;
|
||||
audio?: boolean;
|
||||
content?: Array<{ text?: string; type?: string }>;
|
||||
duration?: number;
|
||||
instructions?: string;
|
||||
mask?: string;
|
||||
messages?: Array<{ content?: string }>;
|
||||
model?: string;
|
||||
previous_response_id?: string;
|
||||
prompt?: string;
|
||||
input?: unknown;
|
||||
query?: string;
|
||||
documents?: string[];
|
||||
resolution?: string;
|
||||
top_n?: number;
|
||||
dimensions?: number;
|
||||
};
|
||||
return {
|
||||
...current,
|
||||
aspectRatio: body.aspect_ratio ?? current.aspectRatio,
|
||||
dimensions: numberOrCurrent(body.dimensions, current.dimensions),
|
||||
documents: Array.isArray(body.documents) ? body.documents.join('\n') : current.documents,
|
||||
duration: numberOrCurrent(body.duration, current.duration),
|
||||
image: body.image ?? current.image,
|
||||
instructions: body.instructions ?? current.instructions,
|
||||
mask: body.mask ?? current.mask,
|
||||
model: body.model ?? current.model,
|
||||
outputAudio: typeof body.audio === 'boolean' ? body.audio : current.outputAudio,
|
||||
previousResponseId: body.previous_response_id ?? current.previousResponseId,
|
||||
prompt: body.prompt ?? body.query ?? inputText(body.input) ?? contentText(body.content) ?? body.messages?.[0]?.content ?? current.prompt,
|
||||
resolution: body.resolution ?? current.resolution,
|
||||
topN: numberOrCurrent(body.top_n, current.topN),
|
||||
};
|
||||
} catch {
|
||||
return current;
|
||||
const body = JSON.parse(value) as unknown;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { body: null, error: '请求 Body 必须是 JSON 对象。' };
|
||||
}
|
||||
return { body: body as Record<string, unknown>, error: '' };
|
||||
} catch (error) {
|
||||
const detail = error instanceof SyntaxError ? error.message : '无法解析 JSON';
|
||||
return { body: null, error: `JSON 格式有误:${detail}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,20 +1152,3 @@ function splitLines(value: string) {
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inputText(value: unknown) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
const texts = value.map((item) => typeof item === 'string' ? item : '').filter(Boolean);
|
||||
return texts.length ? texts.join('\n') : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function contentText(value?: Array<{ text?: string; type?: string }>) {
|
||||
return value?.find((item) => item.type === 'text' && item.text)?.text;
|
||||
}
|
||||
|
||||
function numberOrCurrent(value: unknown, current?: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : current;
|
||||
}
|
||||
|
||||
@@ -322,6 +322,84 @@
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.runnerModeField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runnerModeLabel {
|
||||
color: var(--text-normal);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runnerModeToggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.runnerModeToggle button {
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runnerModeToggle button:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.runnerModeToggle button[data-active='true'] {
|
||||
border-color: #d8dee8;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.runnerModeToggle button:last-child[data-active='true'] {
|
||||
border-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.runnerModeHint {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runnerModeHint[data-mode='production'] {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.runnerBodyError {
|
||||
color: #b42318;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runnerBodyField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.docsRunner .shTextarea[aria-invalid='true'] {
|
||||
border-color: #f04438;
|
||||
box-shadow: 0 0 0 2px rgba(240, 68, 56, 0.1);
|
||||
}
|
||||
|
||||
.docsRunnerUnavailable {
|
||||
padding: 14px;
|
||||
border: 1px dashed var(--border);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
export type AuthMode = 'login' | 'register' | 'external';
|
||||
export type TaskSubmissionMode = 'simulation' | 'production';
|
||||
export type TaskKind =
|
||||
| 'chat.completions'
|
||||
| 'responses'
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"ok": true,
|
||||
"generatedAt": "2026-07-17T06:19:48.217Z",
|
||||
"baseURL": "https://ai.51easyai.com/gateway-api",
|
||||
"release": "2026.07.17-2d6c16f",
|
||||
"platform": {
|
||||
"id": "81fa89a4-67c3-448b-9ee6-b6189f794b63",
|
||||
"name": "漫路(火山兼容)",
|
||||
"priority": 200
|
||||
},
|
||||
"model": {
|
||||
"alias": "deyun-seedance-2.0-canary",
|
||||
"providerModelName": "doubao-seedance-2-0"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "temporary_gateway_api_key",
|
||||
"keyId": "488fb516-235f-425d-b37a-d3d2bd071f98",
|
||||
"deletedAfterTest": true
|
||||
},
|
||||
"task": {
|
||||
"id": "cab496fc-5fc0-4f83-a365-5787d649f0fb",
|
||||
"status": "succeeded",
|
||||
"remoteTaskId": "cgt-20260717141718-blxpt",
|
||||
"requestId": "cgt-20260717141718-blxpt",
|
||||
"attemptCount": 1,
|
||||
"request": {
|
||||
"model": "deyun-seedance-2.0-canary",
|
||||
"prompt": "A calm ocean wave rolls toward a sandy beach at sunrise, locked camera, natural ambient sound.",
|
||||
"resolution": "720p",
|
||||
"ratio": "16:9",
|
||||
"duration": 6,
|
||||
"generate_audio": true,
|
||||
"seed": 72017,
|
||||
"watermark": false,
|
||||
"runMode": "real"
|
||||
},
|
||||
"finalChargeAmount": 200,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 200
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 200,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 200
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingRecordCount": 1
|
||||
},
|
||||
"media": {
|
||||
"byteSize": 3074928,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"duration": 6.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [
|
||||
{
|
||||
"codec": "aac",
|
||||
"channels": 2,
|
||||
"sampleRate": 44100,
|
||||
"duration": 6.06
|
||||
}
|
||||
]
|
||||
},
|
||||
"wallet": {
|
||||
"balanceBefore": 99789,
|
||||
"balanceAfter": 99589,
|
||||
"frozenBefore": 0,
|
||||
"frozenAfter": 0,
|
||||
"debit": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"ok": true,
|
||||
"generatedAt": "2026-07-17T06:28:51.689Z",
|
||||
"model": {
|
||||
"id": "118b4fac-d543-4344-ada0-71add153bfe6",
|
||||
"modelAlias": "豆包Seedance-2.0",
|
||||
"displayName": "豆包Seedance-2.0",
|
||||
"providerModelName": "doubao-seedance-2-0"
|
||||
},
|
||||
"platform": {
|
||||
"id": "81fa89a4-67c3-448b-9ee6-b6189f794b63",
|
||||
"name": "漫路(火山兼容)",
|
||||
"priority": 200
|
||||
},
|
||||
"task": {
|
||||
"id": "6b60099b-7275-4896-b4ee-db2c85bdbbad",
|
||||
"status": "succeeded",
|
||||
"remoteTaskId": "cgt-20260717142458-hmmdc",
|
||||
"requestId": "cgt-20260717142458-hmmdc",
|
||||
"attemptCount": 1,
|
||||
"request": {
|
||||
"duration": 4,
|
||||
"generate_audio": false,
|
||||
"model": "豆包Seedance-2.0",
|
||||
"prompt": "A red paper airplane glides slowly across a bright blue studio background, locked camera.",
|
||||
"ratio": "16:9",
|
||||
"resolution": "720p",
|
||||
"runMode": "real",
|
||||
"seed": 72018,
|
||||
"watermark": false
|
||||
},
|
||||
"finalChargeAmount": 100
|
||||
},
|
||||
"media": {
|
||||
"byteSize": 949084,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"duration": 4.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreamCount": 0
|
||||
},
|
||||
"taskWalletSettlement": {
|
||||
"reserved": 100,
|
||||
"released": 100,
|
||||
"billed": 100,
|
||||
"balanceBefore": 99589,
|
||||
"balanceAfter": 99489
|
||||
},
|
||||
"accountSnapshot": {
|
||||
"balance": 99339,
|
||||
"frozenBalance": 0,
|
||||
"note": "Current global frozen balance belongs to another concurrent task; this task reservation is fully released."
|
||||
},
|
||||
"temporaryAPIKeysRemaining": 0
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:29:53.750Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"selectedCases": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-o1"
|
||||
},
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-3.0-omni"
|
||||
}
|
||||
],
|
||||
"submittedTasks": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"taskId": "7ec7bd46-e9be-4289-a3c7-ee1e662f71e8",
|
||||
"model": "kling-video-o1"
|
||||
}
|
||||
],
|
||||
"error": "compatible-o1-720p-16x9-3s-audio-off failed, code=5000, message={\"msg\":\"该能力暂不支持\"}"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"ok": true,
|
||||
"generatedAt": "2026-07-17T16:53:06.058Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"platformModels": [
|
||||
{
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"modelName": "kling-3.0-omni",
|
||||
"modelAlias": "kling-3.0-omni",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"providerModelName": "kling-video-o1",
|
||||
"modelName": "kling-o1",
|
||||
"modelAlias": "kling-o1",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"wallet": {
|
||||
"balanceBefore": 6515.735312,
|
||||
"balanceAfter": 6435.735312,
|
||||
"frozenBalanceBefore": 0.14133,
|
||||
"frozenBalanceAfter": 0.14133,
|
||||
"debit": 80,
|
||||
"totalCharge": 80
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"taskId": "b2300a36-0f43-4745-9d00-2dea6f9988fe",
|
||||
"remoteTaskId": "task_66af8f819c7d4b9b91e5b6d526e88d0e",
|
||||
"requestId": "eeec9ed2-d94e-4777-9ee0-c875c4efc418",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-3.0-omni",
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"requested": {
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false
|
||||
},
|
||||
"observed": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"shortEdge": 720,
|
||||
"duration": 3.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"ratioErrorPercent": 0,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null
|
||||
},
|
||||
"byteSize": 1577848,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 80,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 80
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 80,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 80
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:35:04.858Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"selectedCases": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-o1"
|
||||
},
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-3.0-omni"
|
||||
}
|
||||
],
|
||||
"submittedTasks": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"taskId": "abfadf1b-ad40-4eec-a38b-4a119b4861c7",
|
||||
"model": "kling-video-o1"
|
||||
}
|
||||
],
|
||||
"error": "compatible-o1-720p-16x9-3s-audio-off failed, code=1201, message=Duration only support 5 or 10 seconds when no refer image"
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:54:24.016Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"platformModels": [
|
||||
{
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"modelName": "kling-3.0-omni",
|
||||
"modelAlias": "kling-3.0-omni",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"providerModelName": "kling-video-o1",
|
||||
"modelName": "kling-o1",
|
||||
"modelAlias": "kling-o1",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"wallet": {
|
||||
"balanceBefore": 6435.735312,
|
||||
"balanceAfter": 6435.735312,
|
||||
"frozenBalanceBefore": 0.14133,
|
||||
"frozenBalanceAfter": 0.14133,
|
||||
"debit": 0,
|
||||
"totalChargeThisRun": 0,
|
||||
"totalHistoricalCharge": 520
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"passed": true,
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"taskId": "84075b74-9c51-4ab4-b154-3280eb947bcb",
|
||||
"remoteTaskId": "task_7dc690f0a77649a3b6f8949b9d003371",
|
||||
"requestId": "67fd025f-0b53-4a56-8967-128f334b2bfb",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-video-o1",
|
||||
"providerModelName": "kling-video-o1",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false
|
||||
},
|
||||
"observed": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"shortEdge": 720,
|
||||
"duration": 3.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null,
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 1275483,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 80,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 80
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 80,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 80
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 0
|
||||
},
|
||||
{
|
||||
"passed": true,
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"taskId": "71491b61-2b6f-47b5-a738-2d4ea3898b28",
|
||||
"remoteTaskId": "task_b4b622c374624c7e82cb34d6bc6c57af",
|
||||
"requestId": "da16b141-44cc-4379-b463-35d06f2f0870",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-v3-omni",
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "1080p",
|
||||
"aspectRatio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true
|
||||
},
|
||||
"observed": {
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"shortEdge": 1080,
|
||||
"duration": 5.041667,
|
||||
"ratio": 0.5625,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [
|
||||
{
|
||||
"codec": "aac",
|
||||
"channels": 2,
|
||||
"sampleRate": 44100
|
||||
}
|
||||
],
|
||||
"volume": {
|
||||
"maxVolumeDb": -3.4,
|
||||
"meanVolumeDb": -33.8
|
||||
},
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 8132835,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 240,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 240
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 240,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 240
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 0
|
||||
},
|
||||
{
|
||||
"passed": false,
|
||||
"validationError": "Task b8e996bb-d69a-4ede-a47f-f6243e478f45 requested audio but output has no audio stream",
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"taskId": "b8e996bb-d69a-4ede-a47f-f6243e478f45",
|
||||
"remoteTaskId": "task_c0e8271648104c72956ea7cd6da80dea",
|
||||
"requestId": "e4206343-6dbb-439b-94ba-ccc1f5a020a8",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-o1",
|
||||
"providerModelName": "kling-video-o1",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "1080p",
|
||||
"aspectRatio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true
|
||||
},
|
||||
"observed": {
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"shortEdge": 1080,
|
||||
"duration": 5.041667,
|
||||
"ratio": 0.5625,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null,
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 12728988,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 120,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 120
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 120,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 120
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 1
|
||||
},
|
||||
{
|
||||
"passed": true,
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"taskId": "b2300a36-0f43-4745-9d00-2dea6f9988fe",
|
||||
"remoteTaskId": "task_66af8f819c7d4b9b91e5b6d526e88d0e",
|
||||
"requestId": "eeec9ed2-d94e-4777-9ee0-c875c4efc418",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-3.0-omni",
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false
|
||||
},
|
||||
"observed": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"shortEdge": 720,
|
||||
"duration": 3.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null,
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 1577848,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 80,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 80
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 80,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 80
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:49:49.164Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"selectedCases": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-o1"
|
||||
},
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-3.0-omni"
|
||||
}
|
||||
],
|
||||
"submittedTasks": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"taskId": "84075b74-9c51-4ab4-b154-3280eb947bcb",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"taskId": "71491b61-2b6f-47b5-a738-2d4ea3898b28",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"taskId": "b8e996bb-d69a-4ede-a47f-f6243e478f45",
|
||||
"model": "kling-o1"
|
||||
}
|
||||
],
|
||||
"error": "Task b8e996bb-d69a-4ede-a47f-f6243e478f45 requested audio but output has no audio stream"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Kling Omni 兼容接口
|
||||
|
||||
EasyAI AI Gateway 提供 Kling 旧版 Omni 协议兼容接口。调用方继续使用 Gateway API Key,任务仍经过网关候选选择、异步队列、审计和计费;响应中的 `task_id` 是网关任务 UUID,不是上游任务 ID。
|
||||
|
||||
## 模型与参数映射
|
||||
|
||||
| 请求 `model_name` | 网关模型别名 | TranStreams 原生 `model_name` | 时长范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| `kling-video-o1`、`kling-o1` | `kling-o1` | `kling-video-o1` | 3–10 秒 |
|
||||
| `kling-v3-omni`、`kling-3.0-omni` | `kling-3.0-omni` | `kling-v3-omni` | 3–15 秒 |
|
||||
|
||||
网关别名用于候选匹配,原生模型名用于发往 TranStreams 的 Kling Omni 请求;两类名称不会混用。
|
||||
|
||||
`kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 3–15 秒。
|
||||
|
||||
真实上游结果表明 `kling-video-o1` 不生成音频,因此该模型的 `sound=on` 会返回 `1201`,标准接口的 `audio=true` 也会在参数预处理阶段失败,避免静默返回无声视频。`kling-v3-omni` 支持 `sound=on/off`。
|
||||
|
||||
`mode` 映射为网关分辨率:`std` = 720p,`pro` = 1080p,`4k` = 2160p。4K 只有在平台模型能力也声明支持时才能执行。`sound=on/off` 映射为 `audio=true/false`,`duration` 同时接受 JSON 字符串和整数。
|
||||
|
||||
兼容字段包括:`prompt`、`multi_shot`、`shot_type`、`multi_prompt`、`image_list`、`element_list`、`video_list`、`sound`、`mode`、`aspect_ratio`、`duration`、`watermark_info`、`external_task_id`。`callback_url` 可以省略或传空字符串;非空值会返回业务码 `1201`,本期不投递回调。
|
||||
|
||||
## 创建任务
|
||||
|
||||
`POST /v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A quiet street in the rain with natural ambient sound",
|
||||
"mode": "pro",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"sound": "on",
|
||||
"watermark_info": {"enabled": false},
|
||||
"external_task_id": "client-job-001"
|
||||
}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "SUCCEED",
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"task_info": {"external_task_id": "client-job-001"},
|
||||
"task_status": "submitted",
|
||||
"created_at": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 查询任务
|
||||
|
||||
使用创建任务时的同一个 Gateway API Key 轮询。跨用户查询与不存在的任务统一返回 HTTP 404 和业务码 `1203`。
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_BASE_URL/v1/videos/omni-video/$TASK_ID"
|
||||
```
|
||||
|
||||
`task_status` 为 `submitted`、`processing`、`succeed` 或 `failed`。成功时结果位于 `data.task_result.videos`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "SUCCEED",
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"task_status": "succeed",
|
||||
"task_result": {
|
||||
"videos": [
|
||||
{
|
||||
"id": "...",
|
||||
"url": "https://.../video.mp4",
|
||||
"watermark_url": "https://.../watermark.mp4",
|
||||
"duration": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 网关标准视频接口
|
||||
|
||||
标准接口仍为 `POST /api/v1/videos/generations`。异步调用需要 `X-Async: true`,再通过 `GET /api/v1/tasks/{taskId}` 轮询。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Async: true" \
|
||||
-d '{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A product reveal in a daylight studio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
"watermark": false,
|
||||
"runMode": "real"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_BASE_URL/api/v1/tasks/$TASK_ID"
|
||||
```
|
||||
|
||||
## 错误格式
|
||||
|
||||
所有兼容接口错误都返回同一包络:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1201,
|
||||
"message": "duration must be between 3 and 10 seconds",
|
||||
"request_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
业务码分类:`1001/1002` 为鉴权错误,`1101/1103` 为余额或权限错误,`1201/1203` 为参数或资源错误,`1302/1303` 为限流错误,`5000/5001` 为网关或上游服务错误。HTTP 状态码仍反映错误类型。
|
||||
|
||||
OpenAPI 文档由服务的 `/openapi.json` 和 `/openapi.yaml` 提供。
|
||||
@@ -0,0 +1,43 @@
|
||||
# Seedance 真人资产(Volces)
|
||||
|
||||
网关会把真人源文件保存到既有文件存储,并在 Volces 平台的火山 Assets 库创建绑定。生成时只能引用已经对当前用户、当前平台激活的资产,最终发送给火山的视频内容 URL 为 `asset://<remote_asset_id>`。
|
||||
|
||||
## 平台配置
|
||||
|
||||
在 `integration_platforms.config` 为对应的 `volces` 平台添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"seedancePrivateAsset": {
|
||||
"enabled": true,
|
||||
"accessKey": "AK...",
|
||||
"secretKey": "SK...",
|
||||
"projectName": "default",
|
||||
"assetGroupId": "asset-group-id",
|
||||
"assetEndpoint": "https://ark.cn-beijing.volcengineapi.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`assetEndpoint` 可省略。源文件 URL 必须是火山可访问的绝对 `http(s)` 地址;同步时会拒绝本地路径、`file://` 与相对 URL。因此生产环境应配置带公网 URL 的文件存储或 `PublicBaseURL`。
|
||||
|
||||
## Desktop / server-main 兼容路由
|
||||
|
||||
- `GET /api/v1/resource/material/seedance-portrait-assets/capability`
|
||||
- `GET /api/v1/resource/material/user/materials?category=seedance_portrait_asset`
|
||||
- `POST /api/v1/resource/material`(multipart:`file`、`data`)
|
||||
- `POST /api/v1/resource/material/seedance-portrait-assets/sync`
|
||||
- `POST /api/v1/video/generations` 与 `GET /api/v1/ai/result/{taskID}`
|
||||
|
||||
创建真人资产要求 `private_avatar_eligible: true`。同步接口可重复调用;资产在火山返回 `Active` 前,视频提交会返回 `portrait_asset_processing`,而不会把原始人像媒体当成普通参考图发送。
|
||||
|
||||
## 火山任务兼容路由
|
||||
|
||||
- `POST /api/v3/contents/generations/tasks`
|
||||
- `GET /api/v3/contents/generations/tasks`
|
||||
- `GET /api/v3/contents/generations/tasks/{taskID}`
|
||||
- `DELETE /api/v3/contents/generations/tasks/{taskID}`
|
||||
|
||||
列表接口兼容火山的 `page_num`、`page_size`、`filter.status`、`filter.task_ids`(可重复)和 `filter.model`,并返回官方 `items`、`total` 字段;`data`、`page` 是保留的网关附加字段。
|
||||
|
||||
公开 `id` 是网关任务 ID,原始火山任务 ID 保留在 `upstream_task_id`。响应保留火山的 `content`、`usage`、`seed`、`resolution` 等字段,并额外保留网关账单字段。
|
||||
@@ -0,0 +1,634 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, '');
|
||||
const platformKey = process.env.KELING_VIDEO_PLATFORM_KEY || 'transtreams_keling';
|
||||
const pollIntervalMs = positiveInteger(process.env.KELING_VIDEO_POLL_INTERVAL_MS, 10_000);
|
||||
const taskTimeoutMs = positiveInteger(process.env.KELING_VIDEO_TASK_TIMEOUT_MS, 30 * 60 * 1000);
|
||||
const outputPath =
|
||||
process.env.KELING_VIDEO_E2E_OUTPUT ||
|
||||
`artifacts/keling-video-e2e-${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`;
|
||||
const database = {
|
||||
container: process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres',
|
||||
name: process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway',
|
||||
user: process.env.GATEWAY_E2E_DB_USER || 'easyai',
|
||||
};
|
||||
|
||||
const validationCases = [
|
||||
{
|
||||
name: 'compatible-o1-720p-16x9-3s-audio-off',
|
||||
interface: 'kling-compatible',
|
||||
requestedModel: 'kling-video-o1',
|
||||
gatewayModel: 'kling-o1',
|
||||
providerModel: 'kling-video-o1',
|
||||
request: {
|
||||
model_name: 'kling-video-o1',
|
||||
prompt: 'A blue ceramic cup rotates slowly on a clean studio table, locked camera, no sound.',
|
||||
mode: 'std',
|
||||
aspect_ratio: '16:9',
|
||||
duration: '3',
|
||||
sound: 'off',
|
||||
image_list: [
|
||||
{
|
||||
image_url: 'https://placehold.co/1024x1024/png',
|
||||
},
|
||||
],
|
||||
watermark_info: { enabled: false },
|
||||
external_task_id: 'gateway-keling-e2e-compatible-o1',
|
||||
},
|
||||
expected: { resolution: '720p', shortEdge: 720, ratio: 16 / 9, duration: 3, audio: false },
|
||||
},
|
||||
{
|
||||
name: 'compatible-v3-1080p-9x16-5s-audio-on',
|
||||
interface: 'kling-compatible',
|
||||
requestedModel: 'kling-v3-omni',
|
||||
gatewayModel: 'kling-3.0-omni',
|
||||
providerModel: 'kling-v3-omni',
|
||||
request: {
|
||||
model_name: 'kling-v3-omni',
|
||||
prompt: 'Vertical close-up of gentle rain falling on green leaves, with clear natural rain ambience.',
|
||||
mode: 'pro',
|
||||
aspect_ratio: '9:16',
|
||||
duration: 5,
|
||||
sound: 'on',
|
||||
watermark_info: { enabled: false },
|
||||
external_task_id: 'gateway-keling-e2e-compatible-v3',
|
||||
},
|
||||
expected: { resolution: '1080p', shortEdge: 1080, ratio: 9 / 16, duration: 5, audio: true },
|
||||
},
|
||||
{
|
||||
name: 'standard-o1-1080p-9x16-5s-audio-on',
|
||||
interface: 'gateway-standard',
|
||||
requestedModel: 'kling-o1',
|
||||
gatewayModel: 'kling-o1',
|
||||
providerModel: 'kling-video-o1',
|
||||
request: {
|
||||
model: 'kling-o1',
|
||||
prompt: 'Vertical view of small ocean waves reaching a sandy beach, with audible natural surf.',
|
||||
resolution: '1080p',
|
||||
aspect_ratio: '9:16',
|
||||
duration: 5,
|
||||
audio: true,
|
||||
watermark: false,
|
||||
runMode: 'real',
|
||||
},
|
||||
expected: { resolution: '1080p', shortEdge: 1080, ratio: 9 / 16, duration: 5, audio: true },
|
||||
},
|
||||
{
|
||||
name: 'standard-v3-720p-16x9-3s-audio-off',
|
||||
interface: 'gateway-standard',
|
||||
requestedModel: 'kling-3.0-omni',
|
||||
gatewayModel: 'kling-3.0-omni',
|
||||
providerModel: 'kling-v3-omni',
|
||||
request: {
|
||||
model: 'kling-3.0-omni',
|
||||
prompt: 'Wide shot of a paper windmill turning slowly beside a window, locked camera, no sound.',
|
||||
resolution: '720p',
|
||||
aspect_ratio: '16:9',
|
||||
duration: 3,
|
||||
audio: false,
|
||||
watermark: false,
|
||||
runMode: 'real',
|
||||
},
|
||||
expected: { resolution: '720p', shortEdge: 720, ratio: 16 / 9, duration: 3, audio: false },
|
||||
},
|
||||
];
|
||||
|
||||
const selectedNames = new Set(
|
||||
String(process.env.KELING_VIDEO_CASES || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const resumeTaskIds = parseResumeTaskIds(process.env.KELING_VIDEO_RESUME_TASKS);
|
||||
const continueOnValidationFailure = process.env.KELING_VIDEO_CONTINUE_ON_VALIDATION_FAILURE === 'true';
|
||||
if (selectedNames.size > 0) {
|
||||
const selected = validationCases.filter((validationCase) => selectedNames.has(validationCase.name));
|
||||
if (selected.length !== selectedNames.size) {
|
||||
throw new Error(`Unknown KELING_VIDEO_CASES value; known cases: ${validationCases.map((item) => item.name).join(', ')}`);
|
||||
}
|
||||
validationCases.splice(0, validationCases.length, ...selected);
|
||||
}
|
||||
|
||||
const submittedTasks = [];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseResumeTaskIds(raw) {
|
||||
if (!String(raw || '').trim()) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`KELING_VIDEO_RESUME_TASKS must be a JSON object: ${error.message}`);
|
||||
}
|
||||
assert(parsed && typeof parsed === 'object' && !Array.isArray(parsed), 'KELING_VIDEO_RESUME_TASKS must be a JSON object');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nearlyEqual(left, right, tolerance = 1e-6) {
|
||||
return Math.abs(Number(left) - Number(right)) <= tolerance;
|
||||
}
|
||||
|
||||
function sqlLiteral(value) {
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function runPSQL(query) {
|
||||
return execFileSync(
|
||||
'docker',
|
||||
['exec', database.container, 'psql', '-U', database.user, '-d', database.name, '-At', '-F', '\t', '-c', query],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
}
|
||||
|
||||
function preflightMediaTools() {
|
||||
for (const command of ['ffprobe', 'ffmpeg']) {
|
||||
const result = spawnSync(command, ['-version'], { encoding: 'utf8' });
|
||||
assert(result.status === 0, `${command} is required for Keling video acceptance`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlatform() {
|
||||
const fields = runPSQL(`
|
||||
SELECT id::text, name, provider, status, base_url
|
||||
FROM integration_platforms
|
||||
WHERE platform_key = ${sqlLiteral(platformKey)}
|
||||
LIMIT 1`).split('\t');
|
||||
assert(fields.length === 5 && fields[0], `Platform ${platformKey} was not found`);
|
||||
assert(fields[2] === 'keling', `Platform ${platformKey} provider=${fields[2]}, expected keling`);
|
||||
assert(fields[3] === 'enabled', `Platform ${platformKey} status=${fields[3]}, expected enabled`);
|
||||
assert(
|
||||
fields[4].replace(/\/+$/, '') === 'https://api-aigv.transtreams.com/kling/v1',
|
||||
`Platform ${platformKey} base URL is not the configured TranStreams Kling endpoint`,
|
||||
);
|
||||
return { id: fields[0], name: fields[1], provider: fields[2], status: fields[3] };
|
||||
}
|
||||
|
||||
function validatePlatformModels(platformId) {
|
||||
const output = runPSQL(`
|
||||
SELECT provider_model_name, model_name, model_alias, enabled::text, model_type::text, capabilities::text
|
||||
FROM platform_models
|
||||
WHERE platform_id = ${sqlLiteral(platformId)}::uuid
|
||||
AND model_alias IN ('kling-o1', 'kling-3.0-omni')
|
||||
ORDER BY model_alias`);
|
||||
const rows = output
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split('\t'));
|
||||
const expectedModels = new Map([
|
||||
['kling-o1', 'kling-video-o1'],
|
||||
['kling-3.0-omni', 'kling-v3-omni'],
|
||||
]);
|
||||
for (const [gatewayModel, providerModel] of expectedModels) {
|
||||
const row = rows.find((candidate) => candidate[2] === gatewayModel);
|
||||
assert(row, `Platform ${platformKey} is missing gateway model alias ${gatewayModel}`);
|
||||
assert(row[0] === providerModel, `Platform model ${gatewayModel} provider_model_name=${row[0]}, expected ${providerModel}`);
|
||||
assert(row[1] === gatewayModel, `Platform model ${gatewayModel} model_name=${row[1]}, expected ${gatewayModel}`);
|
||||
assert(row[3] === 'true', `Platform model ${gatewayModel} is disabled`);
|
||||
const modelTypes = JSON.parse(row[4]);
|
||||
const capabilities = JSON.parse(row[5]);
|
||||
assert(modelTypes.includes('omni_video'), `Platform model ${gatewayModel} must include omni_video`);
|
||||
const omni = capabilities.omni_video || {};
|
||||
assert(Array.isArray(omni.output_resolutions), `Platform model ${gatewayModel} is missing omni_video.output_resolutions`);
|
||||
assert(omni.output_resolutions.includes('720p'), `Platform model ${gatewayModel} does not advertise 720p`);
|
||||
assert(omni.output_resolutions.includes('1080p'), `Platform model ${gatewayModel} does not advertise 1080p`);
|
||||
const expectedOutputAudio = gatewayModel !== 'kling-o1';
|
||||
assert(
|
||||
omni.output_audio === expectedOutputAudio,
|
||||
`Platform model ${gatewayModel} omni_video.output_audio=${omni.output_audio}, expected ${expectedOutputAudio}`,
|
||||
);
|
||||
}
|
||||
return rows.map((row) => ({ providerModelName: row[0], modelName: row[1], modelAlias: row[2], enabled: row[3] === 'true' }));
|
||||
}
|
||||
|
||||
function resolveGatewayAPIKey() {
|
||||
if (process.env.GATEWAY_E2E_API_KEY) {
|
||||
assert(process.env.GATEWAY_E2E_API_KEY_ID, 'Set GATEWAY_E2E_API_KEY_ID when GATEWAY_E2E_API_KEY is provided');
|
||||
return { ...apiKeyMetadata(process.env.GATEWAY_E2E_API_KEY_ID), secret: process.env.GATEWAY_E2E_API_KEY, source: 'environment' };
|
||||
}
|
||||
const fields = runPSQL(`
|
||||
SELECT key.id::text,
|
||||
key.key_secret,
|
||||
key.gateway_user_id::text,
|
||||
wallet.balance::float8,
|
||||
wallet.frozen_balance::float8
|
||||
FROM gateway_api_keys key
|
||||
JOIN gateway_wallet_accounts wallet
|
||||
ON wallet.gateway_user_id = key.gateway_user_id
|
||||
AND wallet.currency = 'resource'
|
||||
AND wallet.status = 'active'
|
||||
WHERE key.deleted_at IS NULL
|
||||
AND key.status = 'active'
|
||||
AND COALESCE(key.key_secret, '') <> ''
|
||||
AND (key.expires_at IS NULL OR key.expires_at > now())
|
||||
AND (key.scopes ? 'video' OR key.scopes ? '*' OR key.scopes ? 'all')
|
||||
AND (wallet.balance - wallet.frozen_balance) > 0
|
||||
ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC
|
||||
LIMIT 1`).split('\t');
|
||||
assert(fields.length === 5 && fields[0] && fields[1], 'No recoverable video-scoped Gateway API Key with positive balance was found');
|
||||
return {
|
||||
keyId: fields[0],
|
||||
secret: fields[1],
|
||||
userId: fields[2],
|
||||
balance: Number(fields[3]),
|
||||
frozenBalance: Number(fields[4]),
|
||||
source: 'database',
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyMetadata(keyId) {
|
||||
const fields = runPSQL(`
|
||||
SELECT key.id::text, key.gateway_user_id::text, wallet.balance::float8, wallet.frozen_balance::float8
|
||||
FROM gateway_api_keys key
|
||||
JOIN gateway_wallet_accounts wallet
|
||||
ON wallet.gateway_user_id = key.gateway_user_id
|
||||
AND wallet.currency = 'resource'
|
||||
WHERE key.id = ${sqlLiteral(keyId)}::uuid
|
||||
LIMIT 1`).split('\t');
|
||||
assert(fields.length === 4 && fields[0], `Gateway API Key ${keyId} was not found`);
|
||||
return { keyId: fields[0], userId: fields[1], balance: Number(fields[2]), frozenBalance: Number(fields[3]) };
|
||||
}
|
||||
|
||||
function walletState(userId) {
|
||||
const fields = runPSQL(`
|
||||
SELECT balance::float8, frozen_balance::float8
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id = ${sqlLiteral(userId)}::uuid
|
||||
AND currency = 'resource'
|
||||
LIMIT 1`).split('\t');
|
||||
assert(fields.length === 2, `Wallet for Gateway user ${userId} was not found`);
|
||||
return { balance: Number(fields[0]), frozenBalance: Number(fields[1]) };
|
||||
}
|
||||
|
||||
async function gatewayRequest(path, init = {}) {
|
||||
const response = await fetch(`${baseURL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authentication.secret}`,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`${init.method || 'GET'} ${path} returned non-JSON HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const code = body.code ?? body.error?.code ?? 'unknown';
|
||||
const message = body.message ?? body.error?.message ?? 'request failed';
|
||||
throw new Error(`${init.method || 'GET'} ${path} failed HTTP ${response.status}, code=${code}, message=${message}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createTask(validationCase) {
|
||||
let taskId = '';
|
||||
if (validationCase.interface === 'kling-compatible') {
|
||||
const accepted = await gatewayRequest('/v1/videos/omni-video', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validationCase.request),
|
||||
});
|
||||
assert(accepted.code === 0, `${validationCase.name} compatibility create code=${accepted.code}`);
|
||||
assert(accepted.data?.task_status === 'submitted', `${validationCase.name} was not submitted`);
|
||||
taskId = accepted.data?.task_id;
|
||||
} else {
|
||||
const accepted = await gatewayRequest('/api/v1/videos/generations', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Async': 'true' },
|
||||
body: JSON.stringify(validationCase.request),
|
||||
});
|
||||
taskId = accepted.taskId || accepted.task?.id;
|
||||
}
|
||||
assert(taskId, `${validationCase.name} create response is missing a Gateway task ID`);
|
||||
submittedTasks.push({ name: validationCase.name, taskId, model: validationCase.requestedModel, reusedExistingTask: false });
|
||||
return taskId;
|
||||
}
|
||||
|
||||
async function pollTask(validationCase, taskId) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < taskTimeoutMs) {
|
||||
if (validationCase.interface === 'kling-compatible') {
|
||||
const response = await gatewayRequest(`/v1/videos/omni-video/${taskId}`);
|
||||
const status = response.data?.task_status;
|
||||
if (status === 'succeed') break;
|
||||
if (status === 'failed') {
|
||||
throw new Error(`${validationCase.name} failed, code=${response.data?.task_status_code || 'unknown'}, message=${response.data?.task_status_msg || 'unknown'}`);
|
||||
}
|
||||
} else {
|
||||
const task = await gatewayRequest(`/api/v1/tasks/${taskId}`);
|
||||
if (task.status === 'succeeded') return task;
|
||||
if (task.status === 'failed' || task.status === 'cancelled') {
|
||||
throw new Error(`${validationCase.name} failed, code=${task.errorCode || 'unknown'}, message=${task.errorMessage || task.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
const task = await gatewayRequest(`/api/v1/tasks/${taskId}`);
|
||||
if (task.status !== 'succeeded') {
|
||||
throw new Error(`${validationCase.name} timed out after ${taskTimeoutMs}ms with status=${task.status}`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async function taskEvents(taskId) {
|
||||
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}/events`, {
|
||||
headers: { Authorization: `Bearer ${authentication.secret}` },
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok, `GET task events failed HTTP ${response.status}`);
|
||||
return text
|
||||
.split(/\r?\n\r?\n/)
|
||||
.flatMap((block) => {
|
||||
const data = block
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trim())
|
||||
.join('\n');
|
||||
return data ? [JSON.parse(data)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function taskPreprocessingLogs(taskId) {
|
||||
return gatewayRequest(`/api/v1/tasks/${taskId}/param-preprocessing`);
|
||||
}
|
||||
|
||||
function videoURLFromTask(task) {
|
||||
const data = Array.isArray(task.result?.data) ? task.result.data : [];
|
||||
return data.find((item) => item?.type === 'video')?.url || data.find((item) => item?.url)?.url || '';
|
||||
}
|
||||
|
||||
async function downloadVideo(url, filePath) {
|
||||
const response = await fetch(url);
|
||||
assert(response.ok, `Video download failed HTTP ${response.status}`);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
assert(bytes.length > 0, 'Downloaded video is empty');
|
||||
await writeFile(filePath, bytes);
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
function probeVideo(filePath) {
|
||||
return JSON.parse(
|
||||
execFileSync('ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', filePath], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function probeAudioVolume(filePath) {
|
||||
const result = spawnSync(
|
||||
'ffmpeg',
|
||||
['-hide_banner', '-nostats', '-i', filePath, '-map', '0:a:0', '-af', 'volumedetect', '-f', 'null', '-'],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
assert(result.status === 0, 'ffmpeg volume analysis failed');
|
||||
const diagnostics = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
const maxMatch = diagnostics.match(/max_volume:\s*(-?(?:\d+(?:\.\d+)?|inf))\s*dB/i);
|
||||
const meanMatch = diagnostics.match(/mean_volume:\s*(-?(?:\d+(?:\.\d+)?|inf))\s*dB/i);
|
||||
assert(maxMatch, 'ffmpeg did not report max_volume');
|
||||
const maxVolumeDb = Number(maxMatch[1]);
|
||||
assert(Number.isFinite(maxVolumeDb), 'Audio stream is fully silent (max_volume=-inf)');
|
||||
return { maxVolumeDb, meanVolumeDb: meanMatch && Number.isFinite(Number(meanMatch[1])) ? Number(meanMatch[1]) : null };
|
||||
}
|
||||
|
||||
function validateRequestSnapshots(task, validationCase) {
|
||||
const request = task.request || {};
|
||||
assert(request.model === validationCase.gatewayModel, `Task ${task.id} request.model=${request.model}, expected ${validationCase.gatewayModel}`);
|
||||
assert(request.resolution === validationCase.expected.resolution, `Task ${task.id} request.resolution=${request.resolution}`);
|
||||
assert(request.aspect_ratio === validationCase.request.aspect_ratio, `Task ${task.id} request.aspect_ratio=${request.aspect_ratio}`);
|
||||
assert(Number(request.duration) === validationCase.expected.duration, `Task ${task.id} request.duration=${request.duration}`);
|
||||
assert(request.audio === validationCase.expected.audio, `Task ${task.id} request.audio=${request.audio}`);
|
||||
if (validationCase.interface === 'kling-compatible') {
|
||||
assert(request._gateway_compatibility === 'keling_omni_v1', `Task ${task.id} compatibility marker is missing`);
|
||||
assert(request.external_task_id === validationCase.request.external_task_id, `Task ${task.id} external_task_id was not preserved`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTaskAudit(task, events, validationCase) {
|
||||
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
|
||||
assert(attempts.length === 1, `Task ${task.id} has ${attempts.length} attempts, expected exactly one`);
|
||||
const attempt = attempts[0];
|
||||
assert(attempt.status === 'succeeded', `Task ${task.id} attempt status=${attempt.status}`);
|
||||
assert(attempt.platformName === platform.name, `Task ${task.id} used platform ${attempt.platformName}, expected ${platform.name}`);
|
||||
assert(attempt.provider === 'keling', `Task ${task.id} used provider ${attempt.provider}, expected keling`);
|
||||
assert(
|
||||
attempt.providerModelName === validationCase.providerModel,
|
||||
`Task ${task.id} providerModelName=${attempt.providerModelName}, expected ${validationCase.providerModel}`,
|
||||
);
|
||||
assert(task.remoteTaskId, `Task ${task.id} is missing remoteTaskId`);
|
||||
assert(attempt.requestId, `Task ${task.id} attempt is missing requestId`);
|
||||
const attemptRequest = attempt.requestSnapshot || {};
|
||||
assert(attemptRequest.model === validationCase.gatewayModel, `Task ${task.id} attempt request.model=${attemptRequest.model}`);
|
||||
assert(attemptRequest.resolution === validationCase.expected.resolution, `Task ${task.id} attempt request.resolution=${attemptRequest.resolution}`);
|
||||
assert(attemptRequest.aspect_ratio === validationCase.request.aspect_ratio, `Task ${task.id} attempt request.aspect_ratio=${attemptRequest.aspect_ratio}`);
|
||||
assert(Number(attemptRequest.duration) === validationCase.expected.duration, `Task ${task.id} attempt request.duration=${attemptRequest.duration}`);
|
||||
assert(attemptRequest.audio === validationCase.expected.audio, `Task ${task.id} attempt request.audio=${attemptRequest.audio}`);
|
||||
assert(Array.isArray(task.billings) && task.billings.length > 0, `Task ${task.id} has no billing lines`);
|
||||
assert(Number(task.finalChargeAmount) > 0, `Task ${task.id} finalChargeAmount must be positive`);
|
||||
assert(nearlyEqual(task.billingSummary?.totalAmount, task.finalChargeAmount), `Task ${task.id} billing total does not match final charge`);
|
||||
const startedEvents = events.filter((event) => event.eventType === 'task.attempt.started');
|
||||
assert(startedEvents.length === 1, `Task ${task.id} emitted ${startedEvents.length} task.attempt.started events`);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
function inspectMedia(probe, filePath, taskId) {
|
||||
const streams = Array.isArray(probe.streams) ? probe.streams : [];
|
||||
const video = streams.find((stream) => stream.codec_type === 'video');
|
||||
const audioStreams = streams.filter((stream) => stream.codec_type === 'audio');
|
||||
assert(video, `Task ${taskId} output has no video stream`);
|
||||
const width = Number(video.width);
|
||||
const height = Number(video.height);
|
||||
const duration = Number(video.duration || probe.format?.duration);
|
||||
const shortEdge = Math.min(width, height);
|
||||
const ratio = width / height;
|
||||
assert(Number.isFinite(duration), `Task ${taskId} output duration is unavailable`);
|
||||
const volume = audioStreams.length > 0 ? probeAudioVolume(filePath) : null;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
shortEdge,
|
||||
duration,
|
||||
ratio,
|
||||
videoCodec: video.codec_name || null,
|
||||
audioStreams: audioStreams.map((stream) => ({
|
||||
codec: stream.codec_name || null,
|
||||
channels: Number(stream.channels || 0),
|
||||
sampleRate: Number(stream.sample_rate || 0),
|
||||
})),
|
||||
volume,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMedia(observed, expected, taskId) {
|
||||
const ratioError = Math.abs(observed.ratio - expected.ratio) / expected.ratio;
|
||||
observed.ratioErrorPercent = ratioError * 100;
|
||||
assert(observed.shortEdge >= expected.shortEdge, `Task ${taskId} short edge=${observed.shortEdge}, expected at least ${expected.shortEdge}`);
|
||||
assert(ratioError <= 0.01, `Task ${taskId} aspect ratio error=${(ratioError * 100).toFixed(2)}%, expected <=1%`);
|
||||
assert(Math.abs(observed.duration - expected.duration) <= 1, `Task ${taskId} duration=${observed.duration}s, expected ${expected.duration}s ±1s`);
|
||||
if (expected.audio) {
|
||||
assert(observed.audioStreams.length > 0, `Task ${taskId} requested audio but output has no audio stream`);
|
||||
assert(observed.volume && Number.isFinite(observed.volume.maxVolumeDb), `Task ${taskId} audio stream is fully silent`);
|
||||
} else {
|
||||
assert(observed.audioStreams.length === 0, `Task ${taskId} disabled audio but output has ${observed.audioStreams.length} audio stream(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizedFailureMessage(error) {
|
||||
return String(error instanceof Error ? error.message : error)
|
||||
.replace(/https?:\/\/[^\s,}]+/gi, '[redacted-url]')
|
||||
.replace(/Authorization:\s*Bearer\s+\S+/gi, 'Authorization: Bearer [redacted]');
|
||||
}
|
||||
|
||||
async function writeFailureReport(error) {
|
||||
const report = {
|
||||
ok: false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseURL,
|
||||
platform: { key: platformKey, name: platform?.name || null, provider: platform?.provider || null },
|
||||
authentication: authentication ? { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId } : null,
|
||||
selectedCases: validationCases.map((item) => ({ name: item.name, interface: item.interface, model: item.requestedModel })),
|
||||
submittedTasks,
|
||||
error: sanitizedFailureMessage(error),
|
||||
};
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
return report;
|
||||
}
|
||||
|
||||
let platform;
|
||||
let authentication;
|
||||
|
||||
async function main() {
|
||||
preflightMediaTools();
|
||||
platform = resolvePlatform();
|
||||
const platformModels = validatePlatformModels(platform.id);
|
||||
authentication = resolveGatewayAPIKey();
|
||||
const health = await fetch(`${baseURL}/healthz`);
|
||||
assert(health.ok, `Gateway health check failed HTTP ${health.status}`);
|
||||
|
||||
const walletBefore = walletState(authentication.userId);
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'keling-video-e2e-'));
|
||||
const results = [];
|
||||
try {
|
||||
for (const validationCase of validationCases) {
|
||||
const resumedTaskId = String(resumeTaskIds[validationCase.name] || '').trim();
|
||||
let taskId;
|
||||
if (resumedTaskId) {
|
||||
taskId = resumedTaskId;
|
||||
submittedTasks.push({
|
||||
name: validationCase.name,
|
||||
taskId,
|
||||
model: validationCase.requestedModel,
|
||||
reusedExistingTask: true,
|
||||
});
|
||||
console.error(`Revalidating existing task for ${validationCase.name}`);
|
||||
} else {
|
||||
console.error(`Submitting ${validationCase.name} exactly once`);
|
||||
taskId = await createTask(validationCase);
|
||||
}
|
||||
const task = await pollTask(validationCase, taskId);
|
||||
const [events, preprocessing] = await Promise.all([taskEvents(taskId), taskPreprocessingLogs(taskId)]);
|
||||
validateRequestSnapshots(task, validationCase);
|
||||
const attempt = validateTaskAudit(task, events, validationCase);
|
||||
assert(Array.isArray(preprocessing.items), `Task ${taskId} preprocessing audit is unavailable`);
|
||||
const videoURL = videoURLFromTask(task);
|
||||
assert(videoURL, `Task ${taskId} result is missing a video URL`);
|
||||
const videoPath = join(tempDirectory, `${validationCase.name}.mp4`);
|
||||
const byteSize = await downloadVideo(videoURL, videoPath);
|
||||
const observed = inspectMedia(probeVideo(videoPath), videoPath, taskId);
|
||||
let validationError = '';
|
||||
try {
|
||||
validateMedia(observed, validationCase.expected, taskId);
|
||||
} catch (error) {
|
||||
validationError = sanitizedFailureMessage(error);
|
||||
if (!continueOnValidationFailure) throw error;
|
||||
}
|
||||
results.push({
|
||||
passed: validationError === '',
|
||||
validationError: validationError || undefined,
|
||||
name: validationCase.name,
|
||||
interface: validationCase.interface,
|
||||
taskId,
|
||||
remoteTaskId: task.remoteTaskId,
|
||||
requestId: attempt.requestId,
|
||||
platform: { key: platformKey, name: attempt.platformName, provider: attempt.provider },
|
||||
requestedModel: validationCase.requestedModel,
|
||||
providerModelName: attempt.providerModelName,
|
||||
reusedExistingTask: Boolean(resumedTaskId),
|
||||
requested: {
|
||||
resolution: validationCase.expected.resolution,
|
||||
aspectRatio: validationCase.request.aspect_ratio,
|
||||
duration: validationCase.expected.duration,
|
||||
audio: validationCase.expected.audio,
|
||||
},
|
||||
observed,
|
||||
byteSize,
|
||||
attemptCount: task.attemptCount,
|
||||
billingLineCount: task.billings.length,
|
||||
finalChargeAmount: Number(task.finalChargeAmount),
|
||||
billingSummary: task.billingSummary || {},
|
||||
eventTypes: events.map((event) => event.eventType),
|
||||
preprocessingChangeCount: preprocessing.items.reduce((sum, item) => sum + Number(item.changeCount || 0), 0),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const walletAfter = walletState(authentication.userId);
|
||||
const totalHistoricalCharge = results.reduce((sum, result) => sum + result.finalChargeAmount, 0);
|
||||
const totalChargeThisRun = results
|
||||
.filter((result) => !result.reusedExistingTask)
|
||||
.reduce((sum, result) => sum + result.finalChargeAmount, 0);
|
||||
const walletDebit = walletBefore.balance - walletAfter.balance;
|
||||
assert(nearlyEqual(walletDebit, totalChargeThisRun), `Wallet debit=${walletDebit}, expected this-run charge=${totalChargeThisRun}`);
|
||||
assert(
|
||||
nearlyEqual(walletAfter.frozenBalance, walletBefore.frozenBalance),
|
||||
`Wallet frozen balance changed from ${walletBefore.frozenBalance} to ${walletAfter.frozenBalance}`,
|
||||
);
|
||||
|
||||
const report = {
|
||||
ok: results.every((result) => result.passed),
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseURL,
|
||||
platform: { key: platformKey, name: platform.name, provider: platform.provider },
|
||||
platformModels,
|
||||
authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId },
|
||||
wallet: {
|
||||
balanceBefore: walletBefore.balance,
|
||||
balanceAfter: walletAfter.balance,
|
||||
frozenBalanceBefore: walletBefore.frozenBalance,
|
||||
frozenBalanceAfter: walletAfter.frozenBalance,
|
||||
debit: walletDebit,
|
||||
totalChargeThisRun,
|
||||
totalHistoricalCharge,
|
||||
},
|
||||
results,
|
||||
};
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
console.log(JSON.stringify({ ...report, outputPath }, null, 2));
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
const report = await writeFailureReport(error);
|
||||
console.error(report.error);
|
||||
console.error(`Failure report: ${outputPath}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user