feat(model):添加对阿里万象和可灵模型的支持
This commit is contained in:
1 parent
5084406687
commit
d7ef88fa6e
5 files changed
+604
-55
No files matched your search
@@ -9,6 +9,7 @@ const (
|
||||
TaskPlatformSuno TaskPlatform = "suno"
|
||||
TaskPlatformMidjourney TaskPlatform = "midjourney"
|
||||
TaskPlatformVolcengine TaskPlatform = "volcengine"
|
||||
TaskPlatformAli TaskPlatform = "ali"
|
||||
)
|
||||
|
||||
// TaskAction 异步任务动作类型
|
||||
@@ -81,6 +82,8 @@ func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) {
|
||||
return TaskPlatformMidjourney, true
|
||||
case ProviderVolcengine:
|
||||
return TaskPlatformVolcengine, true
|
||||
case ProviderAli:
|
||||
return TaskPlatformAli, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
"github.com/qianfree/team-api/relay/taskchannel"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/ali"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/kling"
|
||||
"github.com/qianfree/team-api/relay/taskchannel/midjourney"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/sora"
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
"github.com/qianfree/team-api/relay/taskchannel"
|
||||
)
|
||||
|
||||
func init() {
|
||||
taskchannel.Register(constant.ProviderAli, func() common.TaskAdaptor {
|
||||
return &AliVideoAdaptor{}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 请求/响应结构体 ====================
|
||||
|
||||
// dashScopeVideoRequest DashScope 视频生成请求
|
||||
type dashScopeVideoRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashScopeVideoInput `json:"input"`
|
||||
Parameters *dashScopeVideoParams `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type dashScopeVideoInput struct {
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
AudioURL string `json:"audio_url,omitempty"`
|
||||
}
|
||||
|
||||
type dashScopeVideoParams struct {
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeSubmitResponse DashScope 异步提交响应
|
||||
type dashScopeSubmitResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"output"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// dashScopeTaskResponse DashScope 异步任务查询响应
|
||||
type dashScopeTaskResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
Duration float64 `json:"duration"`
|
||||
VideoCount int `json:"video_count"`
|
||||
OutputVideoDuration int `json:"output_video_duration"`
|
||||
SR int `json:"SR"`
|
||||
Ratio string `json:"ratio"`
|
||||
} `json:"usage"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// ==================== Adaptor 实现 ====================
|
||||
|
||||
type AliVideoAdaptor struct {
|
||||
info *common.RelayInfo
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) Init(info *common.RelayInfo) {
|
||||
a.info = info
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"}
|
||||
}
|
||||
if _, ok := req["model"]; !ok {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
var req map[string]any
|
||||
if json.Unmarshal(body, &req) != nil {
|
||||
return ratios
|
||||
}
|
||||
// duration 影响计费,传递给计费引擎
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["duration"].(float64); ok && v > 0 {
|
||||
ratios["duration"] = v
|
||||
}
|
||||
}
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d > 0 {
|
||||
ratios["duration"] = float64(d)
|
||||
}
|
||||
}
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
return baseURL + "/api/v1/services/aigc/video-generation/video-synthesis", nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error {
|
||||
header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey)
|
||||
header.Set("Content-Type", "application/json")
|
||||
header.Set("Accept", "application/json")
|
||||
header.Set("X-DashScope-Async", "enable")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return strings.NewReader(string(body)), nil
|
||||
}
|
||||
|
||||
dsReq := dashScopeVideoRequest{
|
||||
Input: dashScopeVideoInput{},
|
||||
}
|
||||
|
||||
// 模型名
|
||||
if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" {
|
||||
dsReq.Model = info.ChannelMeta.UpstreamModelName
|
||||
} else if m, ok := req["model"].(string); ok {
|
||||
dsReq.Model = m
|
||||
}
|
||||
|
||||
// prompt → input.prompt
|
||||
if v, ok := req["prompt"].(string); ok {
|
||||
dsReq.Input.Prompt = v
|
||||
}
|
||||
|
||||
// 从 metadata 提取参数
|
||||
params := &dashScopeVideoParams{}
|
||||
hasParams := false
|
||||
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["negative_prompt"].(string); ok {
|
||||
dsReq.Input.NegativePrompt = v
|
||||
}
|
||||
if v, ok := metadata["audio_url"].(string); ok {
|
||||
dsReq.Input.AudioURL = v
|
||||
}
|
||||
|
||||
// wan2.7+ 使用 resolution + ratio
|
||||
if v, ok := metadata["resolution"].(string); ok {
|
||||
params.Resolution = v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["ratio"].(string); ok {
|
||||
params.Ratio = v
|
||||
hasParams = true
|
||||
}
|
||||
// wan2.6 及以下使用 size
|
||||
if v, ok := metadata["size"].(string); ok {
|
||||
params.Size = strings.ReplaceAll(v, "x", "*")
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["duration"].(float64); ok && v > 0 {
|
||||
d := int(v)
|
||||
params.Duration = &d
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["prompt_extend"].(bool); ok {
|
||||
params.PromptExtend = &v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["watermark"].(bool); ok {
|
||||
params.Watermark = &v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["seed"].(float64); ok {
|
||||
s := int(v)
|
||||
params.Seed = &s
|
||||
hasParams = true
|
||||
}
|
||||
}
|
||||
|
||||
// seconds 字段映射到 duration
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d > 0 {
|
||||
params.Duration = &d
|
||||
hasParams = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasParams {
|
||||
dsReq.Parameters = params
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dsReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal dashscope video request: %w", err)
|
||||
}
|
||||
return strings.NewReader(string(data)), nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
url, err := a.BuildRequestURL(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if err := a.BuildRequestHeader(req.Header, info); err != nil {
|
||||
return nil, fmt.Errorf("setup header: %w", err)
|
||||
}
|
||||
client := &http.Client{Timeout: 120 * 1e9}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"}
|
||||
}
|
||||
|
||||
// DashScope 错误时可能在顶层返回 code + message
|
||||
var errResp dashScopeSubmitResponse
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Code != "" {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: errResp.Message,
|
||||
ErrCode: errResp.Code,
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
var result dashScopeSubmitResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"}
|
||||
}
|
||||
|
||||
if result.Output.TaskID == "" {
|
||||
// DashScope 有时在 200 响应中返回错误
|
||||
if result.Output.Code != "" {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: result.Output.Message,
|
||||
ErrCode: result.Output.Code,
|
||||
}
|
||||
}
|
||||
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"}
|
||||
}
|
||||
|
||||
return result.Output.TaskID, body, nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) {
|
||||
var data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
if err := json.Unmarshal(taskData, &data); err != nil {
|
||||
return nil, fmt.Errorf("ali: invalid task data: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/tasks/%s", strings.TrimRight(baseURL, "/"), data.TaskID)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * 1e9}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
var resp dashScopeTaskResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("ali: parse task result: %w", err)
|
||||
}
|
||||
|
||||
info := &common.TaskInfo{Data: body}
|
||||
|
||||
switch resp.Output.TaskStatus {
|
||||
case "PENDING":
|
||||
info.Status = common.TaskStatusSubmitted
|
||||
info.Progress = "10%"
|
||||
case "RUNNING":
|
||||
info.Status = common.TaskStatusInProgress
|
||||
info.Progress = "50%"
|
||||
case "SUCCEEDED":
|
||||
info.Status = common.TaskStatusSuccess
|
||||
info.Progress = "100%"
|
||||
info.ResultURL = resp.Output.VideoURL
|
||||
case "FAILED":
|
||||
info.Status = common.TaskStatusFailure
|
||||
info.FailReason = resp.Output.Message
|
||||
case "CANCELED", "UNKNOWN":
|
||||
info.Status = common.TaskStatusFailure
|
||||
info.FailReason = fmt.Sprintf("task status: %s", resp.Output.TaskStatus)
|
||||
default:
|
||||
info.Status = common.TaskStatusSubmitted
|
||||
info.Progress = "10%"
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *AliVideoAdaptor) GetChannelName() string {
|
||||
return channelName
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ali
|
||||
|
||||
// ModelList 阿里云 DashScope 支持视频生成的模型列表
|
||||
var ModelList = []string{
|
||||
"wan2.7-t2v-2026-04-25",
|
||||
"wan2.6-t2v",
|
||||
"wan2.6-t2v-us",
|
||||
"wan2.5-t2v-preview",
|
||||
"wan2.2-t2v-plus",
|
||||
"wanx2.1-t2v-turbo",
|
||||
"wanx2.1-t2v-plus",
|
||||
}
|
||||
|
||||
const channelName = "AliVideo"
|
||||
@@ -15,33 +15,118 @@ import (
|
||||
|
||||
const channelName = "Kling"
|
||||
|
||||
// ModelList 可灵视频生成模型列表
|
||||
var ModelList = []string{
|
||||
"kling-v1",
|
||||
"kling-v1-5",
|
||||
"kling-v1-6",
|
||||
"kling-v2-master",
|
||||
"kling-v2-1-master",
|
||||
"kling-v2-5-turbo",
|
||||
"kling-v2-6",
|
||||
"kling-v3",
|
||||
"kling-video-o1",
|
||||
}
|
||||
|
||||
func init() {
|
||||
taskchannel.Register(constant.ProviderKling, func() common.TaskAdaptor {
|
||||
return &KlingAdaptor{}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 请求/响应结构体 ====================
|
||||
|
||||
// klingRequest 可灵视频生成请求(文生视频 + 图生视频共用)
|
||||
type klingRequest struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
CfgScale *float64 `json:"cfg_scale,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageTail string `json:"image_tail,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
CameraControl map[string]any `json:"camera_control,omitempty"`
|
||||
}
|
||||
|
||||
// klingSubmitResponse 可灵任务提交响应
|
||||
type klingSubmitResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
} `json:"data"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// klingTaskResponse 可灵任务查询响应
|
||||
type klingTaskResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
TaskResult struct {
|
||||
Videos []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos"`
|
||||
} `json:"task_result"`
|
||||
} `json:"data"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// ==================== Adaptor 实现 ====================
|
||||
|
||||
type KlingAdaptor struct {
|
||||
info *common.RelayInfo
|
||||
info *common.RelayInfo
|
||||
taskType string // text2video / image2video / omni-video
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) Init(info *common.RelayInfo) {
|
||||
a.info = info
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError {
|
||||
func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"}
|
||||
}
|
||||
if _, ok := req["model"]; !ok {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
var req map[string]any
|
||||
if json.Unmarshal(body, &req) == nil {
|
||||
// 高清模式加价
|
||||
if mode, ok := req["mode"]; ok && fmt.Sprintf("%v", mode) == "hq" {
|
||||
ratios["quality"] = 2.0
|
||||
if json.Unmarshal(body, &req) != nil {
|
||||
return ratios
|
||||
}
|
||||
|
||||
// 高清模式加价
|
||||
if mode, ok := req["mode"].(string); ok && mode == "pro" {
|
||||
ratios["quality"] = 3.5
|
||||
}
|
||||
|
||||
// 时长加价:10s = 2x
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["duration"].(float64); ok && v >= 10 {
|
||||
ratios["duration_multiplier"] = 2.0
|
||||
}
|
||||
}
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d >= 10 {
|
||||
ratios["duration_multiplier"] = 2.0
|
||||
}
|
||||
}
|
||||
|
||||
return ratios
|
||||
}
|
||||
|
||||
@@ -49,20 +134,47 @@ func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineTaskType 根据请求内容判断任务类型
|
||||
func determineTaskType(req map[string]any) string {
|
||||
// Omni-Video 模型
|
||||
if modelName, ok := req["model"].(string); ok {
|
||||
if strings.HasPrefix(modelName, "kling-video-o") || strings.HasPrefix(modelName, "kling-v3") {
|
||||
return "omni-video"
|
||||
}
|
||||
}
|
||||
// 有图片输入则 image2video
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if _, hasImg := metadata["image"]; hasImg {
|
||||
return "image2video"
|
||||
}
|
||||
}
|
||||
if _, hasImg := req["images"].([]any); hasImg {
|
||||
return "image2video"
|
||||
}
|
||||
return "text2video"
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
// 根据是否有 image 参数决定 text2video 或 image2video
|
||||
// 默认使用 text2video
|
||||
// 根据 model 名判断路径(URL 在 BuildRequestBody 阶段确定 taskType,此处用 model 前置判断)
|
||||
modelName := info.ChannelMeta.UpstreamModelName
|
||||
if modelName == "" {
|
||||
modelName = info.OriginModelName
|
||||
}
|
||||
|
||||
if strings.HasPrefix(modelName, "kling-video-o") || strings.HasPrefix(modelName, "kling-v3") {
|
||||
return fmt.Sprintf("%s/v1/videos/omni-video", baseURL), nil
|
||||
}
|
||||
|
||||
// 默认 text2video,image2video 由 BuildRequestBody 设置 task_type 后 FetchTask 使用
|
||||
return fmt.Sprintf("%s/v1/videos/text2video", baseURL), nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error {
|
||||
apiKey := info.ChannelMeta.ApiKey
|
||||
// 如果是 sk- 开头的 key,直接用 Bearer token
|
||||
if strings.HasPrefix(apiKey, "sk-") {
|
||||
header.Set("Authorization", "Bearer "+apiKey)
|
||||
} else {
|
||||
// 否则生成 JWT token(accessKey|secretKey 格式)
|
||||
token := buildKlingJWT(apiKey)
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
@@ -76,34 +188,107 @@ func (a *KlingAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInf
|
||||
return strings.NewReader(string(body)), nil
|
||||
}
|
||||
|
||||
if info.ChannelMeta.UpstreamModelName != "" {
|
||||
req["model_name"] = info.ChannelMeta.UpstreamModelName
|
||||
req["model"] = info.ChannelMeta.UpstreamModelName
|
||||
taskType := determineTaskType(req)
|
||||
kReq := klingRequest{}
|
||||
|
||||
// 模型名
|
||||
if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" {
|
||||
kReq.ModelName = info.ChannelMeta.UpstreamModelName
|
||||
} else if m, ok := req["model"].(string); ok {
|
||||
kReq.ModelName = m
|
||||
}
|
||||
|
||||
// 如果没有指定 mode,默认 std
|
||||
if _, ok := req["mode"]; !ok {
|
||||
req["mode"] = "std"
|
||||
}
|
||||
// 如果没有指定 duration,默认 5
|
||||
if _, ok := req["duration"]; !ok {
|
||||
req["duration"] = "5"
|
||||
// prompt
|
||||
if v, ok := req["prompt"].(string); ok {
|
||||
kReq.Prompt = v
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(req)
|
||||
// 从 metadata 提取参数
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["negative_prompt"].(string); ok {
|
||||
kReq.NegativePrompt = v
|
||||
}
|
||||
if v, ok := metadata["mode"].(string); ok {
|
||||
kReq.Mode = v
|
||||
}
|
||||
if v, ok := metadata["duration"].(string); ok {
|
||||
kReq.Duration = v
|
||||
} else if v, ok := metadata["duration"].(float64); ok {
|
||||
kReq.Duration = fmt.Sprintf("%d", int(v))
|
||||
}
|
||||
if v, ok := metadata["aspect_ratio"].(string); ok {
|
||||
kReq.AspectRatio = v
|
||||
}
|
||||
if v, ok := metadata["cfg_scale"].(float64); ok {
|
||||
kReq.CfgScale = &v
|
||||
}
|
||||
if v, ok := metadata["image"].(string); ok {
|
||||
kReq.Image = v
|
||||
}
|
||||
if v, ok := metadata["image_tail"].(string); ok {
|
||||
kReq.ImageTail = v
|
||||
}
|
||||
if v, ok := metadata["sound"].(string); ok {
|
||||
kReq.Sound = v
|
||||
}
|
||||
if v, ok := metadata["camera_control"].(map[string]any); ok {
|
||||
kReq.CameraControl = v
|
||||
}
|
||||
}
|
||||
|
||||
// seconds 字段映射到 duration
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
kReq.Duration = seconds
|
||||
}
|
||||
|
||||
// images 数组 → 取第一张作为 image
|
||||
if images, ok := req["images"].([]any); ok && len(images) > 0 {
|
||||
if url, ok := images[0].(string); ok && kReq.Image == "" {
|
||||
kReq.Image = url
|
||||
}
|
||||
}
|
||||
|
||||
// 默认值
|
||||
if kReq.Mode == "" {
|
||||
kReq.Mode = "std"
|
||||
}
|
||||
if kReq.Duration == "" {
|
||||
kReq.Duration = "5"
|
||||
}
|
||||
|
||||
// 记录 taskType 供 DoRequest 使用正确的 URL
|
||||
if taskType == "image2video" || taskType == "omni-video" {
|
||||
a.taskType = taskType
|
||||
}
|
||||
|
||||
data, err := json.Marshal(kReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal kling request: %w", err)
|
||||
}
|
||||
return strings.NewReader(string(data)), nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
url, err := a.BuildRequestURL(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
|
||||
// 根据 taskType 决定 URL
|
||||
var reqURL string
|
||||
switch a.taskType {
|
||||
case "image2video":
|
||||
reqURL = fmt.Sprintf("%s/v1/videos/image2video", baseURL)
|
||||
case "omni-video":
|
||||
reqURL = fmt.Sprintf("%s/v1/videos/omni-video", baseURL)
|
||||
default:
|
||||
reqURL = fmt.Sprintf("%s/v1/videos/text2video", baseURL)
|
||||
}
|
||||
req, err := http.NewRequest("POST", url, requestBody)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, reqURL, requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if err := a.BuildRequestHeader(req.Header, info); err != nil {
|
||||
return nil, fmt.Errorf("setup header: %w", err)
|
||||
}
|
||||
a.BuildRequestHeader(req.Header, info)
|
||||
client := &http.Client{Timeout: 120 * 1e9}
|
||||
return client.Do(req)
|
||||
}
|
||||
@@ -111,19 +296,14 @@ func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requ
|
||||
func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"}
|
||||
return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
var result klingSubmitResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"}
|
||||
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"}
|
||||
}
|
||||
|
||||
if result.Code != 0 {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
@@ -131,16 +311,18 @@ func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *com
|
||||
ErrCode: fmt.Sprintf("%d", result.Code),
|
||||
}
|
||||
}
|
||||
|
||||
if result.Data.TaskID == "" {
|
||||
return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"}
|
||||
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"}
|
||||
}
|
||||
|
||||
return result.Data.TaskID, body, nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) {
|
||||
var data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"` // text2video 或 image2video
|
||||
TaskType string `json:"task_type"`
|
||||
}
|
||||
if err := json.Unmarshal(taskData, &data); err != nil {
|
||||
return nil, fmt.Errorf("kling: invalid task data: %w", err)
|
||||
@@ -150,9 +332,10 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http
|
||||
if taskType == "" {
|
||||
taskType = "text2video"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/videos/%s/%s", strings.TrimRight(baseURL, "/"), taskType, data.TaskID)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,27 +345,14 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+buildKlingJWT(apiKey))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * 1e9}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
TaskResult struct {
|
||||
Videos []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos"`
|
||||
} `json:"task_result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
var resp klingTaskResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("kling: parse task result: %w", err)
|
||||
}
|
||||
@@ -198,6 +368,7 @@ func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
switch resp.Data.TaskStatus {
|
||||
case "submitted":
|
||||
info.Status = common.TaskStatusSubmitted
|
||||
info.Progress = "10%"
|
||||
case "processing":
|
||||
info.Status = common.TaskStatusInProgress
|
||||
info.Progress = "50%"
|
||||
@@ -212,15 +383,24 @@ func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
info.FailReason = resp.Data.TaskStatusMsg
|
||||
default:
|
||||
info.Status = common.TaskStatusSubmitted
|
||||
info.Progress = "10%"
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) GetModelList() []string {
|
||||
return []string{"kling-v1", "kling-v1-6", "kling-v2-master"}
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) GetChannelName() string {
|
||||
return channelName
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
Reference in new issue
Block a user