diff --git a/constant/channel.go b/constant/channel.go index 48502bedc52c..72af026867b4 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -55,6 +55,7 @@ const ( ChannelTypeSora = 55 ChannelTypeReplicate = 56 ChannelTypeCodex = 57 + ChannelTypeHappyHorse = 58 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "https://dashscope.aliyuncs.com", //58 } var ChannelTypeNames = map[int]string{ @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "Codex", + ChannelTypeHappyHorse: "HappyHorse", } func GetChannelTypeName(channelType int) string { diff --git a/controller/model.go b/controller/model.go index cc2b1effac31..0dd0b21bba78 100644 --- a/controller/model.go +++ b/controller/model.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "strconv" "strings" "time" @@ -88,14 +89,30 @@ func init() { OwnedBy: "midjourney", }) } - openAIModelsMap = make(map[string]dto.OpenAIModels) - for _, aiModel := range openAIModels { - openAIModelsMap[aiModel.Id] = aiModel - } channelId2Models = make(map[int][]string) for i := 1; i <= constant.ChannelTypeDummy; i++ { apiType, success := common.ChannelType2APIType(i) if !success || apiType == constant.APITypeAIProxyLibrary { + // Try task adaptor for channels not mapped to a standard API type + platform := constant.TaskPlatform(strconv.Itoa(i)) + taskAdaptor := relay.GetTaskAdaptor(platform) + if taskAdaptor != nil { + taskAdaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: i, + }}) + models := taskAdaptor.GetModelList() + if len(models) > 0 { + channelId2Models[i] = models + for _, modelName := range models { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: taskAdaptor.GetChannelName(), + }) + } + } + } continue } meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ @@ -108,6 +125,10 @@ func init() { openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string { return m.Id }) + openAIModelsMap = make(map[string]dto.OpenAIModels, len(openAIModels)) + for _, aiModel := range openAIModels { + openAIModelsMap[aiModel.Id] = aiModel + } } func channelOwnerName(channelType int) string { diff --git a/relay/channel/task/happyhorse/adaptor.go b/relay/channel/task/happyhorse/adaptor.go new file mode 100644 index 000000000000..31b7fd00038c --- /dev/null +++ b/relay/channel/task/happyhorse/adaptor.go @@ -0,0 +1,526 @@ +package happyhorse + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +// HappyHorseRequest 百炼乘风视频生成请求 +type HappyHorseRequest struct { + Model string `json:"model"` + Input HappyHorseInput `json:"input"` + Parameters *HappyHorseParameters `json:"parameters,omitempty"` +} + +// HappyHorseInput 视频输入参数(使用 media[] 数组) +type HappyHorseInput struct { + Prompt string `json:"prompt,omitempty"` + Media []MediaItem `json:"media,omitempty"` +} + +// MediaItem 媒体素材 +type MediaItem struct { + Type string `json:"type"` + ImageURL *MediaURL `json:"image_url,omitempty"` + VideoURL *MediaURL `json:"video_url,omitempty"` +} + +// MediaURL 媒体地址 +type MediaURL struct { + URL string `json:"url"` +} + +// HappyHorseParameters 视频生成参数 +type HappyHorseParameters struct { + Resolution string `json:"resolution,omitempty"` // 分辨率: 720P/1080P + Duration *int `json:"duration,omitempty"` // 时长: 2-15秒 + PromptExtend *bool `json:"prompt_extend,omitempty"` // 是否开启prompt智能改写 + Seed *int `json:"seed,omitempty"` // 随机种子,0 或 nil 表示不指定 + Watermark *bool `json:"watermark,omitempty"` // 是否添加水印 +} + +// HappyHorseMetadata 用户通过 metadata 字段传入的额外参数 +// 优先级最高: Metadata > 直接字段(Size/Duration) > 默认值 +type HappyHorseMetadata struct { + Resolution *string `json:"resolution,omitempty"` + Duration *int `json:"duration,omitempty"` + PromptExtend *bool `json:"prompt_extend,omitempty"` + Seed *int `json:"seed,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} + +// HappyHorseUsage 上游返回的用量信息 +type HappyHorseUsage struct { + Duration int `json:"duration,omitempty"` + VideoCount int `json:"video_count,omitempty"` +} + +// HappyHorseResponse 百炼乘风响应 +type HappyHorseResponse struct { + Output HappyHorseOutput `json:"output"` + RequestID string `json:"request_id"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Usage *HappyHorseUsage `json:"usage,omitempty"` +} + +// HappyHorseOutput 输出信息 +type HappyHorseOutput struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + VideoURL string `json:"video_url,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + taskcommon.BaseBilling + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + if taskErr := relaycommon.ValidateMultipartDirect(c, info); taskErr != nil { + return taskErr + } + + // 模型特定验证 + taskReq, err := relaycommon.GetTaskRequest(c) + if err != nil { + return &dto.TaskError{ + Code: "get_task_request_failed", + Message: err.Error(), + StatusCode: http.StatusBadRequest, + LocalError: true, + } + } + + model := taskReq.Model + hasImage := taskReq.InputReference != "" || len(taskReq.Images) > 0 + + // Validate duration and resolution locally before forwarding to upstream + if taskReq.Duration > 0 && (taskReq.Duration < 2 || taskReq.Duration > 15) { + return &dto.TaskError{ + Code: "invalid_duration", + Message: "duration must be between 2 and 15 seconds", + StatusCode: http.StatusBadRequest, + LocalError: true, + } + } + if taskReq.Size != "" { + resolution := strings.ToUpper(strings.TrimSpace(taskReq.Size)) + if !strings.HasSuffix(resolution, "P") { + resolution += "P" + } + if resolution != "720P" && resolution != "1080P" { + return &dto.TaskError{ + Code: "invalid_resolution", + Message: "resolution must be 720P or 1080P", + StatusCode: http.StatusBadRequest, + LocalError: true, + } + } + taskReq.Size = resolution // normalize for downstream consumers + } + // Validate metadata duration (same override path as EstimateBilling / request assembly) + if taskReq.Metadata != nil { + var meta HappyHorseMetadata + if err := taskcommon.UnmarshalMetadata(taskReq.Metadata, &meta); err == nil { + if meta.Duration != nil && *meta.Duration > 0 && (*meta.Duration < 2 || *meta.Duration > 15) { + return &dto.TaskError{ + Code: "invalid_duration", + Message: "metadata duration must be between 2 and 15 seconds", + StatusCode: http.StatusBadRequest, + LocalError: true, + } + } + } + } + + switch { + case strings.Contains(model, "i2v"), strings.Contains(model, "r2v"): + if !hasImage { + return &dto.TaskError{ + Code: "missing_image_input", + Message: fmt.Sprintf("images or input_reference is required for %s model", model), + StatusCode: http.StatusBadRequest, + LocalError: true, + } + } + case strings.Contains(model, "video-edit"): + if taskReq.InputReference == "" { + hasVideoInMeta := false + if taskReq.Metadata != nil { + if _, ok := taskReq.Metadata["video_url"]; ok { + hasVideoInMeta = true + } + } + if !hasVideoInMeta && !hasImage { + return &dto.TaskError{ + Code: "missing_video_input", + Message: fmt.Sprintf("video input (input_reference or images) is required for %s model", model), + StatusCode: http.StatusBadRequest, + LocalError: true, + } + } + } + } + + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/api/v1/services/aigc/video-generation/video-synthesis", a.baseURL), nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Authorization", "Bearer "+a.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-DashScope-Async", "enable") + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + taskReq, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil, errors.Wrap(err, "get_task_request_failed") + } + + hhReq, err := convertToHappyHorseRequest(info, taskReq) + if err != nil { + return nil, errors.Wrap(err, "convert_to_happyhorse_request_failed") + } + logger.LogJson(c, "happyhorse video request body", hhReq) + + bodyBytes, err := common.Marshal(hhReq) + if err != nil { + return nil, errors.Wrap(err, "marshal_happyhorse_request_failed") + } + return bytes.NewReader(bodyBytes), nil +} + +func convertToHappyHorseRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*HappyHorseRequest, error) { + upstreamModel := req.Model + if info.IsModelMapped { + upstreamModel = info.UpstreamModelName + } + + // 默认参数 + defaultDuration := 5 + promptExtend := true + hhReq := &HappyHorseRequest{ + Model: upstreamModel, + Input: HappyHorseInput{ + Prompt: req.Prompt, + }, + Parameters: &HappyHorseParameters{ + Resolution: "720P", + Duration: &defaultDuration, + PromptExtend: &promptExtend, + }, + } + + // 处理分辨率 + if req.Size != "" { + resolution := strings.ToUpper(req.Size) + if !strings.HasSuffix(resolution, "P") { + resolution = resolution + "P" + } + hhReq.Parameters.Resolution = resolution + } + + // 处理时长 + if req.Duration > 0 { + d := req.Duration + hhReq.Parameters.Duration = &d + } + + // 构建 media 数组 + isVideoEdit := strings.Contains(upstreamModel, "video-edit") + + if isVideoEdit { + // video-edit 模型:使用 video_url 类型 + if req.InputReference != "" { + hhReq.Input.Media = []MediaItem{ + { + Type: "video_url", + VideoURL: &MediaURL{URL: req.InputReference}, + }, + } + } + // 支持多图(编辑场景可能有参考图) + for _, img := range req.Images { + if img != "" { + hhReq.Input.Media = append(hhReq.Input.Media, MediaItem{ + Type: "image_url", + ImageURL: &MediaURL{URL: img}, + }) + } + } + } else { + // i2v / r2v 模型:使用 image_url 类型 + // 优先用 InputReference (单图) + if req.InputReference != "" { + hhReq.Input.Media = append(hhReq.Input.Media, MediaItem{ + Type: "image_url", + ImageURL: &MediaURL{URL: req.InputReference}, + }) + } + // 再追加 Images 多图(r2v 首尾帧场景) + for _, img := range req.Images { + if img != "" { + hhReq.Input.Media = append(hhReq.Input.Media, MediaItem{ + Type: "image_url", + ImageURL: &MediaURL{URL: img}, + }) + } + } + } + + // 解析 metadata + var meta HappyHorseMetadata + if req.Metadata != nil { + if err := taskcommon.UnmarshalMetadata(req.Metadata, &meta); err != nil { + return nil, errors.Wrap(err, "unmarshal happyhorse metadata failed") + } + } + + // Metadata 覆盖(最高优先级) + if meta.Resolution != nil { + resolution := strings.ToUpper(*meta.Resolution) + if !strings.HasSuffix(resolution, "P") { + resolution = resolution + "P" + } + hhReq.Parameters.Resolution = resolution + } + if meta.Duration != nil { + hhReq.Parameters.Duration = meta.Duration + } + if meta.PromptExtend != nil { + hhReq.Parameters.PromptExtend = meta.PromptExtend + } + if meta.Seed != nil { + hhReq.Parameters.Seed = meta.Seed + } + if meta.Watermark != nil { + hhReq.Parameters.Watermark = meta.Watermark + } + + return hhReq, nil +} + +// EstimateBilling 按时长计算预消费倍率 +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + taskReq, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil + } + duration := 5 + if taskReq.Duration > 0 { + duration = taskReq.Duration + } + // Apply metadata override (same precedence as request assembly) + if taskReq.Metadata != nil { + var meta HappyHorseMetadata + if err := taskcommon.UnmarshalMetadata(taskReq.Metadata, &meta); err == nil { + if meta.Duration != nil && *meta.Duration > 0 { + duration = *meta.Duration + } + } + } + return map[string]float64{ + "seconds": float64(duration), + } +} + +// DoRequest delegates to common helper +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + var hhResp HappyHorseResponse + if err := common.Unmarshal(responseBody, &hhResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if hhResp.Code != "" { + errStatusCode := resp.StatusCode + if errStatusCode == http.StatusOK { + errStatusCode = http.StatusBadRequest + } + taskErr = service.TaskErrorWrapper( + fmt.Errorf("%s: %s", hhResp.Code, hhResp.Message), + "happyhorse_api_error", errStatusCode, + ) + return + } + + if hhResp.Output.TaskID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + + openAIResp := dto.NewOpenAIVideo() + openAIResp.ID = info.PublicTaskID + openAIResp.TaskID = info.PublicTaskID + openAIResp.Model = info.OriginModelName + openAIResp.Status = convertHappyHorseStatus(hhResp.Output.TaskStatus) + openAIResp.CreatedAt = common.GetTimestamp() + + c.JSON(http.StatusOK, openAIResp) + + return hhResp.Output.TaskID, responseBody, nil +} + +// FetchTask 查询任务状态 +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/api/v1/tasks/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+key) + + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +// ParseTaskResult 解析任务结果 +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var hhResp HappyHorseResponse + if err := common.Unmarshal(respBody, &hhResp); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + switch hhResp.Output.TaskStatus { + case "PENDING": + taskResult.Status = model.TaskStatusQueued + case "RUNNING": + taskResult.Status = model.TaskStatusInProgress + case "SUCCEEDED": + taskResult.Status = model.TaskStatusSuccess + taskResult.Url = hhResp.Output.VideoURL + if hhResp.Usage != nil && hhResp.Usage.Duration > 0 { + taskResult.TotalTokens = hhResp.Usage.Duration + } + case "FAILED", "CANCELED", "UNKNOWN": + taskResult.Status = model.TaskStatusFailure + if hhResp.Message != "" { + taskResult.Reason = hhResp.Message + } else if hhResp.Output.Message != "" { + taskResult.Reason = fmt.Sprintf("task failed, code: %s, message: %s", hhResp.Output.Code, hhResp.Output.Message) + } else { + taskResult.Reason = "task failed" + } + default: + taskResult.Status = model.TaskStatusQueued + } + + return &taskResult, nil +} + +// ConvertToOpenAIVideo 将存储的任务数据转换为 OpenAI 视频格式 +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + var hhResp HappyHorseResponse + if err := common.Unmarshal(task.Data, &hhResp); err != nil { + return nil, errors.Wrap(err, "unmarshal happyhorse response failed") + } + + openAIResp := dto.NewOpenAIVideo() + openAIResp.ID = task.TaskID + openAIResp.Status = convertHappyHorseStatus(hhResp.Output.TaskStatus) + openAIResp.Model = task.Properties.OriginModelName + openAIResp.SetProgressStr(task.Progress) + openAIResp.CreatedAt = task.CreatedAt + openAIResp.CompletedAt = task.UpdatedAt + + openAIResp.SetMetadata("url", hhResp.Output.VideoURL) + + if hhResp.Code != "" { + openAIResp.Error = &dto.OpenAIVideoError{ + Code: hhResp.Code, + Message: hhResp.Message, + } + } else if hhResp.Output.Code != "" { + openAIResp.Error = &dto.OpenAIVideoError{ + Code: hhResp.Output.Code, + Message: hhResp.Output.Message, + } + } + + return common.Marshal(openAIResp) +} + +func convertHappyHorseStatus(status string) string { + switch status { + case "PENDING": + return dto.VideoStatusQueued + case "RUNNING": + return dto.VideoStatusInProgress + case "SUCCEEDED": + return dto.VideoStatusCompleted + case "FAILED", "CANCELED", "UNKNOWN": + return dto.VideoStatusFailed + default: + return dto.VideoStatusUnknown + } +} diff --git a/relay/channel/task/happyhorse/adaptor_test.go b/relay/channel/task/happyhorse/adaptor_test.go new file mode 100644 index 000000000000..9b08e414f367 --- /dev/null +++ b/relay/channel/task/happyhorse/adaptor_test.go @@ -0,0 +1,701 @@ +package happyhorse + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================ +// convertToHappyHorseRequest tests +// ============================ + +func TestConvertToHappyHorseRequest_TextToVideo(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "A cat playing piano", + Model: "happyhorse-1.0-t2v", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "happyhorse-1.0-t2v", hhReq.Model) + assert.Equal(t, "A cat playing piano", hhReq.Input.Prompt) + assert.Empty(t, hhReq.Input.Media) + assert.Equal(t, "720P", hhReq.Parameters.Resolution) + assert.Equal(t, 5, *hhReq.Parameters.Duration) + assert.True(t, *hhReq.Parameters.PromptExtend) +} + +func TestConvertToHappyHorseRequest_ImageToVideo(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-i2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "A dog running", + Model: "happyhorse-1.0-i2v", + InputReference: "https://example.com/image.jpg", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "happyhorse-1.0-i2v", hhReq.Model) + require.Len(t, hhReq.Input.Media, 1) + assert.Equal(t, "image_url", hhReq.Input.Media[0].Type) + assert.Equal(t, "https://example.com/image.jpg", hhReq.Input.Media[0].ImageURL.URL) +} + +func TestConvertToHappyHorseRequest_ReferenceToVideo(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-r2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "A flying bird", + Model: "happyhorse-1.0-r2v", + InputReference: "https://example.com/frame1.jpg", + Images: []string{"https://example.com/frame2.jpg", "https://example.com/frame3.jpg"}, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + // InputReference + 2 images = 3 media items + require.Len(t, hhReq.Input.Media, 3) + for _, m := range hhReq.Input.Media { + assert.Equal(t, "image_url", m.Type) + assert.NotNil(t, m.ImageURL) + } + assert.Equal(t, "https://example.com/frame1.jpg", hhReq.Input.Media[0].ImageURL.URL) + assert.Equal(t, "https://example.com/frame2.jpg", hhReq.Input.Media[1].ImageURL.URL) + assert.Equal(t, "https://example.com/frame3.jpg", hhReq.Input.Media[2].ImageURL.URL) +} + +func TestConvertToHappyHorseRequest_VideoEdit(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-video-edit", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "Add sunglasses to the person", + Model: "happyhorse-1.0-video-edit", + InputReference: "https://example.com/video.mp4", + Images: []string{"https://example.com/ref.jpg"}, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + require.Len(t, hhReq.Input.Media, 2) + // First item should be video_url + assert.Equal(t, "video_url", hhReq.Input.Media[0].Type) + assert.NotNil(t, hhReq.Input.Media[0].VideoURL) + assert.Equal(t, "https://example.com/video.mp4", hhReq.Input.Media[0].VideoURL.URL) + // Second item should be image_url + assert.Equal(t, "image_url", hhReq.Input.Media[1].Type) + assert.Equal(t, "https://example.com/ref.jpg", hhReq.Input.Media[1].ImageURL.URL) +} + +func TestConvertToHappyHorseRequest_CustomSizeAndDuration(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "A sunset", + Model: "happyhorse-1.0-t2v", + Size: "1080", + Duration: 8, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "1080P", hhReq.Parameters.Resolution) + assert.Equal(t, 8, *hhReq.Parameters.Duration) +} + +func TestConvertToHappyHorseRequest_SizeAlreadyWithP(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-t2v", + Size: "480p", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "480P", hhReq.Parameters.Resolution) +} + +func TestConvertToHappyHorseRequest_ModelMapped(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "mapped-model-t2v", + IsModelMapped: true, + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "original-model", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "mapped-model-t2v", hhReq.Model) +} + +func TestConvertToHappyHorseRequest_VideoEditNoImages(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-video-edit", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "Edit this video", + Model: "happyhorse-1.0-video-edit", + InputReference: "https://example.com/video.mp4", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + require.Len(t, hhReq.Input.Media, 1) + assert.Equal(t, "video_url", hhReq.Input.Media[0].Type) +} + +func TestConvertToHappyHorseRequest_EmptyImagesSkipped(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-r2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-r2v", + Images: []string{"https://example.com/img.jpg", "", "https://example.com/img2.jpg"}, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + // Empty string should be skipped + require.Len(t, hhReq.Input.Media, 2) +} + +// ============================ +// convertHappyHorseStatus tests +// ============================ + +func TestConvertHappyHorseStatus(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"PENDING", dto.VideoStatusQueued}, + {"RUNNING", dto.VideoStatusInProgress}, + {"SUCCEEDED", dto.VideoStatusCompleted}, + {"FAILED", dto.VideoStatusFailed}, + {"CANCELED", dto.VideoStatusFailed}, + {"UNKNOWN", dto.VideoStatusFailed}, + {"SOMETHING_ELSE", dto.VideoStatusUnknown}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, convertHappyHorseStatus(tc.input)) + }) + } +} + +// ============================ +// ParseTaskResult tests +// ============================ + +func TestParseTaskResult_Pending(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "PENDING"}, + "request_id": "req-001" + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusQueued, result.Status) + assert.Empty(t, result.Url) +} + +func TestParseTaskResult_Running(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "RUNNING"}, + "request_id": "req-002" + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusInProgress, result.Status) +} + +func TestParseTaskResult_Succeeded(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "SUCCEEDED", "video_url": "https://example.com/out.mp4"}, + "request_id": "req-003" + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusSuccess, result.Status) + assert.Equal(t, "https://example.com/out.mp4", result.Url) +} + +func TestParseTaskResult_Failed(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "FAILED", "code": "ERR_INTERNAL", "message": "GPU OOM"}, + "request_id": "req-004" + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusFailure, result.Status) + assert.Contains(t, result.Reason, "GPU OOM") +} + +func TestParseTaskResult_FailedWithTopLevelMessage(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "FAILED"}, + "request_id": "req-005", + "code": "InvalidParameter", + "message": "Bad request" + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusFailure, result.Status) + assert.Equal(t, "Bad request", result.Reason) +} + +func TestParseTaskResult_UnknownStatus(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "WEIRD_STATUS"}, + "request_id": "req-006" + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusQueued, result.Status) // defaults to queued +} + +func TestParseTaskResult_InvalidJSON(t *testing.T) { + a := &TaskAdaptor{} + _, err := a.ParseTaskResult([]byte(`not json`)) + assert.Error(t, err) +} + +// ============================ +// Adaptor basic method tests +// ============================ + +func TestGetModelList(t *testing.T) { + a := &TaskAdaptor{} + models := a.GetModelList() + assert.Equal(t, ModelList, models) + assert.Len(t, models, 4) + assert.Contains(t, models, "happyhorse-1.0-t2v") + assert.Contains(t, models, "happyhorse-1.0-i2v") + assert.Contains(t, models, "happyhorse-1.0-r2v") + assert.Contains(t, models, "happyhorse-1.0-video-edit") +} + +func TestGetChannelName(t *testing.T) { + a := &TaskAdaptor{} + assert.Equal(t, "happyhorse", a.GetChannelName()) +} + +func TestInit(t *testing.T) { + a := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: "https://dashscope.aliyuncs.com", + ApiKey: "sk-test-key", + }, + } + a.Init(info) + assert.Equal(t, "https://dashscope.aliyuncs.com", a.baseURL) + assert.Equal(t, "sk-test-key", a.apiKey) +} + +func TestBuildRequestURL(t *testing.T) { + a := &TaskAdaptor{baseURL: "https://dashscope.aliyuncs.com"} + url, err := a.BuildRequestURL(nil) + require.NoError(t, err) + assert.Equal(t, "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis", url) +} + +func TestBuildRequestHeader(t *testing.T) { + a := &TaskAdaptor{apiKey: "sk-abc123"} + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + err := a.BuildRequestHeader(nil, req, nil) + require.NoError(t, err) + assert.Equal(t, "Bearer sk-abc123", req.Header.Get("Authorization")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + assert.Equal(t, "enable", req.Header.Get("X-DashScope-Async")) +} + +// ============================ +// DoResponse tests +// ============================ + +func TestDoResponse_Success(t *testing.T) { + a := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + OriginModelName: "happyhorse-1.0-t2v", + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + PublicTaskID: "task-public-001", + }, + } + + hhResp := HappyHorseResponse{ + Output: HappyHorseOutput{ + TaskID: "upstream-task-123", + TaskStatus: "PENDING", + }, + RequestID: "req-do-001", + } + body, _ := common.Marshal(hhResp) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytesReader(body)), + } + + w := httptest.NewRecorder() + c, _ := createGinContext(w) + c.Set("model", "happyhorse-1.0-t2v") + + taskID, taskData, taskErr := a.DoResponse(c, resp, info) + assert.Nil(t, taskErr) + assert.Equal(t, "upstream-task-123", taskID) + assert.NotEmpty(t, taskData) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDoResponse_ErrorCode(t *testing.T) { + a := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + PublicTaskID: "task-public-002", + }, + } + + hhResp := HappyHorseResponse{ + Code: "InvalidParameter", + Message: "model not found", + RequestID: "req-do-002", + } + body, _ := common.Marshal(hhResp) + + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytesReader(body)), + } + + w := httptest.NewRecorder() + c, _ := createGinContext(w) + + _, _, taskErr := a.DoResponse(c, resp, info) + assert.NotNil(t, taskErr) +} + +func TestDoResponse_EmptyTaskID(t *testing.T) { + a := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + PublicTaskID: "task-public-003", + }, + } + + hhResp := HappyHorseResponse{ + Output: HappyHorseOutput{TaskStatus: "PENDING"}, + RequestID: "req-do-003", + } + body, _ := common.Marshal(hhResp) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytesReader(body)), + } + + w := httptest.NewRecorder() + c, _ := createGinContext(w) + + _, _, taskErr := a.DoResponse(c, resp, info) + assert.NotNil(t, taskErr) +} + +// ============================ +// FetchTask tests +// ============================ + +func TestFetchTask_InvalidTaskIDType(t *testing.T) { + a := &TaskAdaptor{} + // task_id is not a string + _, err := a.FetchTask("https://example.com", "key", map[string]any{"task_id": 123}, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid task_id") +} + +func TestFetchTask_MissingTaskID(t *testing.T) { + a := &TaskAdaptor{} + _, err := a.FetchTask("https://example.com", "key", map[string]any{}, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid task_id") +} + +// ============================ +// ConvertToOpenAIVideo tests +// ============================ + +func TestConvertToOpenAIVideo_Success(t *testing.T) { + a := &TaskAdaptor{} + hhResp := HappyHorseResponse{ + Output: HappyHorseOutput{ + TaskID: "task-conv-1", + TaskStatus: "SUCCEEDED", + VideoURL: "https://example.com/result.mp4", + }, + RequestID: "req-conv-1", + } + data, _ := common.Marshal(hhResp) + + task := &model.Task{ + TaskID: "task-conv-1", + Properties: model.Properties{ + OriginModelName: "happyhorse-1.0-t2v", + }, + Progress: "100%", + Data: data, + CreatedAt: 1700000000, + UpdatedAt: 1700000100, + } + + result, err := a.ConvertToOpenAIVideo(task) + require.NoError(t, err) + + var video dto.OpenAIVideo + err = common.Unmarshal(result, &video) + require.NoError(t, err) + assert.Equal(t, "task-conv-1", video.ID) + assert.Equal(t, dto.VideoStatusCompleted, video.Status) + assert.Equal(t, "happyhorse-1.0-t2v", video.Model) + assert.Equal(t, 100, video.Progress) + assert.Equal(t, "https://example.com/result.mp4", video.Metadata["url"]) +} + +func TestConvertToOpenAIVideo_WithError(t *testing.T) { + a := &TaskAdaptor{} + hhResp := HappyHorseResponse{ + Output: HappyHorseOutput{ + TaskID: "task-conv-2", + TaskStatus: "FAILED", + Code: "GPU_ERROR", + Message: "GPU out of memory", + }, + Code: "InternalError", + Message: "internal server error", + RequestID: "req-conv-2", + } + data, _ := common.Marshal(hhResp) + + task := &model.Task{ + TaskID: "task-conv-2", + Properties: model.Properties{ + OriginModelName: "happyhorse-1.0-t2v", + }, + Data: data, + } + + result, err := a.ConvertToOpenAIVideo(task) + require.NoError(t, err) + + var video dto.OpenAIVideo + err = common.Unmarshal(result, &video) + require.NoError(t, err) + assert.Equal(t, dto.VideoStatusFailed, video.Status) + require.NotNil(t, video.Error) + assert.Equal(t, "InternalError", video.Error.Code) + assert.Equal(t, "internal server error", video.Error.Message) +} + +// ============================ +// helpers +// ============================ + +func bytesReader(b []byte) *bytes.Reader { + return bytes.NewReader(b) +} + +func createGinContext(w http.ResponseWriter) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + return c, w.(*httptest.ResponseRecorder) +} + +func TestConvertToHappyHorseRequest_MetadataOverride(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-t2v", + Size: "480", + Duration: 3, + Metadata: map[string]interface{}{ + "resolution": "1080", + "duration": 8, + "prompt_extend": false, + "seed": 42, + "watermark": true, + }, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "1080P", hhReq.Parameters.Resolution) + assert.Equal(t, 8, *hhReq.Parameters.Duration) + assert.False(t, *hhReq.Parameters.PromptExtend) + assert.Equal(t, 42, *hhReq.Parameters.Seed) + require.NotNil(t, hhReq.Parameters.Watermark) + assert.True(t, *hhReq.Parameters.Watermark) +} + +func TestConvertToHappyHorseRequest_WatermarkNotSetByDefault(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-t2v", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Nil(t, hhReq.Parameters.Watermark) +} + +func TestConvertToHappyHorseRequest_MetadataWatermarkFalse(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-t2v", + Metadata: map[string]interface{}{ + "watermark": false, + }, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + require.NotNil(t, hhReq.Parameters.Watermark) + assert.False(t, *hhReq.Parameters.Watermark) +} + +func TestConvertToHappyHorseRequest_SeedNotSetByDefault(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-t2v", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Nil(t, hhReq.Parameters.Seed) +} + +func TestConvertToHappyHorseRequest_VideoEditModelMapped(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "custom-video-edit-v2", + IsModelMapped: true, + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "Edit this", + Model: "happyhorse-1.0-video-edit", + InputReference: "https://example.com/video.mp4", + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + assert.Equal(t, "custom-video-edit-v2", hhReq.Model) + require.Len(t, hhReq.Input.Media, 1) + assert.Equal(t, "video_url", hhReq.Input.Media[0].Type) +} + +func TestConvertToHappyHorseRequest_MetadataSeedZero(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "happyhorse-1.0-t2v", + }, + } + req := relaycommon.TaskSubmitReq{ + Prompt: "test", + Model: "happyhorse-1.0-t2v", + Metadata: map[string]interface{}{ + "seed": 0, + }, + } + + hhReq, err := convertToHappyHorseRequest(info, req) + require.NoError(t, err) + require.NotNil(t, hhReq.Parameters.Seed) + assert.Equal(t, 0, *hhReq.Parameters.Seed) +} + +func TestParseTaskResult_WithUsage(t *testing.T) { + a := &TaskAdaptor{} + respBody := []byte(`{ + "output": {"task_id": "task-123", "task_status": "SUCCEEDED", "video_url": "https://example.com/out.mp4"}, + "request_id": "req-usage", + "usage": {"duration": 5, "video_count": 1} + }`) + + result, err := a.ParseTaskResult(respBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusSuccess, result.Status) + assert.Equal(t, 5, result.TotalTokens) + assert.Equal(t, "https://example.com/out.mp4", result.Url) +} diff --git a/relay/channel/task/happyhorse/constants.go b/relay/channel/task/happyhorse/constants.go new file mode 100644 index 000000000000..7a7a925ecb3f --- /dev/null +++ b/relay/channel/task/happyhorse/constants.go @@ -0,0 +1,10 @@ +package happyhorse + +var ModelList = []string{ + "happyhorse-1.0-t2v", // 文生视频 + "happyhorse-1.0-i2v", // 图生视频 + "happyhorse-1.0-r2v", // 首尾帧生视频 + "happyhorse-1.0-video-edit", // 视频编辑 +} + +var ChannelName = "happyhorse" diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2dd4a..c365f81f7076 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -31,6 +31,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/siliconflow" "github.com/QuantumNous/new-api/relay/channel/submodel" taskali "github.com/QuantumNous/new-api/relay/channel/task/ali" + taskhappyhorse "github.com/QuantumNous/new-api/relay/channel/task/happyhorse" taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao" taskGemini "github.com/QuantumNous/new-api/relay/channel/task/gemini" "github.com/QuantumNous/new-api/relay/channel/task/hailuo" @@ -143,6 +144,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { switch channelType { case constant.ChannelTypeAli: return &taskali.TaskAdaptor{} + case constant.ChannelTypeHappyHorse: + return &taskhappyhorse.TaskAdaptor{} case constant.ChannelTypeKling: return &kling.TaskAdaptor{} case constant.ChannelTypeJimeng: diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 80702ee42ad2..8c47d2052b88 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -308,6 +308,11 @@ var defaultModelPrice = map[string]float64{ "veo-3.0-fast-generate-001": 0.15, "veo-3.1-generate-preview": 0.4, "veo-3.1-fast-generate-preview": 0.15, + // HappyHorse 百炼乘风视频生成 (¥0.14/秒, 以5秒计基准) + "happyhorse-1.0-t2v": 0.1, + "happyhorse-1.0-i2v": 0.1, + "happyhorse-1.0-r2v": 0.1, + "happyhorse-1.0-video-edit": 0.1, } var defaultAudioRatio = map[string]float64{ diff --git a/web/default/bun.lock b/web/default/bun.lock index 815e17f2ba77..e35c10d6eddc 100644 --- a/web/default/bun.lock +++ b/web/default/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "newapi-web", diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index 6e92519f98b5..7661500a9eb4 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -564,6 +564,18 @@ export async function getAllModels(): Promise<{ return res.data } +/** + * Get models grouped by channel type (channelType -> model[]) + */ +export async function getModelsByChannelType(): Promise<{ + success: boolean + message?: string + data?: Record +}> { + const res = await api.get('/api/models') + return res.data +} + /** * Get all enabled models */ diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 6b26cd171505..e051c8f14d75 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -111,6 +111,7 @@ import { getChannel, getChannelKey, getGroups, + getModelsByChannelType, getPrefillGroups, refreshCodexCredential, } from '../../api' @@ -330,6 +331,12 @@ export function ChannelMutateDrawer({ queryFn: () => getPrefillGroups('model'), }) + // Fetch models grouped by channel type + const { data: channelTypeModelsData } = useQuery({ + queryKey: ['channel_type_models'], + queryFn: getModelsByChannelType, + }) + const { copyToClipboard } = useCopyToClipboard() const { @@ -408,6 +415,12 @@ export function ChannelMutateDrawer({ // Get basic models for the current channel type const basicModels = useMemo(() => { + // If we have channel-type-specific models from the server, use them + const channelTypeMap = channelTypeModelsData?.data + if (channelTypeMap) { + const specific = channelTypeMap[String(currentType)] + if (specific && specific.length > 0) return specific + } if (!allModelsList.length) return [] // Filter models based on common patterns for specific types if (currentType === 1) { @@ -416,7 +429,7 @@ export function ChannelMutateDrawer({ ) } return allModelsList - }, [allModelsList, currentType]) + }, [allModelsList, currentType, channelTypeModelsData]) // Get prefill groups const prefillGroups = useMemo( diff --git a/web/default/src/features/channels/constants.ts b/web/default/src/features/channels/constants.ts index 5fd88d554820..e1fc934e602e 100644 --- a/web/default/src/features/channels/constants.ts +++ b/web/default/src/features/channels/constants.ts @@ -76,12 +76,13 @@ export const CHANNEL_TYPES = { 55: 'Sora', 56: 'Replicate', 57: 'Codex', + 58: 'HappyHorse', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ 1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 50, - 51, 52, 53, 54, 55, 56, + 51, 52, 53, 54, 55, 56, 58, ] export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => { diff --git a/web/default/src/features/channels/lib/channel-utils.ts b/web/default/src/features/channels/lib/channel-utils.ts index 3b55f15eb63c..0a3e25e3453f 100644 --- a/web/default/src/features/channels/lib/channel-utils.ts +++ b/web/default/src/features/channels/lib/channel-utils.ts @@ -101,6 +101,7 @@ export function getChannelTypeIcon(type: number): string { 55: 'OpenAI', // Sora 54: 'Doubao', // DoubaoVideo 56: 'Replicate', // Replicate + 58: 'Tongyi', // HappyHorse // Tools & Platforms 37: 'Dify', // Dify diff --git a/web/default/src/features/playground/api.ts b/web/default/src/features/playground/api.ts index 1b8858fec980..694120a79853 100644 --- a/web/default/src/features/playground/api.ts +++ b/web/default/src/features/playground/api.ts @@ -17,12 +17,15 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' -import { API_ENDPOINTS } from './constants' +import { API_ENDPOINTS, VIDEO_API_ENDPOINTS } from './constants' import type { ChatCompletionRequest, ChatCompletionResponse, ModelOption, GroupOption, + TokenOption, + VideoGenerationRequest, + VideoTaskResponse, } from './types' /** @@ -75,3 +78,65 @@ export async function getUserGroups(): Promise { desc: info.desc, })) } + +/** + * Get user token list (for video API key selector) + */ +export async function getUserTokens(): Promise { + const res = await api.get('/api/token/?p=1&size=100') + const { success, data } = res.data + if (!success || !Array.isArray(data?.items)) return [] + return data.items + .filter((t: { status: number }) => t.status === 1) + .map((t: { id: number; name: string; key: string }) => ({ + id: t.id, + name: t.name, + key: t.key, + })) +} + +/** + * Fetch real (unmasked) key for a token + */ +export async function fetchTokenKey(id: number): Promise { + const res = await api.post(`/api/token/${id}/key`, undefined, { + skipErrorHandler: true, + } as Record) + const { success, data } = res.data + if (!success || !data?.key) return null + return data.key as string +} + +/** + * Submit a video generation task + */ +export async function submitVideoGeneration( + payload: VideoGenerationRequest, + apiKey: string +): Promise { + const res = await api.post(VIDEO_API_ENDPOINTS.SUBMIT, payload, { + skipErrorHandler: true, + skipBusinessError: true, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } as Record) + return res.data +} + +/** + * Fetch video task status by task ID + */ +export async function fetchVideoTaskStatus( + taskId: string, + apiKey: string +): Promise { + const res = await api.get(VIDEO_API_ENDPOINTS.STATUS(taskId), { + skipErrorHandler: true, + skipBusinessError: true, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } as Record) + return res.data +} diff --git a/web/default/src/features/playground/components/media-drop-zone.tsx b/web/default/src/features/playground/components/media-drop-zone.tsx new file mode 100644 index 000000000000..812fbff3f37e --- /dev/null +++ b/web/default/src/features/playground/components/media-drop-zone.tsx @@ -0,0 +1,196 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useState, useCallback, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { UploadIcon, XIcon, ImageIcon, VideoIcon } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +interface MediaDropZoneProps { + accept: 'image' | 'video' + value?: string + onChange: (url: string) => void +} + +export function MediaDropZone({ accept, value, onChange }: MediaDropZoneProps) { + const { t } = useTranslation() + const [isDragging, setIsDragging] = useState(false) + const inputRef = useRef(null) + + const isImage = accept === 'image' + const acceptAttr = isImage ? 'image/*' : 'video/*' + const placeholder = isImage + ? t('Drop image here, paste URL, or click to upload') + : t('Drop video here, paste URL, or click to upload') + + const prevUrlRef = useRef(undefined) + + const handleFile = useCallback( + (file: File) => { + if (prevUrlRef.current?.startsWith('blob:')) { + URL.revokeObjectURL(prevUrlRef.current) + } + const url = URL.createObjectURL(file) + prevUrlRef.current = url + onChange(url) + }, + [onChange], + ) + + // Revoke blob URL on unmount + useEffect(() => { + return () => { + if (prevUrlRef.current?.startsWith('blob:')) { + URL.revokeObjectURL(prevUrlRef.current) + } + } + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFile(file) + }, + [handleFile], + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData.items + for (const item of items) { + if (item.kind === 'file') { + const file = item.getAsFile() + if (file) { + e.preventDefault() + handleFile(file) + return + } + } + } + // If pasted text looks like a URL, use it directly + const text = e.clipboardData.getData('text/plain').trim() + if (text && /^https?:\/\//i.test(text)) { + e.preventDefault() + onChange(text) + } + }, + [handleFile, onChange], + ) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const url = e.target.value.trim() + onChange(url) + }, + [onChange], + ) + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) handleFile(file) + e.target.value = '' + }, + [handleFile], + ) + + const clearValue = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onChange('') + }, + [onChange], + ) + + return ( +
+
inputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + className={cn( + 'border-muted flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-md border border-dashed p-4 transition-colors', + isDragging && 'border-primary bg-primary/5', + !value && 'hover:bg-muted/50', + )} + > + {value ? ( +
+ {isImage ? ( + preview { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> + ) : ( +
+ ) : ( + <> + {isImage ? ( + + ) : ( + + )} + {placeholder} + + )} +
+ + + + +
+ ) +} diff --git a/web/default/src/features/playground/components/video-input-form.tsx b/web/default/src/features/playground/components/video-input-form.tsx new file mode 100644 index 000000000000..51926570d776 --- /dev/null +++ b/web/default/src/features/playground/components/video-input-form.tsx @@ -0,0 +1,452 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useState, useMemo, useEffect } from 'react' +import { FilmIcon, Loader2Icon, KeyRoundIcon, ChevronDownIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Slider } from '@/components/ui/slider' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@/components/ui/collapsible' +import { HAPPYHORSE_MODELS, VIDEO_MODEL_TYPE_LABELS } from '../constants' +import { getUserTokens, fetchTokenKey } from '../api' +import { MediaDropZone } from './media-drop-zone' +import type { VideoGenerationRequest, ModelOption, TokenOption, VideoModelType } from '../types' + +interface VideoInputFormProps { + models: ModelOption[] + onSubmit: ( + req: VideoGenerationRequest, + apiKey: string, + tokenId: number, + meta?: { size?: string; duration?: number; type?: VideoModelType } + ) => Promise + isSubmitting?: boolean +} + +export function VideoInputForm({ + models, + onSubmit, + isSubmitting = false, +}: VideoInputFormProps) { + const { t } = useTranslation() + + // Filter to only happyhorse models available to user + const availableModels = useMemo(() => { + const userModelValues = new Set(models.map((m) => m.value)) + return HAPPYHORSE_MODELS.filter((m) => userModelValues.has(m.model)) + }, [models]) + + const [selectedModel, setSelectedModel] = useState( + availableModels[0]?.model ?? HAPPYHORSE_MODELS[0].model + ) + const [prompt, setPrompt] = useState('') + const [size, setSize] = useState('720P') + const [duration, setDuration] = useState(5) + const [imageUrls, setImageUrls] = useState(['', '']) + const [videoUrl, setVideoUrl] = useState('') + + // Advanced settings + const [promptExtend, setPromptExtend] = useState(true) + const [seed, setSeed] = useState(undefined) + const [watermark, setWatermark] = useState(false) + + // Token (API key) selector state + const [tokens, setTokens] = useState([]) + const [selectedTokenId, setSelectedTokenId] = useState('') + const [isLoadingTokens, setIsLoadingTokens] = useState(false) + + const selectedTokenName = useMemo(() => { + if (!selectedTokenId) return '' + return tokens.find((tk) => String(tk.id) === selectedTokenId)?.name ?? '' + }, [tokens, selectedTokenId]) + + // Load user tokens on mount + useEffect(() => { + setIsLoadingTokens(true) + getUserTokens() + .then((list) => { + setTokens(list) + if (list.length > 0) { + setSelectedTokenId(String(list[0].id)) + } + }) + .finally(() => setIsLoadingTokens(false)) + }, []) + + const modelConfig = useMemo( + () => + HAPPYHORSE_MODELS.find((m) => m.model === selectedModel) ?? + HAPPYHORSE_MODELS[0], + [selectedModel] + ) + + // Keep selectedModel in sync with async model availability + useEffect(() => { + if (availableModels.length === 0) return + const exists = availableModels.some((m) => m.model === selectedModel) + if (!exists) { + setSelectedModel(availableModels[0].model) + } + }, [availableModels, selectedModel]) + + // Helper for API key selector placeholder (avoids nested ternary) + const getKeyPlaceholder = () => { + if (isLoadingTokens) return t('Loading...') + if (tokens.length === 0) return t('No API keys available') + return t('Select API key') + } + + // Submit validation: block when required media is missing + const hasRequiredMedia = (() => { + if (modelConfig.requiresVideo && !videoUrl.trim()) return false + if (modelConfig.requiresImage && imageUrls.filter((u) => u.trim() !== '').length === 0) return false + return true + })() + const canSubmit = !isSubmitting && !!prompt.trim() && !!selectedTokenId && hasRequiredMedia + + const modelsToShow = useMemo( + () => (availableModels.length > 0 ? availableModels : HAPPYHORSE_MODELS), + [availableModels] + ) + + const modelsByType = useMemo(() => { + const groups = new Map() + for (const m of modelsToShow) { + const list = groups.get(m.type) ?? [] + list.push(m) + groups.set(m.type, list) + } + return groups + }, [modelsToShow]) + + const modelsForActiveType = useMemo( + () => modelsByType.get(modelConfig.type) ?? [], + [modelsByType, modelConfig.type] + ) + + const handleSubmit = async () => { + if (isSubmitting) return + if (!prompt.trim()) return + if (!selectedTokenId) return + if (!hasRequiredMedia) return + + const selectedToken = tokens.find((t) => String(t.id) === selectedTokenId) + if (!selectedToken) return + + // Fetch real key (unmasked) + const realKey = await fetchTokenKey(selectedToken.id) + if (!realKey) return + + const req: VideoGenerationRequest = { + model: selectedModel, + prompt: prompt.trim(), + size, + duration, + metadata: { + prompt_extend: promptExtend, + watermark, + ...(seed != null ? { seed } : {}), + }, + } + + if (modelConfig.requiresVideo && videoUrl.trim()) { + req.input_reference = videoUrl.trim() + } + + if (modelConfig.requiresImage) { + const imgs = imageUrls.filter((u) => u.trim() !== '') + if (imgs.length > 0) { + if (modelConfig.type === 'image-to-video') { + req.input_reference = imgs[0] + } else { + // reference-to-video: first/last frames via images[] + req.images = imgs + } + } + } + + await onSubmit(req, realKey, selectedToken.id, { + size, + duration, + type: modelConfig.type, + }) + setPrompt('') + } + + return ( +
+ {/* API Key selector */} +
+ + + {tokens.length === 0 && !isLoadingTokens && ( +

+ {t('Please create an API key first in the Keys page.')} +

+ )} +
+ + {/* Model Type Tabs */} +
+ + { + const type = v as VideoModelType + const typeModels = modelsByType.get(type) + if (typeModels?.[0]) { + setSelectedModel(typeModels[0].model) + } + }} + > + + {Array.from(modelsByType.keys()).map((type) => ( + + {VIDEO_MODEL_TYPE_LABELS[type]} + + ))} + + + {modelsForActiveType.length > 1 && ( + + )} +

+ {t(modelConfig.label)} +

+
+ + {/* Prompt */} +
+ +