mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-12 15:21:09 +00:00
feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)
This commit is contained in:
+250
-37
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user