feat: 合并网关能力与兼容性优化 #15

Merged
wangbo merged 12 commits from codex/gateway-feature-batch-20260721 into main 2026-07-22 01:17:46 +08:00
62 changed files with 7464 additions and 325 deletions
+358
View File
@@ -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-videoBearer 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
+241
View File
@@ -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-videoBearer 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:
+71
View File
@@ -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) {
+79 -7
View File
@@ -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)
}
}
+1
View File
@@ -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
+132 -54
View File
@@ -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) != "" {
+194
View File
@@ -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, &registerResponse)
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
}
+1 -1
View File
@@ -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-videoBearer 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, &registered)
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{}
}
+34 -23
View File
@@ -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 JSONcategory 必须是 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 ""
}
}
+13
View File
@@ -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" }
+528
View File
@@ -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)
}
}
}
+13 -12
View File
@@ -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 {
+47 -1
View File
@@ -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
+56
View File
@@ -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,
+40 -3
View File
@@ -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)
}
}
}
+32
View File
@@ -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},
}
}
+8 -4
View File
@@ -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")
}
+326
View File
@@ -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
}
+39 -29
View File
@@ -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
}
+68 -1
View File
@@ -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
View File
@@ -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 [];
+64
View File
@@ -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,
});
});
});
+4
View File
@@ -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,
+90 -85
View File
@@ -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) {
+11
View File
@@ -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('&quot;runMode&quot;: &quot;simulation&quot;');
expect(html).toContain('&quot;simulation&quot;: true');
});
it('documents async mode as a body-independent capability', () => {
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
+119 -82
View File
@@ -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;
}
+78
View File
@@ -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
View File
@@ -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"
}
+132
View File
@@ -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` | 310 秒 |
| `kling-v3-omni``kling-3.0-omni` | `kling-3.0-omni` | `kling-v3-omni` | 315 秒 |
网关别名用于候选匹配,原生模型名用于发往 TranStreams 的 Kling Omni 请求;两类名称不会混用。
`kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 315 秒。
真实上游结果表明 `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` 提供。
+43
View File
@@ -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` 等字段,并额外保留网关账单字段。
+634
View File
@@ -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;
});