2025-06-08 21:40:57 +08:00
|
|
|
|
package kling
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-01-28 18:33:14 +08:00
|
|
|
|
"math"
|
2025-06-08 21:40:57 +08:00
|
|
|
|
"net/http"
|
2026-01-28 18:33:14 +08:00
|
|
|
|
"strconv"
|
2025-06-08 21:40:57 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2025-10-14 23:03:17 +08:00
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
2025-10-11 15:30:09 +08:00
|
|
|
|
"github.com/QuantumNous/new-api/model"
|
|
|
|
|
|
|
2025-08-25 18:01:10 +08:00
|
|
|
|
"github.com/samber/lo"
|
|
|
|
|
|
|
2025-06-08 21:40:57 +08:00
|
|
|
|
"github.com/gin-gonic/gin"
|
2025-10-09 14:17:49 +08:00
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
2025-06-08 21:40:57 +08:00
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
|
|
|
2025-10-11 15:30:09 +08:00
|
|
|
|
"github.com/QuantumNous/new-api/constant"
|
|
|
|
|
|
"github.com/QuantumNous/new-api/dto"
|
|
|
|
|
|
"github.com/QuantumNous/new-api/relay/channel"
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
|
2025-10-11 15:30:09 +08:00
|
|
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
|
|
|
|
"github.com/QuantumNous/new-api/service"
|
2025-06-08 21:40:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
// Request / Response structures
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
2025-07-14 14:16:12 +08:00
|
|
|
|
type TrajectoryPoint struct {
|
|
|
|
|
|
X int `json:"x"`
|
|
|
|
|
|
Y int `json:"y"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DynamicMask struct {
|
|
|
|
|
|
Mask string `json:"mask,omitempty"`
|
|
|
|
|
|
Trajectories []TrajectoryPoint `json:"trajectories,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type CameraConfig struct {
|
|
|
|
|
|
Horizontal float64 `json:"horizontal,omitempty"`
|
|
|
|
|
|
Vertical float64 `json:"vertical,omitempty"`
|
|
|
|
|
|
Pan float64 `json:"pan,omitempty"`
|
|
|
|
|
|
Tilt float64 `json:"tilt,omitempty"`
|
|
|
|
|
|
Roll float64 `json:"roll,omitempty"`
|
|
|
|
|
|
Zoom float64 `json:"zoom,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type CameraControl struct {
|
|
|
|
|
|
Type string `json:"type,omitempty"`
|
|
|
|
|
|
Config *CameraConfig `json:"config,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-08 21:40:57 +08:00
|
|
|
|
type requestPayload struct {
|
2025-07-14 14:16:12 +08:00
|
|
|
|
Prompt string `json:"prompt,omitempty"`
|
|
|
|
|
|
Image string `json:"image,omitempty"`
|
|
|
|
|
|
ImageTail string `json:"image_tail,omitempty"`
|
|
|
|
|
|
NegativePrompt string `json:"negative_prompt,omitempty"`
|
|
|
|
|
|
Mode string `json:"mode,omitempty"`
|
|
|
|
|
|
Duration string `json:"duration,omitempty"`
|
|
|
|
|
|
AspectRatio string `json:"aspect_ratio,omitempty"`
|
|
|
|
|
|
ModelName string `json:"model_name,omitempty"`
|
|
|
|
|
|
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
|
|
|
|
|
|
CfgScale float64 `json:"cfg_scale,omitempty"`
|
|
|
|
|
|
StaticMask string `json:"static_mask,omitempty"`
|
|
|
|
|
|
DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"`
|
|
|
|
|
|
CameraControl *CameraControl `json:"camera_control,omitempty"`
|
|
|
|
|
|
CallbackUrl string `json:"callback_url,omitempty"`
|
|
|
|
|
|
ExternalTaskId string `json:"external_task_id,omitempty"`
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type responsePayload struct {
|
2025-06-20 15:50:00 +08:00
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
|
Message string `json:"message"`
|
2025-07-21 15:06:26 +08:00
|
|
|
|
TaskId string `json:"task_id"`
|
2025-06-20 15:50:00 +08:00
|
|
|
|
RequestId string `json:"request_id"`
|
|
|
|
|
|
Data struct {
|
|
|
|
|
|
TaskId string `json:"task_id"`
|
|
|
|
|
|
TaskStatus string `json:"task_status"`
|
|
|
|
|
|
TaskStatusMsg string `json:"task_status_msg"`
|
2026-01-28 18:33:14 +08:00
|
|
|
|
TaskInfo struct {
|
|
|
|
|
|
ExternalTaskId string `json:"external_task_id"`
|
|
|
|
|
|
} `json:"task_info"`
|
|
|
|
|
|
WatermarkInfo struct {
|
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
|
} `json:"watermark_info"`
|
|
|
|
|
|
TaskResult struct {
|
2025-06-20 15:50:00 +08:00
|
|
|
|
Videos []struct {
|
2026-01-28 18:33:14 +08:00
|
|
|
|
Id string `json:"id"`
|
|
|
|
|
|
Url string `json:"url"`
|
|
|
|
|
|
WatermarkUrl string `json:"watermark_url"`
|
|
|
|
|
|
Duration string `json:"duration"`
|
2025-06-20 15:50:00 +08:00
|
|
|
|
} `json:"videos"`
|
2026-01-28 18:33:14 +08:00
|
|
|
|
Images []struct {
|
|
|
|
|
|
Index int `json:"index"`
|
|
|
|
|
|
Url string `json:"url"`
|
|
|
|
|
|
WatermarkUrl string `json:"watermark_url"`
|
|
|
|
|
|
} `json:"images"`
|
2025-06-20 15:50:00 +08:00
|
|
|
|
} `json:"task_result"`
|
2026-01-28 18:33:14 +08:00
|
|
|
|
CreatedAt int64 `json:"created_at"`
|
|
|
|
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
|
|
|
|
FinalUnitDeduction string `json:"final_unit_deduction"`
|
2025-06-08 21:40:57 +08:00
|
|
|
|
} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
// Adaptor implementation
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
|
|
type TaskAdaptor struct {
|
2026-02-10 21:15:09 +08:00
|
|
|
|
taskcommon.BaseBilling
|
2025-06-08 21:40:57 +08:00
|
|
|
|
ChannelType int
|
2025-07-21 15:06:26 +08:00
|
|
|
|
apiKey string
|
2025-06-08 21:40:57 +08:00
|
|
|
|
baseURL string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
2025-06-08 21:40:57 +08:00
|
|
|
|
a.ChannelType = info.ChannelType
|
2025-08-14 21:10:04 +08:00
|
|
|
|
a.baseURL = info.ChannelBaseUrl
|
2025-07-21 15:06:26 +08:00
|
|
|
|
a.apiKey = info.ApiKey
|
2025-06-08 21:40:57 +08:00
|
|
|
|
|
2025-06-21 20:50:53 +08:00
|
|
|
|
// apiKey format: "access_key|secret_key"
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
2025-09-12 21:52:32 +08:00
|
|
|
|
// Use the standard validation method for TaskSubmitReq
|
|
|
|
|
|
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BuildRequestURL constructs the upstream URL.
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
2025-06-27 22:43:01 +08:00
|
|
|
|
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
2025-09-16 16:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
if isNewAPIRelay(info.ApiKey) {
|
|
|
|
|
|
return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-23 21:22:01 +08:00
|
|
|
|
return fmt.Sprintf("%s%s", a.baseURL, path), nil
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BuildRequestHeader sets required headers.
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
2025-06-08 21:40:57 +08:00
|
|
|
|
token, err := a.createJWTToken()
|
|
|
|
|
|
if err != nil {
|
2025-06-17 13:37:07 +08:00
|
|
|
|
return fmt.Errorf("failed to create JWT token: %w", err)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("User-Agent", "kling-sdk/1.0")
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BuildRequestBody converts request into Kling specific format.
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
2025-06-20 15:50:00 +08:00
|
|
|
|
v, exists := c.Get("task_request")
|
2025-06-08 21:40:57 +08:00
|
|
|
|
if !exists {
|
|
|
|
|
|
return nil, fmt.Errorf("request not found in context")
|
|
|
|
|
|
}
|
2025-09-12 21:52:32 +08:00
|
|
|
|
req := v.(relaycommon.TaskSubmitReq)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
|
feat(task): add model redirection, per-call billing, and multipart retry fix for async tasks
1. Async task model redirection (aligned with sync tasks):
- Integrate ModelMappedHelper in RelayTaskSubmit after model name
determination, populating OriginModelName / UpstreamModelName on RelayInfo.
- All task adaptors now send UpstreamModelName to upstream providers:
- Gemini & Vertex: BuildRequestURL uses UpstreamModelName.
- Doubao & Ali: BuildRequestBody conditionally overwrites body.Model.
- Vidu, Kling, Hailuo, Jimeng: convertToRequestPayload accepts RelayInfo
and unconditionally uses info.UpstreamModelName.
- Sora: BuildRequestBody parses JSON and multipart bodies to replace
the "model" field with UpstreamModelName.
- Frontend log visibility: LogTaskConsumption and taskBillingOther now
emit is_model_mapped / upstream_model_name in the "other" JSON field.
- Billing safety: RecalculateTaskQuotaByTokens reads model name from
BillingContext.OriginModelName (via taskModelName) instead of
task.Data["model"], preventing billing leaks from upstream model names.
2. Per-call billing (TaskPricePatches lifecycle):
- Rename TaskBillingContext.ModelName → OriginModelName; add PerCallBilling
bool field, populated from TaskPricePatches at submission time.
- settleTaskBillingOnComplete short-circuits when PerCallBilling is true,
skipping both adaptor adjustments and token-based recalculation.
- Remove ModelName from TaskSubmitResult; use relayInfo.OriginModelName
consistently in controller/relay.go for billing context and logging.
3. Multipart retry boundary mismatch fix:
- Root cause: after Sora (or OpenAI audio) rebuilds a multipart body with a
new boundary and overwrites c.Request.Header["Content-Type"], subsequent
calls to ParseMultipartFormReusable on retry would parse the cached
original body with the wrong boundary, causing "NextPart: EOF".
- Fix: ParseMultipartFormReusable now caches the original Content-Type in
gin context key "_original_multipart_ct" on first call and reuses it for
all subsequent parses, making multipart parsing retry-safe globally.
- Sora adaptor reverted to the standard pattern (direct header set/get),
which is now safe thanks to the root fix.
4. Tests:
- task_billing_test.go: update makeTask to use OriginModelName; add
PerCallBilling settlement tests (skip adaptor adjust, skip token recalc);
add non-per-call adaptor adjustment test with refund verification.
2026-02-22 15:32:33 +08:00
|
|
|
|
body, err := a.convertToRequestPayload(&req, info)
|
2025-06-23 21:22:01 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2025-07-18 16:54:16 +08:00
|
|
|
|
if body.Image == "" && body.ImageTail == "" {
|
|
|
|
|
|
c.Set("action", constant.TaskActionTextGenerate)
|
|
|
|
|
|
}
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
data, err := common.Marshal(body)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return bytes.NewReader(data), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DoRequest delegates to common helper.
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
2025-06-23 21:22:01 +08:00
|
|
|
|
if action := c.GetString("action"); action != "" {
|
|
|
|
|
|
info.Action = action
|
|
|
|
|
|
}
|
2025-06-08 21:40:57 +08:00
|
|
|
|
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DoResponse handles upstream response, returns taskID etc.
|
2025-08-25 18:01:10 +08:00
|
|
|
|
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
2025-06-08 21:40:57 +08:00
|
|
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var kResp responsePayload
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
err = common.Unmarshal(responseBody, &kResp)
|
2025-07-21 15:06:26 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
taskErr = service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-07-21 15:06:26 +08:00
|
|
|
|
if kResp.Code != 0 {
|
2025-12-16 17:00:19 +08:00
|
|
|
|
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("%s", kResp.Message), "task_failed", http.StatusBadRequest)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-10-13 11:45:45 +08:00
|
|
|
|
ov := dto.NewOpenAIVideo()
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
ov.ID = info.PublicTaskID
|
|
|
|
|
|
ov.TaskID = info.PublicTaskID
|
2025-10-11 14:37:19 +08:00
|
|
|
|
ov.CreatedAt = time.Now().Unix()
|
|
|
|
|
|
ov.Model = info.OriginModelName
|
|
|
|
|
|
c.JSON(http.StatusOK, ov)
|
2025-07-21 15:06:26 +08:00
|
|
|
|
return kResp.Data.TaskId, responseBody, nil
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FetchTask fetch task status
|
2025-12-09 11:15:27 +08:00
|
|
|
|
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
|
2025-06-08 21:40:57 +08:00
|
|
|
|
taskID, ok := body["task_id"].(string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return nil, fmt.Errorf("invalid task_id")
|
|
|
|
|
|
}
|
2025-06-23 21:22:01 +08:00
|
|
|
|
action, ok := body["action"].(string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return nil, fmt.Errorf("invalid action")
|
|
|
|
|
|
}
|
2025-06-27 22:43:01 +08:00
|
|
|
|
path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
2025-06-23 21:22:01 +08:00
|
|
|
|
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
|
2025-09-16 16:28:27 +08:00
|
|
|
|
if isNewAPIRelay(key) {
|
|
|
|
|
|
url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID)
|
|
|
|
|
|
}
|
2025-06-08 21:40:57 +08:00
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
token, err := a.createJWTTokenWithKey(key)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
token = key
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("User-Agent", "kling-sdk/1.0")
|
|
|
|
|
|
|
2025-12-09 11:15:27 +08:00
|
|
|
|
client, err := service.GetHttpClientWithProxy(proxy)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("new proxy http client failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return client.Do(req)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *TaskAdaptor) GetModelList() []string {
|
|
|
|
|
|
return []string{"kling-v1", "kling-v1-6", "kling-v2-master"}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *TaskAdaptor) GetChannelName() string {
|
|
|
|
|
|
return "kling"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
// helpers
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
feat(task): add model redirection, per-call billing, and multipart retry fix for async tasks
1. Async task model redirection (aligned with sync tasks):
- Integrate ModelMappedHelper in RelayTaskSubmit after model name
determination, populating OriginModelName / UpstreamModelName on RelayInfo.
- All task adaptors now send UpstreamModelName to upstream providers:
- Gemini & Vertex: BuildRequestURL uses UpstreamModelName.
- Doubao & Ali: BuildRequestBody conditionally overwrites body.Model.
- Vidu, Kling, Hailuo, Jimeng: convertToRequestPayload accepts RelayInfo
and unconditionally uses info.UpstreamModelName.
- Sora: BuildRequestBody parses JSON and multipart bodies to replace
the "model" field with UpstreamModelName.
- Frontend log visibility: LogTaskConsumption and taskBillingOther now
emit is_model_mapped / upstream_model_name in the "other" JSON field.
- Billing safety: RecalculateTaskQuotaByTokens reads model name from
BillingContext.OriginModelName (via taskModelName) instead of
task.Data["model"], preventing billing leaks from upstream model names.
2. Per-call billing (TaskPricePatches lifecycle):
- Rename TaskBillingContext.ModelName → OriginModelName; add PerCallBilling
bool field, populated from TaskPricePatches at submission time.
- settleTaskBillingOnComplete short-circuits when PerCallBilling is true,
skipping both adaptor adjustments and token-based recalculation.
- Remove ModelName from TaskSubmitResult; use relayInfo.OriginModelName
consistently in controller/relay.go for billing context and logging.
3. Multipart retry boundary mismatch fix:
- Root cause: after Sora (or OpenAI audio) rebuilds a multipart body with a
new boundary and overwrites c.Request.Header["Content-Type"], subsequent
calls to ParseMultipartFormReusable on retry would parse the cached
original body with the wrong boundary, causing "NextPart: EOF".
- Fix: ParseMultipartFormReusable now caches the original Content-Type in
gin context key "_original_multipart_ct" on first call and reuses it for
all subsequent parses, making multipart parsing retry-safe globally.
- Sora adaptor reverted to the standard pattern (direct header set/get),
which is now safe thanks to the root fix.
4. Tests:
- task_billing_test.go: update makeTask to use OriginModelName; add
PerCallBilling settlement tests (skip adaptor adjust, skip token recalc);
add non-per-call adaptor adjustment test with refund verification.
2026-02-22 15:32:33 +08:00
|
|
|
|
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*requestPayload, error) {
|
2025-06-23 21:22:01 +08:00
|
|
|
|
r := requestPayload{
|
2025-07-14 14:16:12 +08:00
|
|
|
|
Prompt: req.Prompt,
|
|
|
|
|
|
Image: req.Image,
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
Mode: taskcommon.DefaultString(req.Mode, "std"),
|
|
|
|
|
|
Duration: fmt.Sprintf("%d", taskcommon.DefaultInt(req.Duration, 5)),
|
2025-07-14 14:16:12 +08:00
|
|
|
|
AspectRatio: a.getAspectRatio(req.Size),
|
feat(task): add model redirection, per-call billing, and multipart retry fix for async tasks
1. Async task model redirection (aligned with sync tasks):
- Integrate ModelMappedHelper in RelayTaskSubmit after model name
determination, populating OriginModelName / UpstreamModelName on RelayInfo.
- All task adaptors now send UpstreamModelName to upstream providers:
- Gemini & Vertex: BuildRequestURL uses UpstreamModelName.
- Doubao & Ali: BuildRequestBody conditionally overwrites body.Model.
- Vidu, Kling, Hailuo, Jimeng: convertToRequestPayload accepts RelayInfo
and unconditionally uses info.UpstreamModelName.
- Sora: BuildRequestBody parses JSON and multipart bodies to replace
the "model" field with UpstreamModelName.
- Frontend log visibility: LogTaskConsumption and taskBillingOther now
emit is_model_mapped / upstream_model_name in the "other" JSON field.
- Billing safety: RecalculateTaskQuotaByTokens reads model name from
BillingContext.OriginModelName (via taskModelName) instead of
task.Data["model"], preventing billing leaks from upstream model names.
2. Per-call billing (TaskPricePatches lifecycle):
- Rename TaskBillingContext.ModelName → OriginModelName; add PerCallBilling
bool field, populated from TaskPricePatches at submission time.
- settleTaskBillingOnComplete short-circuits when PerCallBilling is true,
skipping both adaptor adjustments and token-based recalculation.
- Remove ModelName from TaskSubmitResult; use relayInfo.OriginModelName
consistently in controller/relay.go for billing context and logging.
3. Multipart retry boundary mismatch fix:
- Root cause: after Sora (or OpenAI audio) rebuilds a multipart body with a
new boundary and overwrites c.Request.Header["Content-Type"], subsequent
calls to ParseMultipartFormReusable on retry would parse the cached
original body with the wrong boundary, causing "NextPart: EOF".
- Fix: ParseMultipartFormReusable now caches the original Content-Type in
gin context key "_original_multipart_ct" on first call and reuses it for
all subsequent parses, making multipart parsing retry-safe globally.
- Sora adaptor reverted to the standard pattern (direct header set/get),
which is now safe thanks to the root fix.
4. Tests:
- task_billing_test.go: update makeTask to use OriginModelName; add
PerCallBilling settlement tests (skip adaptor adjust, skip token recalc);
add non-per-call adaptor adjustment test with refund verification.
2026-02-22 15:32:33 +08:00
|
|
|
|
ModelName: info.UpstreamModelName,
|
|
|
|
|
|
Model: info.UpstreamModelName,
|
2025-07-14 14:16:12 +08:00
|
|
|
|
CfgScale: 0.5,
|
|
|
|
|
|
StaticMask: "",
|
|
|
|
|
|
DynamicMasks: []DynamicMask{},
|
|
|
|
|
|
CameraControl: nil,
|
|
|
|
|
|
CallbackUrl: "",
|
|
|
|
|
|
ExternalTaskId: "",
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
2025-06-23 21:22:01 +08:00
|
|
|
|
if r.ModelName == "" {
|
2025-06-08 21:40:57 +08:00
|
|
|
|
r.ModelName = "kling-v1"
|
feat(task): add model redirection, per-call billing, and multipart retry fix for async tasks
1. Async task model redirection (aligned with sync tasks):
- Integrate ModelMappedHelper in RelayTaskSubmit after model name
determination, populating OriginModelName / UpstreamModelName on RelayInfo.
- All task adaptors now send UpstreamModelName to upstream providers:
- Gemini & Vertex: BuildRequestURL uses UpstreamModelName.
- Doubao & Ali: BuildRequestBody conditionally overwrites body.Model.
- Vidu, Kling, Hailuo, Jimeng: convertToRequestPayload accepts RelayInfo
and unconditionally uses info.UpstreamModelName.
- Sora: BuildRequestBody parses JSON and multipart bodies to replace
the "model" field with UpstreamModelName.
- Frontend log visibility: LogTaskConsumption and taskBillingOther now
emit is_model_mapped / upstream_model_name in the "other" JSON field.
- Billing safety: RecalculateTaskQuotaByTokens reads model name from
BillingContext.OriginModelName (via taskModelName) instead of
task.Data["model"], preventing billing leaks from upstream model names.
2. Per-call billing (TaskPricePatches lifecycle):
- Rename TaskBillingContext.ModelName → OriginModelName; add PerCallBilling
bool field, populated from TaskPricePatches at submission time.
- settleTaskBillingOnComplete short-circuits when PerCallBilling is true,
skipping both adaptor adjustments and token-based recalculation.
- Remove ModelName from TaskSubmitResult; use relayInfo.OriginModelName
consistently in controller/relay.go for billing context and logging.
3. Multipart retry boundary mismatch fix:
- Root cause: after Sora (or OpenAI audio) rebuilds a multipart body with a
new boundary and overwrites c.Request.Header["Content-Type"], subsequent
calls to ParseMultipartFormReusable on retry would parse the cached
original body with the wrong boundary, causing "NextPart: EOF".
- Fix: ParseMultipartFormReusable now caches the original Content-Type in
gin context key "_original_multipart_ct" on first call and reuses it for
all subsequent parses, making multipart parsing retry-safe globally.
- Sora adaptor reverted to the standard pattern (direct header set/get),
which is now safe thanks to the root fix.
4. Tests:
- task_billing_test.go: update makeTask to use OriginModelName; add
PerCallBilling settlement tests (skip adaptor adjust, skip token recalc);
add non-per-call adaptor adjustment test with refund verification.
2026-02-22 15:32:33 +08:00
|
|
|
|
r.Model = "kling-v1"
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil {
|
2025-06-23 21:22:01 +08:00
|
|
|
|
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
|
|
|
|
|
}
|
|
|
|
|
|
return &r, nil
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *TaskAdaptor) getAspectRatio(size string) string {
|
|
|
|
|
|
switch size {
|
|
|
|
|
|
case "1024x1024", "512x512":
|
|
|
|
|
|
return "1:1"
|
|
|
|
|
|
case "1280x720", "1920x1080":
|
|
|
|
|
|
return "16:9"
|
|
|
|
|
|
case "720x1280", "1080x1920":
|
|
|
|
|
|
return "9:16"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "1:1"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
// JWT helpers
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
|
|
func (a *TaskAdaptor) createJWTToken() (string, error) {
|
2025-07-21 15:06:26 +08:00
|
|
|
|
return a.createJWTTokenWithKey(a.apiKey)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
|
2025-09-16 16:28:27 +08:00
|
|
|
|
if isNewAPIRelay(apiKey) {
|
|
|
|
|
|
return apiKey, nil // new api relay
|
|
|
|
|
|
}
|
2025-07-21 15:06:26 +08:00
|
|
|
|
keyParts := strings.Split(apiKey, "|")
|
2025-09-16 16:28:27 +08:00
|
|
|
|
if len(keyParts) != 2 {
|
|
|
|
|
|
return "", errors.New("invalid api_key, required format is accessKey|secretKey")
|
|
|
|
|
|
}
|
2025-07-21 15:06:26 +08:00
|
|
|
|
accessKey := strings.TrimSpace(keyParts[0])
|
|
|
|
|
|
if len(keyParts) == 1 {
|
|
|
|
|
|
return accessKey, nil
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
2025-07-21 15:06:26 +08:00
|
|
|
|
secretKey := strings.TrimSpace(keyParts[1])
|
2025-06-08 21:40:57 +08:00
|
|
|
|
now := time.Now().Unix()
|
|
|
|
|
|
claims := jwt.MapClaims{
|
|
|
|
|
|
"iss": accessKey,
|
|
|
|
|
|
"exp": now + 1800, // 30 minutes
|
|
|
|
|
|
"nbf": now - 5,
|
|
|
|
|
|
}
|
|
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
|
|
token.Header["typ"] = "JWT"
|
|
|
|
|
|
return token.SignedString([]byte(secretKey))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-20 15:50:00 +08:00
|
|
|
|
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
2025-07-21 15:06:26 +08:00
|
|
|
|
taskInfo := &relaycommon.TaskInfo{}
|
2025-06-20 15:50:00 +08:00
|
|
|
|
resPayload := responsePayload{}
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
err := common.Unmarshal(respBody, &resPayload)
|
2025-06-20 15:50:00 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, errors.Wrap(err, "failed to unmarshal response body")
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
2025-06-20 15:50:00 +08:00
|
|
|
|
taskInfo.Code = resPayload.Code
|
|
|
|
|
|
taskInfo.TaskID = resPayload.Data.TaskId
|
2025-12-26 16:35:01 +08:00
|
|
|
|
taskInfo.Reason = resPayload.Data.TaskStatusMsg
|
2025-06-20 15:50:00 +08:00
|
|
|
|
//任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败)
|
|
|
|
|
|
status := resPayload.Data.TaskStatus
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case "submitted":
|
|
|
|
|
|
taskInfo.Status = model.TaskStatusSubmitted
|
|
|
|
|
|
case "processing":
|
|
|
|
|
|
taskInfo.Status = model.TaskStatusInProgress
|
|
|
|
|
|
case "succeed":
|
|
|
|
|
|
taskInfo.Status = model.TaskStatusSuccess
|
2026-01-28 18:33:14 +08:00
|
|
|
|
if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 {
|
|
|
|
|
|
video := videos[0]
|
|
|
|
|
|
taskInfo.Url = video.Url
|
|
|
|
|
|
}
|
|
|
|
|
|
if tokens, err := strconv.ParseFloat(resPayload.Data.FinalUnitDeduction, 64); err == nil {
|
|
|
|
|
|
rounded := int(math.Ceil(tokens))
|
|
|
|
|
|
if rounded > 0 {
|
|
|
|
|
|
taskInfo.CompletionTokens = rounded
|
|
|
|
|
|
taskInfo.TotalTokens = rounded
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-06-20 15:50:00 +08:00
|
|
|
|
case "failed":
|
|
|
|
|
|
taskInfo.Status = model.TaskStatusFailure
|
|
|
|
|
|
default:
|
|
|
|
|
|
return nil, fmt.Errorf("unknown task status: %s", status)
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
2025-06-20 15:50:00 +08:00
|
|
|
|
return taskInfo, nil
|
2025-06-08 21:40:57 +08:00
|
|
|
|
}
|
2025-09-16 16:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
func isNewAPIRelay(apiKey string) bool {
|
|
|
|
|
|
return strings.HasPrefix(apiKey, "sk-")
|
|
|
|
|
|
}
|
2025-10-11 00:05:56 +08:00
|
|
|
|
|
2025-10-14 23:03:17 +08:00
|
|
|
|
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
|
2025-10-11 00:05:56 +08:00
|
|
|
|
var klingResp responsePayload
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
if err := common.Unmarshal(originTask.Data, &klingResp); err != nil {
|
2025-10-11 00:05:56 +08:00
|
|
|
|
return nil, errors.Wrap(err, "unmarshal kling task data failed")
|
|
|
|
|
|
}
|
2025-10-11 14:37:19 +08:00
|
|
|
|
|
2025-10-13 11:45:45 +08:00
|
|
|
|
openAIVideo := dto.NewOpenAIVideo()
|
2025-10-11 14:37:19 +08:00
|
|
|
|
openAIVideo.ID = originTask.TaskID
|
|
|
|
|
|
openAIVideo.Status = originTask.Status.ToVideoStatus()
|
2025-10-11 13:52:37 +08:00
|
|
|
|
openAIVideo.SetProgressStr(originTask.Progress)
|
2025-10-11 14:37:19 +08:00
|
|
|
|
openAIVideo.CreatedAt = klingResp.Data.CreatedAt
|
|
|
|
|
|
openAIVideo.CompletedAt = klingResp.Data.UpdatedAt
|
|
|
|
|
|
|
2025-10-11 00:05:56 +08:00
|
|
|
|
if len(klingResp.Data.TaskResult.Videos) > 0 {
|
|
|
|
|
|
video := klingResp.Data.TaskResult.Videos[0]
|
|
|
|
|
|
if video.Url != "" {
|
2025-10-11 14:37:19 +08:00
|
|
|
|
openAIVideo.SetMetadata("url", video.Url)
|
2025-10-11 00:05:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
if video.Duration != "" {
|
|
|
|
|
|
openAIVideo.Seconds = video.Duration
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if klingResp.Code != 0 && klingResp.Message != "" {
|
2025-10-13 11:45:45 +08:00
|
|
|
|
openAIVideo.Error = &dto.OpenAIVideoError{
|
2025-10-11 00:05:56 +08:00
|
|
|
|
Message: klingResp.Message,
|
|
|
|
|
|
Code: fmt.Sprintf("%d", klingResp.Code),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-06 16:32:31 +08:00
|
|
|
|
|
|
|
|
|
|
// https://app.klingai.com/cn/dev/document-api/apiReference/model/textToVideo
|
|
|
|
|
|
if data := klingResp.Data; data.TaskStatus == "failed" {
|
|
|
|
|
|
openAIVideo.Error = &dto.OpenAIVideoError{
|
|
|
|
|
|
Message: data.TaskStatusMsg,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
refactor(task): extract billing and polling logic from controller to service layer
Restructure the task relay system for better separation of concerns:
- Extract task billing into service/task_billing.go with unified settlement flow
- Move task polling loop from controller to service/task_polling.go (supports Suno + video platforms)
- Split RelayTask into fetch/submit paths with dedicated retry logic (taskSubmitWithRetry)
- Add TaskDto, TaskResponse generics, and FetchReq to dto/task.go
- Add taskcommon/helpers.go for shared task adaptor utilities
- Remove controller/task_video.go (logic consolidated into service layer)
- Update all task adaptors (ali, doubao, gemini, hailuo, jimeng, kling, sora, suno, vertex, vidu)
- Simplify frontend task logs to use new TaskDto response format
2026-02-10 20:40:33 +08:00
|
|
|
|
return common.Marshal(openAIVideo)
|
2025-10-11 00:05:56 +08:00
|
|
|
|
}
|