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
+250 -37
View File
@@ -15,8 +15,10 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
"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"
@@ -344,7 +346,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
if retryTimes <= 0 {
return false
}
if _, ok := c.Get("specific_channel_id"); ok {
if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
code := openaiErr.StatusCode
@@ -397,6 +399,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
}
service.AppendChannelAffinityAdminInfo(c, adminInfo)
other["admin_info"] = adminInfo
service.AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
@@ -474,6 +477,33 @@ func RelayNotFound(c *gin.Context) {
})
}
// RelayTaskPluginEndpoint keeps unclaimed shared-endpoint traffic on its
// existing handler while claimed requests enter the generation-pinned
// host-owned protocol bridge.
func RelayTaskPluginEndpoint(c *gin.Context, fallback gin.HandlerFunc) {
pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint)
if !exists {
fallback(c)
return
}
pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint)
if !ok || pinned.Plugin == nil || pinned.Generation == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Task protocol request failed",
"type": "new_api_error",
"code": "task_protocol_error",
},
})
return
}
if pinned.Protocol != "openai_responses" {
fallback(c)
return
}
serveTaskPluginProtocol(c, pinned, defaultPluginProtocolBridgeDeps())
}
func RelayTaskFetch(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
@@ -489,29 +519,76 @@ func RelayTaskFetch(c *gin.Context) {
}
}
type taskSubmissionOutcome struct {
Result *relay.TaskSubmitResult
Task *model.Task
RelayInfo *relaycommon.RelayInfo
}
func RelayTask(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, &taskdto.TaskError{
respondTaskSubmissionError(c, &taskdto.TaskError{
Code: "gen_relay_info_failed",
Message: err.Error(),
StatusCode: http.StatusInternalServerError,
})
return
}
if action := c.GetString("task_action"); action != "" {
relayInfo.Action = action
}
if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
respondTaskError(c, taskErr)
respondTaskSubmissionError(c, taskErr)
return
}
if taskErr := relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
respondTaskSubmissionError(c, taskErr)
return
}
outcome, taskErr := executeTaskSubmission(c, relayInfo)
if taskErr != nil {
respondTaskSubmissionError(c, taskErr)
return
}
presentTaskSubmission(c, outcome)
}
// executeTaskSubmission owns the retry, billing, and persistence lifecycle.
// It deliberately performs no client response writes so JSON and protocol
// presenters share the same durable task barrier. Its cancellation semantics
// come from c.Request.Context: native task endpoints use the client context,
// while the Responses bridge supplies an independently bounded context.
func executeTaskSubmission(c *gin.Context, relayInfo *relaycommon.RelayInfo) (*taskSubmissionOutcome, *taskdto.TaskError) {
return executeTaskSubmissionWith(c, relayInfo, relay.RelayTaskSubmit)
}
type taskSubmitAttempt func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *taskdto.TaskError)
func executeTaskSubmissionWith(
c *gin.Context,
relayInfo *relaycommon.RelayInfo,
submit taskSubmitAttempt,
) (*taskSubmissionOutcome, *taskdto.TaskError) {
diagnostics := newTaskPluginSubmitDiagnostics(c)
diagnostics.start(relayInfo)
var result *relay.TaskSubmitResult
var taskErr *taskdto.TaskError
durable := false
stage := "start"
defer func() {
if taskErr != nil && relayInfo.Billing != nil {
if !durable && relayInfo.Billing != nil {
diagnostics.refund(stage)
relayInfo.Billing.Refund(c)
}
}()
stage = "before_attempt"
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_attempt", 0)
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
retryParam := &service.RetryParam{
Ctx: c,
@@ -522,6 +599,12 @@ func RelayTask(c *gin.Context) {
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
stage = "select_channel"
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_attempt", retryParam.GetRetry()+1)
taskErr = service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
break
}
var channel *model.Channel
if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil {
@@ -541,10 +624,12 @@ func RelayTask(c *gin.Context) {
break
}
}
diagnostics.attempt(retryParam.GetRetry()+1, channel, relayInfo.LockedChannel != nil)
addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
stage = "read_body"
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
taskErr = service.TaskErrorWrapperLocal(bodyErr, "read_request_body_failed", http.StatusRequestEntityTooLarge)
} else {
@@ -554,8 +639,15 @@ func RelayTask(c *gin.Context) {
}
c.Request.Body = io.NopCloser(bodyStorage)
result, taskErr = relay.RelayTaskSubmit(c, relayInfo)
stage = "submit"
result, taskErr = submit(c, relayInfo)
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("after_submit", retryParam.GetRetry()+1)
taskErr = service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
break
}
if taskErr == nil {
diagnostics.attemptSucceeded(retryParam.GetRetry()+1, result)
break
}
@@ -566,7 +658,9 @@ func RelayTask(c *gin.Context) {
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
}
if !shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry()) {
willRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
diagnostics.attemptFailed(retryParam.GetRetry()+1, channel, taskErr, willRetry)
if !willRetry {
break
}
}
@@ -577,38 +671,157 @@ func RelayTask(c *gin.Context) {
logger.LogInfo(c, retryLogStr)
}
// ── 成功:结算 + 日志 + 插入任务 ──
if taskErr == nil {
if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
common.SysError("settle task billing error: " + settleErr.Error())
}
service.LogTaskConsumption(c, relayInfo)
task := model.InitTask(result.Platform, relayInfo)
task.PrivateData.UpstreamTaskID = result.UpstreamTaskID
task.PrivateData.BillingSource = relayInfo.BillingSource
task.PrivateData.SubscriptionId = relayInfo.SubscriptionId
task.PrivateData.TokenId = relayInfo.TokenId
task.PrivateData.NodeName = common.NodeName
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: relayInfo.PriceData.ModelPrice,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios(),
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
}
task.Quota = result.Quota
task.Data = result.TaskData
task.Action = relayInfo.Action
if insertErr := task.Insert(); insertErr != nil {
common.SysError("insert task error: " + insertErr.Error())
}
}
if taskErr != nil {
respondTaskError(c, taskErr)
diagnostics.failed(stage, "task_error", taskErr, false)
return nil, taskErr
}
if result == nil {
taskErr = service.TaskErrorWrapperLocal(errors.New("task submission returned no result"), "task_submit_failed", http.StatusInternalServerError)
diagnostics.failed("submit", "missing_result", taskErr, false)
return nil, taskErr
}
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_reserve", retryParam.GetRetry()+1)
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
// Reserve any submit-time upward billing adjustment before persistence.
// This keeps insertion failures fully refundable while ensuring settlement
// after the barrier normally has a zero positive delta.
if relayInfo.Billing != nil {
stage = "reserve"
diagnostics.reserve("reserve_start", result.Quota)
if reserveErr := relayInfo.Billing.Reserve(result.Quota); reserveErr != nil {
common.SysError("reserve adjusted task billing error: " + reserveErr.Error())
taskErr = service.TaskErrorWrapperLocal(errors.New("insufficient quota for adjusted task cost"), string(types.ErrorCodeInsufficientUserQuota), http.StatusForbidden)
diagnostics.failed("reserve", "insufficient_quota", taskErr, false)
return nil, taskErr
}
diagnostics.reserve("reserve_complete", result.Quota)
}
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_insert", retryParam.GetRetry()+1)
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
stage = "insert"
task := model.InitTask(result.Platform, relayInfo)
task.PrivateData.Execution = service.TaskExecutionSnapshotFromContext(c)
task.PrivateData.UpstreamTaskID = result.UpstreamTaskID
task.PrivateData.BillingSource = relayInfo.BillingSource
task.PrivateData.SubscriptionId = relayInfo.SubscriptionId
task.PrivateData.TokenId = relayInfo.TokenId
task.PrivateData.NodeName = common.NodeName
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: relayInfo.PriceData.ModelPrice,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios(),
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
TieredSnapshot: relayInfo.TieredBillingSnapshot,
}
task.Quota = result.Quota
task.Data = result.TaskData
task.Action = relayInfo.Action
if immediate := result.Immediate; immediate != nil {
task.Status = model.TaskStatus(immediate.Status)
task.Progress = immediate.Progress
if immediate.Status == model.TaskStatusSuccess || immediate.Status == model.TaskStatusFailure {
task.FinishTime = time.Now().Unix()
}
if immediate.Status == model.TaskStatusFailure {
task.FailReason = immediate.Reason
}
if immediate.Url != "" {
task.PrivateData.ResultURL = immediate.Url
} else if immediate.Status == model.TaskStatusSuccess {
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
}
}
diagnostics.insertStart(task)
if insertErr := task.InsertWithContext(c.Request.Context()); insertErr != nil {
common.SysError("insert task error: " + insertErr.Error())
taskErr = service.TaskErrorWrapperLocal(errors.New("failed to persist task"), "task_insert_failed", http.StatusInternalServerError)
diagnostics.failed("insert", "database_error", taskErr, false)
return nil, taskErr
}
durable = true
stage = "settle"
diagnostics.durable(task)
diagnostics.settleStart(task, result.Quota)
if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
common.SysError("settle task billing error: " + settleErr.Error())
taskErr = service.TaskErrorWrapperLocal(errors.New("failed to settle task billing"), "task_billing_settlement_failed", http.StatusInternalServerError)
diagnostics.failed("settle", "billing_error", taskErr, true)
return nil, taskErr
}
service.LogTaskConsumption(c, relayInfo, task)
diagnostics.complete(task, result.Quota)
return &taskSubmissionOutcome{Result: result, Task: task, RelayInfo: relayInfo}, nil
}
func presentTaskSubmission(c *gin.Context, outcome *taskSubmissionOutcome) {
diagnostics := newTaskPluginSubmitDiagnostics(c)
otherRatios := outcome.RelayInfo.PriceData.OtherRatios()
if otherRatios == nil {
otherRatios = map[string]float64{}
}
if ratiosJSON, err := common.Marshal(otherRatios); err == nil {
c.Header("X-New-Api-Other-Ratios", string(ratiosJSON))
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedRoute); ok && pinned.Plugin != nil && pinned.Route.Render != "" {
view, err := service.BuildTaskPluginView(outcome.Task)
requestValue, _ := c.Get(pluginruntime.ContextKeyRouteRequest)
requestContext, _ := requestValue.(pluginruntime.RouteRequestContext)
if err == nil {
viewValue, valueErr := taskPluginProtocolJSONValue(view)
if valueErr == nil {
if body, callErr := pinned.Plugin.Engine.CallPath(c.Request.Context(), "native", []string{pinned.Route.Render}, requestContext.JSValue(), viewValue); callErr == nil {
diagnostics.present(outcome.Task, "native_presenter")
c.JSON(http.StatusOK, body)
return
} else {
logger.LogError(c, "task plugin native submit presenter failed: "+callErr.Error())
}
} else {
logger.LogError(c, "encode task plugin native submit view failed: "+valueErr.Error())
}
} else {
logger.LogError(c, "build task plugin native submit view failed: "+err.Error())
}
}
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Protocol == "openai_video" && pinned.Operation.Name == "create" {
diagnostics.present(outcome.Task, "openai_video_create")
c.JSON(http.StatusOK, outcome.Task.ToOpenAIVideo())
return
}
}
createdAt := outcome.Task.CreatedAt
if createdAt == 0 {
createdAt = outcome.Task.SubmitTime
}
diagnostics.present(outcome.Task, "host_fallback")
c.JSON(http.StatusOK, map[string]any{
"id": outcome.Task.TaskID,
"task_id": outcome.Task.TaskID,
"status": "queued",
"model": outcome.RelayInfo.OriginModelName,
"created_at": createdAt,
})
}
func respondTaskSubmissionError(c *gin.Context, taskErr *taskdto.TaskError) {
newTaskPluginSubmitDiagnostics(c).presentError(taskErr)
if middleware.RespondTaskPluginError(c, taskErr) {
return
}
respondTaskError(c, taskErr)
}
// respondTaskError 统一输出 Task 错误响应(含 429 限流提示改写)
@@ -629,7 +842,7 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *taskdto.TaskEr
if retryTimes <= 0 {
return false
}
if _, ok := c.Get("specific_channel_id"); ok {
if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
if taskErr.StatusCode == http.StatusTooManyRequests {