mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 18:18:00 +00:00
* feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task plugin
MiniMax-H3 speaks a different contract from the other Hailuo models, so the
hailuo task plugin now branches on the upstream model instead of adding a Go
adaptor:
- submit builds /v2/video_generation with a multimodal `content` array
(text, first/last frame images, reference video/audio, or a full
`metadata.content` passthrough), an explicit `ratio`, and 768P/2K
resolutions; `metadata.callback_url` and `metadata.aigc_watermark` pass
through
- query uses /v2/query/video_generation/{task_id} and parses the
`{"task": {...}}` envelope, falling back to the /v1 shapes for every other
model
- the /v2 result is a public CDN URL, so its artifact is proxied
credentialless instead of through /v1/files/download
- request bounds (duration 4-15, resolution 768P/2K, ratio whitelist, at most
2 frame images and 9/3/3 reference images/videos/audios) are enforced while
the request body is built, which the host runs during validation, so an
out-of-range duration is rejected with a 400 before it can become a billing
multiplier
- duration and resolution are reported as usage facts only. Like the rest of
this plugin, extractUsage returns no billing ratios, so per-call pricing is
flat and 2K/duration pricing is expressed through the model's tiered billing
expression over those facts.
Query hooks are driver hooks and are documented to receive `ctx.model` and
`ctx.upstreamModel`, but polling has no relay info and never populated them.
The polling and realtime-fetch call sites now carry the persisted task model
properties and the plugin adaptor maps them onto the query context, with
`upstreamModel` falling back to the origin name for tasks submitted without a
channel mapping.
* fix(plugin): validate Hailuo H3 requests and errors
647 lines
23 KiB
Go
647 lines
23 KiB
Go
package relay
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/constant"
|
||
"github.com/QuantumNous/new-api/dto"
|
||
"github.com/QuantumNous/new-api/model"
|
||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||
"github.com/QuantumNous/new-api/relay/channel"
|
||
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
|
||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||
"github.com/QuantumNous/new-api/relay/helper"
|
||
"github.com/QuantumNous/new-api/service"
|
||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||
"github.com/QuantumNous/new-api/types"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type TaskSubmitResult struct {
|
||
UpstreamTaskID string
|
||
TaskData []byte
|
||
ClientResponse any
|
||
Platform constant.TaskPlatform
|
||
Quota int
|
||
Immediate *relaycommon.TaskInfo
|
||
//PerCallPrice types.PriceData
|
||
}
|
||
|
||
// ResolveOriginTask 处理基于已有任务的提交(remix / continuation):
|
||
// 查找原始任务、从中提取模型名称、将渠道锁定到原始任务的渠道
|
||
// (通过 info.LockedChannel,重试时复用同一渠道并轮换 key),
|
||
// 以及提取 OtherRatios(时长、分辨率)。
|
||
// 该函数在控制器的重试循环之前调用一次,其结果通过 info 字段和上下文持久化。
|
||
func ResolveOriginTask(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||
// 检测 remix action
|
||
path := c.Request.URL.Path
|
||
if strings.Contains(path, "/v1/videos/") && strings.HasSuffix(path, "/remix") {
|
||
info.Action = constant.TaskActionRemix
|
||
}
|
||
|
||
// 提取 remix 任务的 video_id
|
||
if info.Action == constant.TaskActionRemix {
|
||
videoID := c.Param("video_id")
|
||
if strings.TrimSpace(videoID) == "" {
|
||
return service.TaskErrorWrapperLocal(fmt.Errorf("video_id is required"), "invalid_request", http.StatusBadRequest)
|
||
}
|
||
info.OriginTaskID = videoID
|
||
}
|
||
|
||
if info.OriginTaskID == "" {
|
||
return nil
|
||
}
|
||
|
||
// 查找原始任务
|
||
originTask, exist, err := model.GetByTaskId(info.UserId, info.OriginTaskID)
|
||
if err != nil {
|
||
return service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError)
|
||
}
|
||
if !exist {
|
||
return service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest)
|
||
}
|
||
|
||
// 从原始任务推导模型名称
|
||
if info.OriginModelName == "" {
|
||
if originTask.Properties.OriginModelName != "" {
|
||
info.OriginModelName = originTask.Properties.OriginModelName
|
||
} else if originTask.Properties.UpstreamModelName != "" {
|
||
info.OriginModelName = originTask.Properties.UpstreamModelName
|
||
} else {
|
||
var taskData map[string]interface{}
|
||
_ = common.Unmarshal(originTask.Data, &taskData)
|
||
if m, ok := taskData["model"].(string); ok && m != "" {
|
||
info.OriginModelName = m
|
||
}
|
||
}
|
||
}
|
||
|
||
// 锁定到原始任务的渠道(重试时复用同一渠道,轮换 key)
|
||
ch, err := model.GetChannelById(originTask.ChannelId, true)
|
||
if err != nil {
|
||
return service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest)
|
||
}
|
||
if ch.Status != common.ChannelStatusEnabled {
|
||
return service.TaskErrorWrapperLocal(errors.New("the channel of the origin task is disabled"), "task_channel_disable", http.StatusBadRequest)
|
||
}
|
||
info.LockedChannel = ch
|
||
|
||
if originTask.ChannelId != info.ChannelId {
|
||
key, _, newAPIError := ch.GetNextEnabledKey()
|
||
if newAPIError != nil {
|
||
return service.TaskErrorWrapper(newAPIError, "channel_no_available_key", newAPIError.StatusCode)
|
||
}
|
||
common.SetContextKey(c, constant.ContextKeyChannelKey, key)
|
||
common.SetContextKey(c, constant.ContextKeyChannelType, ch.Type)
|
||
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, ch.GetBaseURL())
|
||
common.SetContextKey(c, constant.ContextKeyChannelId, originTask.ChannelId)
|
||
|
||
info.ChannelBaseUrl = ch.GetBaseURL()
|
||
info.ChannelId = originTask.ChannelId
|
||
info.ChannelType = ch.Type
|
||
info.ApiKey = key
|
||
}
|
||
|
||
// 提取 remix 参数(时长、分辨率 → OtherRatios)
|
||
if info.Action == constant.TaskActionRemix {
|
||
if originTask.PrivateData.BillingContext != nil {
|
||
// 新的 remix 逻辑:直接从原始任务的 BillingContext 中提取 OtherRatios(如果存在)
|
||
for s, f := range originTask.PrivateData.BillingContext.OtherRatios {
|
||
info.PriceData.AddOtherRatio(s, f)
|
||
}
|
||
} else {
|
||
// 旧的 remix 逻辑:直接从 task data 解析 seconds 和 size(如果存在)
|
||
var taskData map[string]interface{}
|
||
_ = common.Unmarshal(originTask.Data, &taskData)
|
||
secondsStr, _ := taskData["seconds"].(string)
|
||
seconds, _ := strconv.Atoi(secondsStr)
|
||
if seconds <= 0 {
|
||
seconds = 4
|
||
}
|
||
// 历史任务数据可能包含未经校验的时长,作为计费乘数前必须钳制
|
||
if seconds > relaycommon.MaxTaskDurationSeconds {
|
||
seconds = relaycommon.MaxTaskDurationSeconds
|
||
}
|
||
sizeStr, _ := taskData["size"].(string)
|
||
info.PriceData.AddOtherRatio("seconds", float64(seconds))
|
||
info.PriceData.AddOtherRatio("size", 1)
|
||
if sizeStr == "1792x1024" || sizeStr == "1024x1792" {
|
||
info.PriceData.AddOtherRatio("size", 1.666667)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ApplyChannelPin copies plugin-declared origin-task facts from the prepare
|
||
// context onto RelayInfo and, when the resolved pin retries on the same
|
||
// channel, writes LockedChannel. ResolveOriginTask is unchanged.
|
||
func ApplyChannelPin(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||
if info == nil {
|
||
return nil
|
||
}
|
||
if info.TaskRelayInfo == nil {
|
||
info.TaskRelayInfo = &relaycommon.TaskRelayInfo{}
|
||
}
|
||
if tasks, ok := common.GetContextKeyType[[]*model.Task](c, constant.ContextKeyOriginTasks); ok {
|
||
refs := make([]relaycommon.OriginTaskRef, 0, len(tasks))
|
||
for _, task := range tasks {
|
||
if task == nil {
|
||
continue
|
||
}
|
||
refs = append(refs, relaycommon.OriginTaskRef{
|
||
TaskID: task.TaskID,
|
||
UpstreamTaskID: task.GetUpstreamTaskID(),
|
||
Action: task.Action,
|
||
Status: string(task.Status),
|
||
Data: append([]byte(nil), task.Data...),
|
||
})
|
||
}
|
||
info.OriginTasks = refs
|
||
}
|
||
pin, found, _ := service.GetChannelConstraints(c).ResolvedPin()
|
||
if !found || pin.RetryMode != dto.PinRetrySameChannel {
|
||
return nil
|
||
}
|
||
ch, err := model.CacheGetChannel(pin.ChannelId)
|
||
if err != nil {
|
||
return service.TaskErrorWrapperLocal(err, "origin_task_channel_disabled", http.StatusBadRequest)
|
||
}
|
||
if ch.Status != common.ChannelStatusEnabled {
|
||
return service.TaskErrorWrapperLocal(errors.New("the channel of the origin task is disabled"), "origin_task_channel_disabled", http.StatusBadRequest)
|
||
}
|
||
info.LockedChannel = ch
|
||
return nil
|
||
}
|
||
|
||
// ApplyOriginTaskAffinity is the compatibility name for ApplyChannelPin.
|
||
func ApplyOriginTaskAffinity(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||
return ApplyChannelPin(c, info)
|
||
}
|
||
|
||
// RelayTaskSubmit 完成 task 提交的全部流程(每次尝试调用一次):
|
||
// 刷新渠道元数据 → 确定 platform/adaptor → 验证请求 →
|
||
// 估算计费(EstimateBilling) → 计算价格 → 预扣费(仅首次)→
|
||
// 构建/发送/解析上游请求 → 提交后计费调整(AdjustBillingOnSubmit)。
|
||
// 共享控制器编排负责未落库退款、最终额度预留、落库和结算。
|
||
func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitResult, *dto.TaskError) {
|
||
info.InitChannelMeta(c)
|
||
|
||
// 1. 确定 platform → 创建适配器 → 验证请求
|
||
platform := constant.TaskPlatform(c.GetString("platform"))
|
||
if platform == "" {
|
||
platform = GetTaskPlatform(c)
|
||
}
|
||
platform, adaptor := getTaskAdaptorForRequest(c, platform)
|
||
if adaptor == nil {
|
||
code, message := TaskPlatformUnavailableError(platform)
|
||
return nil, service.TaskErrorWrapperLocal(errors.New(message), code, http.StatusBadRequest)
|
||
}
|
||
// buildSubmitRequest runs during validation and the unreleased plugin
|
||
// contract exposes this host-generated id to that hook.
|
||
if info.PublicTaskID == "" {
|
||
info.PublicTaskID = model.GenerateTaskID()
|
||
}
|
||
adaptor.Init(info)
|
||
// Plugin submit hooks run during ValidateRequestAndSetAction and cache the
|
||
// upstream body. OriginModelName is already seeded on that line (protocol
|
||
// resolved_task_model, legacy submit, or GenRelayInfo original_model), so
|
||
// map before validation. The empty-name CoverTaskActionToModelName
|
||
// synthesis happens after validate and cannot move; skip the late block
|
||
// when early mapping ran so a chain is never applied twice.
|
||
mappedBeforeValidate := info.OriginModelName != ""
|
||
if mappedBeforeValidate {
|
||
info.UpstreamModelName = info.OriginModelName
|
||
if err := helper.ModelMappedHelper(c, info, nil); err != nil {
|
||
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
|
||
}
|
||
}
|
||
if taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil {
|
||
return nil, taskErr
|
||
}
|
||
|
||
// 2. 确定模型名称
|
||
modelName := info.OriginModelName
|
||
if modelName == "" {
|
||
modelName = service.CoverTaskActionToModelName(platform, info.Action)
|
||
}
|
||
|
||
if !mappedBeforeValidate {
|
||
info.OriginModelName = modelName
|
||
info.UpstreamModelName = modelName
|
||
if err := helper.ModelMappedHelper(c, info, nil); err != nil {
|
||
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
|
||
}
|
||
}
|
||
|
||
// 4. 价格计算:基础模型价格
|
||
info.OriginModelName = modelName
|
||
var priceData types.PriceData
|
||
var err error
|
||
useTiered := billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr
|
||
var exprStr string
|
||
var exists bool
|
||
if useTiered {
|
||
exprStr, exists = billing_setting.GetBillingExpr(modelName)
|
||
} else if info.IsModelMapped {
|
||
if billing_setting.GetBillingMode(info.UpstreamModelName) == billing_setting.BillingModeTieredExpr {
|
||
if tailExpr, tailOK := billing_setting.GetBillingExpr(info.UpstreamModelName); tailOK && strings.TrimSpace(tailExpr) != "" {
|
||
exprStr = tailExpr
|
||
exists = true
|
||
useTiered = true
|
||
}
|
||
}
|
||
}
|
||
if useTiered {
|
||
provider, supported := adaptor.(channel.TaskUsageFactsProvider)
|
||
if !exists || !supported {
|
||
return nil, service.TaskErrorWrapper(fmt.Errorf("task model %s has no usage expression or meter", modelName), "model_price_error", http.StatusBadRequest)
|
||
}
|
||
var facts map[string]any
|
||
if validatedProvider, ok := adaptor.(channel.TaskValidatedUsageFactsProvider); ok {
|
||
facts, err = validatedProvider.ExtractUsageFactsValidated(c, info)
|
||
if err != nil {
|
||
return nil, service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
|
||
}
|
||
} else {
|
||
facts = provider.ExtractUsageFacts(c, info)
|
||
}
|
||
cost, trace, runErr := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: facts})
|
||
if runErr != nil || cost < 0 {
|
||
if runErr == nil {
|
||
runErr = fmt.Errorf("negative task expression result")
|
||
}
|
||
return nil, service.TaskErrorWrapper(runErr, "model_price_error", http.StatusBadRequest)
|
||
}
|
||
groupRatioInfo := helper.HandleGroupRatio(c, info)
|
||
quota, clamp := common.QuotaRoundChecked(cost * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||
noteTaskQuotaClamp(info, clamp)
|
||
priceData = types.PriceData{Quota: quota, QuotaToPreConsume: quota, GroupRatioInfo: groupRatioInfo}
|
||
info.TieredBillingSnapshot = &billingexpr.BillingSnapshot{BillingMode: billing_setting.BillingModeTieredExpr, ModelName: modelName, ExprString: exprStr, ExprHash: billingexpr.ExprHashString(exprStr), GroupRatio: groupRatioInfo.GroupRatio, EstimatedQuotaBeforeGroup: cost * common.QuotaPerUnit, EstimatedQuotaAfterGroup: quota, EstimatedTier: trace.MatchedTier, QuotaPerUnit: common.QuotaPerUnit, ExprVersion: billingexpr.ExprVersion(exprStr), TaskUsageBilling: true, UsageFacts: facts}
|
||
} else {
|
||
priceData, err = helper.ModelPriceHelperPerCall(c, info)
|
||
if err != nil {
|
||
return nil, service.TaskErrorWrapper(err, "model_price_error", http.StatusBadRequest)
|
||
}
|
||
}
|
||
info.PriceData = priceData
|
||
|
||
// 5. 计费估算:让适配器根据用户请求提供 OtherRatios(时长、分辨率等)
|
||
// 必须在 ModelPriceHelperPerCall 之后调用(它会重建 PriceData)。
|
||
// ResolveOriginTask 可能已在 remix 路径中预设了 OtherRatios,此处合并。
|
||
if info.TieredBillingSnapshot == nil {
|
||
var estimatedRatios map[string]float64
|
||
if validatedProvider, ok := adaptor.(channel.TaskValidatedBillingProvider); ok {
|
||
estimatedRatios, err = validatedProvider.EstimateBillingValidated(c, info)
|
||
if err != nil {
|
||
return nil, service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
|
||
}
|
||
} else {
|
||
estimatedRatios = adaptor.EstimateBilling(c, info)
|
||
}
|
||
if len(estimatedRatios) > 0 {
|
||
for k, v := range estimatedRatios {
|
||
info.PriceData.AddOtherRatio(k, v)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. 将 OtherRatios 应用到基础额度(饱和转换,防止溢出成负数)
|
||
if info.TieredBillingSnapshot == nil && !common.StringsContains(constant.TaskPricePatches, modelName) {
|
||
quotaWithRatios := info.PriceData.ApplyOtherRatiosToFloat(float64(info.PriceData.Quota))
|
||
quota, clamp := common.QuotaFromFloatChecked(quotaWithRatios)
|
||
info.PriceData.Quota = quota
|
||
noteTaskQuotaClamp(info, clamp)
|
||
}
|
||
|
||
// 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过)
|
||
if info.Billing == nil && !info.PriceData.FreeModel {
|
||
info.ForcePreConsume = true
|
||
if apiErr := service.PreConsumeBilling(c, info.PriceData.Quota, info); apiErr != nil {
|
||
return nil, service.TaskErrorFromAPIError(apiErr)
|
||
}
|
||
}
|
||
|
||
// 8. 构建请求体
|
||
requestBody, err := adaptor.BuildRequestBody(c, info)
|
||
if err != nil {
|
||
return nil, service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError)
|
||
}
|
||
|
||
// 9. 发送请求
|
||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||
if err != nil {
|
||
return nil, service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
|
||
}
|
||
if resp == nil {
|
||
return nil, service.TaskErrorWrapperLocal(errors.New("upstream returned an empty response"), "fail_to_fetch_task", http.StatusBadGateway)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
responseBody, _ := io.ReadAll(resp.Body)
|
||
return nil, service.TaskErrorWrapper(fmt.Errorf("%s", string(responseBody)), "fail_to_fetch_task", resp.StatusCode)
|
||
}
|
||
|
||
// 10. Parse only. The controller presents the response after the durable
|
||
// task barrier and billing settlement.
|
||
parsed, taskErr := adaptor.ParseResponse(c, resp, info)
|
||
if taskErr != nil {
|
||
return nil, taskErr
|
||
}
|
||
if parsed == nil {
|
||
return nil, service.TaskErrorWrapperLocal(errors.New("task adaptor returned an empty response"), "plugin_submit_response_invalid", http.StatusBadGateway)
|
||
}
|
||
|
||
// 11. 提交后计费调整:让适配器根据上游实际返回调整 OtherRatios
|
||
finalQuota := info.PriceData.Quota
|
||
if info.TieredBillingSnapshot == nil {
|
||
if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, parsed.TaskData); len(adjustedRatios) > 0 {
|
||
if adjustedQuota, ok := recalcQuotaFromRatios(info, adjustedRatios); ok {
|
||
// 基于调整后的 ratios 重新计算 quota
|
||
finalQuota = adjustedQuota
|
||
info.PriceData.ReplaceOtherRatios(adjustedRatios)
|
||
info.PriceData.Quota = finalQuota
|
||
}
|
||
}
|
||
}
|
||
|
||
return &TaskSubmitResult{
|
||
UpstreamTaskID: parsed.UpstreamTaskID,
|
||
TaskData: parsed.TaskData,
|
||
ClientResponse: parsed.ClientResponse,
|
||
Platform: platform,
|
||
Quota: finalQuota,
|
||
Immediate: parsed.Immediate,
|
||
}, nil
|
||
}
|
||
|
||
// recalcQuotaFromRatios 根据 adjustedRatios 重新计算 quota。
|
||
// 公式: baseQuota × ∏(ratio) — 其中 baseQuota 是不含 OtherRatios 的基础额度。
|
||
func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64) (int, bool) {
|
||
// 从 PriceData 获取不含 OtherRatios 的基础价格
|
||
baseQuota := info.PriceData.RemoveOtherRatiosFromFloat(float64(info.PriceData.Quota))
|
||
priceData := info.PriceData
|
||
if !priceData.ReplaceOtherRatios(ratios) {
|
||
return 0, false
|
||
}
|
||
// 应用新的 ratios
|
||
result := priceData.ApplyOtherRatiosToFloat(baseQuota)
|
||
quota, clamp := common.QuotaFromFloatChecked(result)
|
||
noteTaskQuotaClamp(info, clamp)
|
||
return quota, true
|
||
}
|
||
|
||
// noteTaskQuotaClamp records the first quota saturation event onto the task's
|
||
// RelayInfo so LogTaskConsumption can surface it on the submit log's
|
||
// admin_info. First non-nil clamp wins.
|
||
func noteTaskQuotaClamp(info *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
|
||
if clamp == nil || info == nil {
|
||
return
|
||
}
|
||
if info.QuotaClamp == nil {
|
||
info.QuotaClamp = clamp
|
||
}
|
||
}
|
||
|
||
var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){
|
||
relayconstant.RelayModeVideoFetchByID: videoFetchByIDRespBodyBuilder,
|
||
}
|
||
|
||
func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) {
|
||
respBuilder, ok := fetchRespBuilders[relayMode]
|
||
if !ok {
|
||
taskResp = service.TaskErrorWrapperLocal(errors.New("invalid_relay_mode"), "invalid_relay_mode", http.StatusBadRequest)
|
||
}
|
||
|
||
respBody, taskErr := respBuilder(c)
|
||
if taskErr != nil {
|
||
return taskErr
|
||
}
|
||
if len(respBody) == 0 {
|
||
respBody = []byte("{\"code\":\"success\",\"data\":null}")
|
||
}
|
||
|
||
c.Writer.Header().Set("Content-Type", "application/json")
|
||
_, err := io.Copy(c.Writer, bytes.NewBuffer(respBody))
|
||
if err != nil {
|
||
taskResp = service.TaskErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
return
|
||
}
|
||
|
||
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
|
||
taskId := c.Param("task_id")
|
||
if taskId == "" {
|
||
taskId = c.GetString("task_id")
|
||
}
|
||
userId := c.GetInt("id")
|
||
|
||
originTask, exist, err := model.GetByTaskId(userId, taskId)
|
||
if err != nil {
|
||
taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !exist {
|
||
taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
isOpenAIVideoAPI := strings.HasPrefix(c.Request.RequestURI, "/v1/videos/")
|
||
|
||
// Gemini/Vertex 支持实时查询:用户 fetch 时直接从上游拉取最新状态
|
||
if realtimeResp := tryRealtimeFetch(originTask, isOpenAIVideoAPI); len(realtimeResp) > 0 {
|
||
respBody = realtimeResp
|
||
return
|
||
}
|
||
|
||
// OpenAI Video API 格式: 走各 adaptor 的 ConvertToOpenAIVideo
|
||
if isOpenAIVideoAPI {
|
||
adaptor := GetTaskAdaptor(originTask.Platform)
|
||
if adaptor == nil {
|
||
taskResp = service.TaskErrorWrapperLocal(fmt.Errorf("invalid channel id: %d", originTask.ChannelId), "invalid_channel_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if converter, ok := adaptor.(channel.OpenAIVideoConverter); ok {
|
||
openAIVideoData, err := converter.ConvertToOpenAIVideo(originTask)
|
||
if err != nil {
|
||
taskResp = service.TaskErrorWrapper(err, "convert_to_openai_video_failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
respBody = openAIVideoData
|
||
return
|
||
}
|
||
taskResp = service.TaskErrorWrapperLocal(fmt.Errorf("not_implemented:%s", originTask.Platform), "not_implemented", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
|
||
// 通用 TaskDto 格式
|
||
respBody, err = common.Marshal(dto.TaskResponse[any]{
|
||
Code: "success",
|
||
Data: TaskModel2Dto(originTask),
|
||
})
|
||
if err != nil {
|
||
taskResp = service.TaskErrorWrapper(err, "marshal_response_failed", http.StatusInternalServerError)
|
||
}
|
||
return
|
||
}
|
||
|
||
// tryRealtimeFetch 尝试从上游实时拉取 Gemini/Vertex 任务状态。
|
||
// 仅当渠道类型为 Gemini 或 Vertex 时触发;其他渠道或出错时返回 nil。
|
||
// 当非 OpenAI Video API 时,还会构建自定义格式的响应体。
|
||
func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
|
||
channelModel, err := model.GetChannelById(task.ChannelId, true)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if channelModel.Type != constant.ChannelTypeVertexAi && channelModel.Type != constant.ChannelTypeGemini {
|
||
return nil
|
||
}
|
||
|
||
baseURL := constant.GetChannelBaseURL(channelModel.Type)
|
||
if channelModel.GetBaseURL() != "" {
|
||
baseURL = channelModel.GetBaseURL()
|
||
}
|
||
proxy := channelModel.GetSetting().Proxy
|
||
adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type)))
|
||
if adaptor == nil {
|
||
return nil
|
||
}
|
||
|
||
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
|
||
"task_id": task.GetUpstreamTaskID(),
|
||
"action": constant.NormalizeTaskAction(task.Action),
|
||
"model": task.Properties.OriginModelName,
|
||
"upstream_model": task.Properties.UpstreamModelName,
|
||
}, proxy)
|
||
if err != nil || resp == nil {
|
||
return nil
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
ti, err := adaptor.ParseTaskResult(body)
|
||
if err != nil || ti == nil {
|
||
return nil
|
||
}
|
||
|
||
snap := task.Snapshot()
|
||
|
||
// 将上游最新状态更新到 task
|
||
if ti.Status != "" {
|
||
task.Status = model.TaskStatus(ti.Status)
|
||
}
|
||
if ti.Progress != "" {
|
||
task.Progress = ti.Progress
|
||
}
|
||
if strings.HasPrefix(ti.Url, "data:") {
|
||
// data: URI — kept in Data, not ResultURL
|
||
} else if ti.Url != "" {
|
||
task.PrivateData.ResultURL = ti.Url
|
||
} else if task.Status == model.TaskStatusSuccess {
|
||
// No URL from adaptor — construct proxy URL using public task ID
|
||
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
|
||
}
|
||
|
||
if !snap.Equal(task.Snapshot()) {
|
||
_, _ = task.UpdateWithStatus(snap.Status)
|
||
}
|
||
|
||
// OpenAI Video API 由调用者的 ConvertToOpenAIVideo 分支处理
|
||
if isOpenAIVideoAPI {
|
||
return nil
|
||
}
|
||
|
||
// 非 OpenAI Video API: 构建自定义格式响应
|
||
format := detectVideoFormat(body)
|
||
out := map[string]any{
|
||
"error": nil,
|
||
"format": format,
|
||
"metadata": nil,
|
||
"status": mapTaskStatusToSimple(task.Status),
|
||
"task_id": task.TaskID,
|
||
"url": task.GetResultURL(),
|
||
}
|
||
respBody, _ := common.Marshal(dto.TaskResponse[any]{
|
||
Code: "success",
|
||
Data: out,
|
||
})
|
||
return respBody
|
||
}
|
||
|
||
// detectVideoFormat 从 Gemini/Vertex 原始响应中探测视频格式
|
||
func detectVideoFormat(rawBody []byte) string {
|
||
var raw map[string]any
|
||
if err := common.Unmarshal(rawBody, &raw); err != nil {
|
||
return "mp4"
|
||
}
|
||
respObj, ok := raw["response"].(map[string]any)
|
||
if !ok {
|
||
return "mp4"
|
||
}
|
||
vids, ok := respObj["videos"].([]any)
|
||
if !ok || len(vids) == 0 {
|
||
return "mp4"
|
||
}
|
||
v0, ok := vids[0].(map[string]any)
|
||
if !ok {
|
||
return "mp4"
|
||
}
|
||
mt, ok := v0["mimeType"].(string)
|
||
if !ok || mt == "" || strings.Contains(mt, "mp4") {
|
||
return "mp4"
|
||
}
|
||
return mt
|
||
}
|
||
|
||
// mapTaskStatusToSimple 将内部 TaskStatus 映射为简化状态字符串
|
||
func mapTaskStatusToSimple(status model.TaskStatus) string {
|
||
switch status {
|
||
case model.TaskStatusSuccess:
|
||
return "succeeded"
|
||
case model.TaskStatusFailure:
|
||
return "failed"
|
||
case model.TaskStatusQueued, model.TaskStatusSubmitted:
|
||
return "queued"
|
||
default:
|
||
return "processing"
|
||
}
|
||
}
|
||
|
||
func TaskModel2Dto(task *model.Task) *dto.TaskDto {
|
||
return &dto.TaskDto{
|
||
ID: task.ID,
|
||
CreatedAt: task.CreatedAt,
|
||
UpdatedAt: task.UpdatedAt,
|
||
TaskID: task.TaskID,
|
||
Platform: string(task.Platform),
|
||
UserId: task.UserId,
|
||
Group: task.Group,
|
||
ChannelId: task.ChannelId,
|
||
Quota: task.Quota,
|
||
Action: constant.NormalizeTaskAction(task.Action),
|
||
Status: string(task.Status),
|
||
FailReason: task.FailReason,
|
||
ResultURL: task.GetResultURL(),
|
||
SubmitTime: task.SubmitTime,
|
||
StartTime: task.StartTime,
|
||
FinishTime: task.FinishTime,
|
||
Progress: task.Progress,
|
||
Properties: task.Properties,
|
||
Username: task.Username,
|
||
Data: task.Data,
|
||
}
|
||
}
|