feat: 完善模型请求适配与输出限制
This commit is contained in:
@@ -9,9 +9,11 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -32,14 +34,34 @@ func (c KelingClient) Run(ctx context.Context, request Request) (Response, error
|
||||
if request.Kind != "videos.generations" {
|
||||
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported keling request kind", Retryable: false}
|
||||
}
|
||||
token, err := kelingAuthToken(request.Candidate)
|
||||
token, err := kelingAuthTokenForRequest(request)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return c.runVideo(ctx, request, token)
|
||||
}
|
||||
|
||||
func kelingAuthTokenForRequest(request Request) (string, error) {
|
||||
if kelingIs30TurboRequest(request) {
|
||||
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||
if apiKey == "" {
|
||||
return "", &ClientError{
|
||||
Code: "missing_credentials",
|
||||
Message: "keling 3.0 turbo requires the new API key; legacy accessKey/secretKey credentials do not support new models",
|
||||
Retryable: false,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
return kelingAuthToken(request.Candidate)
|
||||
}
|
||||
|
||||
func (c KelingClient) runVideo(ctx context.Context, request Request, token string) (Response, error) {
|
||||
if kelingIs30TurboRequest(request) {
|
||||
return c.runTaskAPIVideo(ctx, request, token)
|
||||
}
|
||||
|
||||
submitStartedAt := time.Now()
|
||||
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
|
||||
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
@@ -143,6 +165,105 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
|
||||
}
|
||||
}
|
||||
|
||||
func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, token string) (Response, error) {
|
||||
submitStartedAt := time.Now()
|
||||
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
|
||||
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
taskAPIBaseURL := kelingTaskAPIBaseURL(request.Candidate.BaseURL)
|
||||
|
||||
if upstreamTaskID == "" {
|
||||
payload, endpoint, err := keling30TurboPayload(request)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload)
|
||||
submitRequestID = requestID
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
|
||||
}
|
||||
upstreamTaskID = strings.TrimSpace(stringFromAny(kelingData(submitResult)["id"]))
|
||||
if upstreamTaskID == "" {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: "keling 3.0 turbo task id is missing", RequestID: submitRequestID, Retryable: false}
|
||||
}
|
||||
if request.OnRemoteTaskSubmitted != nil {
|
||||
if err := request.OnRemoteTaskSubmitted(upstreamTaskID, map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"taskApi": "keling_tasks_v2",
|
||||
"submit": submitResult,
|
||||
}); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interval := kelingPollInterval(request)
|
||||
timeout := kelingPollTimeout(request)
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastStatus string
|
||||
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.getJSONAt(
|
||||
ctx,
|
||||
request,
|
||||
taskAPIBaseURL,
|
||||
"/tasks?task_ids="+url.QueryEscape(upstreamTaskID),
|
||||
token,
|
||||
)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
}
|
||||
|
||||
task := kelingTaskAPITask(pollResult, upstreamTaskID)
|
||||
lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"])))
|
||||
switch lastStatus {
|
||||
case "succeeded", "succeed":
|
||||
return Response{
|
||||
Result: kelingTaskAPIVideoSuccessResult(request, upstreamTaskID, task, pollResult),
|
||||
RequestID: requestID,
|
||||
Progress: kelingVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed":
|
||||
return Response{}, &ClientError{
|
||||
Code: "keling_task_failed",
|
||||
Message: kelingTaskAPIErrorMessage(request.Candidate, task, 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}
|
||||
case <-deadline.C:
|
||||
return Response{}, &ClientError{
|
||||
Code: "timeout",
|
||||
Message: fmt.Sprintf("keling 3.0 turbo task %s did not finish before timeout; last status: %s", upstreamTaskID, lastStatus),
|
||||
RequestID: requestID,
|
||||
Retryable: true,
|
||||
}
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c KelingClient) prepareVideoTask(ctx context.Context, request Request, token string) (kelingPreparedTask, error) {
|
||||
if kelingIsOmniRequest(request) {
|
||||
payload, cleanupIDs, err := c.kelingOmniPayload(ctx, request, token)
|
||||
@@ -400,8 +521,12 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request
|
||||
}
|
||||
|
||||
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
|
||||
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body)
|
||||
}
|
||||
|
||||
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any) (map[string]any, string, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, path), bytes.NewReader(raw))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -423,7 +548,11 @@ func (c KelingClient) postJSON(ctx context.Context, request Request, path string
|
||||
}
|
||||
|
||||
func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(request.Candidate.BaseURL, path), nil)
|
||||
return c.getJSONAt(ctx, request, request.Candidate.BaseURL, path, token)
|
||||
}
|
||||
|
||||
func (c KelingClient) getJSONAt(ctx context.Context, request Request, baseURL string, path string, token string) (map[string]any, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -560,6 +689,127 @@ func kelingIsOmniRequest(request Request) bool {
|
||||
request.Candidate.Capabilities["omni"] != nil
|
||||
}
|
||||
|
||||
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":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func kelingTaskAPIBaseURL(baseURL string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if strings.HasSuffix(strings.ToLower(trimmed), "/v1") {
|
||||
return trimmed[:len(trimmed)-len("/v1")]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func keling30TurboPayload(request Request) (map[string]any, string, error) {
|
||||
body := cleanProviderBody(request.Body)
|
||||
content := contentItems(body["content"])
|
||||
if len(content) == 0 {
|
||||
content = buildVolcesContentFromBody(body)
|
||||
}
|
||||
|
||||
shots := kelingShotPrompts(content)
|
||||
if len(shots) > 6 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo supports at most 6 shots", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
prompt := firstKelingPrompt(content)
|
||||
duration := numericValue(body["duration"], 5)
|
||||
if len(shots) > 0 {
|
||||
var promptBuilder strings.Builder
|
||||
duration = 0
|
||||
for index, shot := range shots {
|
||||
if shot.duration < 1 || math.Abs(shot.duration-math.Round(shot.duration)) > 1e-9 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo shot duration must be an integer of at least 1 second", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
duration += shot.duration
|
||||
fmt.Fprintf(&promptBuilder, "shot %d, %ds, %s; ", index+1, int(math.Round(shot.duration)), shot.text)
|
||||
}
|
||||
prompt = strings.TrimSpace(promptBuilder.String())
|
||||
}
|
||||
if prompt == "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo video prompt is required", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if math.Abs(duration-math.Round(duration)) > 1e-9 || duration < 3 || duration > 15 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo duration must be an integer between 3 and 15 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
|
||||
firstFrame, lastFrame, referenceImages := kelingImageInputs(content)
|
||||
if lastFrame != "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports first frame only; last frame is not supported", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
imageCount := len(referenceImages)
|
||||
if firstFrame != "" {
|
||||
imageCount++
|
||||
}
|
||||
if imageCount > 1 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports exactly one first-frame image", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if firstFrame == "" && len(referenceImages) == 1 {
|
||||
firstFrame = referenceImages[0]
|
||||
}
|
||||
isImageToVideo := firstFrame != ""
|
||||
|
||||
resolution := strings.TrimSpace(firstNonEmptyStringValue(body, "resolution", "size"))
|
||||
if resolution == "" {
|
||||
resolution = "720p"
|
||||
}
|
||||
if resolution != "720p" && resolution != "1080p" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo resolution must be 720p or 1080p", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
|
||||
promptLimit := 3072
|
||||
if isImageToVideo {
|
||||
promptLimit = 2500
|
||||
}
|
||||
if utf8.RuneCountInString(prompt) > promptLimit {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("keling 3.0 turbo prompt exceeds %d characters", promptLimit), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"duration": int(math.Round(duration)),
|
||||
"resolution": resolution,
|
||||
}
|
||||
options := map[string]any{
|
||||
"watermark_info": map[string]any{"enabled": boolValue(body, "watermark")},
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
|
||||
options["callback_url"] = callbackURL
|
||||
}
|
||||
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
|
||||
options["external_task_id"] = externalTaskID
|
||||
}
|
||||
|
||||
if isImageToVideo {
|
||||
return map[string]any{
|
||||
"contents": []any{
|
||||
map[string]any{"type": "prompt", "text": prompt},
|
||||
map[string]any{"type": "first_frame", "url": firstFrame},
|
||||
},
|
||||
"settings": settings,
|
||||
"options": options,
|
||||
}, "/image-to-video/kling-3.0-turbo", nil
|
||||
}
|
||||
|
||||
aspectRatio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio"))
|
||||
if aspectRatio == "" || aspectRatio == "adaptive" || aspectRatio == "keep_ratio" {
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
if aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
settings["aspect_ratio"] = aspectRatio
|
||||
return map[string]any{
|
||||
"prompt": prompt,
|
||||
"settings": settings,
|
||||
"options": options,
|
||||
}, "/text-to-video/kling-3.0-turbo", nil
|
||||
}
|
||||
|
||||
func firstKelingPrompt(content []map[string]any) string {
|
||||
for _, item := range content {
|
||||
if stringFromAny(item["type"]) == "text" && stringFromAny(item["role"]) != "shot_prompt" && item["shot_index"] == nil {
|
||||
@@ -822,6 +1072,30 @@ func kelingTaskStatus(result map[string]any) string {
|
||||
return strings.ToLower(strings.TrimSpace(stringFromAny(kelingData(result)["task_status"])))
|
||||
}
|
||||
|
||||
func kelingTaskAPITask(result map[string]any, taskID string) map[string]any {
|
||||
tasks := mapListFromAny(result["data"])
|
||||
for _, task := range tasks {
|
||||
if strings.TrimSpace(stringFromAny(task["id"])) == taskID {
|
||||
return task
|
||||
}
|
||||
}
|
||||
if len(tasks) > 0 {
|
||||
return tasks[0]
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func kelingTaskAPIErrorMessage(candidate store.RuntimeModelCandidate, task map[string]any, result map[string]any) string {
|
||||
message := strings.TrimSpace(stringFromAny(task["message"]))
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(stringFromAny(result["message"]))
|
||||
}
|
||||
if message == "" {
|
||||
message = "keling 3.0 turbo video task failed"
|
||||
}
|
||||
return fmt.Sprintf("Platform:%s,Code:%v,requestId:%s,message:%s", candidate.Provider, result["code"], stringFromAny(result["request_id"]), message)
|
||||
}
|
||||
|
||||
func kelingTaskErrorCode(result map[string]any) string {
|
||||
if code := intFromAny(result["code"]); code != 0 {
|
||||
return fmt.Sprintf("keling_%d", code)
|
||||
@@ -887,6 +1161,42 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
}
|
||||
}
|
||||
|
||||
func kelingTaskAPIVideoSuccessResult(request Request, upstreamTaskID string, task map[string]any, raw map[string]any) map[string]any {
|
||||
outputs := mapListFromAny(task["outputs"])
|
||||
items := make([]any, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
if strings.ToLower(strings.TrimSpace(stringFromAny(output["type"]))) != "video" {
|
||||
continue
|
||||
}
|
||||
videoURL := strings.TrimSpace(stringFromAny(output["url"]))
|
||||
if videoURL == "" {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{"url": videoURL, "video_url": videoURL, "type": "video"}
|
||||
if duration := numericValue(output["duration"], 0); duration > 0 {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(output["watermark_url"])); watermarkURL != "" {
|
||||
item["watermark_url"] = watermarkURL
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
created := intFromAny(task["create_time"])
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": items,
|
||||
"raw": raw,
|
||||
}
|
||||
}
|
||||
|
||||
func kelingVideoProgress(request Request, upstreamTaskID string) []Progress {
|
||||
progress := providerProgress(request)
|
||||
progress = append(progress, Progress{
|
||||
|
||||
Reference in New Issue
Block a user