From 42f2b2e1c000f57f7431cba3654bc9eb63ebb966 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 17 May 2026 15:59:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=E9=98=BF=E9=87=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B7=BB=E5=8A=A0=E5=BC=82=E6=AD=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/cmd.go | 7 + internal/handler/relay/task.go | 34 +++++ relay/constant/task.go | 3 + relay/handler/task_handler.go | 1 + relay/taskchannel/ali/adaptor.go | 199 +++++++++++++++++++++++++++++ relay/taskchannel/ali/constants.go | 22 ++++ relay/taskchannel/ali/converter.go | 99 ++++++++++++++ relay/taskchannel/ali/types.go | 45 +++++++ 8 files changed, 410 insertions(+) create mode 100644 relay/taskchannel/ali/adaptor.go create mode 100644 relay/taskchannel/ali/constants.go create mode 100644 relay/taskchannel/ali/converter.go create mode 100644 relay/taskchannel/ali/types.go diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 0551a57..da3c3f5 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -358,6 +358,13 @@ func registerRelayRoutes(server *ghttp.Server) { group.GET("/video/generations/{task_id}", relay.HandleTaskFetch) }) + // 异步图片生成端点(阿里云 DashScope 等) + server.Group("/v1", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.POST("/images/generations/async", relay.HandleAliImageSubmit) + group.GET("/images/generations/async/{task_id}", relay.HandleTaskFetch) + }) + // Suno 端点 server.Group("/suno", func(group *ghttp.RouterGroup) { group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) diff --git a/internal/handler/relay/task.go b/internal/handler/relay/task.go index 1caffe9..5a3b265 100644 --- a/internal/handler/relay/task.go +++ b/internal/handler/relay/task.go @@ -154,6 +154,40 @@ func HandleMjSubmit(r *ghttp.Request) { ) } +// HandleAliImageSubmit 处理异步图片生成任务提交(POST /v1/images/generations/async) +func HandleAliImageSubmit(r *ghttp.Request) { + body := r.GetBody() + if len(body) == 0 { + r.Response.WriteStatus(400, g.Map{ + "error": g.Map{"type": "invalid_request_error", "message": "request body is empty"}, + }) + return + } + + rc := &relay_handler.TaskRelayContext{ + TenantID: middleware.GetTenantID(r.Context()), + UserID: middleware.GetUserID(r.Context()), + ApiKeyID: middleware.GetApiKeyID(r.Context()), + RequestID: r.GetCtxVar("RequestId").String(), + Writer: r.Response.Writer, + Scope: r.GetCtxVar("ApiKeyScope").String(), + ClientIP: r.GetClientIp(), + } + + channelMeta, err := selectTaskChannel(r, body) + if err != nil { + r.Response.WriteStatus(503, g.Map{ + "error": g.Map{"type": "server_error", "message": err.Error()}, + }) + return + } + + relay_handler.HandleTaskSubmit( + r.Context(), body, r.URL.Path, r.Header, + rc, taskDataProvider, taskBillingProvider, channelMeta, + ) +} + // HandleMjFetch 处理 Midjourney 任务查询(GET /mj/task/:id/fetch) func HandleMjFetch(r *ghttp.Request) { taskID := r.Get("task_id").String() diff --git a/relay/constant/task.go b/relay/constant/task.go index 77dbe52..5725421 100644 --- a/relay/constant/task.go +++ b/relay/constant/task.go @@ -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 } diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index 7660833..e2f8811 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -13,6 +13,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" diff --git a/relay/taskchannel/ali/adaptor.go b/relay/taskchannel/ali/adaptor.go new file mode 100644 index 0000000..60fdf02 --- /dev/null +++ b/relay/taskchannel/ali/adaptor.go @@ -0,0 +1,199 @@ +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 &AliImageAdaptor{} + }) +} + +// AliImageAdaptor 阿里云 DashScope 异步图片生成适配器 +type AliImageAdaptor struct { + info *common.RelayInfo +} + +func (a *AliImageAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *AliImageAdaptor) 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"} + } + if _, ok := req["prompt"]; !ok { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"} + } + return nil +} + +func (a *AliImageAdaptor) 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 + } + if n, ok := req["n"].(float64); ok && n > 1 { + ratios["count"] = n + } + return ratios +} + +func (a *AliImageAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *AliImageAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + return baseURL + "/api/v1/services/aigc/text2image/image-synthesis", nil +} + +func (a *AliImageAdaptor) 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 *AliImageAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + converted, err := convertToDashScopeRequest(body, info) + if err != nil { + return nil, err + } + return strings.NewReader(string(converted)), nil +} + +func (a *AliImageAdaptor) 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 *AliImageAdaptor) 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"} + } + + 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 但带有 code/message + 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 *AliImageAdaptor) 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 *AliImageAdaptor) 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%" + if len(resp.Output.Results) > 0 { + info.ResultURL = resp.Output.Results[0].URL + for i, r := range resp.Output.Results { + info.SubTasks = append(info.SubTasks, common.SubTask{ + Index: i, + ResultURL: r.URL, + }) + } + } + 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 *AliImageAdaptor) GetModelList() []string { + return ModelList +} + +func (a *AliImageAdaptor) GetChannelName() string { + return channelName +} diff --git a/relay/taskchannel/ali/constants.go b/relay/taskchannel/ali/constants.go new file mode 100644 index 0000000..b2f212d --- /dev/null +++ b/relay/taskchannel/ali/constants.go @@ -0,0 +1,22 @@ +package ali + +const channelName = "AliImage" + +// ModelList 支持异步图片生成的 DashScope 模型 +var ModelList = []string{ + "wanx-v1", + "wanx-v2", + "wanx2.1-t2i-ediff", + "wanx-sketch-to-image-v2", + "wanx-image-inpainting", + "wanx-style-repaint", + "wanx2.1-image-edit", + "wanx2.5-image-edit", + "wanx-image-outpainting", + "qwen-image-generation", + "qwen-image-edit", + "zimage", + "flux-dev", + "flux-schnell", + "stable-diffusion-xl", +} diff --git a/relay/taskchannel/ali/converter.go b/relay/taskchannel/ali/converter.go new file mode 100644 index 0000000..8f9dbfd --- /dev/null +++ b/relay/taskchannel/ali/converter.go @@ -0,0 +1,99 @@ +package ali + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// convertToDashScopeRequest 将 OpenAI Images 格式转换为 DashScope 原生格式 +// OpenAI: {model, prompt, size, n, style, ...} +// DashScope: {model, input: {prompt, negative_prompt}, parameters: {size, n, style, ...}} +func convertToDashScopeRequest(body []byte, info *common.RelayInfo) ([]byte, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse request: %w", err) + } + + req := dashScopeRequest{ + Input: dashScopeInput{}, + Parameters: make(map[string]any), + } + + // model + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + req.Model = info.ChannelMeta.UpstreamModelName + } else if v, ok := raw["model"]; ok { + _ = json.Unmarshal(v, &req.Model) + } + + // prompt → input.prompt + if v, ok := raw["prompt"]; ok { + _ = json.Unmarshal(v, &req.Input.Prompt) + } + + // negative_prompt → input.negative_prompt + if v, ok := raw["negative_prompt"]; ok { + _ = json.Unmarshal(v, &req.Input.NegativePrompt) + } + + // size: 1024x1024 → 1024*1024 + if v, ok := raw["size"]; ok { + var size string + if err := json.Unmarshal(v, &size); err == nil && size != "" { + req.Parameters["size"] = strings.ReplaceAll(size, "x", "*") + } + } + + // n + if v, ok := raw["n"]; ok { + var n int + if err := json.Unmarshal(v, &n); err == nil && n > 0 { + req.Parameters["n"] = n + } + } + + // seed + if v, ok := raw["seed"]; ok { + var seed int + if err := json.Unmarshal(v, &seed); err == nil { + req.Parameters["seed"] = seed + } + } + + // style + if v, ok := raw["style"]; ok { + var style string + if err := json.Unmarshal(v, &style); err == nil && style != "" { + req.Parameters["style"] = style + } + } + + // ref_strength + if v, ok := raw["ref_strength"]; ok { + var strength float64 + if err := json.Unmarshal(v, &strength); err == nil { + req.Parameters["ref_strength"] = strength + } + } + + // ref_img + if v, ok := raw["ref_img"]; ok { + var refImg string + if err := json.Unmarshal(v, &refImg); err == nil && refImg != "" { + req.Parameters["ref_img"] = refImg + } + } + + if len(req.Parameters) == 0 { + req.Parameters = nil + } + + result, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal dashscope request: %w", err) + } + return result, nil +} diff --git a/relay/taskchannel/ali/types.go b/relay/taskchannel/ali/types.go new file mode 100644 index 0000000..9bf5404 --- /dev/null +++ b/relay/taskchannel/ali/types.go @@ -0,0 +1,45 @@ +package ali + +// dashScopeRequest DashScope 异步图片生成请求 +type dashScopeRequest struct { + Model string `json:"model"` + Input dashScopeInput `json:"input"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +// dashScopeInput DashScope 请求输入 +type dashScopeInput struct { + Prompt string `json:"prompt,omitempty"` + NegativePrompt string `json:"negative_prompt,omitempty"` +} + +// dashScopeSubmitResponse 异步提交响应 +type dashScopeSubmitResponse struct { + Output struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + } `json:"output"` + RequestID string `json:"request_id"` +} + +// dashScopeTaskResponse 异步任务轮询响应 +type dashScopeTaskResponse struct { + Output struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + Results []dashScopeResult `json:"results"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + } `json:"output"` + Usage struct { + ImageCount int `json:"image_count"` + } `json:"usage,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// dashScopeResult 单个图片结果 +type dashScopeResult struct { + URL string `json:"url"` +}