feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)

This commit is contained in:
Calcium-Ion
2026-08-29 18:51:57 +08:00
committed by GitHub
parent 7037ac15bd
commit eb48396d5f
336 changed files with 52333 additions and 6369 deletions
+135 -91
View File
@@ -13,20 +13,25 @@ import (
"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
}
@@ -137,11 +142,57 @@ func ResolveOriginTask(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskErr
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)。
// 控制器负责 defer Refund 和成功后 Settle
// 共享控制器编排负责未落库退款、最终额度预留、落库和结算
func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitResult, *dto.TaskError) {
info.InitChannelMeta(c)
@@ -150,9 +201,15 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
if platform == "" {
platform = GetTaskPlatform(c)
}
adaptor := GetTaskAdaptor(platform)
platform, adaptor := getTaskAdaptorForRequest(c, platform)
if adaptor == nil {
return nil, service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest)
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)
if taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil {
@@ -172,30 +229,67 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
}
// 3. 预生成公开 task ID(仅首次)
if info.PublicTaskID == "" {
info.PublicTaskID = model.GenerateTaskID()
}
// 4. 价格计算:基础模型价格
info.OriginModelName = modelName
priceData, err := helper.ModelPriceHelperPerCall(c, info)
if err != nil {
return nil, service.TaskErrorWrapper(err, "model_price_error", http.StatusBadRequest)
var priceData types.PriceData
var err error
if billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr {
exprStr, exists := billing_setting.GetBillingExpr(modelName)
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 estimatedRatios := adaptor.EstimateBilling(c, info); len(estimatedRatios) > 0 {
for k, v := range estimatedRatios {
info.PriceData.AddOtherRatio(k, v)
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 !common.StringsContains(constant.TaskPricePatches, modelName) {
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
@@ -221,41 +315,45 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
if err != nil {
return nil, service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
}
if resp != nil && resp.StatusCode != http.StatusOK {
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. 返回 OtherRatios 给下游(header 必须在 DoResponse 写 body 之前设置)
otherRatios := info.PriceData.OtherRatios()
if otherRatios == nil {
otherRatios = map[string]float64{}
}
ratiosJSON, _ := common.Marshal(otherRatios)
c.Header("X-New-Api-Other-Ratios", string(ratiosJSON))
// 11. 解析响应
upstreamTaskID, taskData, taskErr := adaptor.DoResponse(c, resp, info)
// 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 adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 {
if adjustedQuota, ok := recalcQuotaFromRatios(info, adjustedRatios); ok {
// 基于调整后的 ratios 重新计算 quota
finalQuota = adjustedQuota
info.PriceData.ReplaceOtherRatios(adjustedRatios)
info.PriceData.Quota = finalQuota
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: upstreamTaskID,
TaskData: taskData,
UpstreamTaskID: parsed.UpstreamTaskID,
TaskData: parsed.TaskData,
ClientResponse: parsed.ClientResponse,
Platform: platform,
Quota: finalQuota,
Immediate: parsed.Immediate,
}, nil
}
@@ -288,8 +386,6 @@ func noteTaskQuotaClamp(info *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
}
var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){
relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder,
relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder,
relayconstant.RelayModeVideoFetchByID: videoFetchByIDRespBodyBuilder,
}
@@ -316,58 +412,6 @@ func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) {
return
}
func sunoFetchRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
userId := c.GetInt("id")
var condition = struct {
IDs []any `json:"ids"`
Action string `json:"action"`
}{}
err := c.BindJSON(&condition)
if err != nil {
taskResp = service.TaskErrorWrapper(err, "invalid_request", http.StatusBadRequest)
return
}
var tasks []any
if len(condition.IDs) > 0 {
taskModels, err := model.GetByTaskIds(userId, condition.IDs)
if err != nil {
taskResp = service.TaskErrorWrapper(err, "get_tasks_failed", http.StatusInternalServerError)
return
}
for _, task := range taskModels {
tasks = append(tasks, TaskModel2Dto(task))
}
} else {
tasks = make([]any, 0)
}
respBody, err = common.Marshal(dto.TaskResponse[[]any]{
Code: "success",
Data: tasks,
})
return
}
func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
taskId := c.Param("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
}
respBody, err = common.Marshal(dto.TaskResponse[any]{
Code: "success",
Data: TaskModel2Dto(originTask),
})
return
}
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
taskId := c.Param("task_id")
if taskId == "" {
@@ -436,7 +480,7 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
return nil
}
baseURL := constant.ChannelBaseURLs[channelModel.Type]
baseURL := constant.GetChannelBaseURL(channelModel.Type)
if channelModel.GetBaseURL() != "" {
baseURL = channelModel.GetBaseURL()
}
@@ -448,7 +492,7 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
"task_id": task.GetUpstreamTaskID(),
"action": task.Action,
"action": constant.NormalizeTaskAction(task.Action),
}, proxy)
if err != nil || resp == nil {
return nil
@@ -558,7 +602,7 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto {
Group: task.Group,
ChannelId: task.ChannelId,
Quota: task.Quota,
Action: task.Action,
Action: constant.NormalizeTaskAction(task.Action),
Status: string(task.Status),
FailReason: task.FailReason,
ResultURL: task.GetResultURL(),