diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 4540ec7..7a9cc57 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -4971,6 +4971,49 @@ } } }, + "/api/v1/api-keys/assignable-models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。", + "produces": [ + "application/json" + ], + "tags": [ + "api-keys" + ], + "summary": "列出 API Key 可分配模型", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.PlatformModelListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/api-keys/{apiKeyID}": { "delete": { "security": [ @@ -10516,6 +10559,139 @@ } } }, + "/v1/videos/omni-video": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "kling-compatible" + ], + "summary": "创建 Kling Omni 视频任务", + "parameters": [ + { + "description": "Kling Omni 官方兼容请求", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.KelingOmniVideoRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + } + } + } + }, + "/v1/videos/omni-video/{taskID}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。", + "produces": [ + "application/json" + ], + "tags": [ + "kling-compatible" + ], + "summary": "查询 Kling Omni 视频任务", + "parameters": [ + { + "type": "string", + "description": "网关任务 ID", + "name": "taskID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.KelingCompatibleEnvelope" + } + } + } + } + }, "/v1/voice_clone": { "post": { "security": [ @@ -11485,6 +11661,176 @@ } } }, + "httpapi.KelingCompatibleEnvelope": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": {}, + "message": { + "type": "string", + "example": "SUCCEED" + }, + "request_id": { + "type": "string" + } + } + }, + "httpapi.KelingOmniElementInput": { + "type": "object", + "properties": { + "element_id": {} + } + }, + "httpapi.KelingOmniImageInput": { + "type": "object", + "properties": { + "image_url": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "first_frame", + "end_frame" + ] + } + } + }, + "httpapi.KelingOmniMultiPrompt": { + "type": "object", + "properties": { + "duration": { + "type": "string", + "example": "3" + }, + "index": { + "type": "integer", + "example": 1 + }, + "prompt": { + "type": "string", + "example": "A wide establishing shot" + } + } + }, + "httpapi.KelingOmniVideoInput": { + "type": "object", + "properties": { + "keep_original_sound": { + "type": "string", + "enum": [ + "yes", + "no" + ] + }, + "refer_type": { + "type": "string", + "enum": [ + "base", + "feature" + ] + }, + "video_url": { + "type": "string" + } + } + }, + "httpapi.KelingOmniVideoRequest": { + "type": "object", + "properties": { + "aspect_ratio": { + "type": "string", + "enum": [ + "16:9", + "9:16", + "1:1" + ], + "example": "9:16" + }, + "callback_url": { + "type": "string" + }, + "duration": { + "type": "string", + "example": "5" + }, + "element_list": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.KelingOmniElementInput" + } + }, + "external_task_id": { + "type": "string" + }, + "image_list": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.KelingOmniImageInput" + } + }, + "mode": { + "type": "string", + "enum": [ + "std", + "pro", + "4k" + ], + "example": "pro" + }, + "model_name": { + "type": "string", + "example": "kling-v3-omni" + }, + "multi_prompt": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.KelingOmniMultiPrompt" + } + }, + "multi_shot": { + "type": "boolean", + "example": false + }, + "prompt": { + "type": "string", + "example": "A quiet street in the rain with natural ambient sound" + }, + "shot_type": { + "type": "string", + "example": "customize" + }, + "sound": { + "type": "string", + "enum": [ + "on", + "off" + ], + "example": "on" + }, + "video_list": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.KelingOmniVideoInput" + } + }, + "watermark_info": { + "$ref": "#/definitions/httpapi.KelingOmniWatermarkInfo" + } + } + }, + "httpapi.KelingOmniWatermarkInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "example": false + } + } + }, "httpapi.ModelCatalogFilterOption": { "type": "object", "properties": { @@ -12242,6 +12588,14 @@ "httpapi.TaskRequest": { "type": "object", "properties": { + "aspect_ratio": { + "type": "string", + "example": "16:9" + }, + "audio": { + "type": "boolean", + "example": false + }, "audioWeight": { "type": "number", "example": 0.65 @@ -12368,6 +12722,10 @@ "type": "number", "example": 1 }, + "watermark": { + "type": "boolean", + "example": false + }, "weirdnessConstraint": { "type": "number", "example": 0.35 diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 5580469..c378c00 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -393,6 +393,125 @@ definitions: example: easyai-ai-gateway type: string type: object + httpapi.KelingCompatibleEnvelope: + properties: + code: + example: 0 + type: integer + data: {} + message: + example: SUCCEED + type: string + request_id: + type: string + type: object + httpapi.KelingOmniElementInput: + properties: + element_id: {} + type: object + httpapi.KelingOmniImageInput: + properties: + image_url: + type: string + type: + enum: + - first_frame + - end_frame + type: string + type: object + httpapi.KelingOmniMultiPrompt: + properties: + duration: + example: "3" + type: string + index: + example: 1 + type: integer + prompt: + example: A wide establishing shot + type: string + type: object + httpapi.KelingOmniVideoInput: + properties: + keep_original_sound: + enum: + - "yes" + - "no" + type: string + refer_type: + enum: + - base + - feature + type: string + video_url: + type: string + type: object + httpapi.KelingOmniVideoRequest: + properties: + aspect_ratio: + enum: + - "16:9" + - "9:16" + - "1:1" + example: "9:16" + type: string + callback_url: + type: string + duration: + example: "5" + type: string + element_list: + items: + $ref: '#/definitions/httpapi.KelingOmniElementInput' + type: array + external_task_id: + type: string + image_list: + items: + $ref: '#/definitions/httpapi.KelingOmniImageInput' + type: array + mode: + enum: + - std + - pro + - 4k + example: pro + type: string + model_name: + example: kling-v3-omni + type: string + multi_prompt: + items: + $ref: '#/definitions/httpapi.KelingOmniMultiPrompt' + type: array + multi_shot: + example: false + type: boolean + prompt: + example: A quiet street in the rain with natural ambient sound + type: string + shot_type: + example: customize + type: string + sound: + enum: + - "on" + - "off" + example: "on" + type: string + video_list: + items: + $ref: '#/definitions/httpapi.KelingOmniVideoInput' + type: array + watermark_info: + $ref: '#/definitions/httpapi.KelingOmniWatermarkInfo' + type: object + httpapi.KelingOmniWatermarkInfo: + properties: + enabled: + example: false + type: boolean + type: object httpapi.ModelCatalogFilterOption: properties: count: @@ -909,6 +1028,12 @@ definitions: type: object httpapi.TaskRequest: properties: + aspect_ratio: + example: "16:9" + type: string + audio: + example: false + type: boolean audioWeight: example: 0.65 type: number @@ -1005,6 +1130,9 @@ definitions: vol: example: 1 type: number + watermark: + example: false + type: boolean weirdnessConstraint: example: 0.35 type: number @@ -6408,6 +6536,33 @@ paths: summary: 批量写入 API Key 访问规则 tags: - api-keys + /api/v1/api-keys/assignable-models: + get: + description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.PlatformModelListResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 列出 API Key 可分配模型 + tags: + - api-keys /api/v1/auth/login: post: consumes: @@ -9904,6 +10059,92 @@ paths: summary: 取消异步任务 tags: - tasks + /v1/videos/omni-video: + post: + consumes: + - application/json + description: 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API + Key。任务固定异步执行,返回的 task_id 为网关任务 ID。 + parameters: + - description: Kling Omni 官方兼容请求 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.KelingOmniVideoRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + security: + - BearerAuth: [] + summary: 创建 Kling Omni 视频任务 + tags: + - kling-compatible + /v1/videos/omni-video/{taskID}: + get: + description: 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。 + parameters: + - description: 网关任务 ID + in: path + name: taskID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.KelingCompatibleEnvelope' + security: + - BearerAuth: [] + summary: 查询 Kling Omni 视频任务 + tags: + - kling-compatible /v1/voice_clone: post: consumes: diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index f941145..700f6d9 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -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) { diff --git a/apps/api/internal/clients/keling.go b/apps/api/internal/clients/keling.go index 55d5071..d5c82f5 100644 --- a/apps/api/internal/clients/keling.go +++ b/apps/api/internal/clients/keling.go @@ -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, diff --git a/apps/api/internal/clients/keling_compat_test.go b/apps/api/internal/clients/keling_compat_test.go new file mode 100644 index 0000000..e4fd247 --- /dev/null +++ b/apps/api/internal/clients/keling_compat_test.go @@ -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) + } +} diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 6f1db88..9b2d924 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -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 diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index 9eefa56..297105a 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -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) != "" { diff --git a/apps/api/internal/clients/volces_assets.go b/apps/api/internal/clients/volces_assets.go new file mode 100644 index 0000000..3adb11b --- /dev/null +++ b/apps/api/internal/clients/volces_assets.go @@ -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) +} diff --git a/apps/api/internal/clients/volces_assets_test.go b/apps/api/internal/clients/volces_assets_test.go new file mode 100644 index 0000000..82684af --- /dev/null +++ b/apps/api/internal/clients/volces_assets_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go index 9601608..7cd20ef 100644 --- a/apps/api/internal/httpapi/access_rule_handlers.go +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -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 管理端创建一条访问控制规则。 diff --git a/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go b/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go new file mode 100644 index 0000000..0edf1b6 --- /dev/null +++ b/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go @@ -0,0 +1,150 @@ +package httpapi + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestAPIKeyAssignableModelsIgnoreAPIKeyRules(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the API key assignable-model integration flow") + } + ctx := context.Background() + applyMigration(t, ctx, databaseURL) + + db, err := store.Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect store: %v", err) + } + defer db.Close() + + serverCtx, cancelServer := context.WithCancel(ctx) + defer cancelServer() + server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{ + AppEnv: "test", + HTTPAddr: ":0", + DatabaseURL: databaseURL, + IdentityMode: "hybrid", + JWTSecret: "test-secret", + CORSAllowedOrigin: "*", + }, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer server.Close() + + suffixText := strconv.FormatInt(time.Now().UnixNano(), 10) + username := "api_key_assignable_" + suffixText + password := "password123" + var registerResponse struct { + AccessToken string `json:"accessToken"` + } + doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{ + "username": username, + "email": username + "@example.com", + "password": password, + }, http.StatusCreated, ®isterResponse) + + testPool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("connect test pool: %v", err) + } + defer testPool.Close() + if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil { + t.Fatalf("promote test user: %v", err) + } + + var loginResponse struct { + AccessToken string `json:"accessToken"` + } + doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "account": username, + "password": password, + }, http.StatusOK, &loginResponse) + + createAPIKey := func(name string) string { + t.Helper() + var response struct { + APIKey struct { + ID string `json:"id"` + } `json:"apiKey"` + } + doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{ + "name": name, + }, http.StatusCreated, &response) + return response.APIKey.ID + } + firstAPIKeyID := createAPIKey("first assignable key") + secondAPIKeyID := createAPIKey("second assignable key") + + var platform struct { + ID string `json:"id"` + } + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{ + "provider": "openai", + "platformKey": "api-key-assignable-" + suffixText, + "name": "API Key Assignable Test", + "baseUrl": "https://api.openai.com/v1", + "authType": "bearer", + "credentials": map[string]any{"mode": "simulation"}, + "config": map[string]any{"testMode": true}, + }, http.StatusCreated, &platform) + + var model struct { + ID string `json:"id"` + } + modelName := "api-key-assignable-model-" + suffixText + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{ + "canonicalModelKey": "openai:gpt-4o-mini", + "modelName": modelName, + "modelAlias": modelName, + "modelType": []string{"text_generate"}, + "displayName": "API Key Assignable Model", + }, http.StatusCreated, &model) + + assertAssignable := func() { + t.Helper() + var response struct { + Items []struct { + ID string `json:"id"` + ModelName string `json:"modelName"` + } `json:"items"` + } + doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/assignable-models", loginResponse.AccessToken, nil, http.StatusOK, &response) + if !modelListContains(response.Items, model.ID) { + t.Fatalf("user-owned model should remain assignable regardless of API key rules: %+v", response.Items) + } + } + assignModel := func(apiKeyID string) { + t.Helper() + doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", loginResponse.AccessToken, map[string]any{ + "subjectType": "api_key", + "subjectId": apiKeyID, + "effect": "allow", + "upsertResources": []map[string]any{{ + "resourceType": "platform_model", + "resourceId": model.ID, + "priority": 100, + "minPermissionLevel": 0, + "status": "active", + }}, + "deleteResources": []map[string]any{}, + }, http.StatusOK, nil) + } + + assertAssignable() + assignModel(firstAPIKeyID) + assertAssignable() + assignModel(secondAPIKeyID) + assertAssignable() +} diff --git a/apps/api/internal/httpapi/gateway_task_creation.go b/apps/api/internal/httpapi/gateway_task_creation.go new file mode 100644 index 0000000..76b3c7e --- /dev/null +++ b/apps/api/internal/httpapi/gateway_task_creation.go @@ -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 +} diff --git a/apps/api/internal/httpapi/gemini_compat.go b/apps/api/internal/httpapi/gemini_compat.go index 16366fc..3e3b93e 100644 --- a/apps/api/internal/httpapi/gemini_compat.go +++ b/apps/api/internal/httpapi/gemini_compat.go @@ -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) { diff --git a/apps/api/internal/httpapi/gemini_compat_test.go b/apps/api/internal/httpapi/gemini_compat_test.go index d7e5c79..fcf20ec 100644 --- a/apps/api/internal/httpapi/gemini_compat_test.go +++ b/apps/api/internal/httpapi/gemini_compat_test.go @@ -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{ diff --git a/apps/api/internal/httpapi/keling_compat_handlers.go b/apps/api/internal/httpapi/keling_compat_handlers.go new file mode 100644 index 0000000..56151ad --- /dev/null +++ b/apps/api/internal/httpapi/keling_compat_handlers.go @@ -0,0 +1,885 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +const kelingOmniCompatibilityMarker = "keling_omni_v1" + +type kelingCompatRequestIDKey struct{} + +type KelingOmniVideoRequest struct { + ModelName string `json:"model_name" example:"kling-v3-omni"` + Prompt string `json:"prompt" example:"A quiet street in the rain with natural ambient sound"` + MultiShot bool `json:"multi_shot" example:"false"` + ShotType string `json:"shot_type,omitempty" example:"customize"` + MultiPrompt []KelingOmniMultiPrompt `json:"multi_prompt,omitempty"` + ImageList []KelingOmniImageInput `json:"image_list,omitempty"` + ElementList []KelingOmniElementInput `json:"element_list,omitempty"` + VideoList []KelingOmniVideoInput `json:"video_list,omitempty"` + Sound string `json:"sound" enums:"on,off" example:"on"` + Mode string `json:"mode" enums:"std,pro,4k" example:"pro"` + AspectRatio string `json:"aspect_ratio" enums:"16:9,9:16,1:1" example:"9:16"` + Duration any `json:"duration" swaggertype:"string" example:"5"` + WatermarkInfo KelingOmniWatermarkInfo `json:"watermark_info,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + ExternalTask string `json:"external_task_id,omitempty"` +} + +type KelingOmniMultiPrompt struct { + Index int `json:"index" example:"1"` + Prompt string `json:"prompt" example:"A wide establishing shot"` + Duration any `json:"duration" swaggertype:"string" example:"3"` +} + +type KelingOmniImageInput struct { + ImageURL string `json:"image_url"` + Type string `json:"type,omitempty" enums:"first_frame,end_frame"` +} + +type KelingOmniElementInput struct { + ElementID any `json:"element_id"` +} + +type KelingOmniVideoInput struct { + VideoURL string `json:"video_url"` + ReferType string `json:"refer_type,omitempty" enums:"base,feature"` + KeepOriginalSound string `json:"keep_original_sound,omitempty" enums:"yes,no"` +} + +type KelingOmniWatermarkInfo struct { + Enabled bool `json:"enabled" example:"false"` +} + +type KelingCompatibleEnvelope struct { + Code int `json:"code" example:"0"` + Message string `json:"message" example:"SUCCEED"` + RequestID string `json:"request_id"` + Data any `json:"data,omitempty"` +} + +type kelingCompatError struct { + HTTPStatus int + Code int + Message string + RequestID string +} + +func (e *kelingCompatError) Error() string { + if e == nil { + return "keling compatibility error" + } + return e.Message +} + +func newKelingCompatError(status int, code int, message string) *kelingCompatError { + return &kelingCompatError{HTTPStatus: status, Code: code, Message: message} +} + +func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := newKelingCompatRequestID(r) + r = r.WithContext(context.WithValue(r.Context(), kelingCompatRequestIDKey{}, requestID)) + user, err := s.auth.Authenticate(r) + if err != nil { + code := 1002 + message := "Authorization is invalid" + if strings.TrimSpace(r.Header.Get("Authorization")) == "" { + code = 1001 + message = "Authorization is required" + } + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, code, message)) + return + } + if user == nil || strings.TrimSpace(user.APIKeyID) == "" { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "a Gateway API Key is required")) + return + } + if !apiKeyScopeAllowed(user, "videos.generations") { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusForbidden, 1103, "API Key scope does not allow video generation")) + return + } + next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user))) + }) +} + +// createKelingOmniVideo godoc +// @Summary 创建 Kling Omni 视频任务 +// @Description 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。 +// @Tags kling-compatible +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param input body KelingOmniVideoRequest true "Kling Omni 官方兼容请求" +// @Success 200 {object} KelingCompatibleEnvelope +// @Failure 400 {object} KelingCompatibleEnvelope +// @Failure 401 {object} KelingCompatibleEnvelope +// @Failure 403 {object} KelingCompatibleEnvelope +// @Failure 429 {object} KelingCompatibleEnvelope +// @Failure 500 {object} KelingCompatibleEnvelope +// @Failure 503 {object} KelingCompatibleEnvelope +// @Router /v1/videos/omni-video [post] +func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) { + requestID := kelingCompatRequestID(r) + user, ok := auth.UserFromContext(r.Context()) + if !ok || user == nil { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid")) + return + } + body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations") + if err != nil { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, err.Error())) + return + } + normalized, compatErr := normalizeKelingOmniRequest(body) + if compatErr != nil { + writeKelingCompatError(w, requestID, compatErr) + return + } + model := strings.TrimSpace(stringFromKelingCompat(normalized["model"])) + if normalized["resolution"] == "2160p" { + candidates, candidateErr := s.store.ListModelCandidates(r.Context(), model, "omni_video", user) + if candidateErr != nil { + writeKelingCompatError(w, requestID, kelingCompatGatewayError(candidateErr)) + return + } + if !kelingCompatCandidatesSupport4K(candidates) { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, "mode=4k is not enabled by the selected model capabilities")) + return + } + } + task, createErr := s.prepareAndCreateGatewayTask( + r.Context(), + r, + user, + "videos.generations", + model, + normalized, + true, + ) + if createErr != nil { + var staged *gatewayTaskCreationError + if errors.As(createErr, &staged) && staged.Stage == gatewayTaskCreationPrepare { + writeKelingCompatError(w, requestID, kelingCompatGatewayError(staged.Err)) + return + } + s.logger.Error("create Kling-compatible task failed", "error", createErr) + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed")) + return + } + if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil { + s.logger.Error("enqueue Kling-compatible task failed", "taskId", task.ID, "error", err) + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusServiceUnavailable, 5001, "video task queue is unavailable")) + return + } + writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{ + Code: 0, + Message: "SUCCEED", + RequestID: requestID, + Data: kelingCompatTaskData(task), + }) +} + +// getKelingOmniVideo godoc +// @Summary 查询 Kling Omni 视频任务 +// @Description 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。 +// @Tags kling-compatible +// @Produce json +// @Security BearerAuth +// @Param taskID path string true "网关任务 ID" +// @Success 200 {object} KelingCompatibleEnvelope +// @Failure 401 {object} KelingCompatibleEnvelope +// @Failure 403 {object} KelingCompatibleEnvelope +// @Failure 404 {object} KelingCompatibleEnvelope +// @Failure 500 {object} KelingCompatibleEnvelope +// @Router /v1/videos/omni-video/{taskID} [get] +func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) { + requestID := kelingCompatRequestID(r) + user, ok := auth.UserFromContext(r.Context()) + if !ok || user == nil { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid")) + return + } + task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID"))) + if err != nil { + if store.IsNotFound(err) { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found")) + return + } + s.logger.Error("get Kling-compatible task failed", "error", err) + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "query task failed")) + return + } + if !kelingCompatTaskOwnedBy(task, user) || !isKelingCompatTask(task) { + writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found")) + return + } + writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{ + Code: 0, + Message: "SUCCEED", + RequestID: requestID, + Data: kelingCompatTaskData(task), + }) +} + +func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCompatError) { + if input == nil { + input = map[string]any{} + } + if callbackURL := strings.TrimSpace(stringFromKelingCompat(input["callback_url"])); callbackURL != "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "callback_url is not supported by this Gateway endpoint") + } + requestedModel := strings.TrimSpace(stringFromKelingCompat(input["model_name"])) + if requestedModel == "" { + requestedModel = "kling-video-o1" + } + model, maxDuration, ok := kelingCompatModel(requestedModel) + if !ok { + return nil, newKelingCompatError(http.StatusNotFound, 1203, "unsupported model_name: "+requestedModel) + } + + mode := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["mode"]))) + if mode == "" { + mode = "pro" + } + resolutionByMode := map[string]string{"std": "720p", "pro": "1080p", "4k": "2160p"} + resolution := resolutionByMode[mode] + if resolution == "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "mode must be std, pro, or 4k") + } + + sound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["sound"]))) + if sound == "" { + sound = "off" + } + if sound != "on" && sound != "off" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off") + } + if model == "kling-o1" && sound == "on" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off") + } + + content := make([]any, 0) + prompt := strings.TrimSpace(stringFromKelingCompat(input["prompt"])) + images, hasFirstFrame, imageErr := normalizeKelingImageList(input["image_list"]) + if imageErr != nil { + return nil, imageErr + } + content = append(content, images...) + elements, elementErr := normalizeKelingElementList(input["element_list"]) + if elementErr != nil { + return nil, elementErr + } + content = append(content, elements...) + videos, hasBaseVideo, hasVideo, videoErr := normalizeKelingVideoList(input["video_list"]) + if videoErr != nil { + return nil, videoErr + } + content = append(content, videos...) + if hasVideo && sound == "on" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be off when video_list is provided") + } + + multiShot, multiShotPresent, boolErr := kelingCompatOptionalBool(input, "multi_shot") + if boolErr != nil { + return nil, boolErr + } + shotType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["shot_type"]))) + multiPrompts, shotDuration, multiPromptErr := normalizeKelingMultiPrompts(input["multi_prompt"]) + if multiPromptErr != nil { + return nil, multiPromptErr + } + if !multiShotPresent { + multiShot = false + } + if multiShot { + if shotType != "customize" && shotType != "intelligence" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type must be customize or intelligence when multi_shot is true") + } + if shotType == "customize" && len(multiPrompts) == 0 { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is required for customized multi-shot generation") + } + if shotType == "intelligence" && len(multiPrompts) > 0 { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is only supported when shot_type is customize") + } + } else if len(multiPrompts) > 0 || shotType != "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type and multi_prompt require multi_shot=true") + } + if (len(multiPrompts) == 0 || shotType == "intelligence") && prompt == "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "prompt is required") + } + if prompt != "" { + content = append([]any{map[string]any{"type": "text", "text": prompt}}, content...) + } + content = append(content, multiPrompts...) + + duration, durationProvided, durationErr := kelingCompatOptionalInt(input, "duration") + if durationErr != nil { + return nil, durationErr + } + if hasBaseVideo { + if durationProvided { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration is not supported for base video editing") + } + } else { + if len(multiPrompts) > 0 { + if durationProvided && duration != shotDuration { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration must equal the sum of multi_prompt durations") + } + duration = shotDuration + } else if !durationProvided { + duration = 5 + } + if duration < 3 || duration > maxDuration { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration)) + } + if model == "kling-o1" && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds") + } + } + + aspectRatio := strings.TrimSpace(stringFromKelingCompat(input["aspect_ratio"])) + if aspectRatio != "" && aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio must be 16:9, 9:16, or 1:1") + } + if hasFirstFrame || hasBaseVideo { + if aspectRatio != "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is not supported with a first frame or base video") + } + } else if aspectRatio == "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is required when no first frame or base video is provided") + } + + watermarkEnabled, watermarkErr := kelingCompatWatermarkEnabled(input["watermark_info"]) + if watermarkErr != nil { + return nil, watermarkErr + } + externalTaskID := strings.TrimSpace(stringFromKelingCompat(input["external_task_id"])) + normalized := map[string]any{ + "model": model, + "model_name": requestedModel, + "modelType": "omni_video", + "runMode": "real", + "content": content, + "resolution": resolution, + "mode": mode, + "sound": sound, + "audio": sound == "on", + "multi_shot": multiShot, + "watermark": watermarkEnabled, + "watermark_info": map[string]any{"enabled": watermarkEnabled}, + "external_task_id": externalTaskID, + "_gateway_compatibility": kelingOmniCompatibilityMarker, + } + if prompt != "" { + normalized["prompt"] = prompt + } + if shotType != "" { + normalized["shot_type"] = shotType + } + if !hasBaseVideo { + normalized["duration"] = duration + } + if aspectRatio != "" { + normalized["aspect_ratio"] = aspectRatio + } + return normalized, nil +} + +func normalizeKelingImageList(value any) ([]any, bool, *kelingCompatError) { + items, err := kelingCompatObjectList(value, "image_list") + if err != nil { + return nil, false, err + } + out := make([]any, 0, len(items)) + hasFirstFrame := false + hasEndFrame := false + for index, item := range items { + url := strings.TrimSpace(stringFromKelingCompat(item["image_url"])) + if url == "" { + return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].image_url is required", index)) + } + frameType := strings.TrimSpace(stringFromKelingCompat(item["type"])) + role := "reference_image" + switch frameType { + case "": + case "first_frame": + role = "first_frame" + hasFirstFrame = true + case "end_frame": + role = "last_frame" + hasEndFrame = true + default: + return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].type must be first_frame or end_frame", index)) + } + out = append(out, map[string]any{ + "type": "image_url", + "role": role, + "image_url": map[string]any{"url": url}, + }) + } + if hasEndFrame && !hasFirstFrame { + return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, "end_frame requires first_frame") + } + return out, hasFirstFrame, nil +} + +func normalizeKelingElementList(value any) ([]any, *kelingCompatError) { + items, err := kelingCompatObjectList(value, "element_list") + if err != nil { + return nil, err + } + out := make([]any, 0, len(items)) + for index, item := range items { + id := item["element_id"] + if strings.TrimSpace(stringFromKelingCompat(id)) == "" { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("element_list[%d].element_id is required", index)) + } + out = append(out, map[string]any{"type": "element", "element": map[string]any{"element_id": id}}) + } + return out, nil +} + +func normalizeKelingVideoList(value any) ([]any, bool, bool, *kelingCompatError) { + items, err := kelingCompatObjectList(value, "video_list") + if err != nil { + return nil, false, false, err + } + if len(items) > 1 { + return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, "video_list supports at most one video") + } + out := make([]any, 0, len(items)) + hasBase := false + for index, item := range items { + url := strings.TrimSpace(stringFromKelingCompat(item["video_url"])) + if url == "" { + return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].video_url is required", index)) + } + referType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["refer_type"]))) + if referType == "" { + referType = "base" + } + if referType != "base" && referType != "feature" { + return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].refer_type must be base or feature", index)) + } + keepSound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["keep_original_sound"]))) + if keepSound != "" && keepSound != "yes" && keepSound != "no" { + return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].keep_original_sound must be yes or no", index)) + } + nested := map[string]any{"url": url, "refer_type": referType} + if keepSound != "" { + nested["keep_original_sound"] = keepSound + } + role := "video_feature" + if referType == "base" { + role = "video_base" + hasBase = true + } + out = append(out, map[string]any{"type": "video_url", "role": role, "video_url": nested}) + } + return out, hasBase, len(items) > 0, nil +} + +func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) { + items, err := kelingCompatObjectList(value, "multi_prompt") + if err != nil { + return nil, 0, err + } + if len(items) > 6 { + return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt supports at most six shots") + } + out := make([]any, 0, len(items)) + seen := map[int]bool{} + total := 0 + for index, item := range items { + shotIndex, ok := kelingCompatInt(item["index"]) + if !ok || shotIndex < 1 || shotIndex > 6 || seen[shotIndex] { + return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].index must be a unique integer from 1 to 6", index)) + } + seen[shotIndex] = true + prompt := strings.TrimSpace(stringFromKelingCompat(item["prompt"])) + if prompt == "" { + return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].prompt is required", index)) + } + duration, ok := kelingCompatInt(item["duration"]) + if !ok || duration < 1 { + return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].duration must be an integer of at least 1 second", index)) + } + total += duration + out = append(out, map[string]any{ + "type": "text", + "role": "shot_prompt", + "shot_index": shotIndex, + "text": prompt, + "duration": duration, + }) + } + return out, total, nil +} + +func kelingCompatModel(value string) (string, int, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "kling-video-o1", "kling-o1": + return "kling-o1", 10, true + case "kling-v3-omni", "kling-3.0-omni": + return "kling-3.0-omni", 15, true + default: + return "", 0, false + } +} + +func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) { + if value == nil { + return nil, nil + } + raw, ok := value.([]any) + if !ok { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, field+" must be an array") + } + out := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok { + return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("%s[%d] must be an object", field, index)) + } + out = append(out, object) + } + return out, nil +} + +func kelingCompatOptionalInt(body map[string]any, key string) (int, bool, *kelingCompatError) { + value, present := body[key] + if !present || value == nil || strings.TrimSpace(stringFromKelingCompat(value)) == "" { + return 0, false, nil + } + parsed, ok := kelingCompatInt(value) + if !ok { + return 0, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be an integer") + } + return parsed, true, nil +} + +func kelingCompatOptionalBool(body map[string]any, key string) (bool, bool, *kelingCompatError) { + value, present := body[key] + if !present || value == nil { + return false, false, nil + } + parsed, ok := value.(bool) + if !ok { + return false, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be a boolean") + } + return parsed, true, nil +} + +func kelingCompatWatermarkEnabled(value any) (bool, *kelingCompatError) { + if value == nil { + return false, nil + } + object, ok := value.(map[string]any) + if !ok { + return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info must be an object") + } + enabled, present := object["enabled"] + if !present { + return false, nil + } + result, ok := enabled.(bool) + if !ok { + return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info.enabled must be a boolean") + } + return result, nil +} + +func kelingCompatInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int64: + return int(typed), true + case float64: + if math.Abs(typed-math.Round(typed)) > 1e-9 { + return 0, false + } + return int(math.Round(typed)), true + case json.Number: + parsed, err := strconv.Atoi(typed.String()) + return parsed, err == nil + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + return parsed, err == nil + default: + return 0, false + } +} + +func stringFromKelingCompat(value any) string { + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64: + if math.Abs(typed-math.Round(typed)) < 1e-9 { + return strconv.FormatInt(int64(math.Round(typed)), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func kelingCompatTaskData(task store.GatewayTask) map[string]any { + data := map[string]any{ + "task_id": task.ID, + "task_status": kelingCompatTaskStatus(task.Status), + "task_info": map[string]any{ + "external_task_id": strings.TrimSpace(stringFromKelingCompat(task.Request["external_task_id"])), + }, + "created_at": task.CreatedAt.UnixMilli(), + "updated_at": task.UpdatedAt.UnixMilli(), + "watermark_info": map[string]any{ + "enabled": kelingCompatTaskWatermark(task.Request), + }, + } + if message := kelingCompatTaskMessage(task); message != "" { + data["task_status_msg"] = message + } + if kelingCompatTaskStatus(task.Status) == "failed" { + data["task_status_code"] = kelingCompatBusinessCode(task.ErrorCode, kelingCompatTaskMessage(task)) + } + if videos := kelingCompatTaskVideos(task.Result); len(videos) > 0 { + data["task_result"] = map[string]any{"videos": videos} + } + if task.FinalChargeAmount > 0 { + data["final_unit_deduction"] = strconv.FormatFloat(task.FinalChargeAmount, 'f', -1, 64) + } + return data +} + +func kelingCompatTaskStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "succeeded", "success", "completed": + return "succeed" + case "failed", "cancelled", "canceled": + return "failed" + case "running", "processing": + return "processing" + default: + return "submitted" + } +} + +func kelingCompatTaskVideos(result map[string]any) []any { + raw, _ := result["data"].([]any) + out := make([]any, 0, len(raw)) + for _, itemValue := range raw { + item, _ := itemValue.(map[string]any) + if item == nil { + continue + } + url := strings.TrimSpace(stringFromKelingCompat(firstKelingCompatValue(item["url"], item["video_url"]))) + if url == "" { + continue + } + video := map[string]any{"url": url} + if id := strings.TrimSpace(stringFromKelingCompat(item["id"])); id != "" { + video["id"] = id + } + if watermarkURL := strings.TrimSpace(stringFromKelingCompat(item["watermark_url"])); watermarkURL != "" { + video["watermark_url"] = watermarkURL + } + if duration := strings.TrimSpace(stringFromKelingCompat(item["duration"])); duration != "" { + video["duration"] = duration + } + out = append(out, video) + } + return out +} + +func firstKelingCompatValue(values ...any) any { + for _, value := range values { + if strings.TrimSpace(stringFromKelingCompat(value)) != "" { + return value + } + } + return nil +} + +func kelingCompatTaskMessage(task store.GatewayTask) string { + return strings.TrimSpace(firstNonEmpty(task.ErrorMessage, task.Error, task.Message)) +} + +func kelingCompatTaskWatermark(request map[string]any) bool { + if value, ok := request["watermark"].(bool); ok { + return value + } + if info, ok := request["watermark_info"].(map[string]any); ok { + value, _ := info["enabled"].(bool) + return value + } + return false +} + +func kelingCompatCandidatesSupport4K(candidates []store.RuntimeModelCandidate) bool { + for _, candidate := range candidates { + if strings.ToLower(strings.TrimSpace(candidate.Provider)) != "keling" { + continue + } + capability, _ := candidate.Capabilities["omni_video"].(map[string]any) + if capability == nil { + capability, _ = candidate.Capabilities["omni"].(map[string]any) + } + for _, resolution := range kelingCompatStringList(capability["output_resolutions"]) { + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "2160p", "4k": + return true + } + } + } + return false +} + +func kelingCompatStringList(value any) []string { + switch typed := value.(type) { + case []string: + return typed + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + if text := strings.TrimSpace(stringFromKelingCompat(item)); text != "" { + result = append(result, text) + } + } + return result + default: + return nil + } +} + +func kelingCompatGatewayError(err error) *kelingCompatError { + if err == nil { + return newKelingCompatError(http.StatusInternalServerError, 5000, "unknown gateway error") + } + codeText := clients.ErrorCode(err) + businessCode := kelingCompatBusinessCode(codeText, err.Error()) + status := http.StatusInternalServerError + switch businessCode { + case 1101: + status = http.StatusPaymentRequired + case 1103: + status = http.StatusForbidden + case 1201: + status = http.StatusBadRequest + case 1203: + status = http.StatusNotFound + case 1302, 1303: + status = http.StatusTooManyRequests + case 5001: + status = http.StatusBadGateway + } + return newKelingCompatError(status, businessCode, err.Error()) +} + +func kelingCompatBusinessCode(errorCode string, message string) int { + combined := strings.ToLower(strings.TrimSpace(errorCode + " " + message)) + containsAny := func(values ...string) bool { + for _, value := range values { + if strings.Contains(combined, value) { + return true + } + } + return false + } + switch { + case containsAny("insufficient_balance", "insufficient balance", "balance_not_enough", "wallet balance", "余额不足", "欠费", "quota exceeded"): + return 1101 + case containsAny("permission_denied", "permission denied", "forbidden", "access denied", "scope does not allow"): + return 1103 + case containsAny("concurrent", "concurrency"): + return 1303 + case containsAny("rate_limit", "rate limit", "too many requests", "rpm", "tpm"): + return 1302 + case containsAny("no_model_candidate", "no model candidate", "model_not_found", "unsupported model", "resource not found"): + return 1203 + case containsAny("invalid_parameter", "invalid parameter", "bad_request", "parameter_preprocessing", "duration", "aspect_ratio"): + return 1201 + case containsAny("upload_", "request_asset_", "network", "timeout", "upstream", "service unavailable", "bad gateway"): + return 5001 + default: + return 5000 + } +} + +func isKelingCompatTask(task store.GatewayTask) bool { + return task.Kind == "videos.generations" && strings.TrimSpace(stringFromKelingCompat(task.Request["_gateway_compatibility"])) == kelingOmniCompatibilityMarker +} + +func kelingCompatTaskOwnedBy(task store.GatewayTask, user *auth.User) bool { + if user == nil { + return false + } + taskOwner := strings.TrimSpace(firstNonEmpty(task.GatewayUserID, task.UserID)) + requestOwner := strings.TrimSpace(firstNonEmpty(user.GatewayUserID, user.ID)) + return taskOwner != "" && requestOwner != "" && taskOwner == requestOwner +} + +func newKelingCompatRequestID(r *http.Request) string { + if r != nil { + if value := strings.TrimSpace(firstNonEmpty(r.Header.Get("X-Request-ID"), r.Header.Get("X-Request-Id"))); value != "" { + return value + } + } + random := make([]byte, 16) + if _, err := rand.Read(random); err == nil { + return hex.EncodeToString(random) + } + return strconv.FormatInt(time.Now().UnixNano(), 36) +} + +func kelingCompatRequestID(r *http.Request) string { + if r != nil { + if value, ok := r.Context().Value(kelingCompatRequestIDKey{}).(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return newKelingCompatRequestID(r) +} + +func writeKelingCompatError(w http.ResponseWriter, requestID string, err *kelingCompatError) { + if err == nil { + err = newKelingCompatError(http.StatusInternalServerError, 5000, "internal error") + } + if err.RequestID != "" { + requestID = err.RequestID + } + if requestID == "" { + requestID = newKelingCompatRequestID(nil) + } + status := err.HTTPStatus + if status == 0 { + status = http.StatusInternalServerError + } + writeJSON(w, status, KelingCompatibleEnvelope{ + Code: err.Code, + Message: err.Message, + RequestID: requestID, + }) +} diff --git a/apps/api/internal/httpapi/keling_compat_handlers_test.go b/apps/api/internal/httpapi/keling_compat_handlers_test.go new file mode 100644 index 0000000..0a59403 --- /dev/null +++ b/apps/api/internal/httpapi/keling_compat_handlers_test.go @@ -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") + } +} diff --git a/apps/api/internal/httpapi/keling_compat_integration_test.go b/apps/api/internal/httpapi/keling_compat_integration_test.go new file mode 100644 index 0000000..c48b85c --- /dev/null +++ b/apps/api/internal/httpapi/keling_compat_integration_test.go @@ -0,0 +1,293 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestKelingOmniCompatibleHTTPFlow(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling-compatible HTTP integration flow") + } + + var upstreamTaskSequence atomic.Int64 + var upstreamPayloadMu sync.Mutex + var upstreamPayloads []map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream-keling-key" { + t.Fatalf("unexpected upstream Authorization: %q", r.Header.Get("Authorization")) + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/videos/omni-video": + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode upstream request: %v", err) + } + upstreamPayloadMu.Lock() + upstreamPayloads = append(upstreamPayloads, payload) + upstreamPayloadMu.Unlock() + id := "upstream-" + strconv.FormatInt(upstreamTaskSequence.Add(1), 10) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "submit-" + id, + "data": map[string]any{"task_id": id, "task_status": "submitted"}, + }) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/videos/omni-video/upstream-"): + id := strings.TrimPrefix(r.URL.Path, "/videos/omni-video/") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "poll-" + id, + "data": map[string]any{ + "task_id": id, + "task_status": "succeed", + "created_at": time.Now().UnixMilli(), + "task_result": map[string]any{"videos": []any{map[string]any{ + "id": "video-" + id, + "url": "https://example.com/" + id + ".mp4", + "watermark_url": "https://example.com/" + id + "-watermark.mp4", + "duration": "3", + }}}, + }, + }) + default: + t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL.Path) + } + })) + defer upstream.Close() + + ctx := context.Background() + applyMigration(t, ctx, databaseURL) + db, err := store.Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect store: %v", err) + } + defer db.Close() + + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{ + Provider: "keling", + PlatformKey: "keling-compatible-test-" + suffix, + Name: "Kling Compatible Test", + BaseURL: upstream.URL, + AuthType: "APIKey", + Credentials: map[string]any{"apiKey": "upstream-keling-key"}, + Config: map[string]any{ + "kelingPollIntervalMs": 100, + "kelingPollTimeoutSeconds": 5, + }, + Priority: 1, + Status: "enabled", + }) + if err != nil { + t.Fatalf("create test platform: %v", err) + } + _, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{ + PlatformID: platform.ID, + CanonicalModelKey: "keling:kling-video-o1", + ModelName: "kling-o1", + ProviderModelName: "kling-video-o1", + ModelAlias: "kling-o1", + ModelType: store.StringList{"omni_video", "video_generate"}, + DisplayName: "Kling O1 Compatible Test", + Capabilities: map[string]any{ + "omni_video": map[string]any{ + "supported_modes": []any{"text_to_video", "image_reference"}, + "output_resolutions": []any{"720p", "1080p"}, + "aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"}, + "duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10}, + "output_audio": false, + "max_images": 7, + }, + "video_generate": map[string]any{ + "supported_modes": []any{"text_to_video"}, + "output_resolutions": []any{"720p", "1080p"}, + "aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"}, + "duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10}, + "output_audio": true, + }, + }, + Enabled: true, + }) + if err != nil { + t.Fatalf("create test platform model: %v", err) + } + + serverCtx, cancelServer := context.WithCancel(ctx) + defer cancelServer() + gateway := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{ + AppEnv: "test", + HTTPAddr: ":0", + DatabaseURL: databaseURL, + IdentityMode: "hybrid", + JWTSecret: "test-secret", + CORSAllowedOrigin: "*", + }, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer gateway.Close() + + firstUserToken, firstAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "first", suffix, true) + _ = firstUserToken + _, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false) + + var created KelingCompatibleEnvelope + doJSON(t, gateway.URL, http.MethodPost, "/v1/videos/omni-video", firstAPIKey, map[string]any{ + "model_name": "kling-video-o1", + "prompt": "A clean product reveal", + "mode": "std", + "aspect_ratio": "16:9", + "duration": "3", + "sound": "off", + "image_list": []any{map[string]any{"image_url": "https://example.com/reference.png"}}, + "external_task_id": "compat-http-1", + }, http.StatusOK, &created) + createdData, _ := created.Data.(map[string]any) + if created.Code != 0 || created.RequestID == "" || strings.TrimSpace(stringFromKelingCompat(createdData["task_id"])) == "" || createdData["task_status"] != "submitted" { + t.Fatalf("unexpected compatible create response: %+v", created) + } + taskID := stringFromKelingCompat(createdData["task_id"]) + + var hidden KelingCompatibleEnvelope + doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden) + if hidden.Code != 1203 { + t.Fatalf("cross-user task must be hidden: %+v", hidden) + } + + completed := waitForKelingCompatTask(t, gateway.URL, firstAPIKey, taskID, 5*time.Second) + if completed.Code != 0 { + t.Fatalf("compatible task failed: %+v", completed) + } + completedData, _ := completed.Data.(map[string]any) + if completedData["task_status"] != "succeed" { + t.Fatalf("compatible task did not succeed: %+v", completedData) + } + taskResult, _ := completedData["task_result"].(map[string]any) + videos, _ := taskResult["videos"].([]any) + video, _ := videos[0].(map[string]any) + if video["id"] == "" || video["watermark_url"] == "" || video["duration"] != "3" { + t.Fatalf("compatible result lost video metadata: %+v", video) + } + + var standard struct { + TaskID string `json:"taskId"` + } + doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{ + "model": "kling-o1", + "prompt": "A second product reveal", + "resolution": "720p", + "aspect_ratio": "16:9", + "duration": 3, + "audio": false, + }, map[string]string{"X-Async": "true"}, http.StatusAccepted, &standard) + if standard.TaskID == "" { + t.Fatal("standard video generation did not return taskId") + } + waitForTaskStatus(t, gateway.URL, firstAPIKey, standard.TaskID, []string{"succeeded"}, 5*time.Second) + + var unsupportedAudio struct { + TaskID string `json:"taskId"` + } + doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{ + "model": "kling-o1", + "prompt": "An O1 request that must not silently ignore audio", + "resolution": "1080p", + "aspect_ratio": "9:16", + "duration": 5, + "audio": true, + }, map[string]string{"X-Async": "true"}, http.StatusAccepted, &unsupportedAudio) + if unsupportedAudio.TaskID == "" { + t.Fatal("unsupported O1 audio request did not return taskId") + } + waitForTaskStatus(t, gateway.URL, firstAPIKey, unsupportedAudio.TaskID, []string{"failed"}, 5*time.Second) + var failedAudioTask store.GatewayTask + doJSON(t, gateway.URL, http.MethodGet, "/api/v1/tasks/"+unsupportedAudio.TaskID, firstAPIKey, nil, http.StatusOK, &failedAudioTask) + if failedAudioTask.ErrorCode != "invalid_parameter" || !strings.Contains(failedAudioTask.ErrorMessage, "does not support generated audio") { + t.Fatalf("O1 audio request must fail visibly before upstream submission: %+v", failedAudioTask) + } + + upstreamPayloadMu.Lock() + defer upstreamPayloadMu.Unlock() + if len(upstreamPayloads) != 2 { + t.Fatalf("expected two upstream submissions, got %d", len(upstreamPayloads)) + } + compatiblePayload := upstreamPayloads[0] + if compatiblePayload["model_name"] != "kling-video-o1" || compatiblePayload["mode"] != "std" || compatiblePayload["sound"] != "off" || compatiblePayload["duration"] != "3" || compatiblePayload["aspect_ratio"] != "16:9" || compatiblePayload["external_task_id"] != "compat-http-1" { + t.Fatalf("unexpected compatible upstream payload: %+v", compatiblePayload) + } +} + +func createKelingCompatIntegrationUser(t *testing.T, ctx context.Context, db *store.Store, baseURL string, prefix string, suffix string, fund bool) (string, string) { + t.Helper() + username := fmt.Sprintf("kling_compat_%s_%s", prefix, suffix) + password := "password123" + var registered struct { + AccessToken string `json:"accessToken"` + } + doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{ + "username": username, + "email": username + "@example.com", + "password": password, + }, http.StatusCreated, ®istered) + var apiKey struct { + Secret string `json:"secret"` + } + doJSON(t, baseURL, http.MethodPost, "/api/v1/api-keys", registered.AccessToken, map[string]any{ + "name": "Kling compatible integration key", + "scopes": []string{"video"}, + }, http.StatusCreated, &apiKey) + if fund { + if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil { + t.Fatalf("promote integration user: %v", err) + } + var loggedIn struct { + AccessToken string `json:"accessToken"` + } + doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "account": username, + "password": password, + }, http.StatusOK, &loggedIn) + var gatewayUserID string + if err := db.Pool().QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&gatewayUserID); err != nil { + t.Fatalf("read integration user id: %v", err) + } + doJSON(t, baseURL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loggedIn.AccessToken, map[string]any{ + "currency": "resource", + "balance": 1000, + "reason": "seed Kling compatible integration wallet", + }, http.StatusOK, nil) + } + return registered.AccessToken, apiKey.Secret +} + +func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID string, timeout time.Duration) KelingCompatibleEnvelope { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + var response KelingCompatibleEnvelope + doJSON(t, baseURL, http.MethodGet, "/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response) + data, _ := response.Data.(map[string]any) + switch data["task_status"] { + case "succeed": + return response + case "failed": + t.Fatalf("Kling-compatible task failed: %+v", response) + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("timed out waiting for Kling-compatible task %s", taskID) + return KelingCompatibleEnvelope{} +} diff --git a/apps/api/internal/httpapi/model_response.go b/apps/api/internal/httpapi/model_response.go index ded775b..0aa90dc 100644 --- a/apps/api/internal/httpapi/model_response.go +++ b/apps/api/internal/httpapi/model_response.go @@ -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 { diff --git a/apps/api/internal/httpapi/model_response_test.go b/apps/api/internal/httpapi/model_response_test.go index e3fd661..e4c5fae 100644 --- a/apps/api/internal/httpapi/model_response_test.go +++ b/apps/api/internal/httpapi/model_response_test.go @@ -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) diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 19a922d..918ce20 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -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"` diff --git a/apps/api/internal/httpapi/portrait_asset_handlers.go b/apps/api/internal/httpapi/portrait_asset_handlers.go new file mode 100644 index 0000000..898ada5 --- /dev/null +++ b/apps/api/internal/httpapi/portrait_asset_handlers.go @@ -0,0 +1,329 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/runner" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +const seedancePortraitAssetCategory = "seedance_portrait_asset" + +// getSeedancePortraitAssetCapability godoc +// @Summary 查询 Seedance 真人资产能力 +// @Description 返回当前网关是否已配置可创建、同步和引用的火山 Seedance 真人资产平台。 +// @Tags portrait-assets +// @Produce json +// @Security BearerAuth +// @Success 200 {object} runner.PortraitAssetCapability +// @Router /api/v1/resource/material/seedance-portrait-assets/capability [get] +func (s *Server) getSeedancePortraitAssetCapability(w http.ResponseWriter, r *http.Request) { + capability, err := s.runner.PortraitAssetCapability(r.Context()) + if err != nil { + s.logger.Error("get portrait asset capability failed", "error", err) + writeError(w, http.StatusInternalServerError, "get portrait asset capability failed") + return + } + writeJSON(w, http.StatusOK, capability) +} + +// listSeedancePortraitAssets godoc +// @Summary 列出 Seedance 真人资产 +// @Description 返回当前用户的真人资产;兼容 server-main material 列表响应字段。 +// @Tags portrait-assets +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]any +// @Router /api/v1/resource/material/user/materials [get] +func (s *Server) listSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok || user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + if category := strings.TrimSpace(r.URL.Query().Get("category")); category != seedancePortraitAssetCategory { + writeError(w, http.StatusNotFound, "material category not found") + return + } + items, err := s.store.ListPortraitAssets(r.Context(), user, store.PortraitAssetListFilter{ + Keyword: r.URL.Query().Get("keyword"), + SourceType: firstNonEmptyQuery(r, "fileType", "sourceType"), + Page: portraitAssetQueryInt(r, "pageNumber", "page"), + PageSize: portraitAssetQueryInt(r, "pageSize"), + }) + if err != nil { + s.logger.Error("list portrait assets failed", "error", err) + writeError(w, http.StatusInternalServerError, "list portrait assets failed") + return + } + responseItems := make([]any, 0, len(items.Items)) + for _, item := range items.Items { + responseItems = append(responseItems, s.portraitAssetResponse(r, item)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": responseItems, + "total": items.Total, + "page": items.Page, + "pageSize": items.PageSize, + }) +} + +// createSeedancePortraitAsset godoc +// @Summary 上传并创建 Seedance 真人资产 +// @Description 文件先写入网关文件存储;仅在 private_avatar_eligible=true 时登记到火山 Assets。创建后会立即触发一次状态同步。 +// @Tags portrait-assets +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param file formData file true "真人资产源文件(图片、视频或音频)" +// @Param data formData string true "material JSON,category 必须是 seedance_portrait_asset" +// @Success 200 {object} map[string]any +// @Router /api/v1/resource/material [post] +func (s *Server) createSeedancePortraitAsset(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok || user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes) + if err := r.ParseMultipartForm(multipartTaskMemoryBytes); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form-data body") + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + var data map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(r.FormValue("data"))), &data); err != nil || data == nil { + writeError(w, http.StatusBadRequest, "data must be a JSON object") + return + } + if strings.TrimSpace(portraitAssetString(data["category"])) != seedancePortraitAssetCategory { + writeError(w, http.StatusBadRequest, "category must be seedance_portrait_asset") + return + } + privateEligible, _ := data["private_avatar_eligible"].(bool) + if !privateEligible { + writeError(w, http.StatusBadRequest, "private_avatar_eligible must be true after the user confirms authorization", "portrait_asset_authorization_required") + return + } + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "file is required") + return + } + defer file.Close() + payload, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, "read portrait asset file failed") + return + } + contentType := strings.TrimSpace(header.Header.Get("Content-Type")) + if contentType == "" && len(payload) > 0 { + contentType = http.DetectContentType(payload) + } + sourceType := strings.ToLower(strings.TrimSpace(firstNonEmpty(portraitAssetString(data["fileType"]), portraitAssetString(data["sourceType"])))) + if !portraitAssetSourceMatchesContentType(sourceType, contentType) { + writeError(w, http.StatusBadRequest, "fileType must be image, video, or audio and match the uploaded file", "portrait_asset_unsupported_type") + return + } + upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{ + Bytes: payload, ContentType: contentType, FileName: header.Filename, Source: "seedance-portrait-asset", Scene: store.FileStorageSceneUpload, + }) + if err != nil { + s.logger.Error("upload portrait asset failed", "error", err) + writeError(w, http.StatusBadGateway, err.Error(), clients.ErrorCode(err)) + return + } + url := strings.TrimSpace(portraitAssetString(upload["url"])) + if url == "" { + writeError(w, http.StatusBadGateway, "portrait asset upload returned no URL", "portrait_asset_source_url_required") + return + } + digest := sha256.Sum256(payload) + asset, reused, err := s.runner.CreatePortraitAsset(r.Context(), user, runner.PortraitAssetCreateInput{ + Name: strings.TrimSpace(portraitAssetString(data["name"])), + Description: strings.TrimSpace(portraitAssetString(data["description"])), + SourceType: sourceType, + URL: url, + Preview: firstNonEmpty(portraitAssetString(data["preview"]), url), + MimeType: contentType, + ByteSize: int64(len(payload)), + SourceSHA256: hex.EncodeToString(digest[:]), + PrivateAvatarEligible: privateEligible, + Metadata: map[string]any{ + "tags": data["tags"], + "materialGroupId": data["material_group_id"], + "uploadedFileName": header.Filename, + "uploadAssetStorage": upload["assetStorage"], + }, + }) + if err != nil { + writePortraitAssetError(w, err) + return + } + _, _ = s.runner.SyncPortraitAssets(r.Context(), user, []string{asset.ID}) + asset, _, err = s.refreshPortraitAssetForResponse(r, user, asset.ID, asset) + if err != nil { + s.logger.Error("refresh portrait asset after create failed", "error", err) + writeError(w, http.StatusInternalServerError, "refresh portrait asset failed") + return + } + response := map[string]any{"asset": s.portraitAssetResponse(r, asset)} + if reused { + response["dedupe"] = map[string]any{"reused": true, "code": "PORTRAIT_ASSET_REUSED", "reason": "same_source", "message": "已复用相同源文件的真人资产,并触发状态刷新。"} + } + writeJSON(w, http.StatusOK, response) +} + +// syncSeedancePortraitAssets godoc +// @Summary 同步 Seedance 真人资产状态 +// @Description 调用火山 CreateAsset/GetAsset;多次调用可把 Processing 状态刷新为 Active 或 Failed。 +// @Tags portrait-assets +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} runner.PortraitAssetSyncResponse +// @Router /api/v1/resource/material/seedance-portrait-assets/sync [post] +func (s *Server) syncSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok || user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + var request struct { + IDs []string `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if len(request.IDs) == 0 { + writeJSON(w, http.StatusOK, runner.PortraitAssetSyncResponse{SyncedIDs: []string{}, Skipped: []runner.PortraitAssetIssue{}, Failed: []runner.PortraitAssetIssue{}, Assets: []store.PortraitAsset{}}) + return + } + response, err := s.runner.SyncPortraitAssets(r.Context(), user, request.IDs) + if err != nil { + s.logger.Error("sync portrait assets failed", "error", err) + writePortraitAssetError(w, err) + return + } + assets := make([]any, 0, len(response.Assets)) + for _, asset := range response.Assets { + assets = append(assets, s.portraitAssetResponse(r, asset)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "requested": response.Requested, "accepted": response.Accepted, "syncedIds": response.SyncedIDs, + "skipped": response.Skipped, "failed": response.Failed, "assets": assets, + }) +} + +func (s *Server) refreshPortraitAssetForResponse(r *http.Request, user *auth.User, assetID string, fallback store.PortraitAsset) (store.PortraitAsset, bool, error) { + asset, found, err := s.store.FindPortraitAssetForUser(r.Context(), user, assetID) + if err != nil || !found { + return fallback, found, err + } + return asset, true, nil +} + +func (s *Server) portraitAssetResponse(r *http.Request, asset store.PortraitAsset) map[string]any { + active, total, lastError, updatedAt, err := s.store.PortraitAssetBindingSummary(r.Context(), asset.ID) + if err != nil { + active, total, lastError, updatedAt = 0, 0, asset.LastError, asset.UpdatedAt.UTC().Format(time.RFC3339Nano) + } + summaryStatus := asset.Status + if summaryStatus == "not_synced" && total == 0 { + summaryStatus = "not_synced" + } + response := map[string]any{ + "id": asset.ID, "name": asset.Name, "description": asset.Description, "url": asset.URL, "preview": firstNonEmpty(asset.Preview, asset.URL), + "type": "personal", "fileType": asset.SourceType, "sourceType": asset.SourceType, "size": asset.ByteSize, + "privateAvatarEligible": asset.PrivateAvatarEligible, + "createdAt": asset.CreatedAt.UTC().Format(time.RFC3339Nano), "updatedAt": asset.UpdatedAt.UTC().Format(time.RFC3339Nano), + "seedanceAssetSummary": map[string]any{ + "eligible": asset.PrivateAvatarEligible, "status": summaryStatus, "provider": "volces", "activePlatformCount": active, + "totalPlatformCount": total, "sourceType": asset.SourceType, "updatedAt": updatedAt, + }, + } + if lastError != "" { + response["seedanceAssetSummary"].(map[string]any)["lastError"] = lastError + } + if asset.SourceType == "image" { + response["thumbnail"] = firstNonEmpty(asset.Preview, asset.URL) + } + return response +} + +func writePortraitAssetError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + if clientErr := clients.ErrorCode(err); clientErr != "client_error" { + switch clientErr { + case "portrait_asset_not_found": + status = http.StatusNotFound + case "portrait_asset_processing": + status = http.StatusServiceUnavailable + case "portrait_asset_authorization_required", "portrait_asset_unsupported_type", "portrait_asset_source_url_required", "portrait_asset_id_required", "portrait_asset_unsupported_model", "portrait_asset_audio_only": + status = http.StatusBadRequest + } + writeError(w, status, err.Error(), clientErr) + return + } + writeError(w, status, err.Error()) +} + +func portraitAssetSourceMatchesContentType(sourceType string, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + switch sourceType { + case "image": + return strings.HasPrefix(contentType, "image/") + case "video": + return strings.HasPrefix(contentType, "video/") + case "audio": + return strings.HasPrefix(contentType, "audio/") + default: + return false + } +} + +func portraitAssetQueryInt(r *http.Request, keys ...string) int { + for _, key := range keys { + value := strings.TrimSpace(r.URL.Query().Get(key)) + if value == "" { + continue + } + var parsed int + if _, err := fmt.Sscan(value, &parsed); err == nil { + return parsed + } + } + return 0 +} + +func firstNonEmptyQuery(r *http.Request, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" { + return value + } + } + return "" +} + +func portraitAssetString(value any) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + default: + return "" + } +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 7834444..3802515 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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))) diff --git a/apps/api/internal/httpapi/volces_compat_handlers.go b/apps/api/internal/httpapi/volces_compat_handlers.go new file mode 100644 index 0000000..f089c77 --- /dev/null +++ b/apps/api/internal/httpapi/volces_compat_handlers.go @@ -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 "" + } +} diff --git a/apps/api/internal/httpapi/volces_compat_handlers_test.go b/apps/api/internal/httpapi/volces_compat_handlers_test.go new file mode 100644 index 0000000..2069843 --- /dev/null +++ b/apps/api/internal/httpapi/volces_compat_handlers_test.go @@ -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) + } +} diff --git a/apps/api/internal/runner/keling_audio_test.go b/apps/api/internal/runner/keling_audio_test.go new file mode 100644 index 0000000..2538e17 --- /dev/null +++ b/apps/api/internal/runner/keling_audio_test.go @@ -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) + } +} diff --git a/apps/api/internal/runner/param_processor_media.go b/apps/api/internal/runner/param_processor_media.go index 11dbbda..bf0ffda 100644 --- a/apps/api/internal/runner/param_processor_media.go +++ b/apps/api/internal/runner/param_processor_media.go @@ -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" } diff --git a/apps/api/internal/runner/portrait_assets.go b/apps/api/internal/runner/portrait_assets.go new file mode 100644 index 0000000..c082966 --- /dev/null +++ b/apps/api/internal/runner/portrait_assets.go @@ -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" } diff --git a/apps/api/internal/runner/portrait_assets_test.go b/apps/api/internal/runner/portrait_assets_test.go new file mode 100644 index 0000000..8b81bfe --- /dev/null +++ b/apps/api/internal/runner/portrait_assets_test.go @@ -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_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) + } + } +} diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index f149c21..7026ac9 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -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 { diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index 2f94a38..2211ccf 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -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 diff --git a/apps/api/internal/runner/task_cancel.go b/apps/api/internal/runner/task_cancel.go index 600f778..4f18d37 100644 --- a/apps/api/internal/runner/task_cancel.go +++ b/apps/api/internal/runner/task_cancel.go @@ -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, diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go index f30a23c..3eaf1ba 100644 --- a/apps/api/internal/store/access_rules.go +++ b/apps/api/internal/store/access_rules.go @@ -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 { diff --git a/apps/api/internal/store/access_rules_test.go b/apps/api/internal/store/access_rules_test.go new file mode 100644 index 0000000..6458c82 --- /dev/null +++ b/apps/api/internal/store/access_rules_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/store/billing_config.go b/apps/api/internal/store/billing_config.go new file mode 100644 index 0000000..cbf8d19 --- /dev/null +++ b/apps/api/internal/store/billing_config.go @@ -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) +} diff --git a/apps/api/internal/store/billing_config_test.go b/apps/api/internal/store/billing_config_test.go new file mode 100644 index 0000000..06a8ddc --- /dev/null +++ b/apps/api/internal/store/billing_config_test.go @@ -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}, + } +} diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 6c5f7d4..9eb70ca 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -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, diff --git a/apps/api/internal/store/platform_models_integration_test.go b/apps/api/internal/store/platform_models_integration_test.go new file mode 100644 index 0000000..a59f3d9 --- /dev/null +++ b/apps/api/internal/store/platform_models_integration_test.go @@ -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") +} diff --git a/apps/api/internal/store/portrait_assets.go b/apps/api/internal/store/portrait_assets.go new file mode 100644 index 0000000..3527351 --- /dev/null +++ b/apps/api/internal/store/portrait_assets.go @@ -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 +} diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 036793f..4840668 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -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) diff --git a/apps/api/internal/store/remote_task_candidate.go b/apps/api/internal/store/remote_task_candidate.go new file mode 100644 index 0000000..8be9ea9 --- /dev/null +++ b/apps/api/internal/store/remote_task_candidate.go @@ -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 +} diff --git a/apps/api/internal/store/tasks_runtime.go b/apps/api/internal/store/tasks_runtime.go index 3d6d804..d350497 100644 --- a/apps/api/internal/store/tasks_runtime.go +++ b/apps/api/internal/store/tasks_runtime.go @@ -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 diff --git a/apps/api/internal/store/volces_compatible_tasks.go b/apps/api/internal/store/volces_compatible_tasks.go new file mode 100644 index 0000000..5293ae3 --- /dev/null +++ b/apps/api/internal/store/volces_compatible_tasks.go @@ -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 +} diff --git a/apps/api/migrations/0072_glm52_text_capability_sync.sql b/apps/api/migrations/0072_glm52_text_capability_sync.sql new file mode 100644 index 0000000..5e24be9 --- /dev/null +++ b/apps/api/migrations/0072_glm52_text_capability_sync.sql @@ -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' + ) + ); diff --git a/apps/api/migrations/0073_seedance_portrait_assets.sql b/apps/api/migrations/0073_seedance_portrait_assets.sql new file mode 100644 index 0000000..7e7d8ac --- /dev/null +++ b/apps/api/migrations/0073_seedance_portrait_assets.sql @@ -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%'; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6870008..9740241 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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([]); + const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState([]); const [networkProxyConfig, setNetworkProxyConfig] = useState(null); const [clientCustomizationSettings, setClientCustomizationSettings] = useState(null); const [fileStorageChannels, setFileStorageChannels] = useState([]); @@ -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) { + async function submitTask(event: FormEvent, 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 []; diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 470cc3b..8e4c220 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -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, + }); + }); }); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index aa79f36..30ba323 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise>('/api/v1/api-keys/access-rules', { token }); } +export async function listApiKeyAssignableModels(token: string): Promise> { + return request>('/api/v1/api-keys/assignable-models', { token }); +} + export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise { return request('/api/admin/access-rules', { body: input, diff --git a/apps/web/src/lib/run-task.ts b/apps/web/src/lib/run-task.ts index a5fd6ea..6efa599 100644 --- a/apps/web/src/lib/run-task.ts +++ b/apps/web/src/lib/run-task.ts @@ -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; + submissionMode: TaskSubmissionMode; task: GatewayTask; } -export async function runTask(token: string, task: TaskForm): Promise { +export interface RunTaskOptions { + requestBody?: Record; + submissionMode?: TaskSubmissionMode; +} + +const simulationParameterKeys = ['runMode', 'run_mode', 'simulation', 'testMode', 'test_mode'] as const; + +export function applyTaskSubmissionMode( + input: Record, + submissionMode: TaskSubmissionMode, +): Record { + 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 { + 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[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[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[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[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[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[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[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) }; + return { + localOnly: true, + submissionMode: 'production', + task: compatibleTask(task, result as unknown as Record, requestBody, 'production'), + }; } throw new Error(`Unsupported task kind: ${task.kind}`); } -function compatibleTask(task: TaskForm, result: Record): GatewayTask { +function compatibleTask( + task: TaskForm, + result: Record, + requestBody: Record, + 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): 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 { +function defaultRequestBody(task: TaskForm): Record { + 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 { 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 { 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 { 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) { diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx index f3550ce..521fc4f 100644 --- a/apps/web/src/pages/ApiDocsPage.test.tsx +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -36,6 +36,17 @@ describe('ApiDocsPage extended task documentation', () => { expect(html).toContain('任务取回接口'); }); + it('defaults the online runner to test mode and offers an explicit real submission mode', () => { + const html = renderDocs('imageEdit', { kind: 'images.edits', model: 'gpt-image-1', prompt: '移除背景' }); + + expect(html).toContain('运行模式'); + expect(html).toContain('测试模式'); + expect(html).toContain('真实提交'); + expect(html).toContain('aria-pressed="true"'); + expect(html).toContain('"runMode": "simulation"'); + expect(html).toContain('"simulation": true'); + }); + it('documents async mode as a body-independent capability', () => { const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' }); diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index 9cb76bd..72f0aa0 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -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) => void; + onSubmitTask: (event: FormEvent, 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('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) { + 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 ?
在线运行 - @@ -286,6 +330,34 @@ export function ApiDocsPage(props: { )} {runnerAvailable ? ( <> + {runnerModeEnabled && ( +
+ 运行模式 +
+ + +
+

+ {submissionMode === 'simulation' + ? '不触达真实供应商,不消耗上游额度。' + : '请求将真实提交给供应商,可能产生费用。'} +

+
+ )}